Docker 基础体系 · 第 25/70 篇。示例以现代 Docker Engine、BuildKit 与 Compose 规范为基础,并明确 Linux 容器边界。

Rootless Docker:User Namespace、网络、存储、限制和迁移

Rootless Docker 是指 Docker daemon 本身以普通 Linux 用户身份运行,并通过 User Namespace 让容器中的“root”只具备受限的命名空间权限。它解决的不是“容器内进程是否写成 USER 1000”这一问题,而是 Docker daemon、容器进程、网络辅助程序和存储驱动都不再依赖宿主机的 root 权限。

本文讨论 Linux 容器边界内的现代 Docker Engine、BuildKit 与 Compose。Rootless Docker 依赖 Linux 的 User Namespace、网络命名空间、cgroup、文件系统和内核安全机制;在 Docker Desktop、Windows 容器和 macOS 虚拟机中,行为不能直接套用本文结论。


一、Rootless Docker 到底改变了什么

传统 rootful Docker 的基本关系是:

宿主机 root
  └── dockerd
       ├── containerd
       └── 容器进程

即使容器内进程不是 root,Docker daemon 通常仍由宿主机 root 运行。Docker CLI 通过 Unix Socket 请求 daemon 创建容器、挂载文件系统、配置网络和访问设备,因此:

能够控制 rootful Docker Socket
≈ 能够请求 daemon 执行宿主机级操作

这也是为什么 /var/run/docker.sock 不能随意挂载进普通容器。容器内的进程只要能访问该 Socket,通常就可以间接获得 Docker daemon 的管理能力。

Rootless Docker 的关系变为:

宿主机普通用户 alice
  └── rootlesskit
       └── dockerd
            ├── containerd
            └── 容器进程

其关键变化有三点:

  1. dockerd 的宿主机 UID 是普通用户,例如 1000,不是 0
  2. daemon 创建的容器进程位于 User Namespace 中,容器内 UID 0 不再等于宿主机 UID 0
  3. Docker 数据目录、Unix Socket、网络辅助进程和生命周期通常都属于该普通用户。

因此,Rootless Docker 降低了以下风险:

  • daemon 或容器运行时被利用后直接取得宿主机 root;
  • 容器内 root 通过普通文件操作修改宿主机 root 所有文件;
  • Docker daemon 的管理操作天然拥有宿主机 root 权限。

但它不是完整的虚拟机隔离,也不保证容器内的 root 可以执行所有普通 Linux root 操作。User Namespace 改变了 UID 的解释范围,却不会消除内核漏洞、文件共享、设备访问或应用自身的安全问题。


二、User Namespace:UID 映射的形式化解释

2.1 UID 在不同命名空间中不是同一个数

User Namespace 为进程提供独立的 UID/GID 视图。容器中显示的 UID 与宿主机 UID 之间通过映射表转换。

设:

  • unu_n:进程在 User Namespace 中看到的 UID;
  • uhu_h:宿主机内核最终使用的 UID;
  • M(un)M(u_n):User Namespace 到宿主机的映射函数。

一个典型 Rootless 用户 alice 的映射可能近似如下:

namespace UID       host UID
--------------      --------
0                   1000        # alice 本身
1                   231072
2                   231073
3                   231074
...
65536               296607

其中 231072:65536 通常来自 /etc/subuid

alice:231072:65536

对应的 GID 映射由 /etc/subgid 提供:

alice:231072:65536

映射规则可以写成:

M(0)=1000M(0)=1000

对于容器内的其他 UID:

M(un)=231072+(un1),1un65536M(u_n)=231072+(u_n-1),\quad 1\le u_n\le65536

因此:

  • 容器内 UID 0 代表宿主机用户 alice
  • 容器内 UID 1 代表宿主机 UID 231072
  • 容器内 UID 1000 代表宿主机 UID 232071
  • 容器内 UID 70000 超出该映射范围,不能按这张映射表转换。

实际映射可能存在额外区间,且 Docker、RootlessKit、容器运行时的具体布局可能随版本和配置变化。上表用于理解原理,不应把某个固定子 UID 当作 API。

2.2 为什么容器内 root 能写自己的文件,却不能写宿主机 /etc

在容器内执行:

id

可能得到:

uid=0(root) gid=0(root) groups=0(root)

但在宿主机上查看同一进程或同一文件时,内核使用的是映射后的宿主机 UID。容器内 root 执行:

touch /some-file

如果 /some-file 位于容器可写层,文件系统看到的是 User Namespace 内的 UID 0,因此操作可以成功。

如果它尝试访问宿主机上的:

