ROS2 시뮬레이션 환경 혼자 구축하기 | Docker + Gazebo 설정
이 글의 핵심
실물 로봇이나 GPU 워크스테이션 없이도 ROS2 + Gazebo 시뮬레이션 환경을 Docker 컨테이너로 구축해 혼자 연습할 수 있습니다. 이 글은 운영체제별(Linux/macOS/Windows WSL2) GUI 포워딩 설정과 docker-compose 구성, 그리고 실제로 자주 막히는 X11/그래픽 문제를 해결하는 방법을 정리합니다.
ROS2와 Gazebo를 처음 설치해보려는 사람이 가장 먼저 마주치는 건 알고리즘이 아니라 설치 지옥입니다. 특정 Ubuntu 버전에 맞는 ROS2 배포판을 골라야 하고, 시스템에 이미 깔려 있는 다른 패키지와 충돌이 나기도 하고, macOS나 Windows를 쓰고 있다면 애초에 네이티브 설치 자체가 공식 지원 대상이 아닌 경우도 많습니다. 그리고 이 모든 걸 어렵게 설치한 뒤에 문제가 생기면, “처음부터 다시 깨끗하게 설치”하는 것 외에는 되돌릴 방법이 마땅치 않습니다.
이 글은 이 문제를 Docker로 우회하는 방법을 정리합니다. 컨테이너 안에 ROS2와 Gazebo를 격리해두면, 호스트 운영체제가 무엇이든 상관없이 동일한 환경을 재현할 수 있고, 문제가 생기면 컨테이너만 지우고 다시 만들면 됩니다. 자율주행 로봇을 독학하는 전체적인 학습 순서는 자율주행 로봇 개발 독학 로드맵에서 다뤘으므로, 이 글은 그중 “3단계: 시뮬레이터 환경 구축” 부분을 실제로 손에 잡히게 실습하는 데 집중합니다.
기본 구성: ROS2 + Gazebo Docker 이미지
가장 기본이 되는 Dockerfile은 다음과 같습니다.
FROM osrf/ros:humble-desktop-full
RUN apt-get update && apt-get install -y \
ros-humble-turtlebot3* \
ros-humble-turtlebot3-simulations \
&& rm -rf /var/lib/apt/lists/*
ENV TURTLEBOT3_MODEL=burger
WORKDIR /root/ros2_ws
osrf/ros:humble-desktop-full 이미지는 OSRF(Open Source Robotics Foundation)에서 공식으로 배포하는 이미지로, ROS2와 RViz, 새 Gazebo(Fortress, ros_ign 패키지)가 이미 함께 설치되어 있어 처음부터 직접 조합할 필요가 없습니다. TurtleBot3 시뮬레이션 패키지를 추가하면 별도의 로봇 모델(URDF)을 만들지 않고도 바로 실습을 시작할 수 있습니다.
몇 가지 선택의 이유를 짚어 두겠습니다. Humble은 Ubuntu 22.04 기반의 LTS 배포판(2027년 5월까지 지원)이라 튜토리얼과 패키지가 가장 많고, TurtleBot3 공식 문서도 Humble을 기준으로 합니다. ros-humble-desktop-full 메타패키지가 끌어오는 시뮬레이터는 새 Gazebo(Fortress)이고, Gazebo Classic(버전 11) 은 위 Dockerfile에서 ros-humble-turtlebot3-simulations를 설치할 때 turtlebot3_gazebo의 의존성(gazebo_ros_pkgs)으로 함께 들어옵니다. Gazebo Classic은 2025년 1월에 지원이 종료되었고 후속인 새 Gazebo(Humble과는 Fortress가 짝)로 생태계가 옮겨 가는 중이지만, TurtleBot3 예제와 기존 자료 대부분이 아직 Classic 기준이라 처음 연습용으로는 여전히 가장 무난합니다. 나중에 새 Gazebo로 넘어가면 패키지 이름(ros_gz)과 launch 파일이 달라진다는 점만 알아 두면 됩니다.
desktop-full 이미지는 압축 해제 후 수 GB에 이릅니다. RViz와 Gazebo가 모두 필요 없고 노드 개발만 한다면 ros:humble-ros-base에서 시작해 필요한 패키지만 추가하는 편이 훨씬 가볍습니다. apt-get install 줄에서 ros-humble-turtlebot3*처럼 와일드카드를 쓰면 편하지만 설치되는 패키지 목록이 저장소 상태에 따라 바뀌므로, 재현성이 중요하다면 필요한 패키지 이름을 명시하는 것이 좋습니다. ENV TURTLEBOT3_MODEL=burger는 TurtleBot3 launch 파일들이 로봇 모델을 고를 때 읽는 환경 변수라, 빠뜨리면 launch 단계에서 KeyError: 'TURTLEBOT3_MODEL'로 바로 실패합니다.
패키지 이름은 직접 확인했습니다(ros:humble-ros-base 이미지에서 apt-cache로 조회, 2026-10-29). amd64에서는 ros-humble-turtlebot3-simulations(2.3.8), ros-humble-turtlebot3-gazebo, ros-humble-gazebo-ros-pkgs, gazebo(11.10.2)가 모두 있었고, ros-humble-desktop-full의 의존성에는 Gazebo Classic이 없고 ros-humble-simulation(ros_ign)이 있었습니다. 반면 arm64(Apple Silicon의 Docker가 기본으로 받는 이미지)에서는 ros-humble-turtlebot3-simulations, ros-humble-turtlebot3-gazebo, ros-humble-gazebo-ros-pkgs, gazebo가 저장소에 없어서, 위 Dockerfile의 apt-get install이 E: Unable to locate package ros-humble-turtlebot3-simulations로 실패합니다. 같은 Dockerfile을 Apple Silicon에서 쓰려면 FROM --platform=linux/amd64 osrf/ros:humble-desktop-full처럼 amd64 이미지를 에뮬레이션으로 받거나, arm64에서는 TurtleBot3 시뮬레이션을 소스(ROBOTIS-GIT/turtlebot3_simulations의 humble 브랜치)로 받아 새 Gazebo 기준으로 바꿔야 합니다. 다만 소스의 turtlebot3_gazebo는 gazebo_ros_pkgs에 의존하므로 arm64에서 그대로 빌드되지는 않을 가능성이 큽니다. 이 부분은 빌드까지는 시도하지 않았습니다. ros-humble-turtlebot3*처럼 와일드카드로 설치하면 시뮬레이션이 필요 없는 패키지까지 포함돼 arm64에서 apt-get install --dry-run 기준 새로 설치되는 패키지가 1,096개였으므로, 필요한 이름만 적는 편이 이미지 크기와 빌드 시간에도 유리합니다. (GUI 실행과 ros2 launch는 이 환경에서 하지 않았습니다.)
Linux 호스트: X11 포워딩으로 GUI 띄우기
호스트가 Linux(Ubuntu 등)라면 X11을 그대로 포워딩하는 방식이 가장 간단하고 지연도 적습니다.
# 호스트에서 한 번 실행 (컨테이너의 X11 접근 허용)
xhost +local:docker
docker run -it --rm \
--env="DISPLAY=$DISPLAY" \
--env="QT_X11_NO_MITSHM=1" \
--volume="/tmp/.X11-unix:/tmp/.X11-unix:rw" \
--volume="$(pwd)/ros2_ws:/root/ros2_ws" \
--name ros2-sim \
my-ros2-gazebo bash
이 설정에서 가장 자주 빠뜨리는 부분이 xhost +local:docker입니다. 이 명령 없이 컨테이너를 실행하면 Gazebo 프로세스는 정상적으로 뜨는데 창만 나타나지 않는, 원인을 짐작하기 어려운 증상이 나타납니다. 반대로 매번 xhost +(모든 접근 허용)를 쓰는 것은 보안상 권장하지 않습니다 — +local:docker처럼 로컬 연결로 범위를 좁혀두는 것이 안전합니다.
나머지 옵션의 의미도 알아 두면 문제가 생겼을 때 원인을 좁히기 쉽습니다. /tmp/.X11-unix는 X 서버가 여는 유닉스 소켓이 있는 디렉터리로, 이를 마운트하고 DISPLAY 값(보통 :0이나 :1)을 넘겨야 컨테이너 안의 프로그램이 호스트 화면에 창을 그릴 수 있습니다. QT_X11_NO_MITSHM=1은 Qt 기반 GUI(RViz, Gazebo 일부 창)가 X11의 공유 메모리 확장(MIT-SHM)을 쓰지 않게 합니다. 컨테이너는 호스트와 IPC 네임스페이스가 달라 공유 메모리 세그먼트를 공유하지 못하는데, 이 설정이 없으면 창이 검게 나오거나 X Error: BadAccess 같은 에러가 납니다.
또 하나 흔히 만나는 문제는 GPU 가속입니다. 위 명령만으로는 컨테이너가 호스트의 그래픽 드라이버에 접근하지 못해 Gazebo가 소프트웨어 렌더링(llvmpipe)으로 돌고, 간단한 맵에서도 프레임이 크게 떨어집니다. Intel·AMD 내장 그래픽은 --device /dev/dri를 추가하면 대부분 해결되고, NVIDIA GPU는 호스트에 NVIDIA Container Toolkit을 설치한 뒤 --gpus all과 -e NVIDIA_DRIVER_CAPABILITIES=all을 넣어야 합니다. 컨테이너 안에서 glxinfo | grep "OpenGL renderer"를 실행해 llvmpipe가 나오면 아직 소프트웨어 렌더링 중이라는 뜻입니다. 작업이 끝나면 xhost -local:docker로 허용을 되돌려 두는 것도 좋은 습관입니다.
macOS 호스트: XQuartz 대신 noVNC를 권하는 이유
macOS에서는 X11 서버가 기본 내장되어 있지 않아 XQuartz를 별도로 설치해야 하는데, 실제로 써보면 렌더링 성능 문제나 권한 설정 이슈로 삽질하는 시간이 꽤 깁니다. 특히 Apple Silicon(M 시리즈)에서는 x86_64 전용 이미지를 돌릴 경우 Rosetta 에뮬레이션 오버헤드까지 겹쳐 체감 성능이 크게 떨어집니다.
그림: 컨테이너 안에서 Xvfb가 가상 디스플레이를 만들고, x11vnc가 그 화면을 5900 포트로 내보내고, websockify가 웹소켓으로 바꿔 6080 포트로 서비스한다. 호스트는 브라우저로 접속만 하면 된다.
대안으로 noVNC 기반 컨테이너를 권합니다. 컨테이너 안에 가상 디스플레이(Xvfb)와 VNC 서버를 함께 띄우고, 호스트에서는 웹 브라우저로 접속해 화면을 보는 방식입니다.
FROM osrf/ros:humble-desktop-full
RUN apt-get update && apt-get install -y \
xvfb x11vnc novnc websockify \
ros-humble-turtlebot3-simulations \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 6080
ENTRYPOINT ["/entrypoint.sh"]
#!/bin/bash
# entrypoint.sh
Xvfb :1 -screen 0 1280x800x24 &
export DISPLAY=:1
x11vnc -display :1 -forever -nopw &
websockify --web=/usr/share/novnc 6080 localhost:5900 &
exec "$@"
이렇게 구성하면 브라우저에서 http://localhost:6080/vnc.html로 접속해 Gazebo 화면을 그대로 볼 수 있습니다. Xvfb·x11vnc·noVNC 부분은 아키텍처(ARM64/x86_64)와 무관하게 같은 방식으로 동작하고, 호스트에서는 브라우저만 있으면 됩니다. 다만 위 Dockerfile이 설치하는 ros-humble-turtlebot3-simulations는 앞에서 본 것처럼 arm64 저장소에 없으므로, Apple Silicon에서는 --platform=linux/amd64 이미지를 쓰거나 시뮬레이터 부분을 바꿔야 합니다. 다만 VNC 특성상 X11 직접 포워딩보다는 약간의 렌더링 지연이 있으므로, 정밀한 조작이 필요한 실습보다는 관찰 위주의 시뮬레이션에 더 적합합니다.
entrypoint 스크립트의 흐름은 이렇습니다. Xvfb가 화면 없는 가상 X 서버를 디스플레이 :1에 띄우고, x11vnc가 그 화면을 VNC 프로토콜(기본 포트 5900)로 내보내며, websockify가 VNC를 웹소켓으로 바꿔 noVNC 웹 클라이언트와 함께 6080 포트로 서비스합니다. 마지막 exec "$@"는 docker run 뒤에 적은 명령(예: bash나 ros2 launch ...)을 컨테이너의 주 프로세스로 실행합니다.
noVNC 파이프라인은 직접 실행해 확인했습니다(arm64 Ubuntu 22.04 컨테이너, xvfb x11vnc novnc websockify 설치 후 위 entrypoint.sh 그대로 실행). 5초 뒤 http://localhost:6080/vnc.html이 HTTP 200으로 응답했고, 5900(VNC)과 6080(웹)이 LISTEN 상태였으며, xdpyinfo가 1280x800 pixels를 보고했습니다. Gazebo 화면을 띄워 브라우저에서 실제로 보는 단계까지는 하지 않았습니다.
이 구성에는 세 가지 주의점이 있습니다. 첫째, Xvfb에는 GPU가 없으므로 Gazebo는 항상 CPU 소프트웨어 렌더링으로 돕니다. 관찰용으로는 충분하지만 센서가 많은 월드는 느려지므로, 창을 띄울 필요가 없는 실험은 gzserver만 실행하는 헤드리스 모드가 낫습니다. 둘째, x11vnc -nopw는 비밀번호 없이 화면을 공개하므로 docker run -p 127.0.0.1:6080:6080처럼 로컬호스트에만 포트를 여는 것이 안전합니다. -p 6080:6080으로 열면 같은 네트워크의 누구나 브라우저로 접속해 컨테이너를 조작할 수 있습니다. 셋째, 이 ENTRYPOINT는 공식 이미지의 /ros_entrypoint.sh를 대체하므로 ROS 환경이 자동으로 설정되지 않습니다. 스크립트의 exec "$@" 앞에 source /opt/ros/humble/setup.bash를 추가하지 않으면 ros2: command not found가 납니다.
Windows 호스트: WSL2 + WSLg
Windows 11의 WSL2에는 WSLg가 기본 통합되어 있어, 별도의 X 서버 설치 없이도 Linux GUI 애플리케이션을 바로 띄울 수 있습니다.
# Windows PowerShell(관리자)에서 실행 — WSL2와 Ubuntu 22.04 설치
wsl --install -d Ubuntu-22.04
wsl --install은 WSL 안이 아니라 Windows 쪽 PowerShell에서 실행하는 명령입니다. 설치 후 Ubuntu 셸에 들어가 Docker(Docker Desktop의 WSL 통합 또는 WSL 안에 직접 설치한 Docker Engine)를 준비합니다.
WSL2 안에서는 Linux 호스트와 거의 같은 방식으로 docker run을 쓰면 됩니다. WSLg가 WSL 배포판 안에 X 서버와 소켓(/tmp/.X11-unix, 실제로는 /mnt/wslg/.X11-unix)을 미리 만들어 두고 DISPLAY=:0을 설정해 주므로 xhost나 별도 X 서버 설치가 필요 없습니다. 다만 컨테이너 입장에서는 여전히 소켓을 받아야 하므로 Linux 절의 명령처럼 -v /tmp/.X11-unix:/tmp/.X11-unix와 -e DISPLAY=$DISPLAY는 그대로 넘겨야 합니다. GPU 가속까지 쓰려면 -v /mnt/wslg:/mnt/wslg와 --device /dev/dxg, -v /usr/lib/wsl:/usr/lib/wsl, -e LD_LIBRARY_PATH=/usr/lib/wsl/lib을 추가해 WSL의 DirectX 기반 GPU 경로를 연결하는 방법이 알려져 있는데, 드라이버와 WSL 버전에 따라 동작이 달라 처음에는 소프트웨어 렌더링으로 시작해 확인하는 편이 덜 헤맵니다. 다만 USB 장치(실물 LiDAR나 아두이노 등)를 나중에 연결하려면 usbipd-win으로 USB 장치를 WSL2로 전달하는 별도 설정이 필요하다는 점은 미리 알아두는 것이 좋습니다 — 시뮬레이션 단계에서는 필요 없지만, 6단계(저가 실물 플랫폼 검증)로 넘어갈 때 처음 겪으면 당황하기 쉬운 부분입니다.
docker-compose로 반복 설정 없애기
매번 긴 docker run 명령을 치는 대신 docker-compose.yml로 고정해두면 실수를 줄일 수 있습니다.
services:
ros2-sim:
build: .
environment:
- DISPLAY=${DISPLAY}
- QT_X11_NO_MITSHM=1
volumes:
- /tmp/.X11-unix:/tmp/.X11-unix:rw
- ./ros2_ws:/root/ros2_ws
network_mode: host
stdin_open: true
tty: true
network_mode: host는 ROS2의 DDS 디스커버리가 멀티캐스트에 의존하기 때문에 필요합니다. 브리지 네트워크를 쓰면 컨테이너 안의 노드끼리는 통신이 되지만, 호스트에서 실행한 ros2 topic list 같은 명령이 컨테이너 안의 노드를 찾지 못하는 문제가 생깁니다. DDS 디스커버리 자체의 동작 원리는 ROS2 통신의 실체에서 더 자세히 다뤘습니다.
network_mode: host와 함께 자주 겪는 함정이 하나 더 있습니다. 호스트와 컨테이너에서 각각 노드를 띄우면 ros2 topic list에는 토픽이 보이는데 ros2 topic echo로는 메시지가 하나도 오지 않는 증상입니다. ROS2 Humble의 기본 미들웨어인 Fast DDS는 같은 기기에 있는 참여자끼리는 네트워크 대신 공유 메모리(SHM) 전송을 쓰려고 하는데, 네트워크는 공유해도 IPC 네임스페이스가 분리된 컨테이너와 호스트는 공유 메모리를 볼 수 없습니다. 디스커버리는 UDP로 성공하지만 실제 데이터 전송은 공유 메모리로 시도하다 실패하는 것입니다. compose에 ipc: host를 추가하거나, Fast DDS XML 프로필로 UDP 전송만 쓰도록 설정하면 해결됩니다. 이 증상은 에러 메시지가 전혀 없어서 원인을 찾기까지 시간이 오래 걸리는 대표적인 문제입니다.
또 Docker Desktop(macOS·Windows)에서는 컨테이너가 실제로는 가상 머신 안에서 돌기 때문에 network_mode: host가 Linux처럼 “호스트 OS의 네트워크”를 뜻하지 않습니다. 최근 버전에서 호스트 네트워킹 지원이 추가되었지만 멀티캐스트 동작은 환경에 따라 다를 수 있으므로, macOS에서는 ROS2 노드를 모두 컨테이너 안에서 실행하고 호스트는 noVNC로 화면만 보는 구성이 가장 문제가 적습니다. 같은 네트워크에 다른 사람의 ROS2 시스템이 있다면 ROS_DOMAIN_ID를 서로 다른 값(0~101 권장)으로 설정해 토픽이 섞이지 않게 해야 합니다.
첫 실습: TurtleBot3를 빈 맵에서 움직여보기
환경이 준비되었다면, 컨테이너 안에서 다음을 실행해 첫 시뮬레이션을 띄웁니다.
# 컨테이너 안에서
ros2 launch turtlebot3_gazebo empty_world.launch.py
다른 터미널(같은 컨테이너에 docker exec로 접속)에서 키보드 조종 노드를 띄우면 로봇을 직접 움직여볼 수 있습니다.
ros2 run turtlebot3_teleop teleop_keyboard
docker exec -it ros2-sim bash로 새 셸을 열었을 때 ros2: command not found가 난다면, 그 셸에 ROS 환경이 설정되지 않은 것입니다. 공식 이미지의 엔트리포인트는 컨테이너의 첫 프로세스에만 setup.bash를 적용하고, docker exec로 연 셸은 이를 거치지 않기 때문입니다. 새 셸마다 source /opt/ros/humble/setup.bash를 실행하거나, Dockerfile에 RUN echo "source /opt/ros/humble/setup.bash" >> /root/.bashrc를 추가해 두면 됩니다. 워크스페이스에서 직접 빌드한 패키지를 쓴다면 source /root/ros2_ws/install/setup.bash도 함께 필요합니다.
Gazebo를 처음 실행하면 모델 파일을 온라인 저장소에서 내려받느라 한참 동안 빈 화면에 멈춰 있는 것처럼 보일 수 있습니다. 이때 컨테이너를 강제로 끄지 말고 기다리면 되며, 오프라인 환경이라면 TurtleBot3 패키지에 포함된 모델만 쓰는 월드를 선택해야 합니다. 텔레옵 노드는 키 입력을 터미널에서 직접 읽으므로 -it 옵션으로 연 대화형 터미널에서 실행해야 하고, 로봇이 움직이지 않는다면 ros2 topic echo /cmd_vel로 명령이 발행되는지부터 확인합니다. 명령은 나오는데 로봇이 멈춰 있다면 Gazebo 쪽 플러그인 문제이고, 명령이 안 나온다면 텔레옵 창에 포커스가 없거나 앞 절의 네트워크 문제일 가능성이 큽니다.
이 시점에서 화면에 로봇이 뜨고 키보드로 움직인다면 환경 구축은 끝난 것입니다. 이후 SLAM(slam_toolbox)과 Nav2를 추가로 실행해보는 것이 다음 실습 단계이며, 이 부분은 로드맵 글의 4단계에서 이어서 다룹니다.
컨테이너에서 노드끼리 안 보일 때 먼저 볼 것
network_mode: host를 빠뜨리면 DDS 디스커버리가 깨져 노드가 서로를 찾지 못합니다. 에러 없이 토픽 목록만 비어 보이기 때문에 코드 문제로 오해하기 쉽습니다. 반대로 ros2 topic list에는 토픽이 보이는데 ros2 topic echo로 데이터가 오지 않는다면, Fast DDS가 같은 호스트라고 판단해 공유 메모리 전송을 시도하다 실패하는 경우이므로 ipc: host 설정을 확인합니다. 또 docker exec로 새로 연 셸은 엔트리포인트 스크립트를 거치지 않으므로, setup.bash를 직접 source해야 ros2 명령과 워크스페이스 패키지가 잡힙니다.
이 절의 내용 중 일부를 직접 실행해 확인했습니다(2026-10-31, arm64 ros:humble-ros-base 컨테이너, OrbStack 위의 Docker). docker run -d … sleep으로 띄운 컨테이너에 docker exec로 들어가 ros2를 치면 ros2: command not found가 나왔고 AMENT_PREFIX_PATH는 비어 있었습니다. 같은 이미지를 docker run의 첫 프로세스로 실행하면 AMENT_PREFIX_PATH=/opt/ros/humble이었고, exec 셸에서 source /opt/ros/humble/setup.bash를 하면 정상 동작해서 이 설명은 글대로였습니다.
반대로 network_mode: host를 빼면 노드끼리 못 찾는다는 설명은 이 환경에서 재현되지 않았습니다. 한 컨테이너에서 ros2 topic pub /chat을 돌리고 다른 컨테이너에서 ros2 topic list와 ros2 topic echo /chat --once를 실행했을 때, 기본 bridge, 사용자 정의 bridge, host 네트워크(IPC 분리), host 네트워크 + ipc: host 네 경우 모두 토픽이 보였고 데이터도 받았습니다. 공유 메모리 문제도 이 실험에서는 나타나지 않았습니다. 같은 Docker 네트워크 안에서는 멀티캐스트 디스커버리가 통하는 것으로 보입니다. 따라서 이 증상은 호스트 OS, Docker 구현(Docker Desktop·Linux 네이티브 등), 여러 호스트에 걸친 구성, 방화벽, ROS_DOMAIN_ID 같은 조건에서 생기는 문제로 읽어야 하고, 위 설정은 “항상 필요하다”기보다 “그런 환경에서 안전한 기본값”입니다. 다른 환경에서의 결과는 확인하지 못했습니다.