Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5.1 容器原理:隔离、限制与镜像

本章回答

容器里跑的还是普通进程,为什么 ps 只看得见自己、/ 也变了?namespace、cgroups、rootfs 各自解决哪一段,缺一段会怎样?不装 Docker,能不能用 unshare 与 pivot_root 手工拼出一个「容器」?

读完你会

说清容器与虚拟机的边界;用 30 行脚本起一个隔离的 PID/mount/UTS/net 视图;看懂 docker inspect 里的镜像层与容器层;判断 --privileged 到底打开了哪些门。

第 2 篇你写过 fork、execve、openat;第 3 篇看过 task_struct、页表、VFS;第 4 篇跟过一个包从网卡到 socket。这四篇合起来,已经包含「容器」所需的全部内核零件。容器没有新增任何系统调用:它只是把这些零件按一种特定方式组合起来。

组合的目标是让一组进程以为自己在独占一台机器。要制造这种错觉,需要同时改写三样东西:进程能看见什么(namespace)、进程能用多少(cgroups)、进程脚下的文件系统长什么样(rootfs + overlayfs)。三样缺一不可,而且都不改变「进程就是一个普通 task_struct」这件事。

这一章先把三件套的整体关系立起来,并亲手搭一个最小容器;namespace 的细节留给 5.2,cgroups 留给 5.3,overlayfs 留给 5.4。读完这一章,你应该能在看到 docker run 时,脑内自动展开成「clone 一串 CLONE_NEW* → 写 cgroup 文件 → pivot_root → execve」。

5.1.1 容器不是虚拟机:共享内核的那条线

先看一个最直接的证据。在一台运行着容器的 Ubuntu 24.04 上:

# 宿主机
uname -r
# 6.8.0-xx-generic
docker run --rm busybox uname -r
# 6.8.0-xx-generic

容器里的 uname 读到的是同一个内核版本号。/proc/version、/proc/cpuinfo、/sys/devices 也都是同一份内核数据的不同投影。没有第二份内核,就没有虚拟机的「客户机」概念。

虚拟机(virtual machine, VM)走的是另一条路:hypervisor 用硬件虚拟化(VT-x/AMD-V)造出一台假机器,里面装一个完整的 guest 内核,再在 guest 里跑进程。容器不模拟硬件,它让宿主机内核在同一个内核里同时维护多套「视图」。这条区别决定了后面所有的性能与安全结论。

维度虚拟机容器
内核每个 VM 一份 guest 内核所有容器共享宿主内核
启动固件 → 引导 → 内核初始化,秒级clone + execve,毫秒级
隔离边界硬件虚拟化 + 虚拟设备内核提供的 namespace/cgroup/LSM
密度一台宿主几个到几十个几十到几百个(受 pid/fd/内存限制)
快照/迁移整机内存与磁盘镜像层 + 可写层(无内存态)
故障域guest 内核 panic 不影响宿主内核 panic 影响所有容器
安全假设假设 guest 内核可被攻破假设宿主内核不可被攻破

这张表最后两行是重点:容器的隔离强度上限由宿主内核决定。内核里一个可被容器内进程触发的提权漏洞,会影响这台机器上的所有容器。这也是为什么「容器里跑不可信代码」需要额外加固(5.1.7 展开),而不能拿虚拟机的标准来类比。

容器的三层隔离:namespace、cgroups、镜像 namespace 决定「看得见什么」,cgroups 决定「用得了多少」,镜像决定「根文件系统长什么样」。 容器 = namespace(看见什么)+ cgroups(能用多少)+ 镜像(根文件系统)HOSTCONTAINER · PID 1PID namespace容器内 PID 从 1 开始mount namespace独立挂载树 / rootfsnetwork namespace独立网卡 / 路由 / 端口UTS / IPC / user主机名 / IPC / uid 映射cgroups v2cpu.max / memory.max / pids.max / io.max进程视图ps 只看到自己 + 少量内核线程文件系统视图overlayfs:lowerdir + upperdir + merged资源视图CPU 配额 / 内存上限 / PID 上限 / IO 权重隔离挂载限额容器不是轻量虚拟机:没有独立内核,隔离靠内核提供的命名空间与资源控制器;共享内核也意味着共享漏洞面
读图:左列是内核机制,右列是容器内看到的效果。三者缺一不可:只有 namespace 没有 cgroups,一个容器可以吃光宿主;只有 cgroups 没有 namespace,进程仍能看见彼此。最下方的「共享内核」是容器与虚拟机的根本区别。

读图时注意左右两列的对应关系:左列是内核机制,右列是容器内观察到的效果。PID namespace 让容器里的 ps 只看到自己这一棵进程树;mount namespace 让 / 指向 overlayfs 的 merged 目录;cgroups v2 决定这个容器最多能用多少 CPU 与内存。图的最后一行写明了根本区别:没有独立内核,隔离靠内核提供,共享内核也意味着共享漏洞面。

定义 5.1.1 · 容器(container)

容器是一组受内核机制约束的进程:它们的命名空间视图与宿主不同,资源被 cgroup 限额,根文件系统来自一个只读镜像加一个可写层。容器不是一台机器,没有独立内核、没有固件、没有引导过程;它也不是一个特殊的进程类型,task_struct 与普通进程完全相同。

为什么「共享内核」不等于「不安全」

共享内核只说明隔离实现在内核里,不说明隔离很弱。进程之间的地址空间隔离、页表隔离、权限检查,本来就是内核的职责;namespace 只是把「全局资源」拆成多份。真正的风险来自内核攻击面变大:更多代码路径可以被非特权进程触发。所以容器安全的工作重点是缩小攻击面(capabilities、seccomp、只读挂载),而不是「换一个内核」。

5.1.2 三件套:各管一段,缺一不可

把「独占一台机器」拆成三个具体问题,对应三个机制:

问题机制内核对象它不负责
能看见哪些进程、网卡、挂载点、主机名?namespacensproxy + 各类 *_namespace限制用量、提供文件
最多能用多少 CPU、内存、IO、PID?cgroups v2cgroup 层级 + 控制器隐藏资源、提供文件
/ 下面有哪些文件?写到哪里?rootfs + overlayfsvfsmount + super_block隔离进程、限制资源

再补一件常被忽略的「第四件套」:权限收窄。capabilities、seccomp、no_new_privs、LSM(AppArmor/SELinux)决定容器里的 root 能做什么。没有它,前三件套只是「看不见」,不是「动不了」。

定义 5.1.2 · 容器运行时(container runtime)

容器运行时是负责把「镜像 + 配置」变成「一组受约束的进程」的程序。它要完成:解包 rootfs、按配置创建 namespace、写 cgroup 限额、切换根、设置 capabilities/seccomp、execve 用户进程,并在生命周期内响应 start/kill/delete。OCI runtime(如 runc)是最底层的一类,见 5.5。

三件套的组合方式可以用一句话概括:namespace 决定「看得到」,cgroup 决定「用得到」,rootfs 决定「摸得到」。三者独立:只加 namespace,容器能看见自己的 / 但可以吃光宿主内存;只加 cgroup,资源被限住但进程仍能看见宿主所有进程;只换 rootfs,文件系统隔离了但 ps 还是全量。

它们也没有被设计成一个「容器开关」。namespace 早于容器十几年,cgroup 最初是为了给「一组进程」做资源核算,overlayfs 是为了 Live CD 与联合挂载。容器是把三套通用机制按一份配置组合起来的结果——这也解释了为什么 OCI 标准描述的是「配置」而不是「实现」:只要组合结果一致,用 runc、crun 还是 Kata(后者其实加了虚拟机)都能满足同一份 config.json。

关于「容器三件套」的四个误解
  1. 「namespace 就是容器」——namespace 只提供视图隔离。没有 cgroup,一个容器里的 fork 炸弹可以拖垮宿主;没有 rootfs,容器仍能读宿主 /etc/shadow(若权限允许)。
  2. 「容器是一个轻量虚拟机」——没有 guest 内核,dmesg、/proc/kcore、/sys 都是宿主的(除非额外屏蔽)。所以容器里看到的 CPU 数、内存总量是宿主的值,不是限额值。
  3. 「cgroups 能隔离 CPU」——cgroup 只做分配与限流,不提供时间片隔离的强保证;它也不会让容器「看不见」其他 CPU。
  4. 「镜像就是一个 rootfs 压缩包」——镜像还包含配置(环境变量、入口命令、工作目录、健康检查)与层元数据;同一个 rootfs 配不同 config 是两个镜像。

5.1.3 从 chroot 到 pivot_root:换掉脚下的根

容器文件系统隔离的起点是 chroot(2):把进程的根目录改成某个目录。它诞生于 1979 年,比容器这个词早得多。但 chroot 有三个硬伤:

  • 它只改 fs_struct 里的 root 与 pwd,不改 mount namespace;进程仍能看到宿主的所有挂载点(通过已打开的 fd、或 .. 之外的绝对路径)。
  • 拥有 CAP_SYS_CHROOT 的进程可以再次 chroot 跳出;如果进程持有指向旧根的 fd,也能 fchdir 回去。
  • 它不隐藏 /proc:/proc/1/root 仍指向宿主根,容器里的进程可以顺着读。