/etc/shadow

则需要同时满足:

  1. 该路径确实被挂载到容器;
  2. 宿主机用户 alice 对该文件和目录具有访问权限;
  3. User Namespace、挂载和安全模块允许该操作。

通常第二项已经失败,因为 alice 不是宿主机 root。容器内的 uid=0 不会把 alice 变成宿主机 root。

2.3 newuidmapnewgidmap 的作用

创建包含多个 UID/GID 区间的 User Namespace 时,普通进程不能任意修改 /proc/<pid>/uid_map。Linux 通常通过 newuidmapnewgidmap 这类带必要特权位的辅助程序,依据 /etc/subuid/etc/subgid 中的授权写入映射。

这不是给用户授予宿主机 root,而是允许用户在预先分配的 subordinate ID(子 UID、子 GID)范围内建立映射。安装缺少这些工具时,Rootless Docker 常见失败表现包括:

newuidmap: write to uid_map failed

或:

failed to setup UID/GID mappings

检查方式:

command -v newuidmap
command -v newgidmap
grep "^$(id -un):" /etc/subuid
grep "^$(id -un):" /etc/subgid

在某些发行版中,工具来自 uidmap 软件包。具体安装命令依发行版而不同,不能把 Debian、RHEL、Arch 的包名混用。


三、Rootless Docker 与 userns-remap 不是同一件事

Docker 还支持 rootful daemon 配合 User Namespace Remap,例如:

{
  "userns-remap": "default"
}

这两种机制都涉及 UID 映射,但保护边界不同。

Rootless Docker

普通用户
  └── rootless dockerd
       └── User Namespace 中的容器
  • daemon 本身不是宿主机 root;
  • Docker Socket 通常位于用户目录或 $XDG_RUNTIME_DIR
  • 网络、挂载、设备和 cgroup 能力受到普通用户权限限制;
  • daemon 被攻破后,直接取得宿主机 root 的路径更窄。

rootful daemon + userns-remap

宿主机 root
  └── dockerd
       └── 经过 UID 映射的容器进程
  • daemon 仍由宿主机 root 运行;
  • 容器内 root 被映射为宿主机非 root UID;
  • daemon 仍可管理宿主机挂载、网络、设备和 cgroup;
  • 兼容性通常更好,但 daemon 仍是高价值 root 服务。

因此,userns-remap 主要降低“容器进程直接成为宿主机 root”的风险;Rootless Docker 进一步降低“Docker daemon 本身拥有宿主机 root”的风险。两者不是简单的名称替换,也不应仅凭“都使用 User Namespace”判断它们等价。


四、安装和启动:Rootless daemon 的生命周期

4.1 前置条件

Rootless Docker 需要满足一组 Linux 条件:

  • 内核支持 User Namespace;
  • 用户具有可用的 subordinate UID/GID;
  • 系统安装 newuidmapnewgidmap
  • 用户的 $HOME、运行时目录和 Docker 数据目录可写;
  • 网络辅助程序和存储驱动满足当前 Docker 版本要求;
  • 如果使用资源限制,需要 cgroup v2 以及可委派的 cgroup 层级。

官方安装脚本通常是:

dockerd-rootless-setuptool.sh install

该命令的具体路径和是否已安装,取决于发行版及 Docker Engine 安装方式。安装前可先确认:

command -v dockerd-rootless-setuptool.sh
docker info

如果机器上已有 rootful Docker,docker 命令默认可能仍连接 /var/run/docker.sock。Rootless Docker 安装后,必须确认 CLI 使用的是哪个 Socket。

4.2 用户级 systemd 服务

在使用 systemd 的 Linux 主机上,Rootless Docker 通常作为用户服务运行:

systemctl --user enable --now docker
systemctl --user status docker

用户服务依赖用户会话的运行时目录。若希望用户退出登录后 daemon 仍然运行,可使用:

loginctl enable-linger "$USER"

这会让 systemd 为该用户保留用户级服务管理环境。它不是“让 Docker 获得 root 权限”,而是改变用户服务的生命周期。

检查 daemon 的实际状态:

docker info

重点查看:

  • Security Options 中是否包含 rootless
  • Storage Driver
  • Cgroup Version
  • Docker Root Dir
  • Docker RootlessKit 或相关 Rootless 信息;
  • 当前连接的 Docker Socket。

也可以检查环境变量:

echo "$DOCKER_HOST"
echo "$XDG_RUNTIME_DIR"
docker context ls
docker context show

常见情况是:

DOCKER_HOST=unix:///run/user/1000/docker.sock

但运行时目录路径由系统和用户 UID 决定,不应硬编码成某一个用户。

