目录

Docker-08 底层原理:namespace、cgroup、overlayfs、containerd 与 runc

1. 容器不是内核对象,而是机制组合

Linux 内核没有一个叫“container”的单独系统调用。容器运行时把多个内核机制组合起来:

namespace    决定进程能看见什么
cgroup       决定进程最多能用多少资源
mount/overlayfs  拼出容器根文件系统
capability/seccomp/LSM  限制进程能做什么
veth/bridge/netfilter   连接容器网络

因此,“容器隔离”要拆开讨论:PID 隔离不等于资源隔离,资源限制不等于安全沙箱,根文件系统隔离也不等于不能访问宿主设备。

2. namespace:改变进程看到的世界

常见 namespace:

类型隔离内容
PID进程 ID 视图,容器内主进程可见为 PID 1
Mount挂载点和根文件系统视图
Network网卡、地址、路由、端口、netfilter
UTShostname 和 domain name
IPCSystem V IPC、POSIX 消息队列、共享内存
UserUID/GID 和 capability 映射
Cgroup进程看到的 cgroup 层级视图
Time部分时钟偏移

查看当前进程的 namespace:

readlink /proc/$$/ns/*
lsns
PID=$(docker inspect -f '{{.State.Pid}}' api)
sudo nsenter -t "$PID" -a readlink /proc/self/ns/*

namespace 通过 clone()unshare()setns() 创建/加入。运行时先创建隔离环境,再在其中挂载 proc、sysfs、rootfs 等文件系统,最后执行应用。

2.1 PID namespace 的 PID 1

容器内的第一个进程是该 PID namespace 的 PID 1。它有两个特殊责任:

  1. 收到终止信号时要正确处理,或者由 init 代理转发。
  2. 回收孤儿进程,避免僵尸积累。
docker run --init example/api:dev

--init 注入轻量 init(通常是 tini 类程序),不能替代应用自己的优雅关闭逻辑。

3. cgroup:限制和统计资源

cgroup 通过层级组织进程,对 CPU、内存、IO、PIDs 等资源进行限制、统计和事件通知。现代 Linux 主要使用 cgroup v2:

stat -fc %T /sys/fs/cgroup
cat /proc/self/cgroup
docker exec api cat /sys/fs/cgroup/memory.max
docker exec api cat /sys/fs/cgroup/memory.current

常见概念:

  • memory.max:硬上限,超过可能触发 cgroup OOM kill。
  • memory.high:压力阈值,超出后进行回收和节流,但不一定马上杀进程。
  • cpu.max:周期/配额,表达 CPU 带宽上限。
  • pids.max:进程/线程数量上限,防 fork bomb。
  • io.max:块设备 I/O 限制,具体能力依内核和存储设备而定。

Docker 的 --memory--cpus--pids-limit 最终会映射到 cgroup 控制器。CPU 限制是配额/带宽,和“绑定某几个 CPU 核”不是一回事;--cpuset-cpus 才是 CPU 集合约束。

3.1 OOM 的两种层级

宿主机全局内存不足和容器 cgroup 超限不是一回事:

docker inspect api --format '{{.State.OOMKilled}} {{.State.ExitCode}}'
docker stats --no-stream api
sudo dmesg -T | rg -i 'oom|killed process|memory cgroup'

退出码 137 只说明收到 SIGKILL,常见但不唯一的原因是 OOM。要结合 memory.events、应用日志、宿主机 dmesg 和进程现场判断。

4. overlayfs:镜像层如何合成

overlayfs 用 lowerdir、upperdir、workdir 和 merged 目录组成一个联合视图:

lowerdir:镜像只读层(多个)
upperdir:容器可写层
workdir :overlayfs 工作目录
merged  :进程看到的根文件系统

读取文件时,overlayfs 从上层向下层查找;修改 lowerdir 中的文件时会发生 copy-up,把文件复制到 upperdir 后再修改;删除下层文件会记录 whiteout。大量小文件修改可能带来 copy-up 开销和额外磁盘占用。

查看存储驱动:

docker info --format '{{.Driver}}'
mount | rg overlay
docker inspect api --format '{{json .GraphDriver.Data}}'

不要直接操作 Docker 内部的 layer 目录。驱动、rootless 和 Desktop VM 的目录结构可能不同,手工修改会破坏元数据。

5. 一次 docker run 的运行时调用链

CLI
 ↓ Engine API
dockerd
 ↓ 生命周期请求
containerd
 ↓ 创建 task / snapshot / shim
containerd-shim
 ↓ OCI bundle + config.json
runc create/start
 ↓ clone/unshare、mount、pivot_root、cgroup、seccomp
容器 PID 1

runc create 通常创建并配置容器环境,runc start 才启动用户进程。具体调用细节会随版本、rootless 模式和 runtime 配置变化,但边界大致稳定:Docker 做产品层管理,containerd 做生命周期,runc 做一次性的低层隔离设置。

5.1 OCI bundle

OCI runtime 需要一个 bundle,里面包括:

bundle/
├── config.json   # root、namespace、mount、process、linux.resources 等
└── rootfs/       # 已准备好的容器根文件系统

config.json 不是 Dockerfile,也不是镜像本身;它是某次容器启动时由上层运行时生成的执行配置。

6. capability、seccomp 和 LSM

传统 root 权限被拆成多个 capability。Docker 默认会丢弃一部分高风险 capability,但容器仍不是普通用户进程:

docker run --rm --cap-drop=ALL \
  --cap-add=NET_BIND_SERVICE \
  example/api:dev

seccomp 通过系统调用过滤器限制进程能调用的 syscall;AppArmor/SELinux 等 LSM 根据安全策略限制对象访问:

docker run --rm --security-opt no-new-privileges:true example/api:dev
docker run --rm alpine sh -c 'grep Seccomp /proc/self/status'

--privileged 会显著放宽 capability、设备和安全限制,不能把它当作“调试开关”长期保留。需要某一项权限时,使用最小的 --cap-add、设备白名单和只读挂载。

7. rootless 的原理和代价

rootless Docker 使用 user namespace 把容器内 UID 0 映射为宿主机普通用户 UID,并以非 root 身份运行 daemon。这样 daemon 被利用后直接修改宿主 root 文件的路径变少,但不是绝对沙箱:

  • 低端口绑定需要额外机制。
  • 网络常使用 slirp4netns/pasta,性能和特性不同。
  • cgroup 管理依赖 systemd delegation 和内核配置。
  • overlayfs、设备、挂载和 NFS 等能力可能受限。

rootless 适合降低共享开发机和部分 CI 场景风险,是否用于生产要以业务需要、内核和运行时支持矩阵为准。

8. 为什么 daemon 重启后容器可能继续运行

containerd-shim 把容器任务与 containerd/dockerd 的管理进程解耦。daemon 重启时,shim 仍可持有容器进程和 IO;daemon 恢复后再重新同步状态。live-restore 可以减少 daemon 重启对容器的影响,但:

  • 内核重启、宿主机断电和存储损坏仍会中断容器。
  • 版本升级、runtime 变化和网络重建可能有额外影响。
  • 它不是跨主机高可用,也不替代多副本和持久化数据。

9. 从原理回到排障

容器启动失败时按层定位:

镜像层:镜像架构、entrypoint、动态库、权限
rootfs:overlayfs、挂载点、只读层、磁盘
namespace:PID、mount、network 是否符合预期
cgroup:memory.max、cpu.max、pids.max 是否触发
安全:capability、seccomp、SELinux/AppArmor 拒绝
运行时:containerd、shim、runc、dockerd 日志
应用:配置、端口、依赖、优雅退出

证据命令:

docker info
docker inspect api
docker events --since 15m
sudo journalctl -u docker -u containerd --since 15m
sudo crictl ps 2>/dev/null || true

10. 用 Linux 证据重建容器

10.1 同一进程的宿主视图和容器视图

docker run -d --name internals-lab \
  --memory 256m --cpus 0.5 \
  alpine:3.20 sh -c 'while true; do sleep 60; done'

PID=$(docker inspect -f '{{.State.Pid}}' internals-lab)
echo "host pid=$PID"
readlink /proc/$PID/ns/{pid,mnt,net,user,cgroup}
sudo nsenter -t "$PID" -p -m -n -u -i sh -c '
  echo "container pid=$$";
  readlink /proc/self/ns/{pid,mnt,net,user,cgroup};
  cat /proc/1/status | rg "^(Name|Pid|NSpid|Cap|Seccomp)";
  ip route;
  findmnt -R /
'

NSpid 可以显示一个进程在嵌套 PID namespace 中的多个编号;/proc/<pid>/ns/* 的 inode 相同意味着位于同一个 namespace。进入 mount namespace 后仍要正确挂载 proc,单纯 nsenter -t PID -p 可能看到旧的 /proc 视图。

10.2 观察 cgroup v2 资源限制

docker inspect internals-lab --format '{{json .HostConfig}}' | jq \
  '{memory: .Memory, nano_cpus: .NanoCpus, pids: .PidsLimit}'

sudo cat /proc/$PID/cgroup
CGROUP_PATH=$(awk -F: '$1=="0" {print $3}' /proc/$PID/cgroup)
sudo cat "/sys/fs/cgroup${CGROUP_PATH}/memory.max"
sudo cat "/sys/fs/cgroup${CGROUP_PATH}/memory.current"
sudo cat "/sys/fs/cgroup${CGROUP_PATH}/cpu.max"
sudo cat "/sys/fs/cgroup${CGROUP_PATH}/cpu.stat"

容器内看到的 cgroup namespace 可能隐藏宿主完整路径;需要把容器 PID、/proc/<pid>/cgroup 和宿主 cgroup mount 对照起来。CPU limit 是配额,不是保证应用永远获得某个固定 CPU;memory limit 也不代表应用可以安全使用到同样大小的 heap。

10.3 观察 overlayfs 的 copy-up

docker exec internals-lab sh -c 'dd if=/dev/zero of=/tmp/big bs=1M count=16'
docker diff internals-lab
docker inspect internals-lab --format '{{json .GraphDriver.Data}}'
docker system df -v

应用修改镜像层文件时,overlayfs 需要把文件复制到 upperdir;如果文件很大,即使只改一个字节也可能产生明显写放大。docker diff 只展示文件变化,不等同于完整磁盘成本;实际目录和层引用由 storage driver 管理,不要手工删除。

11. runc/OCI 层的调试边界

当容器连 entrypoint 都没有启动时,应用日志往往为空,应优先查 runtime 和 daemon:

docker events --since 10m
sudo journalctl -u docker -u containerd --since 10m
docker inspect internals-lab
containerd --version
runc --version

常见层次:

症状优先怀疑
exec format error镜像架构/二进制架构不匹配
no such file or directory 但文件存在动态链接器或 shebang 指向的解释器缺失
permission denied用户、挂载、SELinux/AppArmor、capability
operation not permittednamespace、seccomp、capability、rootless 限制
failed to mountrootfs、overlayfs、挂载传播、文件系统或权限
容器瞬间退出且无日志entrypoint、PID 1、runtime 创建阶段或 stdout 配置

不要只把所有 runtime 错误归因于 Dockerfile;同一个镜像在不同内核、架构、rootless 和安全策略下可能表现不同。

12. rootless 与 rootful 的对照实验

在支持 rootless 的 Linux 用户环境中,比较:

docker info | rg -i 'rootless|security|storage driver|cgroup'
docker run --rm alpine:3.20 id
docker run --rm alpine:3.20 sh -c 'cat /proc/self/uid_map; cat /proc/self/gid_map'

rootless 容器内看到的 UID 0 可能映射到宿主普通 UID;不是所有设备、低端口、cgroup controller 和存储驱动都可用。安全收益来自减少宿主 root 直接权限,不等于忽略内核漏洞、网络和应用安全。

清理:

docker rm -f internals-lab

13. 本篇小结

Docker 的“轻量”来自复用宿主机内核,而不是少了一套神奇的操作系统。namespace 负责视图,cgroup 负责资源,overlayfs 负责镜像层,containerd/runc 负责把这些机制组装成进程。理解每一层的边界,才能解释 OOM、端口、权限、容器逃逸和 daemon 重启等问题。