pivot_root(2) 解决的是「换根」这件事在挂载树层面的正确性:它把当前根挂载整体移到 put_old,把 new_root 提升为新的根挂载,然后你可以 umount 掉旧根。整个过程发生在调用者的 mount namespace 内,配合 CLONE_NEWNS 就不会影响宿主。

pivot_root 之前(容器进程的 mount namespace)
​
  /            ← 宿主根挂载(shared,会传播)
  ├── proc
  ├── sys
  ├── home
  └── mnt/rootfs   ← 准备做新根(必须是挂载点)
​
pivot_root(".", ".oldroot") 之后
​
  /            ← 新根:mnt/rootfs 这一棵
  ├── proc     ← 在 rootfs 内重新 mount 的 proc
  ├── bin
  └── .oldroot ← 旧根被叠到这里,可 umount -l 摘掉

读这张布局图要抓住两点:新根必须先成为一个挂载点(否则 pivot_root 返回 EINVAL),旧根必须被移到新根之下(put_old 要在 new_root 里面)。所以标准动作是:mount --bind rootfs rootfs 造挂载点 → pivot_root(rootfs, rootfs/.oldroot) → umount2("/.oldroot", MNT_DETACH)。

在写代码前,还有一个容易翻车的细节:挂载传播。现代 systemd 发行版把 / 设为 shared,你在容器 mount namespace 里执行的 mount 会沿着 shared 关系传播回宿主。所以进入新 mount namespace 后的第一件事通常是:

# 需要 root;在新建的 mount namespace 内执行
mount --make-rprivate /

--make-rprivate 递归地把整棵挂载树改成 private,切断与宿主的传播。少了这一步,容器里的 mount -t proc 可能把宿主的 /proc 覆盖掉——这是「手工搭容器」最常见的事故。

为什么不用 chroot 而一定要 pivot_root

chroot 的语义是「改一个路径字段」,它不参与挂载树的记账,因此内核无法保证「旧根已经不可达」。pivot_root 的语义是「交换两棵挂载子树」,内核可以保证旧根被移走、新根成为真正的根挂载,并且 put_old 一定在新根之下。对容器运行时来说,后者才是可验证的边界。

5.1.4 手工搭一个最小容器(root,Ubuntu 24.04)

下面不用 Docker,只用 util-linux 的 unshare、mount 与 busybox,做一个具备 PID/mount/UTS/net 隔离、独立 /proc 与独立根文件系统的「容器」。它没有 cgroup 限额、没有 capabilities 收窄、没有 seccomp——那三样在 5.3 与 5.5 补。

第一步:准备一个静态 rootfs

# 需要 root;安装静态 busybox 并搭建最小根目录
sudo apt-get update
sudo apt-get install -y busybox-static

sudo mkdir -p /tmp/minic/rootfs/{bin,proc,sys,dev,tmp}
sudo cp /bin/busybox /tmp/minic/rootfs/bin/
for applet in sh ls cat echo mount ps id hostname sleep; do
    sudo ln -sf busybox "/tmp/minic/rootfs/bin/$applet"
done

busybox-static 是静态链接的单二进制,不依赖宿主 glibc,因此可以直接放进空 rootfs。若你更想用真实发行版的文件树,可以 docker export $(docker create busybox) -o rootfs.tar 再解包,效果相同。

第二步:写进入脚本

#!/bin/bash
# minicont.sh — 用 unshare + pivot_root 手工起一个容器;需要 root
set -euo pipefail
ROOTFS=${1:?usage: minicont.sh ROOTFS}
export ROOTFS

unshare --mount --uts --ipc --pid --net --fork --kill-child \
  /bin/bash -euo pipefail -c '
    rootfs=$ROOTFS

    # 1. 切断挂载传播,避免污染宿主
    mount --make-rprivate /

    # 2. 新根必须先成为挂载点
    mount --bind "$rootfs" "$rootfs"

    # 3. 在 rootfs 内准备伪文件系统
    mount -t proc  proc  "$rootfs/proc"
    mount -t sysfs sysfs "$rootfs/sys"
    mount -t tmpfs tmpfs "$rootfs/tmp"

    # 4. 换根:旧根叠到 .oldroot,再摘掉
    cd "$rootfs"
    mkdir -p .oldroot
    pivot_root . .oldroot
    umount -l /.oldroot
    rmdir /.oldroot

    # 5. 设置 UTS 主机名,exec 用户进程(PID 1)
    hostname minic
    exec /bin/sh
'

逐段解释:

步骤作用漏掉的后果
unshare --mount --uts --ipc --pid --net为子进程创建五个新 namespace容器仍看见宿主进程/网卡/挂载
--fork让子进程成为新 PID namespace 的 1 号unshare -p 只影响「以后创建的子进程」,当前 shell 不在新 ns 里
--kill-child外层退出时给容器内 PID 1 发 SIGKILL手工退出后可能留下孤儿进程
--make-rprivate /切断挂载传播容器里的 mount 可能改到宿主
mount --bind rootfs rootfs让 rootfs 成为挂载点pivot_root 返回 EINVAL
mount -t proc给新 PID namespace 一个匹配的 /procps 看到的还是宿主进程
pivot_root + umount -l换根并摘掉旧根旧根仍可达,隔离不完整

第三步:运行与观察

# 需要 root
sudo chmod +x /tmp/minic/minicont.sh
sudo /tmp/minic/minicont.sh /tmp/minic/rootfs

进入后可以验证四件事:

/ # id
uid=0(root) gid=0(root) groups=0(root)
/ # hostname
minic
/ # ps
PID   USER     TIME  COMMAND
    1 root      0:00 /bin/sh
    5 root      0:00 ps
/ # ip -brief addr
lo               UNKNOWN        127.0.0.1/8
/ # cat /proc/1/status | head -3
Name:   sh
Umask:  0022
State:  S (sleeping)

四个观察分别对应四个 namespace:ps 只有两个进程(PID namespace);hostname 是 minic(UTS namespace);ip addr 只有 lo(network namespace);/ 下只有 busybox 的目录(mount namespace + pivot_root)。此时你在宿主上另开一个终端,ps -ef | grep minicont 能看到这个 sh,它有一个宿主 PID——同一个进程,两套 PID 视图。

第四步:清理

# 退出容器 shell 后
sudo umount -R /tmp/minic/rootfs 2>/dev/null || true
sudo rm -rf /tmp/minic

umount -R 是保险:如果脚本中途失败,rootfs 里可能残留 proc/sys/tmpfs 挂载。清理前先 mount | grep /tmp/minic 确认。

手工容器的五个坑
  1. unshare -p 之后 ps 还是全量——-p 只让之后 fork 的子进程进入新 PID namespace;不加 --fork,当前 shell 仍在旧 ns。
  2. pivot_root: Invalid argument——新根不是挂载点。先 mount --bind "$rootfs" "$rootfs";另外 put_old 必须在 new_root 之下。
  3. 退出后宿主 /proc 被覆盖——没有先 mount --make-rprivate /,挂载沿 shared 传播回宿主。重启宿主可恢复,但生产环境上这是事故。
  4. exec /bin/sh: not found——rootfs 里没有解释器,或动态链接的 busybox 缺少 ld-linux。用 busybox-static,或把 ldd 列出的库一并复制。
  5. unshare: Operation not permitted——创建大多数 namespace 需要 CAP_SYS_ADMIN;非 root 只能先创建 user namespace(见 5.2),而且部分发行版禁用了非特权 user namespace。

5.1.5 限制:把「能用多少」交给 cgroups

最小容器已经能隔离视图,但没有限额。宿主上随便一个 yes 就能把 CPU 吃满。给进程加限额的标准做法是把它放进一个 cgroup v2 的叶子节点,然后写控制器文件。

不写 cgroup 文件也能先看到映射关系:systemd-run 会把 MemoryMax、CPUQuota 翻译成 cgroup 属性。

# 需要 root;起一个受限的 scope,观察它生成的 cgroup 文件
sudo systemd-run --scope --unit=demo-limits \
    -p MemoryMax=64M -p CPUQuota=25% -p TasksMax=32 \
    sleep 300

# 另一个终端:看 systemd 属性与内核文件
systemctl show demo-limits -p MemoryMax -p CPUQuotaPerSecUSec -p TasksMax
cat /sys/fs/cgroup/system.slice/demo-limits.scope/memory.max
cat /sys/fs/cgroup/system.slice/demo-limits.scope/cpu.max
cat /sys/fs/cgroup/system.slice/demo-limits.scope/pids.max

# 清理
sudo systemctl stop demo-limits

预期输出里,memory.max 是 67108864(64 MiB 的字节数),cpu.max 是 25000 100000(每 100 ms 周期最多用 25 ms CPU),pids.max 是 32。这三个文件就是 5.3 的主角;Docker 的 --memory、--cpus、--pids-limit 最终都落到同样的位置,只是路径变成 docker-<id>.scope 或 docker/<id>。

为什么先讲 systemd 而不是直接 echo