4.3 使用 Docker Context 区分 rootful 和 rootless

推荐为两个 daemon 建立不同 context:

docker context ls
docker context use rootless
docker info

如果 rootful daemon 的 context 名称是 default,切换回去:

docker context use default
docker info

也可以只对单条命令指定:

docker --context rootless ps
docker --context default ps

这一步很重要,因为以下命令都可能“成功”,但实际操作的是不同 daemon:

docker ps
docker compose up
docker volume ls
docker image ls

镜像、容器、网络、卷和 BuildKit 缓存属于 daemon 的数据域。rootful daemon 中存在的容器不会自动出现在 rootless daemon 中。


五、从 CLI 到容器的组件和数据流

一次简单的:

docker run --rm -p 8080:80 nginx

在 Rootless Docker 中大致经过如下路径:

flowchart LR
    A[Docker CLI] -->|Unix Socket| B[rootless dockerd]
    B --> C[containerd / OCI runtime]
    B --> D[RootlessKit]
    D --> E[User Namespace]
    D --> F[Network helper]
    C --> G[容器进程]
    G --> H[容器文件系统]
    B --> I[Rootless Docker data-root]
    F --> J[宿主机高端口 8080]

关键路径是:

  1. CLI 向 rootless daemon 的用户级 Socket 发送 API 请求。
  2. daemon 请求 containerd 和 OCI runtime 创建容器。
  3. RootlessKit 帮助建立受限的 namespace、挂载和端口转发环境。
  4. 网络辅助程序在用户权限范围内模拟或转发网络。
  5. 容器进程在 User Namespace 中运行。
  6. 镜像层、容器可写层、命名卷和 BuildKit 数据写入 rootless daemon 的 data-root。

因此,出现问题时必须先区分是:

  • CLI 连接错 daemon;
  • User Namespace 映射失败;
  • RootlessKit 启动失败;
  • 网络辅助程序不可用;
  • 存储驱动不兼容;
  • cgroup 无法委派;
  • 容器自身进程退出。

只看容器日志,往往无法定位 daemon 初始化阶段的错误。


六、网络:为何能通信,却不等于拥有 rootful 网络能力

6.1 Rootless 网络的基本实现

普通用户不能直接执行许多需要 CAP_NET_ADMIN 的宿主机网络操作,例如创建某些类型的虚拟设备、配置宿主机网桥或直接管理低层网络规则。

Rootless Docker 通常通过 RootlessKit 和用户态网络辅助程序解决这一问题。常见实现包括:

  • slirp4netns
  • RootlessKit 的端口驱动;
  • 某些环境中的其他用户态网络后端。

这些实现不是把普通用户变成网络管理员,而是在用户权限范围内模拟或转发网络。不同 Docker Engine、RootlessKit、内核和发行版的默认选择可能不同,应该以 docker info、安装状态和 daemon 日志为准。

6.2 发布端口的实际路径

执行:

docker run -d --name web -p 8080:80 nginx

逻辑上要求:

宿主机 8080 → RootlessKit/端口转发 → 容器 80

测试:

curl http://127.0.0.1:8080

预期可得到 Nginx 响应。

这里的 -p 8080:80 并不意味着 rootless daemon 在宿主机上创建了一个 rootful 网桥规则。通常是用户态转发程序监听宿主机端口,再将连接送入容器网络命名空间。因此它可能有不同的性能、连接语义和故障排查路径。

6.3 低端口限制

Linux 默认要求绑定小于 1024 的端口需要相应权限。Rootless Docker 中执行:

docker run -d --name web -p 80:80 nginx

可能失败,或者端口转发无法建立。典型错误可能包含:

cannot expose privileged port

安全且简单的做法是使用高端口:

docker run -d --name web -p 8080:80 nginx

也可以调整:

sysctl net.ipv4.ip_unprivileged_port_start

如果把系统参数改为 0,普通用户可以绑定所有端口,但这会扩大整台主机上所有普通用户进程的端口绑定能力。它是主机级安全策略,不应为了单个容器随意修改。

另一种方案是由宿主机上的 rootful 反向代理或负载均衡器监听 80/443,再转发到 rootless Docker 的高端口。这样低端口能力集中在明确的边界组件中。

6.4 --network host 的边界

Rootful Docker 中,--network host 通常表示容器共享宿主机网络命名空间。Rootless 模式下,这个语义受到用户权限和 RootlessKit 实现限制,不能假设容器获得了 rootful 模式下完全相同的宿主机网络能力。

