4.4 在容器中开发

备注

本节仅适用于 X2 Ultra(旗舰版) 与 X2 Ultra(new version)(旗舰焕新版)。X2 EDU(人人造版)无开发计算单元,不适用本节。

使用容器隔离开发环境,避免污染机器人主系统、便于多版本并存。

容器通过 bind-mount 直接挂载宿主机(即开发计算单元)的 SDK 目录,在开发计算单元上的修改会实时同步到容器内,编译产物亦然。

注意

在开发计算单元上开发时,自 v1.2.0 起二开程序须在容器内构建与运行,非容器开发模式(在开发计算单元宿主机上直接使用 SDK)的权限将受限,系统软件包(apt / pip 等)的安装仅支持在容器开发模式下进行。

v1.1.0 两种模式均可用,属过渡版本。若二开程序目前仍直接跑在开发计算单元宿主机上,请在 v1.1.0 期间按本章完成迁移:

  1. 按下文构建镜像并启动容器;

  2. 把二开程序源码放到 SDK 目录 /home/agi/aimdk 下(该目录已挂载进容器,宿主机与容器内是同一份文件);

  3. 在容器内重新构建 SDK 与二开程序,把宿主机上手动装过的依赖改为写进 Dockerfile 或在容器内安装;

  4. 在容器内运行并确认功能与非容器模式一致。


4.4.1 准备文件

在开发计算单元上创建一个目录存放镜像相关脚本,例如 ~/aimdk-image/:

mkdir -p ~/aimdk-image
cd ~/aimdk-image

下面两份文件需手动保存到该目录中。

构建前必查:镜像源可达性

下面的 Dockerfile 全部使用国内镜像源。构建前确认四行都返回 200:

for u in https://ngc.nju.edu.cn/v2/nvidia/l4t-base/tags/list \
         https://mirrors.aliyun.com/ubuntu-ports/dists/jammy/Release \
         https://mirrors.aliyun.com/ros2/ubuntu/dists/jammy/Release \
         https://pypi.tuna.tsinghua.edu.cn/simple/ ; do
  printf "%-58s %s\n" "$(echo "$u" | cut -c1-56)" \
    "$(curl -s -o /dev/null -w '%{http_code}' -m 15 "$u")"
done

构建前必查:/etc/subgid 尾随空格

rootless podman 建容器靠 /etc/subgid 授权做 GID 映射。若某行行尾带了尾随空格,解析器会整行丢弃,导致该 GID 授权失效——而报错只点名那个 GID(如 gid range ... -> [1000-1001) not allowed),极具误导性。换机器 / 刷机后容易复发,构建前先查:

cat -A /etc/subgid

看有没有 $ 前带空格 / tab 的行。正常应是 agi:1000:1$(1 直接到 $,无空格)。若看到 agi:1000:1 $ 这种坏行,修复:

sudo sed -i 's/[[:space:]]*$//' /etc/subgid
cat -A /etc/subgid   # Recheck; the bad line should become agi:1000:1$
podman system migrate

修完再继续构建。

Dockerfile

将以下内容保存为 ~/aimdk-image/Dockerfile。可直接用下面的 cat 命令写入(EOF 必须加单引号,避免 $(...) 被提前展开):

cat > ~/aimdk-image/Dockerfile <<'EOF'
# Pull the base image via the NJU NGC mirror; nvcr.io is often unreachable in China.
FROM ngc.nju.edu.cn/nvidia/l4t-base:r36.2.0

LABEL agi.image.purpose="ROS Humble base, no CUDA bundled (mount /usr/local/cuda + /usr/lib/.../libcudnn,libnvinfer at runtime)"
LABEL agi.image.base="l4t-base:r36.2.0"
LABEL agi.image.note="Mount /opt/ros, CUDA toolkit, cuDNN/TensorRT shared libs from host at runtime"

ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Shanghai

# Point the container's system apt at the Aliyun ubuntu-ports mirror: the base image
# defaults to ports.ubuntu.com, which is slow in China, and the hundreds of megabytes
# of build-essential / cmake / OpenCV dependencies below all come from this source.
# Also disable the NVIDIA L4T apt source; this image installs no l4t packages.
RUN for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do \
        [ -f "$f" ] || continue; \
        sed -i -E \
            -e 's@https?://ports\.ubuntu\.com/ubuntu-ports@https://mirrors.aliyun.com/ubuntu-ports@g' \
            -e 's@https?://(archive|security)\.ubuntu\.com/ubuntu@https://mirrors.aliyun.com/ubuntu@g' \
            "$f"; \
    done && \
    if [ -f /etc/apt/sources.list.d/nvidia-l4t-apt-source.list ]; then \
        sed -i -E 's@^[[:space:]]*deb@# deb@' /etc/apt/sources.list.d/nvidia-l4t-apt-source.list; \
    fi && \
    grep -rhE '^[[:space:]]*deb ' /etc/apt/sources.list /etc/apt/sources.list.d/ 2>/dev/null

