在 Void Linux 上用 Turnstile 运行 Rootless Docker

· 5min · sorubedo

Docker 官方的 Rootless 安装方式 默认生成 systemd 用户服务,而 Void Linux 使用 runit。Void 仓库中的 moby 包提供了 dockerd,却没有附带 dockerd-rootless.shdockerd-rootless-setuptool.sh

所以我的做法是手动安装 Docker、RootlessKit 和 pasta,再把启动逻辑写成 Turnstile 管理的 runit 用户服务。

安装依赖

sudo xbps-install -S docker rootlesskit passt turnstile

Turnstile 的安装和用户服务用法直接参考 Void Handbook: Per-User Services。我使用默认的 runit backend,只有 /etc/turnstile/turnstiled.conf 中这一项改为:

manage_rundir = no

用户服务仍放在默认的 ~/.config/service/

编写 Docker 用户服务

创建服务目录:

mkdir -p ~/.config/service/docker

目录中有三个文件:

~/.config/service/docker/
├── run                      # runit 入口
├── conf                     # 本机可调参数
└── dockerd-rootless-shim    # RootlessKit 启动包装脚本

run

run 只做三件事:合并标准错误、加载 conf、把进程替换为包装脚本。

#!/bin/sh
exec 2>&1

service_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) || exit 1

# 加载用户配置
if [ -r "$service_dir/conf" ]; then
    . "$service_dir/conf"
fi

# OPTS 有意进行 shell 参数拆分
exec "$service_dir/dockerd-rootless-shim" ${OPTS:-}

conf

我选择 pasta 网络,并配合 RootlessKit 推荐的 implicit 端口驱动:

#!/bin/sh

# 强制使用 pasta 网络驱动
export DOCKERD_ROOTLESS_ROOTLESSKIT_NET=pasta

# pasta 推荐使用 implicit 端口驱动
export DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER=implicit

# pasta 默认 MTU
export DOCKERD_ROOTLESS_ROOTLESSKIT_MTU=65520

# 使用分离的网络命名空间
export DOCKERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS=true

# 允许容器通过 10.0.2.2 访问宿主机回环地址
#
# true:
#   不向 RootlessKit 传递 --disable-host-loopback
#   pasta 不会启用 --no-map-gw
#
# false:
#   禁止容器访问宿主机 127.0.0.1 上的服务
export DOCKERD_ROOTLESS_ALLOW_HOST_LOOPBACK=true

# 让下面的容器参数:
#
#   --add-host=host.docker.internal:host-gateway
#
# 映射到 pasta 的宿主机网关 10.0.2.2
OPTS="${OPTS:+$OPTS }--host-gateway-ip=10.0.2.2"
export OPTS

pasta 需要配合 --port-driver=implicit 使用,具体见 RootlessKit network drivers。这里同时允许容器通过 10.0.2.2 访问宿主机的回环地址。

只绑定到 127.0.0.1 时的坑

后来我发现,用 -p 127.0.0.1:8080:8080 这种形式转发端口时,宿主机反而无法访问 127.0.0.1:8080。排查之后才发现,不是 Docker 的端口映射有问题,而是 Void 仓库里的 RootlessKit 版本落后了:目前仍是 2.2.0,启动 pasta 时不会传入 --host-lo-to-ns-lo

上游从 RootlessKit 2.3.2 开始会检测 pasta 是否支持这个参数,并在启动 pasta 时自动加上它。升级 RootlessKit 后,绑定到宿主机回环地址的端口就能正常访问了。

唉,Void。Void Linux 是我很喜欢的一个发行版,但无奈维护人手太少,一些包总是落后,另一些则迟迟无人打包。这大概也是使用小众发行版时不得不接受的一部分。

dockerd-rootless-shim

Void 没有提供官方的 dockerd-rootless.sh,所以下面的脚本负责完成等价的 RootlessKit 启动工作,并加入 pasta 和 Docker bridge 所需的处理。

完整的 dockerd-rootless-shim(点击展开)
#!/bin/sh
set -eu

# ============================================================
# RootlessKit 子进程
# ============================================================