需要特别检查:

  • 容器看到的接口和地址;
  • 是否能访问宿主机服务;
  • 端口是否已经被用户进程占用;
  • 是否支持应用依赖的低层网络操作;
  • 是否存在与 IPv6、DNS、MTU 和 UDP 相关的差异。

依赖 CAP_NET_ADMIN、原始套接字或自定义网卡配置的网络软件,在 rootless 网络下更容易失败。

6.5 网络故障诊断

先检查容器和端口:

docker ps
docker port web
docker inspect web
ss -lntp | grep 8080

再检查 daemon 日志:

journalctl --user -u docker.service -n 200 --no-pager

如果容器内部能访问外网,但宿主机访问发布端口失败,问题通常位于端口转发路径,而不是容器进程。相反,如果容器内部 DNS、默认路由或出站连接失败,应检查 RootlessKit 网络后端、DNS 配置和宿主机防火墙。


七、存储:数据目录、OverlayFS、Fuse-OverlayFS 和权限

7.1 Rootless data-root 与 rootful data-root 分离

Rootful Docker 常见数据目录是:

/var/lib/docker

Rootless Docker 通常使用用户目录下的数据目录,例如:

$HOME/.local/share/docker

运行时 Socket 通常在:

$XDG_RUNTIME_DIR/docker.sock

可以用以下命令查看实际数据目录:

docker info --format '{{.DockerRootDir}}'

因此,下面两个命令可能显示完全不同的镜像和卷:

docker --context default image ls
docker --context rootless image ls

直接复制或修改另一个 daemon 的 data-root 是危险的。Docker 的镜像元数据、容器状态、网络状态、插件和存储驱动布局由 daemon 管理,不能把目录当作普通文件夹任意合并。

7.2 OverlayFS 与 Fuse-OverlayFS

容器镜像通常由只读层和一个容器可写层叠加组成。OverlayFS 需要内核允许相关挂载和权限操作;在 Rootless 模式下,宿主机内核版本、挂载选项、文件系统类型和发行版策略会影响能否直接使用内核 OverlayFS。

当直接使用 OverlayFS 不可行时,Docker 可能使用 fuse-overlayfs。它通过 FUSE 在用户态实现叠加文件系统,兼容性通常更好,但会增加用户态路径和性能开销。

查看当前驱动:

docker info --format 'driver={{.Driver}} root={{.DockerRootDir}}'

不要仅凭目录中是否存在 overlay2 推断 rootless 一定使用内核 OverlayFS。应该以 docker info 和 daemon 日志为准。

7.3 命名卷与 bind mount 是两类不同问题

命名卷:

docker volume create app-data
docker run --rm -v app-data:/var/lib/app alpine sh

数据由 Docker 管理,通常位于 rootless data-root 下的卷目录中。容器内的 UID/GID 会经过 User Namespace 映射,因此在宿主机直接 ls -ln 看到的 UID 可能是高位 subordinate UID:

docker volume inspect app-data

不要直接编辑卷目录中的文件作为常规操作。Docker 可能同时维护挂载、元数据和存储驱动状态。

Bind mount:

mkdir -p "$HOME/project/data"
docker run --rm \
  --mount type=bind,src="$HOME/project/data",dst=/data \
  alpine sh -c 'id; touch /data/file; ls -ln /data/file'

这里的文件实际由宿主机用户权限控制。Rootless daemon 首先必须能够访问:

$HOME/project/data

及其所有父目录;容器内的 UID 映射并不能绕过宿主机对目录的 x(遍历)权限。

7.4 Bind mount 的权限算例

假设:

stat -c '%u:%g %a %n' "$HOME/project/data"

输出:

1000:1000 755 /home/alice/project/data

容器内以 UID 1000 运行:

docker run --rm \
  --user 1000:1000 \
  -v "$HOME/project/data":/data \
  alpine sh -c 'touch /data/a'

是否成功,取决于宿主机实际映射后的 UID,以及目录写权限。若目录是 755 且宿主机用户 alice 是所有者,alice 可以写;但容器内 UID 1000 并不必然映射为宿主机 UID 1000。在 Rootless User Namespace 中,它可能映射到 subordinate UID 区间,因此文件创建权限可能与直觉不同。

更稳定的做法是让应用使用与数据策略匹配的 UID,并在宿主机上明确准备目录权限。例如:

mkdir -p "$HOME/project/data"
chmod 700 "$HOME/project/data"
docker run --rm \
  -v "$HOME/project/data":/data \
  alpine sh -c 'id; touch /data/a'

该示例依赖容器内 root 映射为宿主机 alice,所以默认 rootless 容器通常可以写入 alice 自己拥有且可访问的目录。若显式使用非 root 用户,则需要重新计算其映射和宿主机目录权限。