直接 mkdir /sys/fs/cgroup/demo && echo $$ > demo/cgroup.procs 也能做,但会把当前 shell 关进 cgroup,清理时容易把自己锁在外面。systemd-run 帮你做了「建目录、写限额、放进程、退出时回收」四件事,是观察映射关系最安全的入口。手工写文件的完整流程在 5.3.3。

5.1.6 镜像:配置 + 层,而不是一个 rootfs 压缩包

容器启动时需要两样东西:一个解开的 rootfs,一份运行时配置(命令、环境变量、工作目录、用户)。镜像把这两样打包在一起,并额外记录「这些文件是怎么一层层叠出来的」。

以 Docker 镜像为例,它由三部分组成:

组成内容是否共享
manifest层列表、config 文件名、标签每个镜像一份
configEnv、Cmd、Entrypoint、WorkingDir、User、Labels、历史每个镜像一份
layers每层一个 tar,记录相对上一层的文件增删改多层可被多个镜像共享

docker image inspect 能看到 config,docker save 能把整个镜像导出成 tar。后者是理解「层」的最快方式:

# 不需要 root(当前用户在 docker 组)
docker pull busybox:latest
docker image inspect busybox --format '{{json .RootFS.Layers}}' | python3 -m json.tool
docker save busybox -o /tmp/busybox.tar
tar -tf /tmp/busybox.tar | head -20

RootFS.Layers 是一个 SHA256 列表,每个元素对应一个层 tar。docker save 的输出布局随 Docker 版本与镜像存储驱动而变:经典布局是 manifest.json + <config>.json + <layer-id>/layer.tar;启用 containerd 镜像存储后是 OCI 布局(oci-layout、index.json、blobs/sha256/...)。两种布局表达的是同一件事,只是打包格式不同。

镜像分层带来两个直接后果:

  1. 复用:ubuntu:24.04 与 nginx:latest 若共享基础层,磁盘上只存一份;docker pull 只下载缺少的层。
  2. 写时复制:容器启动时不会复制整个 rootfs,而是把镜像层作为只读 lower,再加一个空的可写 upper。第一次修改某个文件才触发 copy-up(5.4 详述)。
例 5.1.1 · 为什么 `docker images` 的大小不等于磁盘占用

docker image ls 的 SIZE 是「该镜像所有层未压缩大小之和」,没有扣除与其他镜像共享的层。docker system df -v 才按层去重统计。三个基于 ubuntu:24.04 的镜像,SIZE 各显示 78 MB,但磁盘上基础层只有一份。判断真实占用要看 docker system df 的 RECLAIMABLE 与 /var/lib/docker 的块占用。

容器层到底落在哪个目录

把「镜像只读、容器可写」这句话落到磁盘上,只需要三条命令:看驱动、写文件、看差异。

# 不需要 root(用户在 docker 组)
docker run -d --name fsdemo busybox sleep 1000
docker inspect fsdemo --format '{{.GraphDriver.Name}}'
# overlay2
docker inspect fsdemo --format '{{json .GraphDriver.Data}}' | python3 -m json.tool

# 在容器里写一个文件,再到宿主上看它落在哪
docker exec fsdemo sh -c 'echo hello > /newfile'
docker diff fsdemo
# A /newfile

