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 展开),而不能拿虚拟机的标准来类比。
读图时注意左右两列的对应关系:左列是内核机制,右列是容器内观察到的效果。PID namespace 让容器里的 ps 只看到自己这一棵进程树;mount namespace 让 / 指向 overlayfs 的 merged 目录;cgroups v2 决定这个容器最多能用多少 CPU 与内存。图的最后一行写明了根本区别:没有独立内核,隔离靠内核提供,共享内核也意味着共享漏洞面。
容器是一组受内核机制约束的进程:它们的命名空间视图与宿主不同,资源被 cgroup 限额,根文件系统来自一个只读镜像加一个可写层。容器不是一台机器,没有独立内核、没有固件、没有引导过程;它也不是一个特殊的进程类型,task_struct 与普通进程完全相同。
共享内核只说明隔离实现在内核里,不说明隔离很弱。进程之间的地址空间隔离、页表隔离、权限检查,本来就是内核的职责;namespace 只是把「全局资源」拆成多份。真正的风险来自内核攻击面变大:更多代码路径可以被非特权进程触发。所以容器安全的工作重点是缩小攻击面(capabilities、seccomp、只读挂载),而不是「换一个内核」。
5.1.2 三件套:各管一段,缺一不可
把「独占一台机器」拆成三个具体问题,对应三个机制:
| 问题 | 机制 | 内核对象 | 它不负责 |
|---|---|---|---|
| 能看见哪些进程、网卡、挂载点、主机名? | namespace | nsproxy + 各类 *_namespace | 限制用量、提供文件 |
| 最多能用多少 CPU、内存、IO、PID? | cgroups v2 | cgroup 层级 + 控制器 | 隐藏资源、提供文件 |
/ 下面有哪些文件?写到哪里? | rootfs + overlayfs | vfsmount + super_block | 隔离进程、限制资源 |
再补一件常被忽略的「第四件套」:权限收窄。capabilities、seccomp、no_new_privs、LSM(AppArmor/SELinux)决定容器里的 root 能做什么。没有它,前三件套只是「看不见」,不是「动不了」。
容器运行时是负责把「镜像 + 配置」变成「一组受约束的进程」的程序。它要完成:解包 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。
- 「namespace 就是容器」——namespace 只提供视图隔离。没有 cgroup,一个容器里的
fork炸弹可以拖垮宿主;没有 rootfs,容器仍能读宿主/etc/shadow(若权限允许)。 - 「容器是一个轻量虚拟机」——没有 guest 内核,
dmesg、/proc/kcore、/sys都是宿主的(除非额外屏蔽)。所以容器里看到的 CPU 数、内存总量是宿主的值,不是限额值。 - 「cgroups 能隔离 CPU」——cgroup 只做分配与限流,不提供时间片隔离的强保证;它也不会让容器「看不见」其他 CPU。
- 「镜像就是一个 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 的语义是「交换两棵挂载子树」,内核可以保证旧根被移走、新根成为真正的根挂载,并且 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 一个匹配的 /proc | ps 看到的还是宿主进程 |
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 确认。
unshare -p之后ps还是全量——-p只让之后 fork 的子进程进入新 PID namespace;不加--fork,当前 shell 仍在旧 ns。pivot_root: Invalid argument——新根不是挂载点。先mount --bind "$rootfs" "$rootfs";另外put_old必须在new_root之下。- 退出后宿主
/proc被覆盖——没有先mount --make-rprivate /,挂载沿 shared 传播回宿主。重启宿主可恢复,但生产环境上这是事故。 exec /bin/sh: not found——rootfs 里没有解释器,或动态链接的 busybox 缺少ld-linux。用busybox-static,或把ldd列出的库一并复制。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>。
直接 mkdir /sys/fs/cgroup/demo && echo $$ > demo/cgroup.procs 也能做,但会把当前 shell 关进 cgroup,清理时容易把自己锁在外面。systemd-run 帮你做了「建目录、写限额、放进程、退出时回收」四件事,是观察映射关系最安全的入口。手工写文件的完整流程在 5.3.3。
5.1.6 镜像:配置 + 层,而不是一个 rootfs 压缩包
容器启动时需要两样东西:一个解开的 rootfs,一份运行时配置(命令、环境变量、工作目录、用户)。镜像把这两样打包在一起,并额外记录「这些文件是怎么一层层叠出来的」。
以 Docker 镜像为例,它由三部分组成:
| 组成 | 内容 | 是否共享 |
|---|---|---|
| manifest | 层列表、config 文件名、标签 | 每个镜像一份 |
| config | Env、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/...)。两种布局表达的是同一件事,只是打包格式不同。
镜像分层带来两个直接后果:
- 复用:
ubuntu:24.04与nginx:latest若共享基础层,磁盘上只存一份;docker pull只下载缺少的层。 - 写时复制:容器启动时不会复制整个 rootfs,而是把镜像层作为只读 lower,再加一个空的可写 upper。第一次修改某个文件才触发 copy-up(5.4 详述)。
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,这层裁剪被整体取消。
在 Docker 中,--privileged 会同时做以下几件事(Linux 6.8 + Docker 26/27):
- 把 所有 capabilities 放进进程的 bounding/effective/permitted 集合;
- 放开 devices cgroup,允许访问宿主所有设备节点;
- 关闭默认 seccomp profile 与 AppArmor/SELinux 限制;
- 清空
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 是三套独立机制,任何一套缺失都不等于逃逸,但三套同时缺失就把内核攻击面完整暴露给容器。
- 「容器里的 root 不是真 root」——只有启用 user namespace 时才成立。默认 Docker 配置下,容器 root 与宿主 root 是同一个 uid,差别只在 capabilities。
- 「
--privileged只是多几个 cap」——它还关闭 seccomp/AppArmor、放开设备、清空只读路径。等价于把宿主 root 交给容器。 - 「seccomp 能防内核漏洞」——seccomp 只能按系统调用号与参数过滤,不能阻止被放行的调用内部的漏洞。它降低攻击面,不提供内存安全。
- 「
--cap-drop=ALL就安全了」——还有no_new_privs、可写/proc/sys、挂载宿主目录、docker.sock暴露等路径。能力只是其中一层。 - 「镜像扫过漏洞就没问题」——镜像扫描看的是用户态包版本;内核漏洞、配置错误(如
privileged、hostPath 挂载)不在扫描范围内。
- 看 namespace:
docker inspect -f '{{.HostConfig.NetworkMode}} {{.HostConfig.PidMode}} {{.HostConfig.IpcMode}}',有host就少了对应隔离。 - 看挂载:
Mounts里有没有/var/run/docker.sock、/、/proc、/sys这类宿主路径。 - 看能力:
CapAdd是否为空;Privileged是否为true。 - 看 seccomp/AppArmor:
SecurityOpt里有没有seccomp=unconfined、apparmor=unconfined、no-new-privileges。 - 看 user:
Config.User是否为空(空 = root);宿主上是否启用userns-remap。 - 看只读:
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、nsproxy | 5.2.2 |
写 cpu.max/memory.max | cgroup v2 控制器 | 5.3 |
mount overlay | overlayfs 的 lower/upper/work | 5.4 |
pivot_root + execve | mount namespace、fs_struct | 5.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_VM | 0x00000100 | mm_struct(地址空间) | 线程共享内存;容器 init 不能与外部共享 |
CLONE_FS | 0x00000200 | fs_struct(root、pwd、umask) | 与 CLONE_NEWNS 冲突,无法独立换根 |
CLONE_FILES | 0x00000400 | files_struct(fd 表) | 共享 fd 表会让容器继承外部打开的 fd |
CLONE_SIGHAND | 0x00000800 | sighand_struct | 线程组语义;与 CLONE_NEWPID 冲突 |
CLONE_THREAD | 0x00010000 | signal_struct、tgid | 同一线程组;容器 init 不能用 |
CLONE_SYSVSEM | 0x00040000 | sem_undo_list | System V 信号量的 undo 记录 |
CLONE_PARENT | 0x00008000 | real_parent | 新进程与调用者成为兄弟;init 进程禁用 |
CLONE_PIDFD | 0x00001000 | 返回 pidfd | 运行时用它监控容器 init,避免 PID 复用 |
CLONE_VFORK | 0x00004000 | completion | 父进程挂起直到子进程 execve/exit |
CLONE_NEWTIME | 0x00000080 | time namespace | 5.6+;改 CLOCK_MONOTONIC/BOOTTIME 偏移 |
CLONE_INTO_CGROUP | 0x200000000 | css_set | 5.7+,仅 clone3;创建即入 cgroup |
CLONE_NEWTIME 的值落在 CSIGNAL(0x000000ff)的低位区间内,这是历史遗留:它复用了信号位中未使用的一位。因此在解析 flags 时不能用「低位就是信号」一概而论,必须按 CSIGNAL 掩码单独取。
合法性矩阵:哪些组合会 EINVAL
copy_process 在分配 task_struct 之前就检查标志组合,不合法直接返回 EINVAL。这些检查不是随意设的,每一条都对应一个语义矛盾:
| 组合 | 结果 | 矛盾在哪 |
|---|---|---|
CLONE_NEWNS | CLONE_FS | EINVAL | 共享 fs_struct 又要求独立 mount namespace |
CLONE_NEWUSER | CLONE_FS | EINVAL | user ns 要重算凭证,共享 fs 会绕过 |
CLONE_THREAD 无 CLONE_SIGHAND | EINVAL | 同线程组必须共享信号处理表 |
CLONE_SIGHAND 无 CLONE_VM | EINVAL | 共享信号处理却不同地址空间,处理器无法安全运行 |
CLONE_PARENT + SIGNAL_UNKILLABLE | EINVAL | init 进程不能造出无法被回收的兄弟 |
CLONE_SIGHAND | CLONE_NEWPID | EINVAL | 共享信号表与独立 PID 视图冲突 |
CLONE_SIGHAND | CLONE_NEWUSER | EINVAL | 同上,凭证与信号表不能跨 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_namespace | CLONE_NEWNS |
pid | PID 编号视图 | pid_namespace | CLONE_NEWPID |
net | 网卡、路由、iptables、端口 | net | CLONE_NEWNET |
uts | 主机名、域名 | uts_namespace | CLONE_NEWUTS |
ipc | SysV/POSIX 消息队列、共享内存 | ipc_namespace | CLONE_NEWIPC |
user | uid/gid 映射、capabilities | user_namespace | CLONE_NEWUSER |
cgroup | /proc/PID/cgroup 的根视图 | cgroup_namespace | CLONE_NEWCGROUP |
time | 单调时钟与启动时钟偏移 | time_namespace | CLONE_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:
| 字段 | 类型 | 含义 |
|---|---|---|
count | refcount_t | 引用计数,多个进程可共享同一 nsproxy |
uts_ns | struct uts_namespace * | 主机名 |
ipc_ns | struct ipc_namespace * | IPC 对象 |
mnt_ns | struct mnt_namespace * | 挂载树 |
pid_ns_for_children | struct pid_namespace * | 子进程将使用的 PID ns |
net_ns | struct net * | 网络栈 |
time_ns | struct time_namespace * | 时间偏移 |
cgroup_ns | struct 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 指针。
命令表
| 目的 | 命令 | 读什么 |
|---|---|---|
| 看某进程的全部 ns | ls -l /proc/<pid>/ns/ | 八个链接与 inode |
| 按类型列出 | lsns -t net -o NS,PID,COMMAND | ns inode、进程、命令 |
| 进入已有 ns | nsenter -t <pid> -m -u -i -n -p | 组合进入多个 ns |
| 只进 mount+pid | nsenter -t <pid> -m -p – ps -ef | 容器内进程树 |
| 创建并运行 | unshare --mount --uts --ipc --pid --net --fork | 新 ns + 子进程 |
| 持久 netns | ip netns add n1(bind mount 到 /run/netns/n1) | 无进程时仍存活 |
| 容器宿主 PID | docker inspect -f ‘{{.State.Pid}}’ <id> | 容器 init 的宿主 PID |
| 判断是否同 ns | stat -Lc '%d:%i' /proc/A/ns/net /proc/B/ns/net | 设备号 + inode |
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/clone3 | kernel_clone → copy_process | EINVAL(flags 冲突)、EAGAIN(pids 限额) |
| 隔离 | 同上 | copy_namespaces | EPERM(缺 CAP_SYS_ADMIN) |
| 限额 | 写 cgroup 文件 / CLONE_INTO_CGROUP | cgroup_can_fork、各控制器 | ENOENT(控制器未启用)、EBUSY |
| 换根 | mount、pivot_root | ovl_fill_super、do_pivot_root | EINVAL(不是挂载点)、EBUSY |
| 启动 | execve | bprm_execve | ENOENT、EACCES、ENOEXEC |
五个可观测点
把这条路径变成排障手段,只需要在每一段找一个可读的证据:
| 阶段 | 在宿主上读什么 | 正常值 | 异常说明 |
|---|---|---|---|
| 进程创建 | ps -o pid,ppid,cmd -p <hostpid> | 父进程是 shim 或 runc | 父进程是 1,说明被 double-fork 或 shim 已退出 |
| namespace | lsns -p <hostpid> | 八类 ns 各有 inode | 某类与宿主相同,说明没隔离 |
| cgroup | cat /proc/<hostpid>/cgroup | 0::/<slice>/docker-<id>.scope | 停在 /,说明没加限额 |
| 挂载 | nsenter -t <hostpid> -m findmnt -T / | 根是 overlay | 是宿主根,说明没换根 |
| 程序 | nsenter -t <hostpid> -p -m -- cat /proc/1/cmdline | entrypoint 的 argv | ENOENT,说明 rootfs 缺解释器 |
这五条按顺序读,能把「容器起不来」压缩到某一段:进程在但 ns 不对是运行时配置问题;ns 对但 cgroup 在根是限额没写;cgroup 对但根挂载是宿主是 pivot_root 失败;最后一段失败则是镜像本身缺文件。
宿主的 /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 不等于限额」的直接反例。
pgrep -P "$!"取到多个 PID——unshare --fork的子进程可能还有自己的子进程;用head -1取第一个,或ps -o pid= --ppid <unshare-pid>。nsenter -p后ps报can't open /proc错误——没有--mount-proc,容器内/proc还是宿主的,PID 视图与目录不匹配。clone测试程序在某些内核上返回EPERM而不是EINVAL——若发行版先做 capabilities 检查,顺序可能不同;EINVAL是 6.8 上的预期值。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"] | execve | ENOENT、EACCES、ENOEXEC |
process.env、cwd | PATH=/bin、/ | execve 的 envp、chdir | ENOENT(cwd 不存在) |
process.user | uid/gid/additionalGids | setgroups、setresgid、setresuid | EPERM(uid 无映射)、EINVAL |
process.capabilities | bounding/effective/permitted/inheritable/ambient | capset + PR_CAP_AMBIENT | EPERM、EINVAL(未知能力名) |
process.rlimits | RLIMIT_NOFILE 1024/1024 | setrlimit | EPERM(无 CAP_SYS_RESOURCE 提高硬限) |
process.noNewPrivileges | true | prctl(PR_SET_NO_NEW_PRIVS) | EINVAL |
process.oomScoreAdj | -500 | 写 /proc/self/oom_score_adj | EACCES、EINVAL(越界) |
root.path、root.readonly | "rootfs"、true | mount + pivot_root + 只读 remount | ENOENT、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、gidMappings | containerID/hostID/size | 写 uid_map/gid_map | EPERM、EINVAL(区间重叠) |
linux.cgroupsPath | "oci-demo" | mkdir + 写 cgroup.procs,或 clone3(CLONE_INTO_CGROUP) | EACCES、ENODEV |
linux.resources | memory.limit、cpu.quota、pids.limit | 写 memory.max、cpu.max、pids.max | ENOENT(控制器未在父层启用)、EINVAL |
linux.seccomp | defaultAction + syscalls | seccomp(SECCOMP_SET_MODE_FILTER) | EINVAL(过滤器非法)、EPERM |
linux.maskedPaths、readonlyPaths | /proc/kcore、/proc/sys | bind /dev/null、remount MS_RDONLY | 路径不存在时被静默跳过 |
hooks | createRuntime/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,才是运行时的完整做法。
- 把
ociVersion当能力开关——spec 在同一主版本内前向兼容,运行时遇到不认识的字段通常忽略而不是报错;判断能力要看运行时版本与runc features(1.2+),不要看 JSON 版本号。 mounts[].destination不存在——runc 不会替你mkdir大多数目标目录,mount返回ENOENT;在 rootfs 里预建/proc、/dev、/tmp,或在 hook 里创建。maskedPaths静默失效——目标路径不存在时 runc 跳过该条,不报错;验证要在容器里cat /proc/kcore看是否Permission denied,而不是看 config 里写了没有。root.readonly=true就以为全只读——/proc、/sys、/dev、/tmp与 volume 各自挂载,仍可写;/proc/sys可写意味着能改宿主内核参数,需配合readonlyPaths与去掉CAP_SYS_ADMIN。- 忘记
gidMappings的setgroups前提——非特权用户写gid_map前必须先echo deny > /proc/PID/setgroups,否则EPERM;uid_map没有这个前置但同样要求区间在/etc/subuid内。 pids.max打满时runc create报EAGAIN——pids在can_fork阶段就拒绝,错误点不在execve;看到fork/exec ... resource temporarily unavailable先查pids.current与pids.events。- 改
ENV就以为镜像层全变——镜像 ID 是 config 的 digest,改环境变量只产生新 config 与新镜像 ID,RootFS.Layers(diffID,未压缩)不变;manifest里的 layer digest 是压缩后的,与RootFS.Layers不是同一个值。 runc delete删不掉 running 容器——必须先kill并等进程退出,否则 state 目录残留;脚本清理用runc delete -f,但-f只删元数据,不保证挂载点已卸载。
5.1.14 本章陷阱清单
下面 14 条是「把三件套拼起来」时最容易踩的边界,按现象、原因、修复排列。与各节末尾的 errata 不重复:这里讲的是组合之后才出现的问题。
| # | 现象 | 原因 | 修复/验证 |
|---|---|---|---|
| 1 | 容器退出后残留僵尸进程 | 容器 PID 1 不负责 wait,孤儿被 reparent 到它 | 用 --init/tini,或让 PID 1 显式回收 |
| 2 | docker 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 文件 |
| 6 | overlay 的 upper 放在 NFS 上挂载失败 | overlayfs 要求 upper/work 支持 d_type 与 xattr | upper 放本地盘;NFS 只做 lower |
| 7 | 改了 Dockerfile 一行,构建却全部重跑 | 层缓存按指令与上下文摘要,COPY 变化会让后续层失效 | 把变化少的指令放前面,用多阶段构建 |
| 8 | docker 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 |
| 14 | docker export 出来的 rootfs 起不来 | export 丢配置、层历史与环境变量 | 用 docker save 保留镜像;export 只取文件 |
--read-only不等于全只读:它只把 rootfs 挂成只读,/proc、/sys、/dev、volume 仍可写;要完整只读需配合--tmpfs与--mount type=bind,ro。--user 1000不等于 user namespace:它只改进程 uid,宿主上仍是同一个 uid 数字,没有映射;真正的隔离要userns-remap或 rootless 运行时。--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* 对应的内核对象 | 至少五个 namespace | 5.1.9 |
解释 CLONE_THREAD 为什么不能配 CLONE_NEWPID | 提到信号表与线程组 | 5.1.9 |
| 判断两个进程是否在同一 namespace | 会比较 st_dev:st_ino | 5.1.9 |
画出 clone → copy_namespaces → execve 的路径 | 三个入口函数不缺 | 5.1.10 |
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/目录项与只读层) |
| OCI | runtime-spec(config.json、生命周期) | 5.1.6、5.5 | 精选(镜像与运行时配置的分界) |
| 内核文档 | Documentation/admin-guide/namespaces/、Documentation/filesystems/overlayfs.rst | 5.1.9、5.1.10 | 精选(nsfs ioctl、挂载语义) |
下一站:5.2 namespace 深入 把七种 namespace 逐个拆开:
clone/unshare/setns的差别、/proc/PID/ns的 inode、PID 1 的信号语义,以及nsenter排障。