7.5 SELinux、ACL 和网络文件系统

Rootless 不会绕过宿主机的:

  • SELinux;
  • AppArmor;
  • POSIX ACL;
  • NFS 或其他远程文件系统限制;
  • 普通 Unix 文件权限;
  • 文件系统是否允许 User Namespace/FUSE 行为。

例如,bind mount 路径在宿主机上看似权限正确,但 SELinux 标签不允许容器访问,仍可能出现 Permission deniedZz 等标签选项的可用性依赖 Docker、SELinux 策略和用户权限,不能把 rootful 环境中的挂载命令原样视为必然可用。


八、容器内 root、Capabilities、Seccomp 与 privileged

Rootless Docker 并不等于容器内永远没有 root。默认情况下,容器内仍可能显示:

uid=0(root)

但这个 root 同时受多层边界约束:

  1. User Namespace:UID 0 不映射为宿主机 UID 0
  2. Linux Capabilities:进程只有被授予的能力集合。
  3. Seccomp:系统调用可能被过滤。
  4. LSM:AppArmor、SELinux 等策略仍可能拒绝操作。
  5. Namespaces:PID、Mount、Network、IPC 等资源通常被隔离。
  6. 文件和设备访问:必须有实际挂载和宿主机权限支持。

例如:

docker run --rm alpine sh -c '
  id
  grep Cap /proc/self/status
  grep Seccomp /proc/self/status
'

输出中的 CapEff 是十六进制能力位图,Seccomp 常见为启用状态。不要把“容器内是 root”或“显示 CAP_SYS_ADMIN”直接解释为“拥有宿主机 root 权限”。

--privileged 的真实边界

在 rootless 模式中:

docker run --rm --privileged alpine id

即使 Docker 接受该参数,也不能把 rootless daemon 变成宿主机 root。--privileged 可以放宽容器内部的一些 capability、设备和安全限制,但它不能凭空获得:

  • 宿主机 root UID;
  • 普通用户没有的宿主机文件权限;
  • 任意宿主机设备;
  • 任意 cgroup 管理权;
  • 被内核拒绝的 namespace 或网络操作。

如果应用依赖真实设备,例如 GPU、USB、块设备或特殊字符设备,Rootless 模式需要同时满足宿主机设备权限、运行时集成和 User Namespace 兼容性。--privileged 不是设备支持的替代品。


九、资源限制:cgroup 不是自动存在的

docker run --memory--cpus--pids-limit 等参数最终通常需要通过 cgroup 控制内核资源。Rootless daemon 能否设置这些限制,取决于:

  • 宿主机使用 cgroup v1 还是 v2;
  • 用户是否被委派了对应的 cgroup 控制器;
  • systemd 用户服务是否拥有可用的 cgroup 层级;
  • 当前 Docker 和发行版是否支持该路径。

检查:

docker info --format 'cgroup={{.CgroupVersion}} driver={{.CgroupDriver}}'
stat -fc %T /sys/fs/cgroup

在 cgroup v2 中,资源控制器通常需要由上级 cgroup 委派给用户服务。例如用户服务可能能够管理:

/user.slice/user-1000.slice/user@1000.service

下的子 cgroup,但不能修改整个系统的 cgroup 配置。

测试一个内存限制:

docker run --rm --memory=128m alpine sh -c '
  cat /sys/fs/cgroup/memory.max 2>/dev/null || true
'

如果输出:

134217728

则表示当前容器层级看到了 128 MiB 限制。若输出 max、参数被警告忽略,或容器仍无法创建,必须查看 daemon 日志和 cgroup 委派状态,不能假设命令行参数已经生效。

Rootless Docker 在资源限制上的典型取舍是:

  • cgroup v2 配置正确时,CPU、内存和进程数控制可以工作;
  • cgroup v1 环境下能力和兼容性明显受限;
  • 不能让普通用户任意改变系统级 cgroup;
  • systemd 用户服务和 loginctl enable-linger 会影响 daemon 的生命周期与 cgroup 层级。

十、BuildKit 和 Compose 中的 Rootless 语义

10.1 BuildKit

Rootless Docker daemon 通常使用其用户范围内的 BuildKit 与镜像存储。构建过程中的 RUN 指令即使显示为 root,也只是构建容器 User Namespace 中的 root。

例如:

cat > Dockerfile <<'EOF'
FROM alpine
RUN id
RUN touch /created-during-build
EOF

docker build -t rootless-demo .
docker run --rm rootless-demo ls -ln /created-during-build