if [ -n "${_DOCKERD_ROOTLESS_CHILD:-}" ]; then
    # /run 已由 RootlessKit copy-up,在子命名空间中清理旧路径
    rm -f /run/docker /run/containerd /run/xtables.lock

    # 默认使用当前进程的网络命名空间。
    # 启用 --detach-netns 时优先使用 RootlessKit 保存的 netns。
    netns="/proc/self/ns/net"

    if [ -n "${ROOTLESSKIT_STATE_DIR:-}" ] &&
       [ -e "$ROOTLESSKIT_STATE_DIR/netns" ]; then
        netns="$ROOTLESSKIT_STATE_DIR/netns"
    fi

    # Docker bridge 和 nftables 需要网络命名空间开启转发
    nsenter --net="$netns" -- \
        sysctl -w net.ipv4.ip_forward=1 >/dev/null

    nsenter --net="$netns" -- \
        sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true

    exec dockerd "$@"
fi

# ============================================================
# RootlessKit 父进程
# ============================================================

if [ "$(id -u)" = "0" ]; then
    echo "错误:不能以 root 身份运行 rootless Docker" >&2
    exit 1
fi

if [ -z "${XDG_RUNTIME_DIR:-}" ]; then
    echo "错误:XDG_RUNTIME_DIR 未设置" >&2
    exit 1
fi

if [ ! -d "$XDG_RUNTIME_DIR" ] ||
   [ ! -w "$XDG_RUNTIME_DIR" ]; then
    echo "错误:XDG_RUNTIME_DIR 不存在或不可写:$XDG_RUNTIME_DIR" >&2
    exit 1
fi

if [ -z "${HOME:-}" ] || [ ! -d "$HOME" ]; then
    echo "错误:HOME 未设置或目录不存在" >&2
    exit 1
fi

# ============================================================
# 查找 RootlessKit
# ============================================================

rootlesskit_bin=""

for candidate in docker-rootlesskit rootlesskit; do
    if command -v "$candidate" >/dev/null 2>&1; then
        rootlesskit_bin="$candidate"
        break
    fi
done

if [ -z "$rootlesskit_bin" ]; then
    echo "错误:找不到 docker-rootlesskit 或 rootlesskit" >&2
    exit 1
fi

# ============================================================
# pasta 配置
# ============================================================

net="${DOCKERD_ROOTLESS_ROOTLESSKIT_NET:-pasta}"

if [ "$net" = "pasta" ] &&
   ! command -v pasta >/dev/null 2>&1; then
    echo "错误:配置了 pasta 网络,但系统中找不到 pasta 命令" >&2
    exit 1
fi

mtu="${DOCKERD_ROOTLESS_ROOTLESSKIT_MTU:-65520}"

if [ -n "${DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER:-}" ]; then
    port_driver="$DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER"
elif [ "$net" = "pasta" ]; then
    port_driver="implicit"
else
    port_driver="builtin"
fi

# RootlessKit 官方建议 pasta 与 implicit 端口驱动配合使用
if [ "$net" = "pasta" ] && [ "$port_driver" != "implicit" ]; then
    echo "警告:pasta 通常应配合 --port-driver=implicit" >&2
fi

# ============================================================
# detach-netns
# ============================================================

detach_flag=""

case "${DOCKERD_ROOTLESS_ROOTLESSKIT_DETACH_NETNS:-true}" in
    1|true|yes|on)
        detach_flag="--detach-netns"
        ;;
    0|false|no|off)
        detach_flag=""
        ;;
    *)
        echo "错误:无效的 DETACH_NETNS 值" >&2
        exit 1
        ;;
esac

# ============================================================
# 宿主机回环访问
# ============================================================

host_loopback_flag=""

case "${DOCKERD_ROOTLESS_ALLOW_HOST_LOOPBACK:-false}" in
    1|true|yes|on)
        # 不传 --disable-host-loopback。
        #
        # 对 pasta 而言,这意味着 RootlessKit 不会给 pasta 添加:
        #
        #   --no-map-gw
        #
        # 容器可通过 10.0.2.2 访问宿主机 127.0.0.1 服务。
        host_loopback_flag=""
        ;;
    0|false|no|off)
        host_loopback_flag="--disable-host-loopback"
        ;;
    *)
        echo "错误:无效的 ALLOW_HOST_LOOPBACK 值" >&2
        exit 1
        ;;
