Docker-10 底层原理: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 |
| UTS | hostname 和 domain name |
| IPC | System V IPC、POSIX 消息队列、共享内存 |
| User | UID/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。它有两个特殊责任:
- 收到终止信号时要正确处理,或者由 init 代理转发。
- 回收孤儿进程,避免僵尸积累。
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 | grep -iE '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 | grep 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};
grep -E "^(Name|Pid|NSpid|Cap|Seccomp)" /proc/1/status;
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 permitted |
namespace、seccomp、capability、rootless 限制 |
failed to mount |
rootfs、overlayfs、挂载传播、文件系统或权限 |
| 容器瞬间退出且无日志 | entrypoint、PID 1、runtime 创建阶段或 stdout 配置 |
不要只把所有 runtime 错误归因于 Dockerfile;同一个镜像在不同内核、架构、rootless 和安全策略下可能表现不同。
12. rootless 与 rootful 的对照实验
在支持 rootless 的 Linux 用户环境中,比较:
docker info | grep -iE '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 重启等问题。
| 上一篇 | 下一篇 |
|---|---|
| 09-Compose 多容器编排 | 11-安全性能与可观测性 |