构建时的文件属于镜像层,最后由 rootless daemon 写入自己的 data-root。它不会因为 Dockerfile 中的 RUN 使用 root,就获得宿主机 root 文件权限。

需要注意的边界包括:

  • 构建缓存属于当前 daemon;
  • bind mount 类型的 BuildKit mount 必须满足宿主机路径权限;
  • 某些需要特权内核能力的构建步骤无法在 rootless builder 中运行;
  • 构建脚本写入宿主机路径的前提是该路径被显式挂载且用户可访问;
  • /var/run/docker.sock 传入构建环境会把 Docker API 权限交给构建步骤,应避免。

检查当前构建使用的 daemon:

docker context show
docker buildx ls

不要把 rootful daemon 中的 builder 名称当作 rootless daemon 中必然存在的对象。BuildKit builder 的生命周期和镜像 daemon 可能分离,需要分别检查。

10.2 Compose

Compose 不需要为 Rootless Docker 设计另一套 YAML。只要 CLI 连接到 rootless daemon,Compose 创建的服务、网络、命名卷和容器就属于该 daemon:

docker context use rootless
docker compose up -d
docker compose ps
docker compose down

示例:

services:
  web:
    image: nginx:alpine
    ports:
      - "8080:80"
    volumes:
      - web-data:/usr/share/nginx/html:ro

volumes:
  web-data:

其中:

  • ports 使用 RootlessKit 的端口转发路径;
  • web-data 是 rootless daemon 的命名卷;
  • :ro 只读限制仍由容器挂载配置实现,但不等价于宿主机文件系统不可写;
  • Compose 文件中写入 privileged: true 也不能突破 rootless daemon 的宿主机权限边界。

如果 Compose 使用 bind mount:

services:
  app:
    image: alpine
    volumes:
      - ./data:/data

则运行目录下的 ./data 必须能被 rootless daemon 对应的宿主机用户访问。项目目录在 NFS、受限 ACL 或 SELinux 策略下时,失败原因通常不在 Compose 语法。


十一、迁移:镜像、容器、卷和权限必须分别处理

从 rootful Docker 迁移到 Rootless Docker,不是把 /var/lib/docker 改名后继续启动。需要分别迁移四类状态:

  1. 镜像;
  2. 容器定义和配置;
  3. 命名卷数据;
  4. bind mount 的宿主机数据。

11.1 镜像迁移

可以使用 registry:

docker --context default tag myapp:latest registry.example.com/team/myapp:latest
docker --context default push registry.example.com/team/myapp:latest
docker --context rootless pull registry.example.com/team/myapp:latest

也可以使用离线归档:

docker --context default save myapp:latest -o myapp.tar
docker --context rootless load -i myapp.tar

docker save 保存的是镜像层和镜像元数据,不保存容器运行时状态,也不保存命名卷中的业务数据。

11.2 迁移容器配置

容器本身通常不应作为唯一的迁移对象。应保存:

  • 镜像名称和 digest;
  • 环境变量;
  • 端口;
  • 挂载;
  • 网络;
  • restart 策略;
  • healthcheck;
  • 用户和 capability 配置;
  • Compose 文件或其他声明式配置。

可以查看现有容器的配置:

docker --context default inspect app > app.inspect.json

inspect JSON 不是直接可移植的 Compose 文件。实际迁移时应把运行参数整理成 Compose、systemd 用户服务或明确的 docker run 脚本,再在 rootless context 中重新创建容器:

docker --context rootless run -d \
  --name app \
  --user 1000:1000 \
  --restart unless-stopped \
  myapp:latest

rootful 和 rootless daemon 中同名容器互不冲突,因为它们属于不同的 daemon。

11.3 命名卷迁移

假设 rootful daemon 中存在:

docker --context default volume create app-data

可以通过临时容器打包:

docker --context default run --rm \
  -v app-data:/src:ro \
  -v "$PWD":/backup \
  alpine sh -c 'cd /src && tar cpf /backup/app-data.tar .'

然后切换到 rootless context,创建并恢复:

docker --context rootless volume create app-data

docker --context rootless run --rm \
  -v app-data:/dst \
  -v "$PWD":/backup:ro \
  alpine sh -c 'cd /dst && tar xpf /backup/app-data.tar'

这个流程成立的原因是:

  1. 第一个临时容器读取 rootful daemon 管理的卷;
  2. tar 保存卷内的目录结构和数值 UID/GID;
  3. 第二个临时容器通过 rootless daemon 挂载新卷;
  4. 容器内的 root 在其 User Namespace 中解包文件;
  5. 文件最终以 rootless 映射允许的 UID/GID 写入新卷。

