Skill v1.0.1
currentAutomated scan100/1001 files
version: "1.0.1" name: docker-ros2-development description: > Best practices for Docker-based ROS2 development including multi-stage Dockerfiles, docker-compose for multi-container robotic systems, DDS discovery across containers, GPU passthrough for perception, and dev-vs-deploy container patterns. Use this skill when containerizing ROS2 workspaces, setting up docker-compose for robot software stacks, debugging DDS communication between containers, configuring NVIDIA Container Toolkit for GPU workloads, forwarding X11/Wayland for rviz2 and GUI tools, or managing USB device passthrough for cameras and serial devices. Trigger whenever the user mentions Docker with ROS2, docker-compose for robots, Dockerfile for colcon workspaces, container networking for DDS, GPU containers for perception, devcontainer for ROS2, multi-stage builds for ROS2, or deploying ROS2 in containers. Also trigger for CI/CD with Docker-based ROS2 builds, CycloneDDS or FastDDS configuration in containers, shared memory in Docker, or X11 forwarding for rviz2. Covers Humble, Iron, Jazzy, and Rolling distributions across Ubuntu 22.04 and 24.04 base images.
Docker-Based ROS2 Development Skill
When to Use This Skill
- Writing Dockerfiles for ROS2 workspaces with colcon builds
- Setting up docker-compose for multi-container robotic systems
- Debugging DDS discovery failures between containers (CycloneDDS, FastDDS)
- Configuring GPU passthrough with NVIDIA Container Toolkit for perception nodes
- Forwarding X11 or Wayland displays for rviz2 and rqt tools
- Managing USB device passthrough for cameras, LiDARs, and serial devices
- Building CI/CD pipelines with Docker-based ROS2 builds and test runners
- Creating devcontainer configurations for VS Code with ROS2 extensions
- Optimizing Docker layer caching for colcon workspace builds
- Designing dev-vs-deploy container strategies with multi-stage builds
ROS2 Docker Image Hierarchy
Official OSRF images follow a layered hierarchy. Always choose the smallest base that satisfies dependencies.
┌──────────────────────────────────────────────────────────────────┐│ ros:<distro>-desktop-full (~3.5 GB) ││ ┌────────────────────────────────────────────────────────────┐ ││ │ ros:<distro>-desktop (~2.8 GB) │ ││ │ ┌──────────────────────────────────────────────────────┐ │ ││ │ │ ros:<distro>-perception (~2.2 GB) │ │ ││ │ │ ┌────────────────────────────────────────────────┐ │ │ ││ │ │ │ ros:<distro>-ros-base (~1.1 GB) │ │ │ ││ │ │ │ ┌──────────────────────────────────────────┐ │ │ │ ││ │ │ │ │ ros:<distro>-ros-core (~700 MB) │ │ │ │ ││ │ │ │ └──────────────────────────────────────────┘ │ │ │ ││ │ │ └────────────────────────────────────────────────┘ │ │ ││ │ └──────────────────────────────────────────────────────┘ │ ││ └────────────────────────────────────────────────────────────┘ │└──────────────────────────────────────────────────────────────────┘
| Image Tag | Base OS | Size | Contents | Use Case | |
|---|---|---|---|---|---|
ros:humble-ros-core | Ubuntu 22.04 | ~700 MB | rclcpp, rclpy, rosout, launch | Minimal runtime for single nodes | |
ros:humble-ros-base | Ubuntu 22.04 | ~1.1 GB | ros-core + common_interfaces, rosbag2 | Most production deployments | |
ros:humble-perception | Ubuntu 22.04 | ~2.2 GB | ros-base + image_transport, cv_bridge, PCL | Camera/lidar perception pipelines | |
ros:humble-desktop | Ubuntu 22.04 | ~2.8 GB | perception + rviz2, rqt, demos | Development with GUI tools | |
ros:jazzy-ros-core | Ubuntu 24.04 | ~750 MB | rclcpp, rclpy, rosout, launch | Minimal runtime (Jazzy/Noble) | |
ros:jazzy-ros-base | Ubuntu 24.04 | ~1.2 GB | ros-core + common_interfaces, rosbag2 | Production deployments (Jazzy) |
Multi-Stage Dockerfiles for ROS2
Dev Stage
The development stage includes build tools, debuggers, and editor support for interactive use.
FROM ros:humble-desktop AS devRUN apt-get update && apt-get install -y --no-install-recommends \build-essential cmake gdb python3-pip \python3-colcon-common-extensions python3-rosdep \ros-humble-ament-lint-auto ros-humble-ament-cmake-pytest \ccache \&& rm -rf /var/lib/apt/lists/*ENV CCACHE_DIR=/ccacheENV CC="ccache gcc"ENV CXX="ccache g++"
Build Stage
Copies only src/ and package.xml files to maximize cache hits during dependency resolution.
FROM ros:humble-ros-base AS buildRUN apt-get update && apt-get install -y --no-install-recommends \python3-colcon-common-extensions python3-rosdep \&& rm -rf /var/lib/apt/lists/*WORKDIR /ros2_ws# Copy package manifests first for dependency cachingCOPY src/my_pkg/package.xml src/my_pkg/package.xmlRUN . /opt/ros/humble/setup.sh && apt-get update && \rosdep install --from-paths src --ignore-src -r -y && \rm -rf /var/lib/apt/lists/*# Source changes invalidate only this layer and belowCOPY src/ src/RUN . /opt/ros/humble/setup.sh && \colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release \--event-handlers console_direct+
Runtime Stage
Contains only the built install space and runtime dependencies. No compilers, no source code.
FROM ros:humble-ros-core AS runtimeRUN apt-get update && apt-get install -y --no-install-recommends \python3-yaml ros-humble-rmw-cyclonedds-cpp \&& rm -rf /var/lib/apt/lists/*COPY --from=build /ros2_ws/install /ros2_ws/installRUN groupadd -r rosuser && useradd -r -g rosuser -m rosuserUSER rosuserCOPY ros_entrypoint.sh /ros_entrypoint.shENTRYPOINT ["/ros_entrypoint.sh"]CMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]
Full Multi-Stage Dockerfile
# syntax=docker/dockerfile:1# Usage:# docker build --target dev -t my_robot:dev .# docker build --target runtime -t my_robot:latest .ARG ROS_DISTRO=humbleARG BASE_IMAGE=ros:${ROS_DISTRO}-ros-base# Stage 1: Dependency base — install apt and rosdep depsFROM ${BASE_IMAGE} AS depsRUN apt-get update && apt-get install -y --no-install-recommends \python3-colcon-common-extensions python3-rosdep \&& rm -rf /var/lib/apt/lists/*WORKDIR /ros2_ws# Copy only package.xml files for rosdep resolution (maximizes cache reuse)COPY src/my_robot_bringup/package.xml src/my_robot_bringup/package.xmlCOPY src/my_robot_perception/package.xml src/my_robot_perception/package.xmlCOPY src/my_robot_msgs/package.xml src/my_robot_msgs/package.xmlCOPY src/my_robot_navigation/package.xml src/my_robot_navigation/package.xmlRUN . /opt/ros/${ROS_DISTRO}/setup.sh && \apt-get update && \rosdep install --from-paths src --ignore-src -r -y && \rm -rf /var/lib/apt/lists/*# Stage 2: Development — full dev environmentFROM deps AS devRUN apt-get update && apt-get install -y --no-install-recommends \build-essential gdb valgrind ccache python3-pip python3-pytest \ros-${ROS_DISTRO}-ament-lint-auto \ros-${ROS_DISTRO}-launch-testing-ament-cmake \ros-${ROS_DISTRO}-rviz2 ros-${ROS_DISTRO}-rqt-graph \&& rm -rf /var/lib/apt/lists/*ENV CCACHE_DIR=/ccache CC="ccache gcc" CXX="ccache g++"COPY src/ src/COPY ros_entrypoint.sh /ros_entrypoint.shENTRYPOINT ["/ros_entrypoint.sh"]CMD ["bash"]# Stage 3: Build — compile workspaceFROM deps AS buildCOPY src/ src/RUN . /opt/ros/${ROS_DISTRO}/setup.sh && \colcon build \--cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF \--event-handlers console_direct+ \--parallel-workers $(nproc)# Stage 4: Runtime — minimal production imageFROM ros:${ROS_DISTRO}-ros-core AS runtimeARG ROS_DISTRO=humbleRUN apt-get update && apt-get install -y --no-install-recommends \python3-yaml ros-${ROS_DISTRO}-rmw-cyclonedds-cpp \&& rm -rf /var/lib/apt/lists/*COPY --from=build /ros2_ws/install /ros2_ws/installRUN groupadd -r rosuser && useradd -r -g rosuser -m -s /bin/bash rosuserUSER rosuserENV RMW_IMPLEMENTATION=rmw_cyclonedds_cppCOPY ros_entrypoint.sh /ros_entrypoint.shENTRYPOINT ["/ros_entrypoint.sh"]CMD ["ros2", "launch", "my_robot_bringup", "robot.launch.py"]
The entrypoint script both dev and runtime stages use:
#!/bin/bashset -esource /opt/ros/${ROS_DISTRO}/setup.bashif [ -f /ros2_ws/install/setup.bash ]; thensource /ros2_ws/install/setup.bashfiexec "$@"
Docker Compose for Multi-Container ROS2 Systems
Basic Multi-Container Setup
Each ROS2 subsystem runs in its own container with process isolation, independent scaling, and per-service resource limits.
# docker-compose.ymlversion: "3.8"x-ros-common: &ros-commonenvironment:- ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0}- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp- CYCLONEDDS_URI=file:///cyclonedds.xmlvolumes:- ./config/cyclonedds.xml:/cyclonedds.xml:ro- /dev/shm:/dev/shmnetwork_mode: hostrestart: unless-stoppedservices:rosbridge:<<: *ros-commonimage: my_robot:latestcommand: ros2 launch rosbridge_server rosbridge_websocket_launch.xml port:=9090perception:<<: *ros-commonimage: my_robot_perception:latestcommand: ros2 launch my_robot_perception perception.launch.pydeploy:resources:reservations:devices:- driver: nvidiacount: 1capabilities: [gpu]devices:- /dev/video0:/dev/video0 # USB camera passthroughnavigation:<<: *ros-commonimage: my_robot_navigation:latestcommand: >ros2 launch my_robot_navigation navigation.launch.pyuse_sim_time:=false map:=/maps/warehouse.yamlvolumes:- ./maps:/maps:rodriver:<<: *ros-commonimage: my_robot_driver:latestcommand: ros2 launch my_robot_driver driver.launch.pydevices:- /dev/ttyUSB0:/dev/ttyUSB0 # Serial motor controller- /dev/ttyACM0:/dev/ttyACM0 # IMU over USB-serialgroup_add:- dialout
Service Dependencies with Health Checks
services:driver:<<: *ros-commonimage: my_robot_driver:latesthealthcheck:test: ["CMD", "bash", "-c","source /opt/ros/humble/setup.bash && ros2 topic list | grep -q /joint_states"]interval: 5stimeout: 10sretries: 5start_period: 15snavigation:<<: *ros-commonimage: my_robot_navigation:latestdepends_on:driver:condition: service_healthy # Wait for driver topicsperception:<<: *ros-commonimage: my_robot_perception:latestdepends_on:driver:condition: service_healthy # Camera driver must be ready
Profiles for Dev vs Deploy
services:driver:<<: *ros-commonimage: my_robot_driver:latestcommand: ros2 launch my_robot_driver driver.launch.pyrviz:<<: *ros-commonprofiles: ["dev"]image: my_robot:devcommand: ros2 run rviz2 rviz2 -d /rviz/config.rvizenvironment:- DISPLAY=${DISPLAY}- QT_X11_NO_MITSHM=1volumes:- /tmp/.X11-unix:/tmp/.X11-unix:rwrosbag_record:<<: *ros-commonprofiles: ["dev"]image: my_robot:devcommand: ros2 bag record -a --storage sqlite3 --max-bag-duration 300 -o /bags/sessionvolumes:- ./bags:/bagswatchdog:<<: *ros-commonprofiles: ["deploy"]image: my_robot:latestcommand: ros2 launch my_robot_bringup watchdog.launch.pyrestart: always
docker compose --profile dev up # Dev tools (rviz, rosbag)docker compose --profile deploy up -d # Production (watchdog, no GUI)
DDS Discovery Across Containers
CycloneDDS XML Config for Unicast Across Containers
When containers use bridge networking (no multicast), configure explicit unicast peer lists.
<!-- cyclonedds.xml --><?xml version="1.0" encoding="UTF-8"?><CycloneDDS xmlns="https://cdds.io/config"><Domain><General><Interfaces><NetworkInterface autodetermine="true" priority="default"/></Interfaces><AllowMulticast>false</AllowMulticast></General><Discovery><!-- Peer list uses docker-compose service names as hostnames --><Peers><Peer address="perception"/><Peer address="navigation"/><Peer address="driver"/><Peer address="rosbridge"/></Peers><ParticipantIndex>auto</ParticipantIndex><MaxAutoParticipantIndex>120</MaxAutoParticipantIndex></Discovery><Internal><SocketReceiveBufferSize min="10MB"/></Internal></Domain></CycloneDDS>
FastDDS XML Config
<!-- fastdds.xml --><?xml version="1.0" encoding="UTF-8"?><dds xmlns="http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles"><profiles><participant profile_name="docker_participant" is_default_profile="true"><rtps><builtin><discovery_config><discoveryProtocol>SIMPLE</discoveryProtocol><leaseDuration><sec>10</sec></leaseDuration></discovery_config><initialPeersList><locator><udpv4><address>perception</address><port>7412</port></udpv4></locator><locator><udpv4><address>navigation</address><port>7412</port></udpv4></locator><locator><udpv4><address>driver</address><port>7412</port></udpv4></locator></initialPeersList></builtin></rtps></participant></profiles></dds>
Mount and activate in compose:
# CycloneDDSenvironment:- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp- CYCLONEDDS_URI=file:///cyclonedds.xmlvolumes:- ./config/cyclonedds.xml:/cyclonedds.xml:ro# FastDDSenvironment:- RMW_IMPLEMENTATION=rmw_fastrtps_cpp- FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds.xmlvolumes:- ./config/fastdds.xml:/fastdds.xml:ro
Shared Memory Transport in Docker
DDS shared memory (zero-copy) requires /dev/shm sharing between containers. This provides highest throughput for large messages (images, point clouds).
services:perception:shm_size: "512m" # Default 64 MB is too small for image topicsvolumes:- /dev/shm:/dev/shm # Share host shm for inter-container zero-copy
<!-- Enable shared memory in CycloneDDS --><CycloneDDS xmlns="https://cdds.io/config"><Domain><SharedMemory><Enable>true</Enable></SharedMemory></Domain></CycloneDDS>
Constraints: all communicating containers must share /dev/shm or use ipc: host. Use --ipc=shareable on one container and --ipc=container:<name> on others for scoped sharing.
Networking Modes and ROS2 Implications
Host Networking
services:my_node:network_mode: host # Shares host network namespace; DDS multicast works natively
Bridge Networking (Default)
services:my_node:networks: [ros_net]networks:ros_net:driver: bridge # DDS multicast blocked; requires unicast peer config
Macvlan Networking
networks:ros_macvlan:driver: macvlandriver_opts:parent: eth0ipam:config:- subnet: 192.168.1.0/24gateway: 192.168.1.1services:my_node:networks:ros_macvlan:ipv4_address: 192.168.1.50 # Real LAN IP; DDS multicast works natively
Decision Table
| Factor | Host | Bridge | Macvlan | |
|---|---|---|---|---|
| DDS discovery | Works natively | Needs unicast peers | Works natively | |
| Network isolation | None | Full isolation | LAN-level isolation | |
| Port conflicts | Yes (host ports) | No (mapped ports) | No (unique IPs) | |
| Performance | Native | Slight overhead | Near-native | |
| Multi-host support | No | With overlay networks | Yes (same LAN) | |
| When to use | Dev, single host | CI/CD, multi-tenant | Multi-robot on LAN |
GPU Passthrough for Perception
NVIDIA Container Toolkit Setup
# Install NVIDIA Container Toolkit on the hostcurl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \| sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpgcurl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \| sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \| sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.listsudo apt-get update && sudo apt-get install -y nvidia-container-toolkitsudo nvidia-ctk runtime configure --runtime=dockersudo systemctl restart docker
Compose Config with deploy.resources
services:perception:image: my_robot_perception:latestdeploy:resources:reservations:devices:- driver: nvidiacount: 1 # Number of GPUs (or "all")capabilities: [gpu]environment:- NVIDIA_VISIBLE_DEVICES=all- NVIDIA_DRIVER_CAPABILITIES=compute,utility,videoshm_size: "1g" # Large shm for GPU<->CPU transfers
For Dockerfiles that need CUDA, start from NVIDIA base and install ROS2 on top:
FROM nvidia/cuda:12.2.0-cudnn8-runtime-ubuntu22.04 AS perception-baseRUN apt-get update && apt-get install -y --no-install-recommends \curl gnupg2 lsb-release \&& curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key \-o /usr/share/keyrings/ros-archive-keyring.gpg \&& echo "deb [arch=$(dpkg --print-architecture) \signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] \http://packages.ros.org/ros2/ubuntu $(lsb_release -cs) main" \> /etc/apt/sources.list.d/ros2.list \&& apt-get update && apt-get install -y --no-install-recommends \ros-humble-ros-base ros-humble-cv-bridge ros-humble-image-transport \&& rm -rf /var/lib/apt/lists/*
Verification
docker compose exec perception bash -c 'nvidia-smipython3 -c "import torch; print(f\"CUDA available: {torch.cuda.is_available()}\")"'
Display Forwarding
X11 Forwarding
services:rviz:image: my_robot:devcommand: ros2 run rviz2 rviz2environment:- DISPLAY=${DISPLAY:-:0} # Forward host display- QT_X11_NO_MITSHM=1 # Disable MIT-SHM (crashes in Docker)volumes:- /tmp/.X11-unix:/tmp/.X11-unix:rw # X11 socket- ${HOME}/.Xauthority:/root/.Xauthority:ro # Auth cookienetwork_mode: host
# Allow local Docker containers to access the X serverxhost +local:docker# More secure variant:xhost +SI:localuser:$(whoami)
Wayland Forwarding
services:rviz:image: my_robot:devcommand: ros2 run rviz2 rviz2environment:- WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-0}- XDG_RUNTIME_DIR=/run/user/1000- QT_QPA_PLATFORM=waylandvolumes:- ${XDG_RUNTIME_DIR}/${WAYLAND_DISPLAY}:/run/user/1000/${WAYLAND_DISPLAY}:rw
Headless Rendering
For CI/CD or remote machines without a physical display:
# Run rviz2 headless with Xvfb for screenshot capture or testingdocker run --rm my_robot:dev bash -c 'apt-get update && apt-get install -y xvfb mesa-utils &&Xvfb :99 -screen 0 1920x1080x24 &export DISPLAY=:99source /opt/ros/humble/setup.bashros2 run rviz2 rviz2 -d /config/test.rviz --screenshot /output/frame.png'
Volume Mounts and Workspace Overlays
Source Mounts for Dev
Mount only src/ during development. Let colcon write build/, install/, and log/ inside named volumes to avoid bind mount performance issues.
# BAD: mounting entire workspace — build artifacts on bind mount are slow# volumes:# - ./my_ros2_ws:/ros2_ws# GOOD: mount only source, use named volumes for build artifactsservices:dev:image: my_robot:devvolumes:- ./src:/ros2_ws/src:rw # Source code (bind mount)- build_vol:/ros2_ws/build # Build artifacts (named volume)- install_vol:/ros2_ws/install # Install space (named volume)- log_vol:/ros2_ws/log # Log output (named volume)working_dir: /ros2_wsvolumes:build_vol:install_vol:log_vol:
ccache Caching
Persist ccache across container rebuilds for faster C++ compilation:
services:dev:volumes:- ccache_vol:/ccacheenvironment:- CCACHE_DIR=/ccache- CCACHE_MAXSIZE=2G- CC=ccache gcc- CXX=ccache g++volumes:ccache_vol:
ROS Workspace Overlay in Docker
Keep upstream packages cached and only rebuild custom packages:
# Stage 1: upstream dependencies (rarely changes)FROM ros:humble-ros-base AS upstreamRUN apt-get update && apt-get install -y --no-install-recommends \ros-humble-nav2-bringup ros-humble-slam-toolbox \ros-humble-robot-localization \&& rm -rf /var/lib/apt/lists/*# Stage 2: custom packages overlay on topFROM upstream AS workspaceWORKDIR /ros2_wsCOPY src/ src/RUN . /opt/ros/humble/setup.sh && colcon build --symlink-install# install/setup.bash automatically sources /opt/ros/humble as underlay
USB Device Passthrough
Cameras and Serial Devices
services:camera_driver:image: my_robot_driver:latestdevices:- /dev/video0:/dev/video0 # USB camera (V4L2)- /dev/video1:/dev/video1group_add:- video # Access /dev/videoN without rootmotor_driver:image: my_robot_driver:latestdevices:- /dev/ttyUSB0:/dev/ttyUSB0 # USB-serial motor controller- /dev/ttyACM0:/dev/ttyACM0 # Arduino/Teensygroup_add:- dialout # Access serial ports without root
Udev Rules Inside Containers
Create stable device symlinks on the host so container paths remain consistent regardless of USB enumeration order.
# /etc/udev/rules.d/99-robot-devices.rules (host-side)SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="robot/motor_controller"SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="robot/lidar"SUBSYSTEM=="video4linux", ATTRS{idVendor}=="046d", ATTRS{idProduct}=="0825", SYMLINK+="robot/camera"
sudo udevadm control --reload-rules && sudo udevadm trigger
services:driver:devices:- /dev/robot/motor_controller:/dev/ttyMOTOR # Stable symlink- /dev/robot/lidar:/dev/ttyLIDAR- /dev/robot/camera:/dev/video0
Dynamic Device Attachment
For devices plugged in after the container starts:
services:driver:# Option 1: privileged (use only when necessary)privileged: truevolumes:- /dev:/dev# Option 2: cgroup device rules (more secure)# device_cgroup_rules:# - 'c 188:* rmw' # USB-serial (major 188)# - 'c 81:* rmw' # Video devices (major 81)
Dev Container Configuration
// .devcontainer/devcontainer.json{"name": "ROS2 Humble Dev","build": {"dockerfile": "../Dockerfile","target": "dev","args": { "ROS_DISTRO": "humble" }},"runArgs": ["--network=host", "--ipc=host", "--pid=host","--privileged", "--gpus", "all","-e", "DISPLAY=${localEnv:DISPLAY}","-e", "QT_X11_NO_MITSHM=1","-v", "/tmp/.X11-unix:/tmp/.X11-unix:rw","-v", "/dev:/dev"],"workspaceMount": "source=${localWorkspaceFolder},target=/ros2_ws/src,type=bind","workspaceFolder": "/ros2_ws","mounts": ["source=ros2-build-vol,target=/ros2_ws/build,type=volume","source=ros2-install-vol,target=/ros2_ws/install,type=volume","source=ros2-log-vol,target=/ros2_ws/log,type=volume","source=ros2-ccache-vol,target=/ccache,type=volume"],"containerEnv": {"ROS_DISTRO": "humble","RMW_IMPLEMENTATION": "rmw_cyclonedds_cpp","CCACHE_DIR": "/ccache","RCUTILS_COLORIZED_OUTPUT": "1"},"customizations": {"vscode": {"extensions": ["ms-iot.vscode-ros","ms-vscode.cpptools","ms-python.python","ms-vscode.cmake-tools","smilerobotics.urdf","redhat.vscode-xml","redhat.vscode-yaml"],"settings": {"ros.distro": "humble","python.defaultInterpreterPath": "/usr/bin/python3","C_Cpp.default.compileCommands": "/ros2_ws/build/compile_commands.json","cmake.configureOnOpen": false}}},"postCreateCommand": "sudo apt-get update && rosdep update && rosdep install --from-paths src --ignore-src -r -y","remoteUser": "rosuser"}
CI/CD with Docker
GitHub Actions Workflow
# .github/workflows/ros2-docker-ci.ymlname: ROS2 Docker CIon:push:branches: [main, develop]pull_request:branches: [main]env:REGISTRY: ghcr.ioIMAGE_NAME: ${{ github.repository }}jobs:build-and-test:runs-on: ubuntu-22.04strategy:fail-fast: falsematrix:ros_distro: [humble, jazzy]include:- ros_distro: humbleubuntu: "22.04"- ros_distro: jazzyubuntu: "24.04"steps:- uses: actions/checkout@v4- uses: docker/setup-buildx-action@v3- uses: docker/login-action@v3with:registry: ${{ env.REGISTRY }}username: ${{ github.actor }}password: ${{ secrets.GITHUB_TOKEN }}- name: Cache Docker layersuses: actions/cache@v4with:path: /tmp/.buildx-cachekey: ${{ runner.os }}-buildx-${{ matrix.ros_distro }}-${{ hashFiles('src/**/package.xml') }}restore-keys: ${{ runner.os }}-buildx-${{ matrix.ros_distro }}-- name: Build and testrun: |docker build --target dev \--build-arg ROS_DISTRO=${{ matrix.ros_distro }} \-t test-image:${{ matrix.ros_distro }} .docker run --rm test-image:${{ matrix.ros_distro }} bash -c 'source /opt/ros/${{ matrix.ros_distro }}/setup.bash &&cd /ros2_ws &&colcon build --cmake-args -DBUILD_TESTING=ON &&colcon test --event-handlers console_direct+ &&colcon test-result --verbose'- name: Push runtime imageif: github.ref == 'refs/heads/main'uses: docker/build-push-action@v5with:context: .target: runtimebuild-args: ROS_DISTRO=${{ matrix.ros_distro }}tags: |${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.ros_distro }}-latest${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ matrix.ros_distro }}-${{ github.sha }}push: truecache-from: type=local,src=/tmp/.buildx-cachecache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max- name: Rotate cacherun: rm -rf /tmp/.buildx-cache && mv /tmp/.buildx-cache-new /tmp/.buildx-cache
Layer Caching
Order Dockerfile instructions from least-frequently-changed to most-frequently-changed:
1. Base image (ros:humble-ros-base) — changes on distro upgrade2. System apt packages — changes on new dependency3. rosdep install (from package.xml) — changes on new ROS dep4. COPY src/ src/ — changes on every code edit5. colcon build — rebuilds on source change
Build Matrix Across Distros
strategy:matrix:ros_distro: [humble, iron, jazzy, rolling]rmw: [rmw_cyclonedds_cpp, rmw_fastrtps_cpp]exclude:- ros_distro: iron # Iron EOL — skiprmw: rmw_fastrtps_cpp
Docker ROS2 Anti-Patterns
1. Running Everything in One Container
Problem: Putting perception, navigation, planning, and drivers in a single container defeats the purpose of containerization. A crash in one subsystem takes down everything.
Fix: Split into one service per subsystem. Use docker-compose to orchestrate.
# BAD: monolithic containerservices:robot:image: my_robot:latestcommand: ros2 launch my_robot everything.launch.py# GOOD: one container per subsystemservices:perception:image: my_robot_perception:latestcommand: ros2 launch my_robot_perception perception.launch.pynavigation:image: my_robot_navigation:latestcommand: ros2 launch my_robot_navigation navigation.launch.pydriver:image: my_robot_driver:latestcommand: ros2 launch my_robot_driver driver.launch.py
2. Using Bridge Networking Without DDS Config
Problem: DDS uses multicast for discovery by default. Docker bridge networks do not forward multicast. Nodes in different containers will not discover each other.
Fix: Use network_mode: host or configure DDS unicast peers explicitly.
# BAD: bridge network with no DDS configservices:node_a:networks: [ros_net]node_b:networks: [ros_net]# GOOD: host networking (simplest)services:node_a:network_mode: hostnode_b:network_mode: host# GOOD: bridge with CycloneDDS unicast peersservices:node_a:networks: [ros_net]environment:- RMW_IMPLEMENTATION=rmw_cyclonedds_cpp- CYCLONEDDS_URI=file:///cyclonedds.xmlvolumes:- ./cyclonedds.xml:/cyclonedds.xml:ro
3. Building Packages in the Runtime Image
Problem: Installing compilers and build tools in the runtime image bloats it by 1-2 GB and increases attack surface.
Fix: Use multi-stage builds. Compile in a build stage, copy only the install space to runtime.
# BAD: build tools in runtime image (2.5 GB)FROM ros:humble-ros-baseRUN apt-get update && apt-get install -y build-essential python3-colcon-common-extensionsCOPY src/ /ros2_ws/src/RUN cd /ros2_ws && colcon buildCMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]# GOOD: multi-stage build (800 MB)FROM ros:humble-ros-base AS buildRUN apt-get update && apt-get install -y python3-colcon-common-extensionsCOPY src/ /ros2_ws/src/RUN cd /ros2_ws && . /opt/ros/humble/setup.sh && colcon buildFROM ros:humble-ros-core AS runtimeCOPY --from=build /ros2_ws/install /ros2_ws/installCMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]
4. Mounting the Entire Workspace as a Volume
Problem: Mounting the full workspace means colcon writes build/, install/, and log/ to a bind mount. On macOS/Windows Docker Desktop, bind mount I/O is 10-50x slower. Builds that take 2 minutes take 20+ minutes.
Fix: Mount only src/ as a bind mount. Use named volumes for build artifacts.
# BAD:volumes:- ./my_ros2_ws:/ros2_ws# GOOD:volumes:- ./my_ros2_ws/src:/ros2_ws/src- build_vol:/ros2_ws/build- install_vol:/ros2_ws/install- log_vol:/ros2_ws/log
5. Running Containers as Root
Problem: Running as root inside containers is a security risk. If compromised, the attacker has root access to mounted volumes and devices.
Fix: Create a non-root user with appropriate group membership.
# BAD:FROM ros:humble-ros-baseCOPY --from=build /ros2_ws/install /ros2_ws/installCMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]# GOOD:FROM ros:humble-ros-baseRUN groupadd -r rosuser && \useradd -r -g rosuser -G video,dialout -m -s /bin/bash rosuserCOPY --from=build --chown=rosuser:rosuser /ros2_ws/install /ros2_ws/installUSER rosuserCMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]
6. Ignoring Layer Cache Order in Dockerfile
Problem: Placing COPY src/ . before rosdep install means every source change invalidates the dependency cache. All apt packages are re-downloaded on every build.
Fix: Copy only package.xml files first, install dependencies, then copy source.
# BAD: source copy before rosdepCOPY src/ /ros2_ws/src/RUN rosdep install --from-paths src --ignore-src -r -yRUN colcon build# GOOD: package.xml first, then rosdep, then sourceCOPY src/my_pkg/package.xml /ros2_ws/src/my_pkg/package.xmlRUN . /opt/ros/humble/setup.sh && rosdep install --from-paths src --ignore-src -r -yCOPY src/ /ros2_ws/src/RUN . /opt/ros/humble/setup.sh && colcon build
7. Hardcoding ROS_DOMAIN_ID
Problem: Hardcoding ROS_DOMAIN_ID=42 causes conflicts when multiple robots or developers share a network. Two robots with the same domain ID will cross-talk.
Fix: Use environment variables with defaults. Set domain ID at deploy time.
# BAD:environment:- ROS_DOMAIN_ID=42# GOOD:environment:- ROS_DOMAIN_ID=${ROS_DOMAIN_ID:-0}
ROS_DOMAIN_ID=1 docker compose up -d # Robot 1ROS_DOMAIN_ID=2 docker compose up -d # Robot 2
8. Forgetting to Source setup.bash in Entrypoint
Problem: The CMD runs ros2 launch ... but the shell has not sourced the ROS2 setup files. Fails with ros2: command not found.
Fix: Use an entrypoint script that sources the underlay and overlay before executing the command.
# BAD: no sourcingFROM ros:humble-ros-coreCOPY --from=build /ros2_ws/install /ros2_ws/installCMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]# GOOD: entrypoint script handles sourcingFROM ros:humble-ros-coreCOPY --from=build /ros2_ws/install /ros2_ws/installCOPY ros_entrypoint.sh /ros_entrypoint.shRUN chmod +x /ros_entrypoint.shENTRYPOINT ["/ros_entrypoint.sh"]CMD ["ros2", "launch", "my_pkg", "bringup.launch.py"]
#!/bin/bash# ros_entrypoint.shset -esource /opt/ros/${ROS_DISTRO}/setup.bashif [ -f /ros2_ws/install/setup.bash ]; thensource /ros2_ws/install/setup.bashfiexec "$@"
Docker ROS2 Deployment Checklist
- Multi-stage build -- Separate dev, build, and runtime stages so production images contain no compilers or build tools
- Non-root user -- Create a dedicated
rosuserwith only the group memberships needed (video, dialout) instead of privileged mode - DDS configuration -- Ship a
cyclonedds.xmlorfastdds.xmlwith explicit peer lists if not using host networking - Health checks -- Every service has a health check that verifies the node is running and publishing on expected topics
- Resource limits -- Set
mem_limit,cpus, and GPU reservations for each service to prevent resource starvation - Restart policy -- Use
restart: unless-stoppedfor all services andrestart: alwaysfor critical drivers and watchdogs - Log management -- Configure Docker logging driver (json-file with max-size/max-file) to prevent disk exhaustion from ROS2 log output
- Environment injection -- Use
.envfiles or orchestrator secrets forROS_DOMAIN_ID, DDS config paths, and device mappings rather than hardcoding - Shared memory -- Set
shm_sizeto at least 256 MB (512 MB for image topics) and configure DDS shared memory transport for high-bandwidth topics - Device stability -- Use udev rules on the host to create stable
/dev/robot/*symlinks and reference those in compose device mappings - Image versioning -- Tag images with both
latestand a commit SHA or semantic version; never deploy unversionedlatestto production - Backup and rollback -- Keep the previous image version available so a failed deployment can be rolled back by reverting the image tag