Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

5.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。

为什么需要 shim

如果 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、安全设置、钩子。
定义 5.5.1 · OCI bundle

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/readonlyrootfs 与只读根5.4
mounts额外挂载(proc/dev/tmpfs/volume)5.2.7
linux.namespaces要创建/加入的 namespace;path 可加入已有5.2
linux.uidMappings/gidMappingsuser namespace 映射5.2.6
linux.cgroupsPath容器 cgroup 路径5.3
linux.resourcescpu/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.argscreated → 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 使用的五个坑
  1. 「runc run 的 terminal: true 在脚本里也能跑」——没有 TTY 时会报错。脚本里先改成 false,或加 --console-socket。
  2. 「runc create 之后进程已经在跑」——没有。init 停在 exec fifo 上,必须 runc start 才执行 process.args。
  3. 「runc delete 会自动杀进程」——对运行中的容器会拒绝;要 --force,或先 kill。删除只清理资源,不负责优雅退出。
  4. 「runc run 退出后容器还在」——runc run 是前台等待,容器退出后它返回;要后台常驻,用 create + start 或交给 containerd。
  5. 「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 是同一套语义,只是名字与默认值不同。

为什么很多应用镜像仍然用 shell 形式

因为 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当前生效的能力,内核检查用这个
Inheritableexecve 后希望保留给新程序的能力
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_privssetuid/文件能力带来的提权已有权限的滥用process.noNewPrivileges
LSM(AppArmor/SELinux)路径/标签级的访问未定义策略的路径process.apparmorProfile
user namespace宿主 uid 与能力作用域内核漏洞、配置错误linux.uidMappings
容器安全的六个错误认知
  1. 「--cap-drop=ALL 之后容器就没法被攻破了」——它只去掉特权操作;非特权系统调用的内核漏洞仍在,seccomp 与内核补丁同样重要。
  2. 「seccomp 默认 profile 是黑名单」——Docker 默认是允许列表(default-deny),但允许的调用仍很多;自定义 profile 要按应用实际调用裁剪。
  3. 「no_new_privs 会阻止容器启动」——不会。它只阻止 execve 获得新特权;普通程序照常运行。
  4. 「--privileged 只是加 capability」——它还关 seccomp/AppArmor、放开设备、清空 masked/readonly 路径,等价于宿主 root(见 5.1.7)。
  5. 「AppArmor 的 docker-default 能防一切」——它只覆盖一组路径规则;对 CAP_SYS_ADMIN、内核漏洞、挂载宿主目录都无能为力。
  6. 「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 最小化

加固的顺序应该是「先减面,再谈检测」:

  1. 镜像:最小基础镜像、非 root 用户、只读 rootfs、无 shell/包管理器。
  2. 运行时:--cap-drop=ALL + 按需 --cap-add、默认 seccomp、no-new-privileges、--pids-limit、--memory/--cpus。
  3. 挂载:只挂必要路径,能 :ro 就 :ro;禁止 docker.sock 与宿主 /。
  4. 网络:默认 bridge,不做 --net=host;出站用 NetworkPolicy/防火墙限制。
  5. 宿主:内核及时更新、rootless 或 userns-remap、审计 docker run 的 privileged 参数。
  6. 检测: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 是容器进程的「出生证明」:

字段类型语义内核落点
terminalbool是否分配伪终端openpty/TIOCSCTTY
consoleSize{height,width}初始终端大小TIOCSWINSZ
user.uid/giduint32容器内运行身份setresuid/setresgid
user.umaskuint32文件创建掩码(OCI 1.0.2+)umask(2)
argsstring[]argv,args[0] 是可执行文件execve(2)
envstring[]KEY=VALUE,不做 shell 展开execve 的 envp
cwdstring工作目录,必须存在chdir(2)
capabilities五个集合bounding/effective/permitted/inheritable/ambientcapset(2)、prctl
rlimits数组RLIMIT_* 软硬限制setrlimit(2)
noNewPrivilegesbool禁止 execve 提权prctl(PR_SET_NO_NEW_PRIVS)
apparmorProfilestringAppArmor profile 名/proc/self/attr/exec
oomScoreAdjintOOM 优先级(-1000..1000)/proc/self/oom_score_adj
selinuxLabelstringSELinux 标签/proc/self/attr/exec
scheduler/ioPriorityobject调度策略与 IO 优先级(1.1)sched_setscheduler、ioprio_set

root 与 mounts 决定容器看到什么文件系统:

字段语义注意
root.pathrootfs 路径,相对 bundle 或绝对路径必须是目录;runtime 会 chdir 到 bundle
root.readonlypivot 后把根 remount 成只读不等于「所有挂载只读」,volume 仍可写
mounts[].destination容器内挂载点,必须是绝对路径目录不存在时 runtime 可能创建,也可能失败
mounts[].type文件系统类型,如 proc、tmpfs、bindbind 实际用 MS_BIND,不是独立 fs
mounts[].source源设备或宿主路径bind 时必须是存在的路径
mounts[].optionsnosuid、nodev、rbind、ro 等顺序敏感;rbind 才能递归复制子挂载
mounts[].uidMappings/gidMappingsidmapped 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中止启动
createRuntimenamespace 建好、pivot_root 之前runtime(宿主)中止创建
createContainerpivot_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/gidMappingsuser namespace 的 uid/gid 映射5.2.6
cgroupsPathcgroup 路径5.3
resourcescpu/memory/pids/blockIO/hugepage 限额5.3
devices[]允许的设备和要创建的设备节点5.3、5.5.5
sysctl写入容器内 /proc/sys 的键值5.2
seccomp系统调用过滤5.5.5
rootfsPropagation挂载传播类型:rprivate/slave/shared5.5.8
maskedPaths用 /dev/null 覆盖的路径5.5.5
readonlyPathsbind 后 remount 只读的路径5.5.5
mountLabelSELinux 挂载标签5.5.6
personality执行域(如 LINUX32)少用
timeOffsetstime namespace 的偏移(OCI 1.1)5.2
unifiedcgroup 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原因修复
EBUSYnew_root 或 put_old 不是挂载点,或当前根是共享传播先 mount --bind rootfs rootfs;mount --make-rprivate /
EINVALput_old 不在 new_root 下,或两者在同一挂载检查路径;用 pivot_root(".", ".") 写法
EPERM缺 CAP_SYS_ADMIN,或 seccomp 拦了 pivot_root给能力;检查 seccomp profile
ENOTDIRnew_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 typeclone 标志隔离什么不加会怎样
pidCLONE_NEWPID进程 ID、/proc 视图容器看见宿主全部进程
networkCLONE_NEWNET网卡、路由、端口、iptables与宿主共享网络栈
mountCLONE_NEWNS挂载表容器挂载影响宿主
ipcCLONE_NEWIPCSysV IPC、POSIX mqueue与宿主共享消息队列
utsCLONE_NEWUTShostname/domainname改 hostname 影响宿主
userCLONE_NEWUSERuid/gid 映射、能力作用域容器 root 就是宿主 root
cgroupCLONE_NEWCGROUPcgroup 根视图容器能看到宿主 cgroup 树
timeCLONE_NEWTIMECLOCK_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 的流程是:

  1. 父进程解析 linux.resources,在 cgroupsPath 建目录;
  2. 写 cpu.max、memory.max、pids.max 等;
  3. clone 出 init,init 通过 sync pipe 等待;
  4. 父进程把 init 的 PID 写入 cgroup.procs;
  5. runc start 写 exec fifo,init 才 execve。

resources 到 cgroup v2 文件的映射:

OCI 字段cgroup v2 文件换算/注意
cpu.sharescpu.weight1 + ((shares-2)*9999)/262142,默认 1024 → 100
cpu.quota/cpu.periodcpu.max写 "quota period";quota=-1 写 max
cpu.cpus/cpu.memscpuset.cpus/cpuset.mems写 CPU 列表,如 0-3
memory.limitmemory.max字节;-1 写 max
memory.reservationmemory.low软保护,不是硬限制
memory.swapmemory.swap.maxOCI 的 swap 是「内存+swap 总量」,映射时要减去 limit
pids.limitpids.max计的是任务数(含线程)
blockIO.weightio.weight1–10000,默认 100
blockIO.throttleReadBpsDeviceio.max"MAJ:MIN rbps=..."
hugepageLimitshugetlb.<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 → stoppedcreate + start,前台等待退出
runc exec--process、-t、--user、--cwd、--env、--cap不变在运行中的容器里起新进程
runc kill--all、信号名/编号running → stopped默认 SIGTERM;只发信号,不等待
runc delete-fstopped → 无删 cgroup、状态目录、ns 引用
runc pause/resume<id>running ↔ pausedcgroup 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
statuscreating/created/running/stopped/paused
bundlebundle 绝对路径
rootfsrootfs 路径
created创建时间(RFC3339)
annotationsconfig.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 命令的五个细节
  1. 「runc run -d 会像 Docker 一样 detach」——会,但容器仍挂在当前终端会话的 shim 之外;关闭终端可能收到 SIGHUP。脚本里用 setsid 或交给 containerd。
  2. 「runc exec 会继承容器的 capabilities」——默认继承 config.json 的 process.capabilities,但可以用 --cap 追加;追加不能超过 bounding set。
  3. 「runc kill 会等进程退出」——不会,它只发信号并立即返回;要等 state 变 stopped,需要自己轮询。
  4. 「runc delete 对 running 容器会自动强杀」——不会,直接报错;先 kill 或加 -f。
  5. 「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流式 execrunc exec + streaming server