迁移后不要只看容器内的 ls -l,还要以应用身份验证:

docker --context rootless run --rm \
  --user 1000:1000 \
  -v app-data:/data \
  alpine sh -c 'find /data -maxdepth 1 -type f -writable -print'

如果应用过去依赖宿主机 UID 1000,迁移后却发现数据目录属于某个 subordinate UID,可能出现:

Permission denied

这不是 tar 失败,而是原来的 UID/GID 语义与新的 User Namespace 映射不一致。处理方法通常是:

  • 让应用继续使用镜像中一致的数值 UID;
  • 在临时容器内以应用用户重设可修改文件的所有权;
  • 重新设计目录的组权限或 ACL;
  • 对 bind mount 直接调整宿主机路径权限。

不能假设在 rootless 容器内执行:

chown 0:0 /data

就能把宿主机文件变成真正的 root:root。它最多只能在当前映射范围内改变所有权;目标 UID 如果没有合法映射,操作会失败。

11.4 bind mount 迁移

bind mount 的数据不属于 Docker 卷,迁移 Docker daemon 本身不会迁移它:

volumes:
  - /srv/app/data:/var/lib/app

迁移前必须确认 rootless 用户能访问 /srv/app/data

namei -l /srv/app/data
sudo -u alice test -r /srv/app/data
sudo -u alice test -w /srv/app/data

若目标目录属于 root 且权限为 700,rootless daemon 不可能凭 Docker 参数绕过它。应先在宿主机修改目录所有权、ACL 或路径设计。例如迁移到用户目录:

mkdir -p "$HOME/app/data"
rsync -a /srv/app/data/ "$HOME/app/data/"

然后使用:

docker --context rootless run --rm \
  -v "$HOME/app/data":/var/lib/app \
  myapp:latest

这一步改变的不只是路径,还改变了数据的宿主机安全边界,必须同步检查备份、权限和服务账号。


十二、停止、清理和恢复

Rootless daemon 的控制命令也属于用户级服务:

systemctl --user stop docker
systemctl --user start docker
systemctl --user restart docker

如果 daemon 无法启动,先看:

systemctl --user status docker
journalctl --user -u docker.service -b --no-pager

再检查:

docker info
docker context show
ls -l "$XDG_RUNTIME_DIR"/docker.sock

典型故障路径如下:

1. CLI 连接不上 Socket

表现:

Cannot connect to the Docker daemon

检查当前 context、DOCKER_HOST 和用户服务状态。rootful Socket 存在并不表示 rootless daemon 正常。

2. permission denied 访问 bind mount

按顺序检查:

namei -l /path/to/source
getfacl /path/to/source 2>/dev/null
ls -Zd /path/to/source 2>/dev/null

原因可能是父目录不可遍历、ACL、SELinux 或路径位于不适合 rootless 挂载的文件系统。

3. operation not permitted

该错误可能来自:

  • User Namespace 不允许的内核操作;
  • 缺少 capability;
  • seccomp;
  • LSM;
  • cgroup 控制器未委派;
  • 网络或设备操作需要宿主机特权;
  • 存储驱动不支持当前内核和文件系统。

不能看到错误字符串就直接加 --privileged。应先确认失败的系统调用或组件。

4. 端口发布失败

检查:

docker port web
ss -lntp
journalctl --user -u docker.service -n 200 --no-pager

重点排除低端口限制、端口已占用、RootlessKit 端口驱动未安装,以及 IPv4/IPv6 地址绑定差异。

5. cgroup 参数不生效

检查:

docker info
cat /proc/filesystems | grep cgroup
mount | grep cgroup
systemctl --user show docker.service

如果主机使用 cgroup v1,或用户服务没有得到控制器委派,--memory 等参数可能无法按预期工作。此时应修复主机 cgroup 配置,不能仅修改 Compose 文件。

6. rootful 和 rootless 数据“消失”

先执行:

docker context ls
docker context show
docker image ls
docker volume ls
docker ps -a

很多迁移误判实际上是 CLI 切换到了另一个 daemon。context 不同,看到的数据自然不同。


十三、Rootless Docker 的明确限制

Rootless Docker 的限制不是实现缺陷的同义词,而是普通用户权限和 Linux 内核边界的直接结果。