# 需要 root:查看 overlay2 的可写层
sudo ls -l /var/lib/docker/overlay2/*/diff/newfile

GraphDriver.Data 会给出四个路径:LowerDir(冒号分隔的只读层)、UpperDir(可写层)、WorkDir、MergedDir(挂载点)。四者正好对应 5.4 的 lowerdir/upperdir/workdir/merged。docker diff 的 A 表示 added、C 表示 changed、D 表示 deleted;删除在 overlayfs 里并不是真的删 lower,而是写一个 whiteout,5.4.5 会验证。

注意:Docker 25 以后若启用了 containerd 镜像存储(docker info 里 Storage Driver: overlayfs 且 driver-type: io.containerd.snapshotter.v1),GraphDriver 可能为空,对应的目录在 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/ 下。机制相同,只是记账的人从 dockerd 换成了 containerd。

清理:

docker rm -f fsdemo

5.1.7 安全边界:容器里的 root 是谁

默认配置下(未启用 user namespace),容器里的 uid=0 就是宿主上的 uid=0。内核只做了一层能力检查:容器进程的 capabilities 被裁剪过,因此「是 root 但不能 mount、不能 mknod、不能改宿主时间」。一旦你给它 --privileged,这层裁剪被整体取消。

定理 5.1.1 · `--privileged` 等价于什么

在 Docker 中,--privileged 会同时做以下几件事(Linux 6.8 + Docker 26/27):

  1. 把 所有 capabilities 放进进程的 bounding/effective/permitted 集合;
  2. 放开 devices cgroup,允许访问宿主所有设备节点;
  3. 关闭默认 seccomp profile 与 AppArmor/SELinux 限制;
  4. 清空 maskedPaths/readonlyPaths,使 /proc/sys、/sys 可写。

因此 --privileged 容器内的 root 基本等价于宿主 root:可以挂载宿主块设备、写 /proc/sys/kernel/core_pattern、加载内核模块(若模块未签名限制不拦),进而逃逸。它不是「多给一点权限」,而是「取消权限边界」。

比 --privileged 小的做法有很多,关键是按需求给最小集合:

需求不要用更小的做法
绑定 80 端口--privileged默认就有 NET_BIND_SERVICE;或改监听 8080 再做端口映射
改容器内主机名--privileged默认 UTS namespace 已隔离,hostname 直接可用
挂载 NFS/overlay--privileged--cap-add=SYS_ADMIN(仍然危险,优先在宿主挂好再 bind)
调试网络--privileged + --net=host--cap-add=NET_ADMIN --cap-add=NET_RAW,或 nsenter 进 netns
跑需要 setuid 的程序--privileged默认 no_new_privs=false 已允许;若开了 no-new-privileges 则需重新评估
读宿主设备--privileged--device=/dev/xxx 只放行单个设备

验证 capabilities 的最短命令:

# 默认容器:CapEff 是 14 个默认能力的位图
docker run --rm busybox grep '^Cap' /proc/self/status

# 只留一个能力
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
    busybox grep '^CapEff' /proc/self/status

# 在宿主上解码位图(需要 libcap2-bin)
capsh --decode=00000000a80425fb

CapEff 是十六进制位图。默认 Docker 容器的值是 00000000a80425fb,对应 CHOWN DAC_OVERRIDE FOWNER FSETID KILL SETGID SETUID SETPCAP NET_BIND_SERVICE NET_RAW SYS_CHROOT MKNOD AUDIT_WRITE SETFCAP。把它清空后,容器里的 root 连 chown 都会失败——这正是「root 但无害」的样子。

user namespace 是另一条路:它把容器内的 uid 映射到宿主上的一个非特权 uid 区间。开启后,容器里的 uid=0 在宿主上显示为 100000,即使容器内进程拿到全部 capabilities,也只对那个 user namespace 有效。

# 不需要 root;把当前用户映射成新 user namespace 里的 root
unshare --user --map-root-user id
# uid=0(root) gid=0(root) groups=0(root)

# 看映射关系(在外层执行)
cat /proc/self/uid_map
#          0       1000          1

第一列是 namespace 内的 uid,第二列是宿主 uid,第三列是范围长度。0 1000 1 表示「容器里的 0 等于宿主的 1000,只映射一个」。多行映射需要 newuidmap/newgidmap 与 /etc/subuid、/etc/subgid 的配合,见 5.2.6。

除了 capabilities,还有两个开关会改变容器内 root 的「可提权性」,它们都能在 /proc/self/status 里直接读到:

# 默认:seccomp 处于 filter 模式(Seccomp: 2),no_new_privs 未开
docker run --rm busybox grep -E '^(Seccomp|NoNewPrivs)' /proc/self/status

# 打开 no_new_privs,并关掉 seccomp
docker run --rm --security-opt no-new-privileges \
    --security-opt seccomp=unconfined \
    busybox grep -E '^(Seccomp|NoNewPrivs)' /proc/self/status
# Seccomp:        0
# NoNewPrivs:     1

NoNewPrivs: 1 表示 execve 时不再获得任何新特权:setuid 位、文件 capabilities 全部失效。它防的是「容器里有个 setuid 的 sudo,被普通用户拿来提权到容器 root」这类路径。Seccomp: 2 表示已装载 BPF 过滤器;0 是完全不过滤。两者与 capabilities 是三套独立机制,任何一套缺失都不等于逃逸,但三套同时缺失就把内核攻击面完整暴露给容器。

容器安全的五个常见错误认知
  1. 「容器里的 root 不是真 root」——只有启用 user namespace 时才成立。默认 Docker 配置下,容器 root 与宿主 root 是同一个 uid,差别只在 capabilities。
  2. 「--privileged 只是多几个 cap」——它还关闭 seccomp/AppArmor、放开设备、清空只读路径。等价于把宿主 root 交给容器。
  3. 「seccomp 能防内核漏洞」——seccomp 只能按系统调用号与参数过滤,不能阻止被放行的调用内部的漏洞。它降低攻击面,不提供内存安全。
  4. 「--cap-drop=ALL 就安全了」——还有 no_new_privs、可写 /proc/sys、挂载宿主目录、docker.sock 暴露等路径。能力只是其中一层。
  5. 「镜像扫过漏洞就没问题」——镜像扫描看的是用户态包版本;内核漏洞、配置错误(如 privileged、hostPath 挂载)不在扫描范围内。
评估一个容器安全姿态的检查顺序
  1. 看 namespace:docker inspect -f '{{.HostConfig.NetworkMode}} {{.HostConfig.PidMode}} {{.HostConfig.IpcMode}}',有 host 就少了对应隔离。
  2. 看挂载:Mounts 里有没有 /var/run/docker.sock、/、/proc、/sys 这类宿主路径。
  3. 看能力:CapAdd 是否为空;Privileged 是否为 true。
  4. 看 seccomp/AppArmor:SecurityOpt 里有没有 seccomp=unconfined、apparmor=unconfined、no-new-privileges。
  5. 看 user:Config.User 是否为空(空 = root);宿主上是否启用 userns-remap。
  6. 看只读:HostConfig.ReadonlyRootfs;临时目录是否用 tmpfs 挂载。

5.1.8 一次 docker run 在内核里做了什么

把前面几节串起来。docker run 在用户看来是一条命令,在系统里是「守护进程 → 容器运行时 → 一组系统调用」的链式调用:

docker CLI
  │  POST /containers/create(REST,unix socket)
  ▼
dockerd ──gRPC──▶ containerd ──▶ containerd-shim-runc-v2
                                        │
                                        │ fork/exec
                                        ▼
                                      runc create
                                        ├─ clone(CLONE_NEWNS|NEWPID|NEWNET|NEWUTS|NEWIPC|NEWCGROUP)
                                        ├─ mount overlay:lower=镜像层, upper=容器可写层, work
                                        ├─ pivot_root + mount /proc /sys /dev
                                        ├─ 写 cgroup:cpu.max / memory.max / pids.max
                                        ├─ capset + seccomp + prctl(PR_SET_NO_NEW_PRIVS)
                                        └─ execve(entrypoint)  ← 容器 PID 1
                                        │
                                        ▼
                                      runc start(通过 exec fifo 放行)
                                        │
                                        ▼
                                      shim 常驻:持有 stdio、回收僵尸、转发信号

这张图解释了一个常被问的问题:为什么重启 dockerd 不会杀掉容器。容器的父进程不是 dockerd,而是 containerd-shim-runc-v2;shim 被 init 收养,dockerd/containerd 重启只是换了控制面,数据面(容器进程与 shim)不受影响。图里每一步对应的章节:

动作内核机制本书位置
clone(CLONE_NEW*)copy_namespaces、nsproxy5.2.2
写 cpu.max/memory.maxcgroup v2 控制器5.3
mount overlayoverlayfs 的 lower/upper/work5.4
pivot_root + execvemount namespace、fs_struct5.1.3、5.5
capset/seccomp/no_new_privs凭证与安全钩子5.1.7、5.5.5

5.1.9 clone flags、/proc/PID/ns 与三件套接口表

这一节把「容器 = 一组 clone 标志 + 一次换根 + 一组 cgroup 文件」落到可查的接口上。不展开每种 namespace 的语义(5.2 负责),只给三张排障时最常用的表:标志位对应哪个内核对象、哪些组合非法、/proc/PID/ns 怎么读。

标志位与共享对象

clone 的 flags 分两类:CLONE_NEW* 创建新 namespace,其余位决定子进程与父进程共享还是复制某个内核对象。后者同样影响容器行为——共享 fs_struct 的进程无法各自 pivot_root,共享 files_struct 的进程无法各自关闭容器的 fd。

标志值共享/复制的对象容器里的后果
CLONE_VM0x00000100mm_struct(地址空间)线程共享内存;容器 init 不能与外部共享
CLONE_FS0x00000200fs_struct(root、pwd、umask)与 CLONE_NEWNS 冲突,无法独立换根
CLONE_FILES0x00000400files_struct(fd 表)共享 fd 表会让容器继承外部打开的 fd
CLONE_SIGHAND0x00000800sighand_struct线程组语义;与 CLONE_NEWPID 冲突
CLONE_THREAD0x00010000signal_struct、tgid同一线程组;容器 init 不能用
CLONE_SYSVSEM0x00040000sem_undo_listSystem V 信号量的 undo 记录
CLONE_PARENT0x00008000real_parent新进程与调用者成为兄弟;init 进程禁用
CLONE_PIDFD0x00001000返回 pidfd运行时用它监控容器 init,避免 PID 复用
CLONE_VFORK0x00004000completion父进程挂起直到子进程 execve/exit
CLONE_NEWTIME0x00000080time namespace5.6+;改 CLOCK_MONOTONIC/BOOTTIME 偏移
CLONE_INTO_CGROUP0x200000000css_set5.7+,仅 clone3;创建即入 cgroup

CLONE_NEWTIME 的值落在 CSIGNAL(0x000000ff)的低位区间内,这是历史遗留:它复用了信号位中未使用的一位。因此在解析 flags 时不能用「低位就是信号」一概而论,必须按 CSIGNAL 掩码单独取。

合法性矩阵:哪些组合会 EINVAL

copy_process 在分配 task_struct 之前就检查标志组合,不合法直接返回 EINVAL。这些检查不是随意设的,每一条都对应一个语义矛盾:

组合结果矛盾在哪
CLONE_NEWNS | CLONE_FSEINVAL共享 fs_struct 又要求独立 mount namespace
CLONE_NEWUSER | CLONE_FSEINVALuser ns 要重算凭证,共享 fs 会绕过
CLONE_THREAD 无 CLONE_SIGHANDEINVAL同线程组必须共享信号处理表
CLONE_SIGHAND 无 CLONE_VMEINVAL共享信号处理却不同地址空间,处理器无法安全运行
CLONE_PARENT + SIGNAL_UNKILLABLEEINVALinit 进程不能造出无法被回收的兄弟
CLONE_SIGHAND | CLONE_NEWPIDEINVAL共享信号表与独立 PID 视图冲突
CLONE_SIGHAND | CLONE_NEWUSEREINVAL同上,凭证与信号表不能跨 user ns 共享

这张表解释了一个常见疑问:为什么 docker run 的 init 进程不能用 CLONE_THREAD 创建?因为容器的 1 号进程必须是一个独立的线程组 leader,而不是宿主某个线程组里的线程。

/proc/PID/ns:把 namespace 变成可比较的 inode

每个进程的 /proc/PID/ns/ 下有八个符号链接。链接内容是 类型:[inode],stat -L 得到的 (st_dev, st_ino) 才是 namespace 的身份:

文件隔离内容内核对象创建标志
mnt挂载点视图mnt_namespaceCLONE_NEWNS
pidPID 编号视图pid_namespaceCLONE_NEWPID
net网卡、路由、iptables、端口netCLONE_NEWNET
uts主机名、域名uts_namespaceCLONE_NEWUTS
ipcSysV/POSIX 消息队列、共享内存ipc_namespaceCLONE_NEWIPC
useruid/gid 映射、capabilitiesuser_namespaceCLONE_NEWUSER
cgroup/proc/PID/cgroup 的根视图cgroup_namespaceCLONE_NEWCGROUP
time单调时钟与启动时钟偏移time_namespaceCLONE_NEWTIME

比较两个进程是否在同一 namespace,不要比较链接字符串(inode 号可能复用),要比较 stat:

# 不需要 root;比较两个进程的 net namespace 身份
a=$(stat -Lc '%d:%i' /proc/1/ns/net)
b=$(stat -Lc '%d:%i' /proc/self/ns/net)
[ "$a" = "$b" ] && echo "same netns" || echo "different netns"

/proc/PID/ns/* 还能 open 成 fd,配合 setns 进入。内核对这个 fd 定义了四个 ioctl:NS_GET_USERNS(取所属 user ns)、NS_GET_PARENT(取父 ns)、NS_GET_NSTYPE(取类型)、NS_GET_OWNER_UID(取 owner uid)。容器运行时正是用这些接口判断「这个 fd 是不是 netns」以及「它属于哪个 user ns」,而不是靠路径字符串。

结构体:nsproxy 与 ns_common

task_struct 不直接持有八个 namespace 指针,而是持有一个 nsproxy:

字段类型含义
countrefcount_t引用计数,多个进程可共享同一 nsproxy
uts_nsstruct uts_namespace *主机名
ipc_nsstruct ipc_namespace *IPC 对象
mnt_nsstruct mnt_namespace *挂载树
pid_ns_for_childrenstruct pid_namespace *子进程将使用的 PID ns
net_nsstruct net *网络栈
time_nsstruct time_namespace *时间偏移
cgroup_nsstruct cgroup_namespace *cgroup 根视图

注意 pid_ns_for_children 的命名:当前进程自己的 PID 编号由它出生时的 PID ns 决定,而它 fork 出的子进程用 pid_ns_for_children。这解释了为什么 unshare --pid 不加 --fork 时,当前 shell 的 $$ 不变,只有下一次 fork 的子进程才进入新 ns。

每个 namespace 的公共头部是 struct ns_common:stashed(嵌入的 nsfs inode)、ops(proc_ns_operations)、inum(inode 号)、count。/proc/PID/ns/* 的链接内容就来自 inum,setns 则通过 ops->get 从 fd 反查 namespace 指针。

命令表

目的命令读什么
看某进程的全部 nsls -l /proc/<pid>/ns/八个链接与 inode
按类型列出lsns -t net -o NS,PID,COMMANDns inode、进程、命令
进入已有 nsnsenter -t <pid> -m -u -i -n -p组合进入多个 ns
只进 mount+pidnsenter -t <pid> -m -p – ps -ef容器内进程树
创建并运行unshare --mount --uts --ipc --pid --net --fork新 ns + 子进程
持久 netnsip netns add n1(bind mount 到 /run/netns/n1)无进程时仍存活
容器宿主 PIDdocker inspect -f ‘{{.State.Pid}}’ <id>容器 init 的宿主 PID
判断是否同 nsstat -Lc '%d:%i' /proc/A/ns/net /proc/B/ns/net设备号 + inode
为什么 `/proc/PID/ns/*` 的 inode 号看起来像 PID

4026531840 这类数字是 nsfs 的 inode 号,不是 PID。它们通常从 0xf0000000 附近开始分配,所以看起来很大。不同 namespace 类型的 inode 号段可能重叠,因此比较时必须同时看 st_dev(nsfs 的设备号)与 st_ino。

5.1.10 内核路径:从 clone 到 execve

这一节回答「一个容器进程从创建到跑起来,内核里依次经过哪些函数」。把这条路径记住,看到 docker run 卡住时就能判断卡在哪一段:clone、挂载、cgroup 还是 exec。

第一段:创建进程与 namespace

clone(2) / clone3(2)
  → kernel_clone()                       kernel/fork.c
      → copy_process()
          → copy_namespaces()            kernel/nsproxy.c
              → create_new_namespaces()
                  → copy_mnt_ns()        fs/namespace.c
                  → copy_utsname()       kernel/utsname.c
                  → copy_ipcs()          ipc/namespace.c
                  → copy_pid_ns()        kernel/pid_namespace.c
                  → copy_net_ns()        net/core/net_namespace.c
                  → copy_cgroup_ns()     kernel/cgroup/namespace.c
                  → copy_time_ns()       kernel/time/namespace.c
          → copy_fs() / copy_files() / copy_mm() / copy_sighand()
          → cgroup_can_fork()            kernel/cgroup/cgroup.c

copy_namespaces 的关键分支很短:如果 flags 里没有任何 CLONE_NEW*,它只增加 nsproxy 引用计数;否则调用 create_new_namespaces 逐个复制。下面这段代码说明了「没有新 namespace 时几乎零成本」:

/* kernel/nsproxy.c(Linux 6.8,节选) */
int copy_namespaces(unsigned long flags, struct task_struct *tsk)
{
    struct nsproxy *old_ns = tsk->nsproxy;
    struct user_namespace *user_ns = task_cred_xxx(tsk, user_ns);
    struct nsproxy *new_ns;

    if (likely(!(flags & (CLONE_NEWNS | CLONE_NEWUTS | CLONE_NEWIPC |
                          CLONE_NEWPID | CLONE_NEWNET |
                          CLONE_NEWCGROUP | CLONE_NEWTIME)))) {
        if ((flags & CLONE_THREAD) && (current->nsproxy != tsk->nsproxy))
            get_nsproxy(tsk->nsproxy);
        return 0;
    }

    new_ns = create_new_namespaces(flags, tsk, user_ns, tsk->fs);
    if (IS_ERR(new_ns))
        return PTR_ERR(new_ns);

    tsk->nsproxy = new_ns;
    return 0;
}