# Install ROS 2 apt source and ros-humble-ros-core to pull in system-level
# dependencies (libspdlog1, libtinyxml2-9, python3-packaging, etc.).
# /opt/ros is overridden by a host bind-mount at runtime.
# The ROS apt source uses the Aliyun mirror, reachable from within China.
# The genuine ROS 2 GPG key is fetched by fingerprint F42ED6FBAB17C654 from a keyserver;
# do NOT use mirrors.aliyun.com/ros/ros.key (it lacks the ROS 2 key and causes NO_PUBKEY).
# Before using a keyserver, install dirmngr and create /root/.gnupg (absent in the base
# image), otherwise gpg fails with "No dirmngr".
RUN apt-get update && \
    apt-get install -y --no-install-recommends \
        curl gnupg2 dirmngr lsb-release ca-certificates && \
    mkdir -p /root/.gnupg && chmod 700 /root/.gnupg && \
    echo "standard-resolver" >> /root/.gnupg/dirmngr.conf && \
    echo "disable-ipv6" >> /root/.gnupg/dirmngr.conf && \
    gpg --no-default-keyring --keyring /usr/share/keyrings/ros-archive-keyring.gpg \
        --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys F42ED6FBAB17C654 && \
    echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] https://mirrors.aliyun.com/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-core && \
    apt-get install -y --no-install-recommends \
        usbutils iproute2 tini python3-pip \
        build-essential binutils git \
        python3-opencv python3-numpy \
        libjpeg-dev libpng-dev libtiff-dev \
        libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \
        libgtk-3-dev \
        openssh-client \
        libboost-python1.74.0 \
        bluez \
        cmake ninja-build \
        python3-colcon-common-extensions \
        libcurl4-openssl-dev \
        libncurses-dev \
        pkg-config && \
    printf '%s\n' \
        '[global]' \
        'index-url = https://pypi.tuna.tsinghua.edu.cn/simple' \
        'extra-index-url = https://mirrors.aliyun.com/pypi/simple/' \
        '                  https://mirrors.cloud.tencent.com/pypi/simple/' \
        'timeout = 60' \
        'retries = 5' \
        > /etc/pip.conf && \
    pip3 install --no-cache-dir -U pip packaging && \
    pip3 install --no-cache-dir "scikit-build-core<0.10" nanobind && \
    pip3 install --no-cache-dir --no-build-isolation ruckig==0.15.3 && \
    cd /opt && \
    ( timeout 900 git clone --depth 1 -b 4.8.0 https://gitee.com/mirrors/opencv.git opencv-4.8.0 || \
      ( echo "gitee clone failed, falling back to a GitHub mirror in China" && rm -rf /opt/opencv-4.8.0 && \
        curl -fSL --retry 3 --retry-delay 5 -m 1800 -o /tmp/opencv-4.8.0.tar.gz \
             https://gh-proxy.com/https://github.com/opencv/opencv/archive/refs/tags/4.8.0.tar.gz && \
        tar -xzf /tmp/opencv-4.8.0.tar.gz -C /opt && rm -f /tmp/opencv-4.8.0.tar.gz ) ) && \
    cd /opt/opencv-4.8.0 && mkdir -p build && cd build && \
    cmake -DCMAKE_BUILD_TYPE=Release \
          -DCMAKE_INSTALL_PREFIX=/usr/local \
          -DBUILD_PYTHON_SUPPORT=OFF \
          -DBUILD_EXAMPLES=OFF -DBUILD_TESTS=OFF -DBUILD_PERF_TESTS=OFF \
          -DWITH_GTK=ON \
          -DBUILD_LIST=core,imgproc,imgcodecs,videoio,highgui,calib3d,features2d,photo,video,objdetect \
          -DOPENCV_GENERATE_PKGCONFIG=ON .. && \
    make -j$(nproc) && make install && \
    rm -rf /opt/opencv-4.8.0 && \
    rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*

# Auto-source the host-mounted ROS setup in interactive shells.
RUN echo 'if [ -f /opt/ros/humble/setup.bash ]; then source /opt/ros/humble/setup.bash; fi' \
        >> /etc/bash.bashrc

# Make the dynamic linker aware of the host-mounted CUDA paths.
RUN echo "/usr/local/cuda/lib64"        > /etc/ld.so.conf.d/cuda.conf && \
    echo "/usr/local/cuda/targets/aarch64-linux/lib" >> /etc/ld.so.conf.d/cuda.conf

WORKDIR /home/agi

# tini handles PID 1 signal forwarding.
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["sleep", "infinity"]
EOF

run.sh

