5.5 OCI 运行时与容器生命周期
一条 docker run 背后到底有几个进程?为什么 runc 创建完容器就退出,容器却还活着?config.json 里哪些字段对应前面讲过的 namespace、cgroup、rootfs?docker stop 等 10 秒才 kill -9,这 10 秒里发生了什么?
画出 CLI → dockerd → containerd → shim → runc → 容器进程的调用链;手写一个 bundle 并用 runc 跑起来;给容器装一个会回收僵尸、转发信号的 init;用 capabilities/seccomp/no_new_privs 三层收窄容器里 root 的权力。
前四章把容器的零件拆开讲完了:namespace 提供视图,cgroups 提供限额,overlayfs 提供分层根文件系统,安全机制收窄权限。这一章讲把这些零件装配起来的标准——OCI(Open Container Initiative)runtime spec,以及最常见的实现 runc。
标准化的价值在于解耦。镜像格式(OCI image spec)与运行时配置(OCI runtime spec)分开,于是构建工具(Buildah、Kaniko)、镜像仓库(Harbor、registry)、运行时(runc、crun、Kata)可以各自演进。Docker 不再是「容器」的代名词,只是这套标准的一个发行版。
这一章会先理清守护进程的分工,再逐字段拆 config.json,然后用 runc 手工走一遍 create/start/kill/delete,最后落到「容器里的 1 号进程」与三层安全机制。所有 runc 命令都需要 root,并在末尾给出清理步骤。
5.5.1 五层分工:从 CLI 到容器进程
在 Docker 26 + containerd 1.7 的栈里,一条 docker run 会经过五个角色:
docker CLI │ REST over /var/run/docker.sock ▼ dockerd(镜像、网络、卷、API) │ gRPC ▼ containerd(镜像解包、容器元数据、任务管理) │ Task API ▼ containerd-shim-runc-v2(每个容器一个,常驻) │ fork/exec ▼ runc(短命进程:create → start → exit) │ clone / mount / pivot_root / execve ▼ 容器进程(PID 1 in its PID namespace) ▲ └── shim 持有 stdio、回收僵尸、把退出码报给 containerd
读这张图要抓住「谁常驻、谁短命」:dockerd 与 containerd 是长期运行的守护进程;runc 只在创建/启动的一瞬间存在;shim 才是容器进程的父进程,常驻到容器退出。所以重启 dockerd 或 containerd 不会杀掉容器——它们只是控制面,数据面在 shim 与容器进程里。
| 角色 | 生命周期 | 主要职责 | 失败时的影响 |
|---|---|---|---|
docker CLI | 一次命令 | 解析参数、调 API、attach 终端 | 命令失败,容器不受影响 |
dockerd | 常驻 | 镜像/网络/卷管理、API、日志驱动 | 不能新建容器;已有容器继续跑 |
containerd | 常驻 | 镜像解包、snapshot、任务生命周期、CRI | 不能管理任务;shim 与容器继续跑 |
containerd-shim-runc-v2 | 每容器一个 | 持有 stdio、回收僵尸、转发信号、上报退出码 | 容器失去控制通道,进程仍在 |
runc | 秒级 | 建 namespace/cgroup、挂 rootfs、execve | 创建失败,容器起不来 |
Kubernetes 走的是另一条入口:kubelet 通过 CRI(Container Runtime Interface)调用 containerd 或 CRI-O,之后的 shim/runc 链路相同。crictl 是 CRI 的命令行客户端,ctr 是 containerd 的原生客户端,nerdctl 是兼容 Docker CLI 的 containerd 前端。三者最终都落到同一套 OCI runtime。
如果 containerd 直接 fork/exec 容器进程,那么容器就是 containerd 的子进程:containerd 重启、升级、崩溃都会影响容器;容器的大量 stdout/stderr 也会压在一个守护进程上。shim 把这两件事接过来:它被 init 收养,持有容器的 stdio 与 exit status,容器退出后由它上报并退出。代价是每个容器多一个常驻进程,几百个容器就是几百个 shim。
5.5.2 OCI runtime spec:bundle 与 config.json
OCI runtime spec 定义的不是「容器长什么样」,而是「运行时如何被调用」。它的核心概念只有两个:
- bundle:一个目录,里面必须有
config.json与rootfs/; - config.json:描述进程、根文件系统、namespace、cgroup、安全设置、钩子。
bundle 是 OCI runtime 的输入单位。config.json 里的 root.path 指向 rootfs(相对 bundle 或绝对路径),运行时据此 pivot_root;process.args 是容器内要执行的命令;linux.namespaces 列出要创建/加入的 namespace;linux.resources 描述 cgroup 限额。runc spec 会生成一份默认配置,runc run {id} 读取当前目录的 bundle。
一份精简后的 config.json(runc spec 生成,删去大量默认字段):
{
"ociVersion": "1.0.2",
"process": {
"terminal": false,
"user": { "uid": 0, "gid": 0 },
"args": ["/bin/sh", "-c", "echo hello from container; id; sleep 2"],
"env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],
"cwd": "/",
"capabilities": {
"bounding": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_KILL"],
"effective": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_KILL"],
"permitted": ["CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_KILL"]
},
"rlimits": [{ "type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024 }],
"noNewPrivileges": true
},
"root": { "path": "rootfs", "readonly": false },
"hostname": "runc-demo",
"mounts": [
{ "destination": "/proc", "type": "proc", "source": "proc" },
{ "destination": "/dev", "type": "tmpfs", "source": "tmpfs",
"options": ["nosuid", "strictatime", "mode=755", "size=65536k"] }
],
"linux": {
"namespaces": [
{ "type": "pid" }, { "type": "network" }, { "type": "ipc" },
{ "type": "uts" }, { "type": "mount" }, { "type": "cgroup" }
],
"uidMappings": [],
"gidMappings": [],
"cgroupsPath": "/runc-demo",
"resources": {
"cpu": { "quota": 50000, "period": 100000 },
"memory": { "limit": 67108864 },
"pids": { "limit": 64 }
},
"maskedPaths": ["/proc/kcore", "/proc/keys", "/sys/firmware"],
"readonlyPaths": ["/proc/sys", "/proc/sysrq-trigger", "/proc/irq"],
"seccomp": {
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{ "names": ["read", "write", "exit", "exit_group", "futex",
"mmap", "mprotect", "brk", "rt_sigreturn"], "action": "SCMP_ACT_ALLOW" }
]
}
},
"hooks": {
"createRuntime": [{ "path": "/usr/bin/true" }],
"poststop": [{ "path": "/usr/bin/true" }]
},
"annotations": { "com.example.owner": "tutorial" }
}
字段与前面章节的对应关系:
| 字段 | 作用 | 对应章节 |
|---|---|---|
process.args/env/cwd | 容器内执行的命令与环境 | 5.5.3 |
process.capabilities | 五个能力集合 | 5.5.5 |
process.noNewPrivileges | 禁止 exec 提权 | 5.5.5 |
root.path/readonly | rootfs 与只读根 | 5.4 |
mounts | 额外挂载(proc/dev/tmpfs/volume) | 5.2.7 |
linux.namespaces | 要创建/加入的 namespace;path 可加入已有 | 5.2 |
linux.uidMappings/gidMappings | user namespace 映射 | 5.2.6 |
linux.cgroupsPath | 容器 cgroup 路径 | 5.3 |
linux.resources | cpu/memory/pids/blockIO 限额 | 5.3 |
linux.seccomp | 系统调用过滤 | 5.5.5 |
linux.maskedPaths/readonlyPaths | 屏蔽/只读关键路径 | 5.5.5 |
hooks | 生命周期钩子 | 5.5.3 |
注意 seccomp 在 linux 下,不在 process 下;capabilities 在 process 下。这是 OCI spec 的历史布局,写配置时最容易放错。runc spec 会生成一份带默认 seccomp 策略的配置;如果你手写,defaultAction 设为 SCMP_ACT_ERRNO 而不放行足够多的系统调用,容器会在 execve 后立刻失败。
runc spec 生成的默认配置还包含 process.apparmorProfile、process.oomScoreAdj、linux.devices、linux.sysctl 等字段。不要在没有理解的情况下删字段:linux.namespaces 少一项就少一层隔离,maskedPaths 清空就多暴露一批内核接口。
5.5.3 runc 生命周期:create、start、kill、delete
OCI runtime 的命令集不多,核心是五个:
| 命令 | 作用 | 状态变化 |
|---|---|---|
create | 创建容器(namespace、cgroup、rootfs、init 进程),但不执行用户进程 | 无 → created |
start | 放行 init,执行 process.args | created → running |
exec | 在运行中的容器里再起一个进程 | 不变 |
kill | 向容器 init 发信号 | 可能 → stopped |
delete | 删除容器资源(cgroup、命名空间引用、状态目录) | stopped → 无 |
runc run 是 create + start + 等待退出的组合;runc state 输出 JSON 状态;runc list 列出所有容器;runc pause/resume 用 cgroup freezer 冻结/解冻。
runc create
├─ 读取 config.json + rootfs
├─ clone(CLONE_NEW* ) 创建 init 进程
├─ 挂载 rootfs(overlay),pivot_root
├─ 创建 cgroup,写 cpu.max/memory.max/pids.max
├─ 写 uid_map/gid_map(若启用 user ns)
├─ 装载 seccomp、设置 capabilities、no_new_privs
└─ init 阻塞在 exec fifo 上 → 状态 created
runc start
└─ 往 exec fifo 写一个字节
└─ init 读到后 execve(process.args) → 状态 running
runc kill {id} SIGTERM
└─ 向容器 PID 1 发信号;进程退出后 → 状态 stopped
runc delete {id}
└─ 删 cgroup、清理状态目录、释放 namespace 引用
这张图解释了「为什么 runc create 之后容器里还没有你的进程」:init 已经建好了 namespace 与 rootfs,但它停在一个 FIFO 上等 start。这个设计让运行时有机会在用户进程启动前完成 cgroup、网络、hook 等配置;Kubernetes 的 pod sandbox 也利用了这个窗口。
下面用 busybox 的 rootfs 走一遍完整生命周期。先准备 bundle:
# 需要 root;安装 runc 并准备 rootfs
sudo apt-get install -y runc
mkdir -p /tmp/runc-demo/rootfs
docker export "$(docker create busybox)" | tar -C /tmp/runc-demo/rootfs -xf -
cd /tmp/runc-demo
sudo runc spec
runc spec 生成的 config.json 默认 terminal: true,在非交互环境里跑会失败。改成 false,并把命令换成一个会停留的进程:
# 需要 root
sudo jq '.process.terminal = false
| .process.args = ["/bin/sh","-c","echo hello; hostname; id; sleep 2"]' \
config.json > /tmp/c.json
sudo mv /tmp/c.json config.json
sudo runc run demo
# hello
# runc-demo
# uid=0(root) gid=0(root) groups=0(root)
runc run demo 会阻塞到容器退出。要看 create/start 的分离,换一个长命令:
# 需要 root
sudo jq '.process.args = ["/bin/sleep","1000"]' config.json > /tmp/c.json
sudo mv /tmp/c.json config.json
sudo runc create demo2
sudo runc state demo2 | jq '{id, status, pid, bundle}'
# {
# "id": "demo2",
# "status": "created",
# "pid": 12345,
# "bundle": "/tmp/runc-demo"
# }
sudo runc start demo2
sudo runc state demo2 | jq -r .status
# running
sudo runc exec demo2 /bin/echo "exec 进入同一个 namespace"
sudo runc kill demo2 TERM
sleep 0.5
sudo runc state demo2 | jq -r .status
# stopped
sudo runc delete demo2
清理:
sudo runc delete -f demo 2>/dev/null || true
sudo rm -rf /tmp/runc-demo
如果 runc create 报 cgroup 相关错误(不同发行版的 cgroup 挂载与委托不同),把 linux.cgroupsPath 显式设成一个绝对路径再试:
sudo jq '.linux.cgroupsPath = "/runc-demo"' config.json > /tmp/c.json
sudo mv /tmp/c.json config.json
这个路径会出现在 /sys/fs/cgroup/runc-demo,runc delete 时会一并删除。生产环境通常由 containerd 生成 cgroupsPath,不需要手工指定。
- 「
runc run的terminal: true在脚本里也能跑」——没有 TTY 时会报错。脚本里先改成false,或加--console-socket。 - 「
runc create之后进程已经在跑」——没有。init 停在 exec fifo 上,必须runc start才执行process.args。 - 「
runc delete会自动杀进程」——对运行中的容器会拒绝;要--force,或先kill。删除只清理资源,不负责优雅退出。 - 「
runc run退出后容器还在」——runc run是前台等待,容器退出后它返回;要后台常驻,用create+start或交给 containerd。 - 「rootfs 里有
/bin/sh就够」——动态链接的程序还需要ld-linux与库;用 busybox 静态二进制最省事,或docker export一个完整 rootfs。
5.5.4 容器里的 1 号进程:信号与僵尸
容器进程在它的 PID namespace 里是 1 号。5.2.5 讲过 PID 1 的三条规则,这里看它们在运行时的后果:
- 不处理
SIGTERM就收不到。很多镜像的ENTRYPOINT写成sh -c "java -jar app.jar",sh是 PID 1,它不会把SIGTERM转发给java。docker stop等 10 秒后只能SIGKILL,应用来不及优雅关闭。 - 不
wait就会积累僵尸。容器里的孤儿进程会挂到 PID 1 名下;如果 PID 1 不回收,ps里会出现大量Z。 - PID 1 退出,整个容器被杀。这是
docker stop最后SIGKILL能生效的原因,也是「主进程崩溃,sidecar 一起退出」的机制。
docker stop 的时序(Docker 26 默认):
docker stop ctr
├─ 向容器 PID 1 发 SIGTERM
├─ 等待 stop-timeout(默认 10 秒)
├─ 仍未退出 → 发 SIGKILL
└─ 容器状态变为 stopped,退出码 137(128 + 9)
两个修法:让应用直接作为 PID 1(exec 形式的 ENTRYPOINT),或加一个 init 进程。Docker 的 --init 会注入 docker-init(基于 tini),它负责转发信号与回收僵尸:
# 对比:shell 形式不转发信号,exec 形式会
docker run --rm --name sig1 busybox sh -c 'sleep 300' &
sleep 1
time docker stop sig1 # 约 10 秒
docker run --rm --name sig2 busybox sleep 300 &
sleep 1
time docker stop sig2 # 很快
第一个容器里 PID 1 是 sh,它收到 SIGTERM 后不会转发给 sleep,只能等 10 秒后被 SIGKILL。第二个容器里 sleep 直接是 PID 1,收到 SIGTERM 就退出。时间差就是「有没有正确的 init」的代价。
如果应用自己不做信号处理与回收,可以写一个最小 init。下面这个程序是 --init 的简化版:
/* tinyinit.c — 最小容器 init:转发 SIGTERM、回收任意子进程 */
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
static volatile sig_atomic_t stop;
static void on_term(int sig)
{
(void)sig;
stop = 1;
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0; /* 不用 SA_RESTART:让 waitpid 被信号打断后返回 EINTR */
if (sigaction(SIGTERM, &sa, NULL) == -1 || sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return EXIT_FAILURE;
}
pid_t child = fork();
if (child == -1) {
perror("fork");
return EXIT_FAILURE;
}
if (child == 0) {
execl("/bin/sleep", "sleep", "1000", (char *)NULL);
perror("execl");
_exit(127);
}
while (!stop) {
int status;
pid_t p = waitpid(-1, &status, 0);
if (p == -1) {
if (errno == EINTR) continue; /* 回到循环顶检查 stop */
if (errno == ECHILD) break; /* 没有子进程了 */
perror("waitpid");
break;
}
printf("init: reaped pid=%d status=0x%x\n", (int)p, status);
fflush(stdout);
}
if (kill(child, SIGTERM) == -1 && errno != ESRCH)
perror("kill");
while (waitpid(-1, NULL, 0) > 0)
;
printf("init: exiting\n");
return 0;
}
编译并在 PID namespace 里运行:
# 需要 root
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g tinyinit.c -o tinyinit
sudo unshare --pid --fork --mount-proc ./tinyinit &
sleep 0.3
# 从宿主给容器 init 发 SIGTERM;它是 PID 1,但装有处理器
sudo kill -TERM "$(pgrep -n -f '/tmp/.*/tinyinit|./tinyinit')"
wait
预期输出 init: reaped ... 与 init: exiting,而不是等 10 秒后被 SIGKILL。注意 pgrep -f 的匹配串要按你的实际路径调整;也可以先在宿主 ps -ef | grep tinyinit 找到 PID。
Kubernetes 里对应的字段是 terminationGracePeriodSeconds(默认 30 秒)与 preStop 钩子:先执行 preStop,再发 SIGTERM,超时后 SIGKILL。它和 docker stop 是同一套语义,只是名字与默认值不同。
因为 sh -c 方便做环境变量展开与多命令串联。代价是信号链断掉。折中方案是 exec 前缀:sh -c 'exec java -jar app.jar',让 sh 被 java 替换,PID 1 变成 java。另一个方案是 --init,把信号转发与收尸交给 tini。最差的是既用 shell 形式、又不加 init,还指望优雅关闭。
5.5.5 capabilities、seccomp 与 no_new_privs
容器里的 root 之所以「不是全能」,靠的是三层独立机制。它们可以叠加,也可以各自被关掉;理解每一层挡住什么,才能判断一个容器的真实攻击面。
第一层:capabilities
Linux 把 root 的特权拆成约 40 个 capability。进程有五个集合:
| 集合 | 含义 |
|---|---|
| Permitted | 进程允许拥有的能力上限 |
| Effective | 当前生效的能力,内核检查用这个 |
| Inheritable | execve 后希望保留给新程序的能力 |
| Bounding | 进程及其后代永远不能获得的能力上限 |
| Ambient | 非特权程序 execve 后仍保留的能力(PR_CAP_AMBIENT) |
Docker 默认给容器 14 个能力:CHOWN、DAC_OVERRIDE、FOWNER、FSETID、KILL、SETGID、SETUID、SETPCAP、NET_BIND_SERVICE、NET_RAW、SYS_CHROOT、MKNOD、AUDIT_WRITE、SETFCAP。CAP_SYS_ADMIN、CAP_SYS_PTRACE、CAP_SYS_MODULE、CAP_NET_ADMIN 都不在其中。
# 默认容器的能力位图
docker run --rm busybox grep '^Cap' /proc/self/status
# CapInh: 0000000000000000
# CapPrm: 00000000a80425fb
# CapEff: 00000000a80425fb
# CapBnd: 00000000a80425fb
# CapAmb: 0000000000000000
# 只留 NET_BIND_SERVICE,其余全部丢弃
docker run --rm --cap-drop=ALL --cap-add=NET_BIND_SERVICE \
busybox grep '^CapEff' /proc/self/status
# CapEff: 0000000000000400
# 宿主上解码
capsh --decode=0000000000000400
# 0x0000000000000400=cap_net_bind_service
CapBnd 是 bounding set:即使容器里的程序是 setuid root,也无法获得 bounding set 之外的能力。--cap-drop=ALL 会清空 effective/permitted/bounding,所以 chown 这种需要 CAP_CHOWN 的操作会返回 EPERM。
第二层:seccomp
seccomp(secure computing mode)用 BPF 程序过滤系统调用。Docker 默认 profile 以允许列表方式工作:列出的系统调用放行,未列出的返回 EPERM(或直接 ENOSYS)。它挡掉的主要是 mount、reboot、kexec_load、add_key、ptrace、bpf、perf_event_open 这类高风险调用。
# Seccomp: 2 表示 filter 模式;0 表示 unconfined
docker run --rm busybox grep '^Seccomp' /proc/self/status
# Seccomp: 2
docker run --rm --security-opt seccomp=unconfined busybox grep '^Seccomp' /proc/self/status
# Seccomp: 0
在 config.json 里,seccomp 位于 linux.seccomp,结构是:
{
"defaultAction": "SCMP_ACT_ERRNO",
"architectures": ["SCMP_ARCH_X86_64"],
"syscalls": [
{ "names": ["read", "write", "close", "exit_group"], "action": "SCMP_ACT_ALLOW" }
]
}
defaultAction 是未匹配时的动作:SCMP_ACT_ALLOW(默认放行)、SCMP_ACT_ERRNO(返回错误)、SCMP_ACT_KILL_PROCESS(杀进程)、SCMP_ACT_LOG(只记录)。上面这段只是结构示例,不能直接当完整 profile 用——放行的系统调用太少,execve 之后的动态链接器会立刻失败。
第三层:no_new_privs
no_new_privs 是一个进程标志:设置后,execve 不再授予任何新特权。setuid 位、setgid 位、文件 capabilities 全部失效;进程只能保持当前权限或更低。
# 默认未设置
docker run --rm busybox grep '^NoNewPrivs' /proc/self/status
# NoNewPrivs: 0
# 打开后为 1
docker run --rm --security-opt no-new-privileges \
busybox grep '^NoNewPrivs' /proc/self/status
# NoNewPrivs: 1
在 OCI 配置里对应 process.noNewPrivileges: true。它防的是「容器内有一个 setuid 的辅助程序,被普通用户拿来提权」这类路径。配合 --cap-drop=ALL 与只读 rootfs,容器里的非 root 用户几乎没有提权路径。
三层机制的对照:
| 机制 | 挡住什么 | 不挡什么 | 配置位置 |
|---|---|---|---|
| capabilities | 特权操作(mount、mknod、ptrace、改时间) | 非特权系统调用里的内核漏洞 | process.capabilities |
| seccomp | 指定系统调用(按号/参数) | 被放行调用内部的漏洞 | linux.seccomp |
| no_new_privs | setuid/文件能力带来的提权 | 已有权限的滥用 | process.noNewPrivileges |
| LSM(AppArmor/SELinux) | 路径/标签级的访问 | 未定义策略的路径 | process.apparmorProfile |
| user namespace | 宿主 uid 与能力作用域 | 内核漏洞、配置错误 | linux.uidMappings |
- 「
--cap-drop=ALL之后容器就没法被攻破了」——它只去掉特权操作;非特权系统调用的内核漏洞仍在,seccomp 与内核补丁同样重要。 - 「seccomp 默认 profile 是黑名单」——Docker 默认是允许列表(default-deny),但允许的调用仍很多;自定义 profile 要按应用实际调用裁剪。
- 「
no_new_privs会阻止容器启动」——不会。它只阻止execve获得新特权;普通程序照常运行。 - 「
--privileged只是加 capability」——它还关 seccomp/AppArmor、放开设备、清空 masked/readonly 路径,等价于宿主 root(见 5.1.7)。 - 「AppArmor 的
docker-default能防一切」——它只覆盖一组路径规则;对CAP_SYS_ADMIN、内核漏洞、挂载宿主目录都无能为力。 - 「rootless 容器一定更安全」——user namespace 缩小了宿主影响,但扩大了内核攻击面;历史上多个提权漏洞与 userns 相关,需要配合 seccomp 与及时打补丁。
5.5.6 逃逸面与加固清单
容器逃逸不是单一漏洞,而是一组「把隔离边界交出去」的配置。下面这张表按危害从高到低排列:
| 逃逸面 | 原理 | 加固 |
|---|---|---|
挂载 /var/run/docker.sock | 容器内可创建 --privileged 容器并挂宿主 / | 不要挂;必须用时通过受限代理 |
--privileged | 全部能力 + 设备 + 关闭 LSM/seccomp | 按需 --cap-add/--device,永不给全量 |
挂载宿主 / 或 /proc、/sys | 直接读写宿主文件与内核接口 | 只挂必要目录,用 :ro,优先 volume |
CAP_SYS_ADMIN | 可 mount、pivot_root、改 cgroup | 除非确有必要,绝不给 |
--pid=host / --net=host | 看见/操作宿主进程与网络 | 用默认隔离;排障时临时 nsenter |
可写 /proc/sys | 改 core_pattern 等实现逃逸 | 保持默认 masked/readonly;rootfs 只读 |
cgroup v1 release_agent | 空 cgroup 触发宿主路径执行 | 用 cgroup v2;旧系统禁用 notify_on_release |
| 内核漏洞(如 Dirty Pipe) | 容器内提权到宿主 root | 及时打内核补丁;seccomp 降低可达性 |
| 设备节点 | 直接访问宿主磁盘 | 默认 devices 白名单;--device 最小化 |
加固的顺序应该是「先减面,再谈检测」:
- 镜像:最小基础镜像、非 root 用户、只读 rootfs、无 shell/包管理器。
- 运行时:
--cap-drop=ALL+ 按需--cap-add、默认 seccomp、no-new-privileges、--pids-limit、--memory/--cpus。 - 挂载:只挂必要路径,能
:ro就:ro;禁止docker.sock与宿主/。 - 网络:默认 bridge,不做
--net=host;出站用 NetworkPolicy/防火墙限制。 - 宿主:内核及时更新、rootless 或 userns-remap、审计
docker run的 privileged 参数。 - 检测:
auditd监控setns/mount/ptrace;定期跑镜像扫描与 CIS benchmark。
# 需要 root;快速审计一个运行中容器的危险配置
docker inspect ctr --format '{{.HostConfig.Privileged}} {{.HostConfig.CapAdd}} {{.HostConfig.PidMode}} {{.HostConfig.NetworkMode}}'
docker inspect ctr --format '{{range .Mounts}}{{.Source}} -> {{.Destination}} {{.RW}}{{"\n"}}{{end}}'
docker inspect ctr --format '{{.HostConfig.SecurityOpt}}'
三条输出分别回答:是否 privileged、加了哪些能力、是否共享宿主 namespace;挂了哪些宿主路径;seccomp/AppArmor 是否被关闭。任何一项异常,都应当作「这个容器等于宿主 root」来处理。
「沙箱」通常假设内部代码完全不可信,边界要能承受任意代码执行;「隔离」假设内部是合作方,边界用于防止误操作与横向移动。默认配置的容器更接近后者:共享内核、共享部分 /sys、默认有 14 个 capability。要在多租户环境跑不可信代码,应该用 microVM(Firecracker/Kata)、gVisor 这类更强边界,而不是给容器加更多 seccomp 规则。
5.5.7 config.json 逐字段:process、root、mounts、hooks
这一节回答:config.json 的每个字段落到内核的哪个机制,写错时容器会以什么方式失败。5.5.2 给了一份精简样例,这里补全字段语义与相互约束。
顶层字段只有九个,但 linux 与 process 下面各有十几个子字段:
| 顶层字段 | 必填 | 作用 | 写错的典型症状 |
|---|---|---|---|
ociVersion | 是 | 规范版本,运行时据此校验字段 | 版本不匹配,runc 拒绝加载 |
process | 是 | 容器内 1 号进程的配置 | 缺 args 则 execve 失败 |
root | 是 | rootfs 路径与只读标志 | pivot_root 失败或根不可写 |
hostname | 否 | UTS namespace 的主机名 | 没有 UTS ns 时写入失败 |
mounts | 否 | 额外挂载,按数组顺序执行 | 顺序错导致覆盖或找不到目录 |
hooks | 否 | 生命周期钩子 | 钩子失败导致创建/启动中止 |
annotations | 否 | 不透明键值,运行时不解译 | 只影响外部工具 |
linux | 是 | namespace、cgroup、安全设置 | 少一项就少一层隔离 |
windows/solaris/vm | 否 | 其他平台专属 | 本书不涉及 |
process 是容器进程的「出生证明」:
| 字段 | 类型 | 语义 | 内核落点 |
|---|---|---|---|
terminal | bool | 是否分配伪终端 | openpty/TIOCSCTTY |
consoleSize | {height,width} | 初始终端大小 | TIOCSWINSZ |
user.uid/gid | uint32 | 容器内运行身份 | setresuid/setresgid |
user.umask | uint32 | 文件创建掩码(OCI 1.0.2+) | umask(2) |
args | string[] | argv,args[0] 是可执行文件 | execve(2) |
env | string[] | KEY=VALUE,不做 shell 展开 | execve 的 envp |
cwd | string | 工作目录,必须存在 | chdir(2) |
capabilities | 五个集合 | bounding/effective/permitted/inheritable/ambient | capset(2)、prctl |
rlimits | 数组 | RLIMIT_* 软硬限制 | setrlimit(2) |
noNewPrivileges | bool | 禁止 execve 提权 | prctl(PR_SET_NO_NEW_PRIVS) |
apparmorProfile | string | AppArmor profile 名 | /proc/self/attr/exec |
oomScoreAdj | int | OOM 优先级(-1000..1000) | /proc/self/oom_score_adj |
selinuxLabel | string | SELinux 标签 | /proc/self/attr/exec |
scheduler/ioPriority | object | 调度策略与 IO 优先级(1.1) | sched_setscheduler、ioprio_set |
root 与 mounts 决定容器看到什么文件系统:
| 字段 | 语义 | 注意 |
|---|---|---|
root.path | rootfs 路径,相对 bundle 或绝对路径 | 必须是目录;runtime 会 chdir 到 bundle |
root.readonly | pivot 后把根 remount 成只读 | 不等于「所有挂载只读」,volume 仍可写 |
mounts[].destination | 容器内挂载点,必须是绝对路径 | 目录不存在时 runtime 可能创建,也可能失败 |
mounts[].type | 文件系统类型,如 proc、tmpfs、bind | bind 实际用 MS_BIND,不是独立 fs |
mounts[].source | 源设备或宿主路径 | bind 时必须是存在的路径 |
mounts[].options | nosuid、nodev、rbind、ro 等 | 顺序敏感;rbind 才能递归复制子挂载 |
mounts[].uidMappings/gidMappings | idmapped mount 的映射(OCI 1.1) | 需要内核 5.12+ 与文件系统支持 |
默认 runc spec 生成的 mounts 不是随便写的:先挂 /proc,再用 tmpfs 挂 /dev,在 /dev 下建 /dev/pts、/dev/shm、/dev/mqueue,然后把宿主 /dev/null、/dev/zero、/dev/random 等 bind 进去。少 /dev/null 是最常见的启动失败之一:很多程序在初始化时 open("/dev/null"),失败就直接退出。
hooks 是唯一在「容器外」执行的配置,时机与命名空间必须分清:
| 钩子 | 执行时机 | 在哪个命名空间 | 失败后果 |
|---|---|---|---|
prestart | 已废弃;start 后、用户进程 exec 前(runc 旧实现放在 create 阶段) | runtime | 中止启动 |
createRuntime | namespace 建好、pivot_root 之前 | runtime(宿主) | 中止创建 |
createContainer | pivot_root 之前、容器 mount ns 内 | 容器 mount ns | 中止创建 |
startContainer | 用户进程 exec 之前 | 容器全部 ns | 中止启动 |
poststart | 用户进程已启动 | runtime(宿主) | 记录错误,不回滚 |
poststop | 容器删除后 | runtime(宿主) | 记录错误 |
每个钩子有 path、args、env、timeout(秒)。path 必须是绝对路径;钩子进程继承 runtime 的环境,不是容器的环境。用 createRuntime 配网络、用 poststop 清理 cgroup 之外的外部资源,是常见组合;不要在 createContainer 里做需要宿主网络的操作。
linux 下的字段与 5.2/5.3 一一对应:
| 字段 | 作用 | 对应章节 |
|---|---|---|
namespaces[] | 要创建或加入的 namespace;path 表示加入已有 | 5.2 |
uidMappings/gidMappings | user namespace 的 uid/gid 映射 | 5.2.6 |
cgroupsPath | cgroup 路径 | 5.3 |
resources | cpu/memory/pids/blockIO/hugepage 限额 | 5.3 |
devices[] | 允许的设备和要创建的设备节点 | 5.3、5.5.5 |
sysctl | 写入容器内 /proc/sys 的键值 | 5.2 |
seccomp | 系统调用过滤 | 5.5.5 |
rootfsPropagation | 挂载传播类型:rprivate/slave/shared | 5.5.8 |
maskedPaths | 用 /dev/null 覆盖的路径 | 5.5.5 |
readonlyPaths | bind 后 remount 只读的路径 | 5.5.5 |
mountLabel | SELinux 挂载标签 | 5.5.6 |
personality | 执行域(如 LINUX32) | 少用 |
timeOffsets | time namespace 的偏移(OCI 1.1) | 5.2 |
unified | cgroup v2 原始键值,绕过 resources 映射 | 5.3 |
字段位置最容易放错的三处:capabilities 在 process 下,seccomp 在 linux 下,uidMappings 在 linux 下而不是 process.user 下。runc 对未知字段通常忽略,对放错位置的已知字段则按缺省处理——例如把 noNewPrivileges 写到 linux 下,它不会生效,容器也不会报错。
5.5.8 rootfs 与 pivot_root:为什么不是 chroot
这一节回答:runtime 把 rootfs/ 变成容器里的 / 时,为什么必须用 pivot_root,以及失败时卡在哪一步。
chroot(2) 只改 fs_struct 里的 root 指针,不改变当前工作目录,也不卸载旧根。一个有 CAP_SYS_CHROOT 的进程可以这样逃出去:
/* chroot 逃逸的经典路径:cwd 还在旧根里 */
mkdir("/tmp/jail", 0755);
chroot("/tmp/jail");
chdir("../../.."); /* cwd 仍在旧根,向上爬 */
chroot("."); /* 新根又变成旧根 */
pivot_root(2) 换的是挂载树:把当前根挂载移到 put_old 下,把 new_root 挂载提升为根,然后旧根可以被 umount 掉。它要求:
new_root与put_old都是目录;new_root是一个挂载点(所以 runtime 先mount --bind rootfs rootfs);put_old在new_root之下;new_root与put_old不在当前根的同一个挂载上;- 调用者拥有
CAP_SYS_ADMIN,且当前根不是共享传播(否则旧根会泄漏到宿主)。
内核路径是 SYSCALL_DEFINE2(pivot_root) → do_pivot_root()(fs/namespace.c):先 may_mount() 检查权限,再确认 new_root 是挂载点、put_old 在 new_root 下,随后 detach_mnt() 摘掉旧根与 new_root,用 attach_recursive_mnt() 把 new_root 接到父挂载、把旧根挂到 put_old,最后 chroot_fs_refs() 更新所有共享 fs_struct 的进程。任何一步失败都返回 EBUSY/EINVAL,容器创建中止。
runc 的初始化顺序可以画成:
runc init(在新建的 mount namespace 里)
├─ mount rootfs(overlay 或 bind),必要时 mount --bind rootfs rootfs
├─ mount --make-rprivate / ← 阻止传播到宿主
├─ pivot_root(rootfs, rootfs/.pivot)
├─ umount2("/.pivot", MNT_DETACH) ← 旧根彻底消失
├─ mount /proc、/dev、/sys、/dev/pts…
├─ 写 hostname、sysctl、maskedPaths/readonlyPaths
└─ execve(process.args)
pivot_root(".", ".") 是常见写法:先 chdir(new_root),再 pivot_root(".", "."),此时旧根被挂到当前目录上,紧接着 umount2(".", MNT_DETACH) 把它摘掉。这样不需要预先创建 put_old 目录,也避免了「put_old 必须在 new_root 下」的路径计算。
runc 的 --no-pivot 走另一条路:用 MS_MOVE 把 rootfs 移到 /,再 chroot。它适用于 pivot_root 不可用的环境(例如 rootfs 本身是 ramfs、或运行在受限的宿主里),但失去了「旧根被移走并卸载」的保证,隔离强度弱于默认路径。除非明确知道自己在做什么,不要在生产里加 --no-pivot。
rootfsPropagation 控制挂载传播。默认 rprivate:容器里的挂载事件不会传给宿主,宿主的也不会传进来。设成 shared 时,容器里 mount 一个新文件系统可能出现在宿主挂载表里,这是很危险的配置;slave 只允许宿主→容器单向传播,适合需要看宿主新挂载的场景。
root.readonly: true 的实现是 pivot 之后对整个根做一次 mount -o remount,ro。它不会阻止容器写 volume、tmpfs、/dev/shm,因为这些是独立的挂载;要限制可写层大小,得靠存储驱动配额或 --storage-opt size。
rootless 容器还有一层:pivot_root 需要 CAP_SYS_ADMIN,在 user namespace 里这个能力只作用于该 userns 的挂载树。配合 idmapped mounts,runtime 可以在不 chown 整个 rootfs 的情况下把宿主 uid 映射成容器 uid;这也是 rootless 场景里 pivot_root 仍能工作的原因。
常见的 pivot_root 失败:
| errno | 原因 | 修复 |
|---|---|---|
EBUSY | new_root 或 put_old 不是挂载点,或当前根是共享传播 | 先 mount --bind rootfs rootfs;mount --make-rprivate / |
EINVAL | put_old 不在 new_root 下,或两者在同一挂载 | 检查路径;用 pivot_root(".", ".") 写法 |
EPERM | 缺 CAP_SYS_ADMIN,或 seccomp 拦了 pivot_root | 给能力;检查 seccomp profile |
ENOTDIR | new_root/put_old 不是目录 | 创建目录并确认类型 |
5.5.9 namespace 与 cgroup 的装配顺序
这一节回答:runc 为什么要把自己 re-exec 两次,user namespace 为什么必须先建,cgroup 又为什么由父进程建。
runc 的 libcontainer/nsenter/nsexec.c 用了一个三阶段的 clone:
stage-0(runc 父进程)
└─ clone(CLONE_NEWUSER) → stage-1
stage-1(userns 里的子进程)
├─ 写 /proc/self/uid_map、gid_map(先 deny setgroups)
└─ clone(CLONE_NEWPID|NEWNS|NEWNET|NEWIPC|NEWUTS|NEWCGROUP) → stage-2
stage-2(容器 init)
└─ 等 exec fifo,收 config.json,execve
顺序不能反:user namespace 必须先建,否则在 userns 之外写 uid_map 会得到 EPERM;gid_map 写入前要先写 /proc/self/setgroups 为 deny,否则非特权用户无法映射 gid。PID namespace 要让「容器里的 1 号」成立,必须由 stage-1 再 clone 出 stage-2——在 PID namespace 里,调用 clone 的进程本身仍是父 namespace 的成员。
namespace 与 OCI 字段、clone 标志的对应:
OCI type | clone 标志 | 隔离什么 | 不加会怎样 |
|---|---|---|---|
pid | CLONE_NEWPID | 进程 ID、/proc 视图 | 容器看见宿主全部进程 |
network | CLONE_NEWNET | 网卡、路由、端口、iptables | 与宿主共享网络栈 |
mount | CLONE_NEWNS | 挂载表 | 容器挂载影响宿主 |
ipc | CLONE_NEWIPC | SysV IPC、POSIX mqueue | 与宿主共享消息队列 |
uts | CLONE_NEWUTS | hostname/domainname | 改 hostname 影响宿主 |
user | CLONE_NEWUSER | uid/gid 映射、能力作用域 | 容器 root 就是宿主 root |
cgroup | CLONE_NEWCGROUP | cgroup 根视图 | 容器能看到宿主 cgroup 树 |
time | CLONE_NEWTIME | CLOCK_MONOTONIC/BOOTTIME 偏移 | 时间命名空间不隔离 |
namespaces[] 里带 path 的项表示「加入已有 namespace」,而不是新建。Pod 内容器就是这样共享 sandbox 的 netns:{ "type": "network", "path": "/proc/<sandbox-pid>/ns/net" }。带 path 的项不能同时出现在新建列表里;加入 userns 的顺序也有额外限制。
cgroup 由 runtime 父进程创建,而不是容器 init。原因很直接:容器 init 已经在 PID namespace 里,它看不到宿主 cgroup 树的父层;而且 cgroup 必须在 start 之前就绪,才能在用户进程运行的第一毫秒生效。runc create 的流程是:
- 父进程解析
linux.resources,在cgroupsPath建目录; - 写
cpu.max、memory.max、pids.max等; - clone 出 init,init 通过 sync pipe 等待;
- 父进程把 init 的 PID 写入
cgroup.procs; runc start写 exec fifo,init 才execve。
resources 到 cgroup v2 文件的映射:
| OCI 字段 | cgroup v2 文件 | 换算/注意 |
|---|---|---|
cpu.shares | cpu.weight | 1 + ((shares-2)*9999)/262142,默认 1024 → 100 |
cpu.quota/cpu.period | cpu.max | 写 "quota period";quota=-1 写 max |
cpu.cpus/cpu.mems | cpuset.cpus/cpuset.mems | 写 CPU 列表,如 0-3 |
memory.limit | memory.max | 字节;-1 写 max |
memory.reservation | memory.low | 软保护,不是硬限制 |
memory.swap | memory.swap.max | OCI 的 swap 是「内存+swap 总量」,映射时要减去 limit |
pids.limit | pids.max | 计的是任务数(含线程) |
blockIO.weight | io.weight | 1–10000,默认 100 |
blockIO.throttleReadBpsDevice | io.max | "MAJ:MIN rbps=..." |
hugepageLimits | hugetlb.<size>.max | 按 2 MiB/1 GiB 页大小 |
unified | 任意 cgroup v2 文件 | 绕过上面的映射,原样写入 |
cgroupsPath 有两种写法:绝对路径(/runc-demo)直接落在 cgroup 根下;相对路径(runc-demo)相对于 runtime 的 cgroup 根。使用 systemd cgroup driver 时,路径写成 slice:prefix:name(如 system.slice:runc:demo),由 systemd 创建 scope 并委托给容器。写错形式时,runc create 会报 EPERM 或 ENOENT,而不是「限额不生效」。
runc update 可以在运行中改限额,它把 CLI 参数转成 resources 再写 cgroup 文件;不是所有字段都支持热更新(pids.max 可以,cpuset 的某些组合会失败)。runc events 读取 cgroup 的 memory.events、cpu.stat 并转成 JSON 事件流,适合做轻量监控。
5.5.10 runc 命令、状态机与实验一
这一节回答:run/create/start/exec/kill/delete 各自改什么状态,脚本里怎么判断每一步成功。
| 命令 | 常用参数 | 状态变化 | 说明 |
|---|---|---|---|
runc spec | --rootless、--bundle | 无 | 在当前目录生成 config.json |
runc create | --bundle、--console-socket、--pid-file、--no-pivot | 无 → created | 建 ns/cgroup/rootfs,init 停在 exec fifo |
runc start | <id> | created → running | 写 exec fifo,执行 process.args |
runc run | -d、--console-socket、--pid-file | 无 → running → stopped | create + start,前台等待退出 |
runc exec | --process、-t、--user、--cwd、--env、--cap | 不变 | 在运行中的容器里起新进程 |
runc kill | --all、信号名/编号 | running → stopped | 默认 SIGTERM;只发信号,不等待 |
runc delete | -f | stopped → 无 | 删 cgroup、状态目录、ns 引用 |
runc pause/resume | <id> | running ↔ paused | cgroup freezer |
runc ps | <id> | 不变 | 列出容器内进程 |
runc state | <id> | 不变 | 输出 JSON 状态 |
runc list | -f json | 不变 | 列出所有容器 |
runc update | --memory、--cpu-quota、--pids-limit | 不变 | 热更新 cgroup |
runc events | --interval | 不变 | 输出 cgroup 事件流 |
runc features | 无 | 不变 | 输出运行时支持的 OCI 特性 JSON |
runc state 的 JSON 字段不多,但每个都能用来做断言:
| 字段 | 含义 |
|---|---|
id | 容器 ID |
pid | 容器 init 在宿主上的 PID |
status | creating/created/running/stopped/paused |
bundle | bundle 绝对路径 |
rootfs | rootfs 路径 |
created | 创建时间(RFC3339) |
annotations | config.json 里的 annotations |
owner | 创建者的 uid |
下面的脚本把生命周期串起来,并在每一步检查状态、cgroup 与 namespace。依赖 runc、docker、jq,需要 root。
#!/usr/bin/env bash
# runc-lab.sh — 走一遍 create/start/exec/kill/delete,并验证 ns 与 cgroup
set -euo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root"; exit 1; }
for c in runc docker jq; do
command -v "$c" >/dev/null || { echo "缺少 $c"; exit 1; }
done
B=/tmp/runc-lab
ID="runc-lab-$$"
CID=$(docker create busybox)
cleanup() {
runc delete -f "$ID" 2>/dev/null || true
docker rm -f "$CID" >/dev/null 2>&1 || true
rm -rf "$B"
}
trap cleanup EXIT
mkdir -p "$B/rootfs"
docker export "$CID" | tar -C "$B/rootfs" -xf -
cd "$B"
runc spec
jq '.process.terminal = false
| .process.args = ["/bin/sh","-c","echo boot; hostname; id; sleep 30"]
| .linux.cgroupsPath = "/runc-lab"' config.json > config.new
mv config.new config.json
runc create "$ID"
runc state "$ID" | jq '{id,status,pid,bundle}'
[[ $(runc state "$ID" | jq -r .status) == created ]] || { echo "create 状态错误"; exit 1; }
runc start "$ID"
[[ $(runc state "$ID" | jq -r .status) == running ]] || { echo "start 状态错误"; exit 1; }
runc exec "$ID" /bin/echo "exec 进入同一个 namespace"
runc ps "$ID"
cpid=$(runc state "$ID" | jq -r .pid)
for ns in pid net mnt uts; do
printf '%s: %s\n' "$ns" "$(readlink "/proc/$cpid/ns/$ns")"
done
# cgroup v2 + cgroupfs driver 时能看到这个文件;systemd driver 路径不同
[[ -f /sys/fs/cgroup/runc-lab/cgroup.procs ]] && cat /sys/fs/cgroup/runc-lab/cgroup.procs
runc kill "$ID" TERM
for _ in $(seq 1 50); do
[[ $(runc state "$ID" | jq -r .status) == stopped ]] && break
sleep 0.1
done
[[ $(runc state "$ID" | jq -r .status) == stopped ]] || { echo "kill 后未停止"; exit 1; }
runc delete "$ID"
echo "[ok] 生命周期完成"
预期输出里,create 后 status=created 且 pid 已存在(那是 init,不是 sleep);start 后变 running;runc ps 能看到 sh 与 sleep;四个 ns 的 inode 与宿主不同;runc-lab/cgroup.procs 里有容器 PID。
- 「
runc run -d会像 Docker 一样 detach」——会,但容器仍挂在当前终端会话的 shim 之外;关闭终端可能收到 SIGHUP。脚本里用setsid或交给 containerd。 - 「
runc exec会继承容器的 capabilities」——默认继承 config.json 的process.capabilities,但可以用--cap追加;追加不能超过 bounding set。 - 「
runc kill会等进程退出」——不会,它只发信号并立即返回;要等state变stopped,需要自己轮询。 - 「
runc delete对 running 容器会自动强杀」——不会,直接报错;先kill或加-f。 - 「
runc run退出后 cgroup 一定被删」——不会,runc run只等进程退出,资源要delete才清理;脚本必须trap cleanup。
5.5.11 containerd、CRI 与 shim
这一节回答:Kubernetes 的 kubelet 不直接调用 runc,中间那层 CRI 与 containerd 各自做什么。
containerd 把自己切成多个 plugin:runtime.v2 负责 task,snapshotter 负责层,content 负责 blob,CRI 是其中一个 gRPC 服务。ctr 能直接操作这些 plugin,crictl 只走 CRI,docker 走 dockerd 的 API 再转成 containerd 调用。
containerd 有 namespace 概念(默认 default,Kubernetes 用 k8s.io),它与 Linux namespace 无关,只是把对象分组,避免 ctr 看到 kubelet 的容器:
# 两套 namespace 下的容器互不可见
sudo ctr -n default containers ls
sudo ctr -n k8s.io containers ls
sudo ctr -n k8s.io tasks ls
sudo ctr -n k8s.io snapshots ls | head
CRI 的 RuntimeService 方法按生命周期排列:
| CRI 方法 | 作用 | 落到 runc/containerd |
|---|---|---|
RunPodSandbox | 创建 Pod 沙箱 | 建 pause 容器、netns、调 CNI ADD |
StopPodSandbox | 停止沙箱 | 停业务容器、CNI DEL、停 pause |
RemovePodSandbox | 删除沙箱 | 删容器与 netns |
CreateContainer | 在沙箱里建业务容器 | 生成 OCI config.json、runc create |
StartContainer | 启动业务容器 | runc start |
StopContainer | 停止业务容器 | SIGTERM + grace + SIGKILL |
RemoveContainer | 删除业务容器 | runc delete |
ExecSync | 同步执行命令 | runc exec,捕获 stdout/stderr |
Exec | 流式 exec | runc exec + streaming server |
Attach | 附着 stdio | shim 的 fifo/socket |
PortForward | 端口转发 | CRI 流式接口 + socat/portforward |
ContainerStats | 容器资源统计 | cgroup cpu.stat/memory.current |
UpdateContainerResources | 热更新限额 | runc update |
Status | 运行时状态 | containerd 版本、条件 |
crictl 是这些 RPC 的薄封装,排障时比 docker 更接近节点真实状态:
| 命令 | 等价操作 |
|---|---|
crictl pods | 列出 sandbox(pause 容器) |
crictl ps -a | 列出业务容器 |
crictl inspectp <id> | 看 sandbox 的 netns、IP |
crictl inspect <id> | 看容器的 runtimeSpec(OCI config) |
crictl logs <id> | 读容器日志 |
crictl exec -it <id> sh | 进容器 |
crictl stats | 读 cgroup 统计 |
crictl pull/images | 镜像状态 |
crictl runp/creat/start/stop/rm | 手工走一遍 Pod 生命周期 |
crictl inspect <id> 的 info.runtimeSpec 就是 5.5.7 讲的 config.json:你能在里面看到 linux.namespaces、process.capabilities、linux.seccomp、linux.resources。kubelet 的 SecurityContext、resources.limits、shareProcessNamespace 最终都翻译成这些字段。
CRI 的版本要注意:Kubernetes 1.24 移除了 dockershim,1.26 起只支持 CRI v1。节点上 crictl --version 与 crictl info 能看出运行时端点与 CRI 版本;端点通常是 unix:///run/containerd/containerd.sock 或 unix:///var/run/crio/crio.sock。
containerd 的配置在 /etc/containerd/config.toml,三个字段最常改:
[plugins."io.containerd.grpc.v1.cri".containerd]
snapshotter = "overlayfs" # 与 5.4 的 overlay2 对应
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc]
runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc.options]
SystemdCgroup = true # 让 runc 用 systemd cgroup driver
SystemdCgroup=false 时 cgroup 路径是 /sys/fs/cgroup/<path>;true 时是 /sys/fs/cgroup/system.slice/containerd-<id>.scope。两种都能工作,但混用会让「在哪个 cgroup 里找限额」变得混乱。Kubernetes 节点通常设为 true,与 kubelet 的 cgroupDriver: systemd 保持一致。
5.5.12 实验二:capabilities、seccomp 与 no_new_privs 边界
这一节用一个 runc bundle 验证「容器里的 root 不是全能」:只留 CAP_NET_BIND_SERVICE、打开 noNewPrivileges、屏蔽 /proc/kcore、只读 /proc/sys,然后逐项尝试特权操作。
#!/usr/bin/env bash
# runc-sec-lab.sh — 验证 capabilities / no_new_privs / masked & readonly paths
set -euo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root"; exit 1; }
for c in runc docker jq; do
command -v "$c" >/dev/null || { echo "缺少 $c"; exit 1; }
done
B=/tmp/runc-sec-lab
ID="runc-sec-$$"
CID=$(docker create busybox)
cleanup() {
runc delete -f "$ID" 2>/dev/null || true
docker rm -f "$CID" >/dev/null 2>&1 || true
rm -rf "$B"
}
trap cleanup EXIT
mkdir -p "$B/rootfs"
docker export "$CID" | tar -C "$B/rootfs" -xf -
cd "$B"
runc spec
jq '.process.terminal = false
| .process.args = ["/bin/sh","-c",
"grep -E \"^(Cap|NoNewPrivs)\" /proc/self/status; " +
"chown 0:0 /bin/busybox; echo chown_rc=$?; " +
"mount -t tmpfs none /; echo mount_rc=$?; " +
"echo 1 > /proc/sysrq-trigger; echo sysrq_rc=$?"]
| .process.capabilities = {
"bounding": ["CAP_NET_BIND_SERVICE"],
"effective": ["CAP_NET_BIND_SERVICE"],
"permitted": ["CAP_NET_BIND_SERVICE"],
"inheritable": [],
"ambient": []
}
| .process.noNewPrivileges = true
| .linux.maskedPaths = ["/proc/kcore","/proc/keys"]
| .linux.readonlyPaths = ["/proc/sys","/proc/sysrq-trigger"]' \
config.json > config.new
mv config.new config.json
runc run "$ID"
预期输出(顺序可能因 busybox 版本略有差异):
CapInh: 0000000000000000
CapPrm: 0000000000000400
CapEff: 0000000000000400
CapBnd: 0000000000000400
CapAmb: 0000000000000000
NoNewPrivs: 1
chown: /bin/busybox: Operation not permitted
chown_rc=1
mount: permission denied (are you root?)
mount_rc=1
sh: can't create /proc/sysrq-trigger: Read-only file system
sysrq_rc=1
三个断言各自说明一件事:CapEff=0x400 只剩 CAP_NET_BIND_SERVICE;NoNewPrivs=1 表示 setuid/文件能力不再提权;chown、mount、写 sysrq-trigger 全部失败,说明能力、只读挂载两层都在生效。
如果想验证 seccomp,把 linux.seccomp 设成 defaultAction: SCMP_ACT_ERRNO,只放行 read/write/exit_group/futex/mmap/brk/rt_sigreturn,再加一条 mount 返回 EPERM 的规则。此时 runc run 仍能启动 busybox(动态链接器需要的调用要放行),但 mount 会得到 EPERM 而不是「能力不足」。不要用 5.5.2 里那份过短的白名单直接跑,execve 之后的动态链接阶段就会失败。
5.5.13 生命周期排障与 12 条陷阱
这一节给一张「容器起不来 / 停不掉」的定位顺序,以及 12 条最常见的配置与命令陷阱。
- 先看状态:
docker ps -a、crictl ps -a、runc list;区分created、running、stopped、Exited (137)。 - 再看事件:
docker events --since 10m、crictl ps -a的STATE、journalctl -u containerd;找OCI runtime create failed的具体 errno。 - 定位是 create 还是 start 失败:
runc create失败多在 rootfs/cgroup/namespace;runc start失败多在execve(找不到动态链接器、args[0]路径错、权限)。 - 看 shim 日志:
journalctl -u containerd | grep shim;shim 的 stderr 有容器退出码与信号。 - 进 namespace 看:
nsenter -t <pid> -m -n -p;确认 rootfs、网卡、PID 1 是否符合预期。 - 看 cgroup:
systemctl status、/sys/fs/cgroup/.../cgroup.procs、memory.events;确认限额没把进程 OOM/限流。 - 复现最小化:用
runc+ busybox bundle 手工跑;能复现就是 runtime/config 问题,不能复现就回到 kubelet/containerd 层。
12 条陷阱:
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | runc run 报 terminal: true 相关错误 | 脚本里没有 TTY | terminal=false,或传 --console-socket |
| 2 | created 状态里看不到业务进程 | init 停在 exec fifo | 这是预期;runc start 才执行 args |
| 3 | runc delete 报容器仍在运行 | 只清理 stopped 容器 | 先 kill,或 delete -f |
| 4 | runc kill 后进程还在 | 只向 PID 1 发信号,子进程可能忽略 | kill --all,或应用自己转发 |
| 5 | execve: no such file or directory | rootfs 缺动态链接器/库 | 用 docker export 的完整 rootfs,或静态 busybox |
| 6 | pivot_root: device or resource busy | rootfs 不是挂载点/传播共享 | mount --bind rootfs rootfs;make-rprivate |
| 7 | cgroup 写入 EPERM/ENOENT | cgroupsPath 形式错,或 systemd 未委托 | 用绝对路径;systemd driver 用 slice:prefix:name |
| 8 | userns 写 gid_map 失败 | 没先写 setgroups=deny | 先 echo deny > /proc/self/setgroups |
| 9 | 容器里 /dev/null 打不开 | mounts/devices 缺设备 | 加 /dev/null bind 或 linux.devices |
| 10 | seccomp 下 execve 直接 EPERM | 白名单太窄,动态链接器调用被拦 | 用发行版默认 profile,按 strace 补调用 |
| 11 | process.args 相对路径找不到 | execve 不按 PATH 搜索 | 写绝对路径,或显式 /bin/sh -c |
| 12 | runc 状态目录残留,ID 被占用 | 进程崩溃/被 SIGKILL,未 delete | runc delete -f <id>;检查 /run/runc/<id> |
第 12 条在 CI 里很常见:上一个任务被超时杀掉,/run/runc/<id> 还在,下一次 create 报 container with id already exists。清理后重跑,不要用随机 ID 掩盖问题——残留状态会一直占用 cgroup 与 netns。
5.5.14 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出 CLI→dockerd→containerd→shim→runc 链 | 说清谁常驻、谁短命 | 5.5.1 |
解释 bundle 与 config.json 的关系 | 知道 root.path 指向 rootfs | 5.5.2 |
说出 create 与 start 的分界 | init 停在 exec fifo | 5.5.3 |
| 用 runc 跑通 create/start/exec/kill/delete | 能读到 state 的四个状态 | 5.5.3 |
解释 docker stop 为什么等 10 秒 | 信号没被 PID 1 处理 | 5.5.4 |
| 写一个转发信号、回收僵尸的 init | 代码能编译运行 | 5.5.4 |
| 区分 capabilities/seccomp/no_new_privs | 各自挡住什么 | 5.5.5 |
| 列出三条最高危逃逸面 | docker.sock、privileged、SYS_ADMIN | 5.5.6 |
说出 process/root/mounts/hooks 的关键字段 | 至少 10 个 | 5.5.7 |
解释 pivot_root 与 chroot 的区别 | 旧根被移走并卸载 | 5.5.8 |
| 说出 userns 为什么先建、cgroup 为什么父进程建 | 两个顺序都说对 | 5.5.9 |
| 用脚本跑通 runc 生命周期并断言状态 | created/running/stopped | 5.5.10 |
| 区分 CRI 与 OCI 的职责 | kubelet→CRI→containerd→runc | 5.5.11 |
| 验证 capabilities/no_new_privs 边界 | CapEff 只剩 0x400 | 5.5.12 |
| 按现象定位 create/start 失败 | 至少五条 | 5.5.13 |
Q1 runc create 之后 runc state 显示 created,此时容器里的 /bin/sleep 在运行吗?为什么?
Q2 一个容器的 ENTRYPOINT 是 ["/bin/sh","-c","exec /app/server"],另一个是 ["/bin/sh","-c","/app/server"]。docker stop 的表现有什么不同?
Q3 CapBnd 与 CapEff 有什么区别?为什么 --cap-drop=ALL 后 CapBnd 也是 0?
Q4 seccomp=unconfined 与 no-new-privileges 是同一类防护吗?各自防什么?
Q5 为什么「挂载 docker.sock」比「--privileged」更危险,或者一样危险?说出理由。
Q6 capabilities 与 seccomp 分别写在 config.json 的哪个顶层字段下?放错位置时 runc 会报错吗?
Q7 pivot_root 报 EBUSY 的常见原因是什么?runc 在调用它之前做了哪一步来避免?
Q8 CRI 的 RunPodSandbox 与 CreateContainer 分别创建了什么?为什么 Pod 的网络配置在 sandbox 阶段完成?
答案
A1 没有运行。create 只创建了 init 进程、namespace、cgroup 与 rootfs,init 阻塞在 exec fifo 上;start 写入 fifo 后,init 才 execve 成 /bin/sleep。所以 created 状态下容器里只有 runc 的 init,没有用户进程。
A2 前者 sh 被 exec 替换,/app/server 成为 PID 1,直接收到 SIGTERM,可以优雅退出;后者 sh 是 PID 1,收到 SIGTERM 后不会转发,docker stop 等 10 秒后 SIGKILL,/app/server 被强杀,退出码 137。
A3 CapEff 是当前生效的能力,CapBnd 是进程及其后代永远不能超出的上限。--cap-drop=ALL 同时清空 permitted/effective 与 bounding,所以两者都是 0;如果只 capsh --drop 而不改 bounding,子进程仍可能通过文件能力重新获得。
A4 不是。seccomp 过滤系统调用,缩小内核攻击面;no-new-privileges 阻止 execve 通过 setuid/文件能力获得新特权。前者管「能调用什么」,后者管「能变成谁」。
A5 一样危险,甚至更直接。挂载 docker.sock 的容器可以向 dockerd 发 API 请求,创建一个挂载宿主 /、--privileged 的新容器,从而完全控制宿主;它不需要自己拥有任何 capability。--privileged 是直接给权限,docker.sock 是给「创建特权容器的权限」,两者都等于宿主 root。
A6 capabilities 在 process 下,seccomp 在 linux 下。放错位置通常不会报错:runc 忽略未知字段,按缺省处理,于是你以为开了 seccomp,实际没有。写完配置后要用 jq '.linux.seccomp, .process.capabilities' 核对路径。
A7 EBUSY 通常因为 new_root 不是挂载点,或当前根处于共享传播,旧根无法被摘走。runc 在 pivot_root 前会先 mount --bind rootfs rootfs 把 rootfs 变成挂载点,并 mount --make-rprivate / 关掉传播;手工实验时这两步不能省。
A8 RunPodSandbox 创建 pause 容器、独立的 net/ipc/uts namespace 与 netns,并调用 CNI ADD 给 netns 配 eth0 与 IP;CreateContainer 只创建业务容器,让它通过 linux.namespaces[].path 加入 sandbox 的 netns。网络放在 sandbox 阶段,是因为同一个 Pod 的所有容器必须共享同一张网卡与同一个 IP,而 sandbox 的生命周期比业务容器长,重启业务容器不会换 IP。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| OCI | runtime-spec:config.json、生命周期、hooks、Linux 字段 | 5.5.2–5.5.13 | 完整(字段语义以 runtime-spec 1.0.2/1.1.0 为准) |
| OCI | runtime-spec:pivot_root、namespace 加入、cgroup 资源 | 5.5.7–5.5.9 | 完整(rootfsPropagation、unified、idmapped mounts) |
| containerd | ctr、CRI plugin、shim v2、snapshotter | 5.5.1、5.5.11 | 完整(ctr -n k8s.io 与 config.toml) |
| Kubernetes | CRI v1 RuntimeService、crictl、Pod sandbox | 5.5.11 | 完整(RPC 与 runc 操作的对应) |
| R | 第 3 章 进程管理(3.1 进程的诞生,印刷页 326–328) | 5.5.9 | 背景(CLONE_NEW* 与 PID namespace) |
| K | 第 39 章 能力(39.1–39.7、39.9) | 5.5.5 | 完整(五个能力集合、exec 转换、bounding set) |
| K | 第 24–26 章 进程创建、终止与监控 | 5.5.4 | 完整(PID 1、waitpid、僵尸回收) |
| N | 第 10 章 容器网络虚拟化 | 5.5.1、5.6 | 背景(shim 与网络 namespace 的关系) |
| R | 第 3 章 进程管理(3.1 进程的诞生) | 5.5.3 | 背景(clone/execve 与容器 init) |
| man-pages | capabilities(7)、seccomp(2)、prctl(2) | 5.5.5 | 精选(PR_SET_NO_NEW_PRIVS、seccomp 动作) |
| Docker 文档 | docker run 安全选项、seccomp 默认 profile | 5.5.5–5.5.6 | 精选(默认能力集与 profile 行为) |
下一站:5.6 容器网络与 Kubernetes 原语 把最后一个缺口补上:veth/bridge/netns 如何组成 Pod 网络,CNI 插件做了什么,Service 的一次 DNAT 如何落到 iptables/nftables,以及 Pod/Service/Node 与 Linux 原语的逐项映射。