Attach附着 stdioshim 的 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 条最常见的配置与命令陷阱。

容器生命周期排障顺序
  1. 先看状态:docker ps -a、crictl ps -a、runc list;区分 created、running、stopped、Exited (137)。
  2. 再看事件:docker events --since 10m、crictl ps -a 的 STATE、journalctl -u containerd;找 OCI runtime create failed 的具体 errno。
  3. 定位是 create 还是 start 失败:runc create 失败多在 rootfs/cgroup/namespace;runc start 失败多在 execve(找不到动态链接器、args[0] 路径错、权限)。
  4. 看 shim 日志:journalctl -u containerd | grep shim;shim 的 stderr 有容器退出码与信号。
  5. 进 namespace 看:nsenter -t <pid> -m -n -p;确认 rootfs、网卡、PID 1 是否符合预期。
  6. 看 cgroup:systemctl status、/sys/fs/cgroup/.../cgroup.procs、memory.events;确认限额没把进程 OOM/限流。
  7. 复现最小化:用 runc + busybox bundle 手工跑;能复现就是 runtime/config 问题,不能复现就回到 kubelet/containerd 层。

12 条陷阱:

#现象原因修复
1runc run 报 terminal: true 相关错误脚本里没有 TTYterminal=false,或传 --console-socket
2created 状态里看不到业务进程init 停在 exec fifo这是预期;runc start 才执行 args
3runc delete 报容器仍在运行只清理 stopped 容器先 kill,或 delete -f
4runc kill 后进程还在只向 PID 1 发信号,子进程可能忽略kill --all,或应用自己转发
5execve: no such file or directoryrootfs 缺动态链接器/库用 docker export 的完整 rootfs,或静态 busybox
6pivot_root: device or resource busyrootfs 不是挂载点/传播共享mount --bind rootfs rootfs;make-rprivate
7cgroup 写入 EPERM/ENOENTcgroupsPath 形式错,或 systemd 未委托用绝对路径;systemd driver 用 slice:prefix:name
8userns 写 gid_map 失败没先写 setgroups=deny先 echo deny > /proc/self/setgroups
9容器里 /dev/null 打不开mounts/devices 缺设备加 /dev/null bind 或 linux.devices
10seccomp 下 execve 直接 EPERM白名单太窄,动态链接器调用被拦用发行版默认 profile,按 strace 补调用
11process.args 相对路径找不到execve 不按 PATH 搜索写绝对路径,或显式 /bin/sh -c
12runc 状态目录残留,ID 被占用进程崩溃/被 SIGKILL,未 deleterunc 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 指向 rootfs5.5.2
说出 create 与 start 的分界init 停在 exec fifo5.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_ADMIN5.5.6
说出 process/root/mounts/hooks 的关键字段至少 10 个5.5.7
解释 pivot_root 与 chroot 的区别旧根被移走并卸载5.5.8
说出 userns 为什么先建、cgroup 为什么父进程建两个顺序都说对5.5.9
用脚本跑通 runc 生命周期并断言状态created/running/stopped5.5.10
区分 CRI 与 OCI 的职责kubelet→CRI→containerd→runc5.5.11
验证 capabilities/no_new_privs 边界CapEff 只剩 0x4005.5.12
按现象定位 create/start 失败至少五条5.5.13
即时自测 · 5.5

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。

母本对应

母本章节本书落点程度
OCIruntime-spec:config.json、生命周期、hooks、Linux 字段5.5.2–5.5.13完整(字段语义以 runtime-spec 1.0.2/1.1.0 为准)
OCIruntime-spec:pivot_root、namespace 加入、cgroup 资源5.5.7–5.5.9完整(rootfsPropagation、unified、idmapped mounts)
containerdctr、CRI plugin、shim v2、snapshotter5.5.1、5.5.11完整(ctr -n k8s.io 与 config.toml)
KubernetesCRI v1 RuntimeService、crictl、Pod sandbox5.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-pagescapabilities(7)、seccomp(2)、prctl(2)5.5.5精选(PR_SET_NO_NEW_PRIVS、seccomp 动作)
Docker 文档docker run 安全选项、seccomp 默认 profile5.5.5–5.5.6精选(默认能力集与 profile 行为)

下一站:5.6 容器网络与 Kubernetes 原语 把最后一个缺口补上:veth/bridge/netns 如何组成 Pod 网络,CNI 插件做了什么,Service 的一次 DNAT 如何落到 iptables/nftables,以及 Pod/Service/Node 与 Linux 原语的逐项映射。