将以下内容保存为 ~/aimdk-image/run.sh,并赋予执行权限 chmod +x run.sh。可直接用下面的 cat 命令写入:

cat > ~/aimdk-image/run.sh <<'EOF'
#!/bin/bash
# Launch agi/aimdk-dev:dev with host ROS, CUDA, cuDNN/TRT and aima mounted in.
set -euo pipefail

IMAGE="${IMAGE:-agi/aimdk-dev:dev}"
NAME="${NAME:-aimdk-dev}"

# ---- helper: only add a -v mount if the host source exists ----
# Usage: m <src> <dst> [opts:ro|rw|rslave]   (default ro)
MOUNTS=()
m() {
  local src="$1" dst="$2" opts="${3:-ro}"
  if [ -e "$src" ]; then
    MOUNTS+=("-v" "$src:$dst:$opts")
  else
    echo "  mount $src NOT present, skipping" >&2
  fi
}

# Host GIDs to add to the container (groups owning device nodes).
HOST_GIDS=(
  20    # dialout - /dev/ttyUSB*
  29    # audio   - /dev/snd
  44    # video   - /dev/nvmap, /dev/nvhost-* (required by CUDA)
  106   # input   - /dev/input/event*
  109   # render  - /dev/dri/renderD*
  995   # debug   - /dev/nvhost-dbg-*
  1000  # run     - /agibot/data/var/MapManagerModule
)

# Translate host GIDs to container-side GIDs via the rootless ID mapping.
declare -a GROUP_ADDS
MAP_RAW=$(podman info --format '{{.Host.IDMappings.GIDMap}}')
for HG in "${HOST_GIDS[@]}"; do
  CG=$(echo "$MAP_RAW" | grep -oE '\{[0-9]+ '"$HG"' [0-9]+\}' | head -1 \
       | grep -oE '^\{[0-9]+' | tr -d '{')
  if [ -z "$CG" ]; then
    echo "GID $HG not mapped in /etc/subgid, skipping" >&2
  else
    GROUP_ADDS+=("--group-add" "$CG")
    echo "  $HG (host) -> $CG (container)" >&2
  fi
done

# Enumerate userspace CUDA shared libraries to bind-mount (cuDNN, TensorRT, cuBLAS, cuFFT, ...).
for f in /usr/lib/aarch64-linux-gnu/libcudnn*.so* \
         /usr/lib/aarch64-linux-gnu/libnvinfer*.so* \
         /usr/lib/aarch64-linux-gnu/libnvonnxparser*.so* \
         /usr/lib/aarch64-linux-gnu/libnvparsers*.so* \
         /usr/lib/aarch64-linux-gnu/libnvcaffe_parser*.so* \
         /usr/lib/aarch64-linux-gnu/libcublas*.so* \
         /usr/lib/aarch64-linux-gnu/libcufft*.so* \
         /usr/lib/aarch64-linux-gnu/libcurand*.so* \
         /usr/lib/aarch64-linux-gnu/libcusolver*.so* \
         /usr/lib/aarch64-linux-gnu/libcusparse*.so* \
         /usr/lib/aarch64-linux-gnu/libcupti*.so* \
         /usr/lib/aarch64-linux-gnu/libnpp*.so* \
         /usr/lib/aarch64-linux-gnu/libnvjpeg*.so* \
         /usr/lib/aarch64-linux-gnu/libnvToolsExt*.so* \
         /usr/lib/aarch64-linux-gnu/libnvrtc*.so* ; do
  m "$f" "$f"
done
echo "  CUDA libs to bind-mount: $(ls /usr/lib/aarch64-linux-gnu/libcuda*.so* 2>/dev/null | wc -l) files" >&2

# ncurses dev files (headers, link-time .so symlinks, pkg-config) for the keyboard example.
for f in /usr/include/curses.h /usr/include/ncurses*.h /usr/include/term.h \
         /usr/include/termcap.h /usr/include/unctrl.h /usr/include/eti.h \
         /usr/lib/aarch64-linux-gnu/libncurses*.so* /usr/lib/aarch64-linux-gnu/libtinfo*.so* \
         /usr/lib/aarch64-linux-gnu/pkgconfig/ncurses*.pc /usr/lib/aarch64-linux-gnu/pkgconfig/tinfo*.pc; do
  m "$f" "$f"
done

# Python include dirs (numpy, ...) that the host /opt/ros CMake configs reference by absolute path.
while read -r d; do
  m "$d" "$d"
done < <(grep -rhoE '/[A-Za-z0-9_./+-]*(dist|site)-packages/[A-Za-z0-9_./+-]*include' \
           --include='*.cmake' /opt/ros/humble/share 2>/dev/null | sort -u)