它通常不适合直接承担以下任务

  • 需要宿主机 root 访问的设备管理;
  • 依赖宿主机网桥、iptables/nftables 或 CAP_NET_ADMIN 的网络组件;
  • 必须监听低于 1024 端口且不允许设置代理;
  • 依赖完整 --privileged 语义的系统容器;
  • 需要修改宿主机 cgroup 层级的监控或调度程序;
  • 依赖特殊文件系统挂载、FUSE 嵌套或内核模块加载;
  • 需要直接访问宿主机任意路径的备份程序;
  • 在不支持 User Namespace 或 cgroup v2 委派的旧环境中运行。

它也不能替代其他安全机制

Rootless 不能阻止:

  • 应用把密钥写入日志;
  • 应用通过网络攻击其他服务;
  • 挂载了敏感目录后泄露数据;
  • 使用错误的文件权限暴露共享卷;
  • 容器内程序消耗正常用户可用的全部 CPU、内存或磁盘;
  • 内核漏洞导致隔离边界被突破。

生产环境仍需组合使用最小权限用户、只读根文件系统、最小 capability、Seccomp、AppArmor/SELinux、资源限制、网络分段和可靠备份。


十四、Rootless、非 root 容器和隔离策略如何组合

可以把三种策略区分为不同层次:

容器内 USER 1000
    ↓
减少应用进程在容器内的权限

Capabilities / Seccomp / AppArmor / SELinux
    ↓
限制系统调用、内核能力和安全策略

Rootless Docker
    ↓
降低 daemon 与容器进程对宿主机 root 权限的依赖

例如 Compose 服务可以写成:

services:
  app:
    image: example/app:1.2
    user: "1000:1000"
    read_only: true
    tmpfs:
      - /tmp
    cap_drop:
      - ALL
    security_opt:
      - no-new-privileges:true
    ports:
      - "8080:8080"

在 Rootless daemon 中:

  • user: "1000:1000" 限制应用使用的容器内 UID/GID;
  • read_only: true 使根文件系统只读,但命名卷和 tmpfs 仍可能可写;
  • cap_drop: ALL 删除容器 capability;
  • no-new-privileges 防止通过 setuid 等方式获得更多权限;
  • ports 仍受 rootless 网络和端口限制。

这些选项相互补充,而不是互相替代。Rootless 只解决 daemon 和容器到宿主机 root 的一部分权限路径;它不会自动让应用获得最小权限,也不会自动限制磁盘空间。


十五、判断是否适合迁移到 Rootless

可以按以下因果关系判断:

  1. daemon 是否必须直接管理宿主机 root 资源?
    如果必须,Rootless 可能不适合,或者需要把该能力拆到独立的 rootful 边界服务。

  2. 容器是否依赖低层网络、设备或特殊挂载?
    如果依赖,需要针对每个组件验证,而不能只验证普通 HTTP 容器。

  3. 数据是命名卷还是 bind mount?
    命名卷可以通过容器内归档迁移;bind mount 必须先解决宿主机路径权限。

  4. 是否需要内存、CPU 和进程数限制?
    先确认 cgroup v2 和用户级委派,再决定是否迁移。

  5. 应用使用的 UID/GID 是否明确?
    不明确的应用在卷迁移后更容易出现所有权问题。

  6. 运维人员能否区分多个 Docker context?
    如果不能,最常见的事故不是容器启动失败,而是对错误 daemon 执行了删除、清理或迁移操作。

一个小型验证流程可以是:

docker context use rootless

docker run --rm alpine id

docker run -d --name test-web -p 8080:80 nginx:alpine
curl -f http://127.0.0.1:8080

docker volume create test-data
docker run --rm -v test-data:/data alpine sh -c \
  'echo ok >/data/status && cat /data/status'

docker run --rm --memory=128m alpine \
  sh -c 'cat /sys/fs/cgroup/memory.max 2>/dev/null || true'

docker rm -f test-web
docker volume rm test-data

这个流程分别验证:

  • User Namespace 下的身份;
  • 网络端口转发;
  • rootless 命名卷写入;
  • cgroup 限制是否可见;
  • 容器和卷的清理路径。

若其中某一步失败,应根据失败组件处理,而不是因为普通容器能运行,就认为所有 rootful 工作负载都能迁移。

Rootless Docker 的核心不是“把 Docker 命令换成普通用户执行”,而是把 daemon、容器身份、网络转发、存储写入和资源控制都放进普通用户可被内核约束的边界中。理解 UID 映射、用户级 Socket、rootless 网络和卷权限之间的关系,才能判断一个工作负载是在获得更小的宿主机权限,还是仅仅换了一种方式遇到权限、端口、设备和 cgroup 限制。


系列导航与关联阅读

官方资料

本文依据 Docker、OCI 与 CNCF 官方文档重新梳理;正文与生产检查清单由 WR BLOG 编写。