create_new_namespaces 里的顺序有讲究:mount namespace 先复制,因为它要基于调用者的 fs_struct 找到根挂载;user namespace 最先解析,因为后面的 net/pid ns 创建都要在目标 user ns 里做权限检查。CLONE_NEWUSER 与 CLONE_FS 的 EINVAL 就是在这个阶段之前被 copy_process 拦下的。

cgroup_can_fork 是 CLONE_INTO_CGROUP 的落点:clone3 把目标 cgroup 的 fd 放在 struct clone_args.cgroup,内核在 fork 时直接把子进程的 css_set 指向该 cgroup,避免「先创建再迁移」的窗口。这个窗口在旧接口下是真实存在的:进程可能在被迁移前就跑完了初始化。

第二段:换根与挂载

mount("overlay", merged, "overlay", 0, "lowerdir=...,upperdir=...,workdir=...")
  → ovl_fill_super()                     fs/overlayfs/super.c
      → 解析 lowerdir/upperdir/workdir
      → ovl_get_root()                   构造 merged 的根 dentry

pivot_root(new_root, put_old)
  → do_pivot_root()                      fs/namespace.c
      → 检查 new_root 是挂载点、put_old 在 new_root 之下
      → attach_mnt() / detach_mnt()      交换挂载子树

pivot_root 的检查全部在 do_pivot_root 里完成,任何一条不满足都返回 EINVAL。它不是「改路径字符串」,而是操作 mount 树:把旧根挂载摘下来挂到 put_old,把新根挂载提升为 fs_struct->root。因此 pivot_root 之后旧根仍然存在(在 put_old 位置),必须再 umount 才真正不可达。

第三段:进入用户进程

execve("/bin/sh")
  → do_execveat_common()                 fs/exec.c
      → bprm_execve()
          → exec_binprm()
              → search_binary_handler()  按魔数找 loader
                  → load_elf_binary()    建立新地址空间、映射解释器
          → begin_new_exec()             切换 mm、关闭 close-on-exec fd

容器的「1 号进程」就是这一步 execve 之后的进程。它的 PID 在容器内是 1,在宿主上是另一个数;它的父进程是 shim 或 runc,取决于运行时是否用了 --pid=host、是否 double-fork。execve 成功后,copy_process 阶段创建的 task_struct 仍在,只是地址空间、fd 表、信号处理都换成了新程序的状态。

把四段串起来

阶段系统调用内核入口失败时的典型 errno
创建clone/clone3kernel_clone → copy_processEINVAL(flags 冲突)、EAGAIN(pids 限额)
隔离同上copy_namespacesEPERM(缺 CAP_SYS_ADMIN)
限额写 cgroup 文件 / CLONE_INTO_CGROUPcgroup_can_fork、各控制器ENOENT(控制器未启用)、EBUSY
换根mount、pivot_rootovl_fill_super、do_pivot_rootEINVAL(不是挂载点)、EBUSY
启动execvebprm_execveENOENT、EACCES、ENOEXEC

五个可观测点

把这条路径变成排障手段,只需要在每一段找一个可读的证据:

阶段在宿主上读什么正常值异常说明
进程创建ps -o pid,ppid,cmd -p <hostpid>父进程是 shim 或 runc父进程是 1,说明被 double-fork 或 shim 已退出
namespacelsns -p <hostpid>八类 ns 各有 inode某类与宿主相同,说明没隔离
cgroupcat /proc/<hostpid>/cgroup0::/<slice>/docker-<id>.scope停在 /,说明没加限额
挂载nsenter -t <hostpid> -m findmnt -T /根是 overlay是宿主根,说明没换根
程序nsenter -t <hostpid> -p -m -- cat /proc/1/cmdlineentrypoint 的 argvENOENT,说明 rootfs 缺解释器

这五条按顺序读,能把「容器起不来」压缩到某一段:进程在但 ns 不对是运行时配置问题;ns 对但 cgroup 在根是限额没写;cgroup 对但根挂载是宿主是 pivot_root 失败;最后一段失败则是镜像本身缺文件。

最后一条要在目标的 PID+mount namespace 里执行

宿主的 /proc/<hostpid>/root 指向容器根,但宿主 /proc/1 是宿主 init。只有 nsenter -p -m 之后,/proc/1 才是容器 init。搞混这两者,会把「容器 entrypoint 缺失」误判成「宿主 init 异常」。

为什么容器启动比虚拟机快三个数量级

虚拟机要跑固件、引导 loader、初始化 guest 内核、枚举虚拟设备;容器只做 copy_process + 几次 mount + execve。前者是「启动一台机器」,后者是「启动一个进程」。真正拖慢容器启动的通常不是内核,而是镜像解包、CNI 配网、存储挂载这些用户态步骤。

5.1.11 两个实验:flags 合法性 与 三件套合体

实验一:验证 copy_process 的 EINVAL 检查

这段程序故意构造五种非法组合,确认内核在创建进程之前就拒绝。它不需要 root:合法性检查发生在权限检查之前。

/* clonecheck.c — 验证 clone flags 的合法性检查;编译:cc -std=c17 -D_GNU_SOURCE -O2 -Wall -Wextra -Werror clonecheck.c -o clonecheck */
#include <errno.h>
#include <sched.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define STACK_SIZE (1 << 20)

static int child(void *arg)
{
    (void)arg;
    return 0;
}