esac

# ============================================================
# 启动 RootlessKit
# ============================================================

state_dir="$XDG_RUNTIME_DIR/dockerd-rootless"

mkdir -p "$state_dir"

export _DOCKERD_ROOTLESS_CHILD=1

exec "$rootlesskit_bin" \
    --state-dir="$state_dir" \
    --net="$net" \
    --mtu="$mtu" \
    --port-driver="$port_driver" \
    --copy-up=/etc \
    --copy-up=/run \
    --propagation=rslave \
    ${host_loopback_flag:+$host_loopback_flag} \
    ${detach_flag:+$detach_flag} \
    "$0" "$@"

脚本会执行两次:父进程检查环境并启动 RootlessKit,子进程在 namespace 内设置网络转发并执行 dockerd/etc/run 使用 copy-up,避免与宿主机的运行文件和 DNS 配置互相影响。

设置权限:

chmod 755 ~/.config/service/docker/run
chmod 755 ~/.config/service/docker/dockerd-rootless-shim
chmod 600 ~/.config/service/docker/conf

Turnstile 的 runsvdir 会自动发现新目录。服务可以直接用路径控制:

sv up ~/.config/service/docker
sv status ~/.config/service/docker
sv restart ~/.config/service/docker

让 Docker CLI 连接 rootless socket

Rootless daemon 默认监听 $XDG_RUNTIME_DIR/docker.sock。让客户端连接这个 socket:

export DOCKER_HOST="unix://$XDG_RUNTIME_DIR/docker.sock"

使用 NVIDIA Container Toolkit

如果要在容器中使用 NVIDIA GPU,安装 NVIDIA Container Toolkit 后,配置 Docker runtime 并启用 CDI:

sudo nvidia-ctk runtime configure --runtime=docker --cdi.enabled

然后在 /etc/rc.local 中加入以下内容,让系统启动时生成 NVIDIA CDI 配置,并为 Rootless Docker 设置共享挂载:

echo "[-] Generating NVIDIA CDI..."
if nvidia-ctk cdi generate --output=/etc/cdi/nvidia.yaml >/dev/null 2>&1; then
    echo "[*] NVIDIA CDI ready."
else
    echo "[!] NVIDIA CDI generation failed."
fi

echo "[-] Setting up shared mounts for Rootless Docker..."
mount_failed=0
for mnt in / /dev /sys /proc /tmp; do
    if ! mount --make-rshared "$mnt" >/dev/null 2>&1; then
        echo "[!] Failed to set rshared on $mnt"
        mount_failed=1
    fi
done

if [ "$mount_failed" -eq 0 ]; then
    echo "[*] Shared mounts ready."
fi

验证

docker version
docker info
docker run --rm hello-world

docker version 的 Server 部分应显示:

rootlesskit:
  NetworkDriver: pasta
  StateDir: /run/user/1000/dockerd-rootless

docker info 应显示:

Security Options:
  rootless

Docker Root Dir: /home/你的用户名/.local/share/docker

不需要使用 sudo docker ...

从容器访问宿主机回环服务

因为本文在 conf 中启用了 host loopback,并把 Docker 的 host-gateway 固定为 pasta 网关 10.0.2.2,可以在 Compose 中这样写:

services:
  app:
    image: example/app
    extra_hosts:
      - "host.docker.internal:host-gateway"

容器中的 host.docker.internal 会解析为 10.0.2.2。访问这个地址,相当于访问宿主机的 127.0.0.1 服务。

cgroup

当前方案会看到:

Cgroup Driver: none
WARNING: Running in rootless-mode without cgroups.

在没有 systemd 用户服务的 Void/runit 上,rootless Docker 不支持 cgroup 资源限制,--memory--cpus--pids-limit 等参数会被忽略。详见 Docker Rootless tips