# Extract AGIBOT_* env vars from the firmware-managed bashrc into an env-file.
AGIBOT_BASHRC="/home/agi/.aima/env/bashrc"
ENV_FILE=$(mktemp /tmp/aimdk-env.XXXXXX)
trap 'rm -f "$ENV_FILE"' EXIT
if [ -r "$AGIBOT_BASHRC" ]; then
  sed -nE "s/^export (AGIBOT_[A-Z_]+)='(.*)'\$/\1=\2/p" "$AGIBOT_BASHRC" > "$ENV_FILE"
  echo "  AGIBOT env vars: $(wc -l < "$ENV_FILE") loaded from $AGIBOT_BASHRC" >&2
else
  echo "  $AGIBOT_BASHRC not readable, skipping AGIBOT_* env" >&2
fi

# Remove any existing container with the same name.
if podman ps -a --format '{{.Names}}' | grep -qx "$NAME"; then
  echo "stopping & removing old $NAME..."
  podman rm -f "$NAME" >/dev/null
fi

# If the DDS config exported by the firmware exists, mount it at the container path
# referenced by FASTRTPS_DEFAULT_PROFILES_FILE (shared discovery with the robot).
m /home/agi/.aima/env/ros_dds_configuration.xml /etc/ros_dds_configuration.xml

# ---- single fixed mount: the SDK dir is always present and must be rw ----
m /home/agi/aimdk /home/agi/aimdk rw

echo "starting $NAME from $IMAGE..."
podman run -d --name "$NAME" \
  --ipc=host --network=host --pid=host \
  --device nvidia.com/gpu=all \
  "${GROUP_ADDS[@]}" \
  -e HOME=/home/agi \
  -e FASTRTPS_DEFAULT_PROFILES_FILE=/etc/ros_dds_configuration.xml \
  -e PATH=/opt/ros/humble/bin:/usr/local/cuda/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
  -e LD_LIBRARY_PATH=/usr/local/lib:/usr/local/cuda/lib64:/opt/ros/humble/lib:/host-libs/aarch64-linux-gnu \
  --env-file "$ENV_FILE" \
  -v /etc/machine-id:/etc/machine-id:ro \
  -v /usr/lib/aarch64-linux-gnu:/host-libs/aarch64-linux-gnu:ro \
  -v /opt/ros:/opt/ros:ro \
  -v /usr/local/bin/aima:/usr/local/bin/aima:ro \
  -v /dev:/dev:rslave \
  -v /run/dbus:/run/dbus:rslave \
  "${MOUNTS[@]}" \
  "$IMAGE"
EOF

4.4.2 构建镜像

cd ~/aimdk-image
podman build -t agi/aimdk-dev:dev .

4.4.3 启动容器

cd ~/aimdk-image
./run.sh

脚本会自动完成:

  • 清理同名旧容器

  • 把宿主机 dialout、audio、video、input、render、debug 等组转译到容器侧 GID 并 --group-add 加入容器

  • 挂载 /opt/ros、CUDA 工具链、cuDNN/TensorRT 等共享库

  • 挂载 /dev,支持访问 USB、声卡、输入设备、外接存储

  • 挂载 SDK 目录 /home/agi/aimdk 到容器内同名路径(读写)

  • 若存在 DDS 配置 /home/agi/.aima/env/ros_dds_configuration.xml,挂到容器内 /etc/ros_dds_configuration.xml(与机器人共享话题发现)

  • 其余宿主路径(CUDA 库、ncurses 开发文件等)存在才挂(m() 函数),缺失自动跳过,跨设备可移植

  • 启动一个长驻容器 aimdk-dev,使用 --ipc=host --network=host --pid=host 与宿主机共享命名空间

备注

默认镜像名 agi/aimdk-dev:dev、容器名 aimdk-dev,可通过环境变量 IMAGE / NAME 覆盖:

IMAGE=agi/aimdk-dev:dev NAME=my-dev ./run.sh

4.4.4 进入容器

podman exec -it aimdk-dev bash

# 容器交互 shell 已自动 source /opt/ros/humble/setup.bash
ros2 topic list   # 应能看到机器人话题

4.4.5 构建并运行

容器内的 /home/agi/aimdk 与宿主机同名目录是同一份文件。在容器中按标准流程构建:

备注

若此前在宿主机上构建过,需先清理旧产物再构建,否则会报 gmake: /usr/local/bin/cmake: No such file or directory(旧产物中记录的 cmake 路径在容器内不存在):

rm -rf ~/aimdk/build ~/aimdk/install ~/aimdk/log

该目录宿主机与容器内是同一份文件,清理后宿主机侧亦需重新构建。

# 非上位机/外接算力包时此步可跳过
source /opt/ros/humble/setup.bash

# 进入 SDK 目录构建(上位机模式请将 SDK 放至 ~/aimdk)
cd ~/aimdk && colcon build

# 加载构建产物
source install/local_setup.bash

之后可前往 运行一个代码示例 继续。