int main(void)
{
    static const struct {
        const char *name;
        int flags;
    } cases[] = {
        { "NEWNS|FS",          CLONE_NEWNS | CLONE_FS | SIGCHLD },
        { "NEWUSER|FS",        CLONE_NEWUSER | CLONE_FS | SIGCHLD },
        { "THREAD 无 SIGHAND",  CLONE_THREAD | CLONE_VM | SIGCHLD },
        { "SIGHAND 无 VM",      CLONE_SIGHAND | SIGCHLD },
        { "SIGHAND|NEWPID",     CLONE_SIGHAND | CLONE_VM | CLONE_NEWPID | SIGCHLD },
    };
    char *stack = malloc(STACK_SIZE);
    if (stack == NULL) {
        perror("malloc");
        return EXIT_FAILURE;
    }

    for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
        errno = 0;
        pid_t pid = clone(child, stack + STACK_SIZE, cases[i].flags, NULL);
        if (pid == -1)
            printf("%-18s -> -1 errno=%d (%s)\n", cases[i].name, errno, strerror(errno));
        else
            printf("%-18s -> pid=%d(不应发生)\n", cases[i].name, (int)pid);
    }

    free(stack);
    return EXIT_SUCCESS;
}

预期输出:

NEWNS|FS           -> -1 errno=22 (Invalid argument)
NEWUSER|FS         -> -1 errno=22 (Invalid argument)
THREAD 无 SIGHAND   -> -1 errno=22 (Invalid argument)
SIGHAND 无 VM       -> -1 errno=22 (Invalid argument)
SIGHAND|NEWPID      -> -1 errno=22 (Invalid argument)

如果某一行返回了 pid,说明该内核版本的检查顺序或规则变了——这本身就是有价值的版本差异证据。注意不要在返回 pid 的分支里 waitpid:这些子进程共享父进程的栈,行为未定义,程序应立即退出。

实验二:三件套合体——隔离 + 限额 + 换根视角

这个实验用 unshare 造出 PID/mount/UTS 隔离,再把容器 init 的宿主 PID 写进 cgroup v2 叶子,观察限额是否真的作用到容器内进程。它不换根(rootfs 留给 5.4),但已经能验证「namespace 管看得见、cgroup 管用多少」是两条独立的线。

#!/usr/bin/env bash
# assemble.sh — 需要 root;把一个 unshare 容器放进 cgroup 并验证
set -euo pipefail
CG=/sys/fs/cgroup/assemble
PID=""

cleanup() {
    [ -n "$PID" ] && kill "$PID" 2>/dev/null || true
    sleep 0.2
    for p in "$CG/box" "$CG"; do
        [ -d "$p" ] && xargs -r kill < "$p/cgroup.procs" 2>/dev/null || true
    done
    sleep 0.2
    rmdir "$CG/box" "$CG" 2>/dev/null || true
}
trap cleanup EXIT

# 1. 造一个带 PID/mount/UTS 隔离的容器 init;--mount-proc 让容器内有独立 /proc
unshare --pid --fork --mount-proc --uts \
    sh -c 'hostname box; exec sh -c "while :; do :; done"' &
sleep 0.3
PID=$(pgrep -P "$!" | head -1)
[ -n "$PID" ] || { echo "找不到容器 init"; exit 1; }

# 2. 建 cgroup 并启用控制器
mkdir -p "$CG"
echo "+cpu +memory +pids" > /sys/fs/cgroup/cgroup.subtree_control 2>/dev/null || true
echo "+cpu +memory +pids" > "$CG/cgroup.subtree_control"
mkdir -p "$CG/box"

# 3. 写限额,把容器 init 迁进去(写任意线程 TID 都会迁移整个线程组)
echo "25000 100000" > "$CG/box/cpu.max"
echo $((64 * 1024 * 1024)) > "$CG/box/memory.max"
echo 32 > "$CG/box/pids.max"
echo "$PID" > "$CG/box/cgroup.procs"

# 4. 从宿主验证:进程确实在 cgroup 里,且限额文件生效
cat "/proc/$PID/cgroup"          # 0::/assemble/box
cat "$CG/box/cpu.max"            # 25000 100000
sleep 3
grep -E 'nr_periods|nr_throttled' "$CG/box/cpu.stat"

# 5. 从容器视角验证:hostname 与进程树都是隔离的
nsenter -t "$PID" -p -m -u -- hostname                 # box
nsenter -t "$PID" -p -m -u -- ps -o pid,ppid,comm      # 只有 1 号与 ps

预期输出:

0::/assemble/box
25000 100000
nr_periods 30
nr_throttled 29
box
    PID   PPID COMMAND
      1      0 sh
     12      1 ps

三行证据分别对应三件套:/proc/$PID/cgroup 证明进程被 cgroup 记账;nr_throttled 证明 cpu.max 对容器内的死循环生效;hostname 与 ps 证明 UTS/PID namespace 独立。注意 nsenter -p 进入 PID ns 后,ps 读的是容器自己的 /proc(--mount-proc 挂的那份),所以只看到两个进程。

如果把第 3 步的 echo "$PID" > cgroup.procs 删掉,第 4 步的 nr_throttled 会一直是 0——这就是「namespace 不等于限额」的直接反例。

两个实验的四个操作坑
  1. pgrep -P "$!" 取到多个 PID——unshare --fork 的子进程可能还有自己的子进程;用 head -1 取第一个,或 ps -o pid= --ppid <unshare-pid>。
  2. nsenter -p 后 ps 报 can't open /proc 错误——没有 --mount-proc,容器内 /proc 还是宿主的,PID 视图与目录不匹配。
  3. clone 测试程序在某些内核上返回 EPERM 而不是 EINVAL——若发行版先做 capabilities 检查,顺序可能不同;EINVAL 是 6.8 上的预期值。
  4. while :; do :; done 在容器里被子 shell 接管——exec sh -c 保证 PID 1 就是死循环,不会被外层 shell 的 wait 干扰。

5.1.12 OCI 配置:三件套在 config.json 里的字段

docker run 的参数最终被翻译成一份 JSON。OCI runtime-spec 规定的是这份 JSON 的字段与生命周期,不规定实现:runc、crun、youki 读同一份 config.json,做同一组系统调用。一个 bundle 只有两样东西:

bundle/  ├── config.json   ← 运行时读的全部配置(本节的表)
         └── rootfs/       ← 解开的根文件系统(overlay 层由运行时另建)
config.json 字段取值示例落到哪个系统调用/文件典型失败
ociVersion"1.2.0"运行时校验,无系统调用高于实现版本时未知字段被忽略
process.args["/bin/sh","-c","ps"]execveENOENT、EACCES、ENOEXEC
process.env、cwdPATH=/bin、/execve 的 envp、chdirENOENT(cwd 不存在)
process.useruid/gid/additionalGidssetgroups、setresgid、setresuidEPERM(uid 无映射)、EINVAL
process.capabilitiesbounding/effective/permitted/inheritable/ambientcapset + PR_CAP_AMBIENTEPERM、EINVAL(未知能力名)
process.rlimitsRLIMIT_NOFILE 1024/1024setrlimitEPERM(无 CAP_SYS_RESOURCE 提高硬限)
process.noNewPrivilegestrueprctl(PR_SET_NO_NEW_PRIVS)EINVAL
process.oomScoreAdj-500写 /proc/self/oom_score_adjEACCES、EINVAL(越界)
root.path、root.readonly"rootfs"、truemount + pivot_root + 只读 remountENOENT、EINVAL(不是挂载点)
mounts[]destination/type/source/options逐条 mount(2)EPERM、EINVAL、ENODEV(未知 fstype)
linux.namespaces[]pid、mount、network…clone/unshare 的 CLONE_NEW*EPERM、EINVAL(组合冲突)
linux.uidMappings、gidMappingscontainerID/hostID/size写 uid_map/gid_mapEPERM、EINVAL(区间重叠)
linux.cgroupsPath"oci-demo"mkdir + 写 cgroup.procs,或 clone3(CLONE_INTO_CGROUP)EACCES、ENODEV
linux.resourcesmemory.limit、cpu.quota、pids.limit写 memory.max、cpu.max、pids.maxENOENT(控制器未在父层启用)、EINVAL
linux.seccompdefaultAction + syscallsseccomp(SECCOMP_SET_MODE_FILTER)EINVAL(过滤器非法)、EPERM
linux.maskedPaths、readonlyPaths/proc/kcore、/proc/sysbind /dev/null、remount MS_RDONLY路径不存在时被静默跳过
hookscreateRuntime/createContainer/startContainer运行时 fork/exec,不经过内核非零退出码使创建失败

表里三处单位容易错:memory.limit 是字节,cpu.quota/cpu.period 是微秒,pids.limit 是任务数(线程也计)。它们与 5.3 的 memory.max、cpu.max、pids.max 一一对应;cpu.max 文件把 quota 与 period 写在同一行,JSON 里则是两个字段。

生命周期只有四步,每一步都能在 state.json 里看到:

操作运行时命令state.json 的 status说明
创建runc create <id>created已 clone、挂载、写 cgroup,但用户进程停在 execve 前
启动runc start <id>running通过 exec fifo 放行,execve 执行 process.args
终止runc kill <id> <sig>stopped信号发给容器 init,是否转发由 init 决定
删除runc delete <id>—回收 cgroup、挂载点与 state 目录;进程未死则失败

state.json 的字段是 ociVersion、id、status、pid、bundle、annotations;pid 是宿主视角的 init PID,不是容器内的 1。hooks 的顺序也由 spec 固定,且执行位置不同:createRuntime 在运行时的 mount namespace 里、pivot_root 之前;createContainer 与 startContainer 在容器 namespace 里;poststart 在用户进程启动后、仍在运行时 namespace;poststop 在删除后。prestart 是 1.0.2 之前的旧名,等价于 createRuntime,新配置不应再写。

内核路径:CLONE_INTO_CGROUP 把「入组」提前到 fork

cgroupsPath 有两条实现路径:先 clone 再写 cgroup.procs(传统),或 clone3 时带 CLONE_INTO_CGROUP(Linux 5.7+,仅 cgroup v2)。后者把子进程的 css_set 在 copy_process 阶段就指向目标 cgroup,消除了「已创建、未入组」的记账窗口。

/* kernel/cgroup/cgroup.c(Linux 6.8,节选) */
static int cgroup_css_set_fork(struct kernel_clone_args *kargs)
{
    ...
    f = fget_raw(kargs->cgroup);
    if (!f) { ret = -EBADF; goto err; }
    sb = f->f_path.dentry->d_sb;
    dst_cgrp = cgroup_get_from_file(f);
    if (IS_ERR(dst_cgrp)) { ret = PTR_ERR(dst_cgrp); goto err; }
    if (cgroup_is_dead(dst_cgrp)) { ret = -ENODEV; goto err; }

    ret = cgroup_may_write(dst_cgrp, sb);
    if (ret) goto err;
    ret = cgroup_attach_permissions(cset->dfl_cgrp, dst_cgrp, sb,
                                    !(kargs->flags & CLONE_THREAD),
                                    current->nsproxy->cgroup_ns);
    ...
}

四个检查决定了常见错误码:fd 必须是 cgroup v2 目录(O_RDONLY 或 O_PATH),不能是 cgroup.procs 文件,否则 EBADF;目录已死返回 ENODEV;没有写权限返回 EACCES;目标 cgroup 处于 domain invalid 状态(cgroup.type 为 domain invalid)返回 EOPNOTSUPP。cgroup_can_fork 随后调用各控制器的 can_fork,pids 控制器在这里返回 -EAGAIN,所以 pids.max 打满时 fork 直接失败,而不是先成功再被踢出。

5.1.13 两个补充实验:最小 OCI bundle 与 pivot_root 的逃逸边界

实验一:不装 Docker,用 runc 跑一份手写 config.json

这个实验验证「OCI 配置就是三件套的声明式写法」:同一份 JSON,runc 执行后你能在 cgroup、/proc、挂载表里找到对应项。需要 root 与 cgroup v2(Ubuntu 24.04 默认)。

# 需要 root;准备 bundle
sudo apt-get install -y runc busybox-static
sudo mkdir -p /tmp/oci/rootfs/{bin,proc,dev,tmp}
sudo cp /bin/busybox /tmp/oci/rootfs/bin/
for a in sh id hostname ps sleep; do
    sudo ln -sf busybox "/tmp/oci/rootfs/bin/$a"
done

sudo tee /tmp/oci/config.json >/dev/null <<'JSON'
{
  "ociVersion": "1.2.0",
  "process": {
    "user": {"uid": 0, "gid": 0},
    "args": ["/bin/sh", "-c", "hostname; id; echo pid=$$; ps; sleep 600"],
    "env": ["PATH=/bin"],
    "cwd": "/",
    "capabilities": {
      "bounding": ["CAP_CHOWN", "CAP_KILL", "CAP_NET_BIND_SERVICE"],
      "effective": ["CAP_CHOWN", "CAP_KILL", "CAP_NET_BIND_SERVICE"], "permitted": ["CAP_CHOWN", "CAP_KILL", "CAP_NET_BIND_SERVICE"] },
    "rlimits": [{"type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024}],
    "noNewPrivileges": true
  },
  "root": {"path": "rootfs", "readonly": true},
  "hostname": "oci-demo",
  "mounts": [
    {"destination": "/proc", "type": "proc", "source": "proc", "options": ["nosuid", "noexec", "nodev"]},
    {"destination": "/dev", "type": "tmpfs", "source": "tmpfs",
     "options": ["nosuid", "strictatime", "mode=755", "size=65536k"]}
  ],
  "linux": {
    "namespaces": [{"type": "pid"}, {"type": "mount"}, {"type": "uts"},
                   {"type": "ipc"}, {"type": "network"}],
    "cgroupsPath": "oci-demo",
    "resources": {
      "memory": {"limit": 67108864},
      "cpu": {"quota": 25000, "period": 100000},
      "pids": {"limit": 32}
    }
  }
}
JSON

cd /tmp/oci
sudo runc run -d oci-demo          # -d:创建后立即返回
sudo runc list
sudo runc exec oci-demo ps -o pid,ppid,comm
cat /sys/fs/cgroup/oci-demo/memory.max
cat /sys/fs/cgroup/oci-demo/cpu.max
cat /sys/fs/cgroup/oci-demo/pids.max
sudo runc kill oci-demo KILL
sudo runc delete oci-demo

预期输出(PID、inode 随机器变化):

ID         PID    STATUS    BUNDLE     CREATED
oci-demo   2417   running   /tmp/oci   2026-10-18T...
    PID   PPID COMMAND
      1      0 sh
     12      1 ps
67108864
25000 100000
32

三组证据:runc list 的 PID 是宿主 PID;runc exec ... ps 只看到两个进程(PID+mount namespace 生效);memory.max/cpu.max/pids.max 与 JSON 的 resources 完全对应(cpu.max 把 quota/period 合成一行);root.readonly=true 下在容器里 touch /x 应返回 Read-only file system——只读的是 rootfs,不是 /proc 等挂载。

如果把 linux.namespaces 里的 pid 删掉,ps 会看到宿主全部进程,而 hostname 仍是 oci-demo(UTS 还在)。这正好演示「namespace 是逐项声明的,不是容器开关」。

实验二:chroot 留下的宿主路径,pivot_root 摘掉旧根

这个实验验证 5.1.3 的结论:chroot 只改根指针,宿主根仍能通过 /proc/1/root 或未卸载的旧根到达;pivot_root 必须配合卸载旧根才算封死。需要 root 与 busybox-static。

# 准备 rootfs(可复用实验一的 /tmp/oci/rootfs)
J=/tmp/jaildemo
sudo rm -rf "$J"
sudo mkdir -p "$J"/{bin,proc,dev,tmp}
sudo cp /bin/busybox "$J/bin/"
for a in sh ls cat chroot; do sudo ln -sf busybox "$J/bin/$a"; done

# (a) 只有 chroot:/proc 仍是宿主的,/proc/1/root 直通宿主根
sudo mount -t proc proc "$J/proc"
sudo chroot "$J" /bin/sh -c 'cat /proc/1/root/etc/hostname; ls /proc/1/root | head -3'
# 输出宿主主机名与宿主根目录项
sudo umount "$J/proc"

/proc/1/root 是宿主 init 的根,chroot 不会改变它;只要 /proc 还是宿主那一份,容器里的进程就能顺着它读写宿主文件。这是 chroot 不能当安全边界的第一条证据。

# (b) pivot_root + 新 /proc + 摘掉旧根:/proc/1/root 只能回到容器根
sudo unshare --mount --fork --pid --uts sh -c '
  mount --make-rprivate /
  mount --bind '"$J"' '"$J"'
  mount -t proc proc '"$J"'/proc
  cd '"$J"'
  mkdir -p .oldroot
  pivot_root . .oldroot
  umount -l /.oldroot
  exec /bin/sh
'
# 容器内:
#   cat /proc/1/root/etc/hostname   → No such file or directory
#   ls /                            → bin dev proc tmp

新 /proc 让 /proc/1 变成容器 init 自己,/proc/1/root 只指向新根。若删掉 umount -l /.oldroot,容器里仍能 ls /.oldroot 看到宿主根,甚至 chroot /.oldroot /bin/sh 回到宿主——pivot_root 只是把旧根挪到 .oldroot,不卸载就不算封死。只读镜像 + 去掉 CAP_SYS_CHROOT,才是运行时的完整做法。

OCI 配置与换根的八个坑
  1. 把 ociVersion 当能力开关——spec 在同一主版本内前向兼容,运行时遇到不认识的字段通常忽略而不是报错;判断能力要看运行时版本与 runc features(1.2+),不要看 JSON 版本号。
  2. mounts[].destination 不存在——runc 不会替你 mkdir 大多数目标目录,mount 返回 ENOENT;在 rootfs 里预建 /proc、/dev、/tmp,或在 hook 里创建。
  3. maskedPaths 静默失效——目标路径不存在时 runc 跳过该条,不报错;验证要在容器里 cat /proc/kcore 看是否 Permission denied,而不是看 config 里写了没有。
  4. root.readonly=true 就以为全只读——/proc、/sys、/dev、/tmp 与 volume 各自挂载,仍可写;/proc/sys 可写意味着能改宿主内核参数,需配合 readonlyPaths 与去掉 CAP_SYS_ADMIN。
  5. 忘记 gidMappings 的 setgroups 前提——非特权用户写 gid_map 前必须先 echo deny > /proc/PID/setgroups,否则 EPERM;uid_map 没有这个前置但同样要求区间在 /etc/subuid 内。
  6. pids.max 打满时 runc create 报 EAGAIN——pids 在 can_fork 阶段就拒绝,错误点不在 execve;看到 fork/exec ... resource temporarily unavailable 先查 pids.current 与 pids.events。
  7. 改 ENV 就以为镜像层全变——镜像 ID 是 config 的 digest,改环境变量只产生新 config 与新镜像 ID,RootFS.Layers(diffID,未压缩)不变;manifest 里的 layer digest 是压缩后的,与 RootFS.Layers 不是同一个值。
  8. runc delete 删不掉 running 容器——必须先 kill 并等进程退出,否则 state 目录残留;脚本清理用 runc delete -f,但 -f 只删元数据,不保证挂载点已卸载。

5.1.14 本章陷阱清单

下面 14 条是「把三件套拼起来」时最容易踩的边界,按现象、原因、修复排列。与各节末尾的 errata 不重复:这里讲的是组合之后才出现的问题。

#现象原因修复/验证
1容器退出后残留僵尸进程容器 PID 1 不负责 wait,孤儿被 reparent 到它用 --init/tini,或让 PID 1 显式回收
2docker stop 等 10 秒才退出PID 1 默认忽略 SIGTERM,除非装了处理器在 entrypoint 里处理 SIGTERM,或缩短 --stop-timeout
3--pid=host 的容器能读写宿主 /proc/1/root共享 PID ns 后 /proc/1 就是宿主 init避免 --pid=host;必须用时加 maskedPaths
4--net=host 后端口冲突、localhost 语义变化没有独立 netns,直接共用宿主栈用端口映射,或显式接受共享
5加了 --cgroupns=private 仍被宿主限额影响cgroup ns 只改视图,不改成员关系限额看 /proc/PID/cgroup 与宿主 cgroup 文件
6overlay 的 upper 放在 NFS 上挂载失败overlayfs 要求 upper/work 支持 d_type 与 xattrupper 放本地盘;NFS 只做 lower
7改了 Dockerfile 一行,构建却全部重跑层缓存按指令与上下文摘要,COPY 变化会让后续层失效把变化少的指令放前面,用多阶段构建
8docker build --no-cache 仍用旧基础镜像--no-cache 只禁用层缓存,不拉新基础镜像加 --pull
9容器里 free -h 显示宿主总内存cgroup 不做资源虚拟化,/proc/meminfo 是全局的看 memory.max/memory.current
10--memory=512m 后容器仍用 swap该参数只限内存,swap 由 --memory-swap 控制设 --memory-swap=512m 等于禁用 swap
11--cpus=0.5 在空闲时也跑不满 1 核它是 CFS 配额,不是权重需要按竞争分配时用 --cpu-shares
12--pids-limit=100 的容器起不来线程也计数按线程峰值设置,或看 pids.events
13--privileged 之外,/proc/sys 仍可写只读挂载可被 CAP_SYS_ADMIN 重新挂载同时 drop SYS_ADMIN,或用 user ns
14docker export 出来的 rootfs 起不来export 丢配置、层历史与环境变量用 docker save 保留镜像;export 只取文件
三个「看起来像隔离,其实没有」的配置
  1. --read-only 不等于全只读:它只把 rootfs 挂成只读,/proc、/sys、/dev、volume 仍可写;要完整只读需配合 --tmpfs 与 --mount type=bind,ro。
  2. --user 1000 不等于 user namespace:它只改进程 uid,宿主上仍是同一个 uid 数字,没有映射;真正的隔离要 userns-remap 或 rootless 运行时。
  3. --cap-drop=ALL 不等于无特权:no_new_privs 未开时,带文件 capabilities 的二进制仍可在 execve 时获得权限;要一起开 --security-opt no-new-privileges。

5.1.15 本章出口检查

检查项达标线没达标去哪
说清容器与虚拟机的三条区别内核、启动、故障域5.1.1
画出三件套与「看得见/用得到/摸得到」的对应不缺第四件套(权限收窄)5.1.2
解释 pivot_root 为什么比 chroot 可靠提到挂载点与旧根可达性5.1.3
手工起一个 PID/mount/UTS/net 隔离的 shell脚本能跑,ps 只有自己5.1.4
把 MemoryMax/CPUQuota 映射到 cgroup 文件说出 memory.max、cpu.max 格式5.1.5
解释镜像层与容器层的关系说清 copy-up 与共享5.1.6
列举 --privileged 的四项效果不全靠「权限大」概括5.1.7
说出 CLONE_NEW* 对应的内核对象至少五个 namespace5.1.9
解释 CLONE_THREAD 为什么不能配 CLONE_NEWPID提到信号表与线程组5.1.9
判断两个进程是否在同一 namespace会比较 st_dev:st_ino5.1.9
画出 clone → copy_namespaces → execve 的路径三个入口函数不缺5.1.10
即时自测 · 5.1

Q1 一个容器里 uname -r 与宿主相同,free -h 也显示宿主总内存。这两件事分别说明什么?限额应该看哪个文件?

Q2 手工容器脚本里,如果把 mount --make-rprivate / 删掉,可能发生什么?为什么会发生?

Q3 docker run --rm --cap-drop=ALL alpine chown root:root /etc/hosts 会成功吗?为什么?

Q4 镜像的 RootFS.Layers 有三个 SHA256,容器启动后修改 /etc/hosts,会在哪里产生新数据?哪些层仍然是只读的?

Q5 一个进程调 clone(CLONE_THREAD | CLONE_VM | SIGCHLD) 返回 EINVAL。缺了哪个标志?为什么内核要求它?

Q6 同一台机器上两个容器都显示 pid:[4026532411],能说明它们在同一个 PID namespace 吗?应该怎么验证?

答案

A1 uname 相同说明共享宿主内核,没有 guest 内核;free 显示宿主总量说明 cgroup 只做限额、不做资源虚拟化,/proc/meminfo 仍是全局数据。限额看 /sys/fs/cgroup/.../memory.max(容器内是命名空间视角下的同一文件),用量看 memory.current。

A2 systemd 把 / 设为 shared,挂载事件会沿 peer 关系传播。容器 mount namespace 里的 mount -t proc proc /rootfs/proc 可能传播到宿主的同一挂载点,覆盖宿主 /proc。表现为宿主命令异常、/proc 内容变成容器视角。应先 mount --make-rprivate / 再挂载。

A3 失败。chown 需要 CAP_CHOWN,--cap-drop=ALL 把 effective/permitted 集合清空,即使 uid=0 也会返回 EPERM。这演示了「root 的权力来自 capabilities,而不是 uid 本身」。

A4 修改发生在容器的可写层(Docker 的 overlay2/<id>/diff,由 overlayfs 的 upperdir 承载)。三个镜像层仍是只读 lower;/etc/hosts 这个文件会先被 copy-up 到 upper,再在 upper 上修改,lower 中的原文件不变。

A5 缺 CLONE_SIGHAND。CLONE_THREAD 表示新任务与调用者在同一线程组,线程组必须共享信号处理表(sighand_struct);只共享地址空间(CLONE_VM)不够。补上 CLONE_SIGHAND 才能创建线程。

A6 不能。inode 号在不同 namespace 类型之间、甚至同一类型的不同实例之间都可能重复,pid:[...] 的字符串只说明「这是 pid namespace 的 inode N」。正确做法是比较 /proc/A/ns/pid 与 /proc/B/ns/pid 的 stat -Lc '%d:%i',两者都相同才是同一实例。

母本对应

母本章节本书落点程度
N第 10 章 容器网络虚拟化(10.2 veth、10.3 netns、10.4 bridge)5.1.1、5.6背景(本章只取「共享内核」视角)
R第 2 章 内存管理(2.8 malloc、2.13 回收)5.1.5、5.3.5背景(内存限额的回收路径)
R第 3 章 进程管理(3.1 进程的诞生、3.1.2 fork 与 clone flags)5.1.4、5.1.9、5.1.10、5.2.2完整(copy_process 的标志检查、CLONE_NEW* 语义)
K第 28 章 详述进程创建和程序执行(28.2 clone())5.1.2、5.2.2完整(clone 标志与子进程属性)
K第 39 章 能力(39.2–39.5)5.1.7、5.5.5完整(capabilities 集合与 exec 转换)
O第 39 章 文件和目录、第 40 章 文件系统实现5.1.6、5.4背景(inode/目录项与只读层)
OCIruntime-spec(config.json、生命周期)5.1.6、5.5精选(镜像与运行时配置的分界)
内核文档Documentation/admin-guide/namespaces/、Documentation/filesystems/overlayfs.rst5.1.9、5.1.10精选(nsfs ioctl、挂载语义)

下一站:5.2 namespace 深入 把七种 namespace 逐个拆开:clone/unshare/setns 的差别、/proc/PID/ns 的 inode、PID 1 的信号语义,以及 nsenter 排障。