一个令人困惑的问题:为什么 containerd 不直接调用 runc,而要在中间加一层 shim?答案涉及三个关键场景:
- 升级安全:containerd 升级重启时,运行中的容器不受影响
- 进程监督:容器进程有独立的父进程,不会因为 containerd 崩溃而变成孤儿
- runc 退出:runc 在创建容器后可以退出,不需要一直运行
shim 的诞生源于一个运维痛点。在早期 Docker 架构中,containerd 直接调用 runc 管理容器进程,runc 作为 containerd 的子进程持续运行。这意味着 containerd 升级重启时,所有容器进程的父进程关系会断裂——容器要么变成孤儿进程,要么跟着重启。2016 年,containerd 引入 shim 作为”中间人”:shim 作为容器进程的父进程独立运行,containerd 通过 gRPC 与 shim 通信。这样,containerd 升级重启不影响运行中的容器,runc 在创建容器后也可以退出。shim 用极小的代价(一个轻量级守护进程)换来了巨大的运维收益——这是容器运行时演进中”用架构换可靠性”的经典案例。
前置知识
- Ch07 containerd 架构:shim 是 containerd 的子组件,理解 containerd 的整体架构是前提
- Ch06 runc 源码分析:shim 调用 runc 创建容器,理解 runc 的生命周期管理
- Linux 进程关系:父进程、孤儿进程、僵尸进程、
prctl(PR_SET_CHILD_SUBREAPER)
shim 的设计思想——“用独立进程解耦父子关系”——在系统编程中并不罕见。systemd 的 socket activation、SSH 的代理跳转,都采用了类似的解耦模式。
一、为什么需要 Shim
1.1 没有 Shim 的问题
假设 containerd 直接调用 runc,不经过 shim:
1.2 有 Shim 的解决方案
1.3 Shim 的核心价值
| 特性 | 无 shim(旧版 Docker) | shim v1 | shim v2 (shim-runc-v2) |
|---|---|---|---|
| daemon 重启影响 | 所有容器退出 | 容器继续运行 | 容器继续运行 |
| shim 进程模型 | 无 | 每个容器一个 shim | 每个容器一个 shim |
| 多容器支持 | N/A | 每个容器独立 shim | 同一 shim 管理多个容器 |
| 资源开销 | 无 | 每容器 ~5MB | 每容器 ~2MB(共享) |
containerd 1.6+ 默认使用 shim v2。在 Kubernetes 中运行大量短生命周期容器时,shim v2 的共享进程模型可以显著减少内存开销。
| 问题 | 没有 Shim | 有 Shim |
|---|---|---|
| containerd 升级 | 影响所有容器 | 容器不受影响 |
| containerd 崩溃 | 容器变孤儿进程 | shim 继续监督 |
| runc 生命周期 | 必须持续运行 | 创建后可退出 |
| 容器 stdout/stderr | 需要容器运行时处理 | shim 负责收集 |
| 容器退出状态 | 需要容器运行时等待 | shim 负责收集 |
二、Shim 的进程关系
2.1 完整的进程树
# 查看容器相关的进程树pstree -p $(pgrep containerd) | head -30
# 典型输出:# containerd(1000)───containerd-shim(2000)───nginx(3000)# └─nginx(3001)# ───containerd-shim(4000)───redis(5000)# ───containerd-shim(6000)───myapp(7000)2.2 进程关系详解
2.3 Shim 与 runc 的交互
# shim 调用 runc 的时序:# 1. shim 启动后,调用 runc create 创建容器# 2. runc create 完成后,runc 进程退出# 3. 容器进程由 shim 监督# 4. 当需要启动容器时,shim 调用 runc start# 5. runc start 完成后,runc 进程退出
# 查看 shim 的命令行参数ps aux | grep containerd-shim# containerd-shim -namespace default -id mynginx -address /run/containerd/containerd.sock三、Shim API
3.1 Task Service API
shim 通过 gRPC 暴露 Task Service API,containerd 通过这个 API 管理容器:
| 方法 | 功能 | 对应 runc 命令 |
|---|---|---|
| Create | 创建容器 | runc create |
| Start | 启动容器 | runc start |
| Kill | 发送信号 | runc kill |
| Delete | 删除容器 | runc delete |
| State | 查询状态 | runc state |
| Exec | 执行命令 | runc exec |
| Pids | 获取 PID | — |
| ResizePty | 调整终端大小 | — |
| CloseIO | 关闭 IO | — |
| Checkpoint | 检查点 | runc checkpoint |
| Restore | 恢复 | runc restore |
| Update | 更新资源 | runc update |
| Wait | 等待退出 | — |
| Stats | 获取统计 | — |
3.2 Shim 的 gRPC 通信
// shim 的 gRPC 服务定义(简化)service Task { rpc Create(CreateTaskRequest) returns (CreateTaskResponse); rpc Start(StartRequest) returns (StartResponse); rpc Kill(KillRequest) returns (google.protobuf.Empty); rpc Delete(DeleteRequest) returns (DeleteResponse); rpc State(StateRequest) returns (StateResponse); rpc Exec(ExecRequest) returns (ExecResponse); rpc Pids(PidsRequest) returns (PidsResponse); rpc Wait(WaitRequest) returns (WaitResponse); rpc Stats(StatsRequest) returns (StatsResponse);}
message CreateTaskRequest { string id = 1; string bundle = 2; repeated Mount rootfs = 3; bool terminal = 4; string stdin = 5; string stdout = 6; string stderr = 7; bool checkpoint = 8; string parent_checkpoint = 9;}3.3 containerd 与 shim 的通信流程
四、Shim 的升级安全机制
4.1 containerd 重启后的恢复
当 containerd 重启时,它需要重新连接所有运行中的 shim:
// containerd 重启后的 shim 恢复(简化)func (m *TaskManager) LoadTasks(ctx context.Context) error { // 1. 扫描 shim 的 socket 文件 shimSockets, err := filepath.Glob("/run/containerd/s/*.sock")
// 2. 逐个连接 shim for _, socket := range shimSockets { shim, err := shim.Connect(ctx, socket) if err != nil { continue // shim 可能已退出 }
// 3. 查询 shim 管理的容器状态 state, err := shim.State(ctx, &task.StateRequest{}) if err != nil { continue }
// 4. 恢复 Task 对象 m.tasks[state.ID] = &Task{ shim: shim, id: state.ID, pid: state.Pid, status: state.Status, } }
return nil}4.2 Shim 的 Socket 文件
每个 shim 在 /run/containerd/s/ 目录下创建一个 Unix socket 文件:
# 查看 shim 的 socket 文件ls /run/containerd/s/
# 输出示例:# 5a3b2c1d4e5f.sock# 7f8e9d0c1b2a.sock
# socket 文件名与容器 ID 对应# containerd 重启后通过这些 socket 重新连接 shim4.3 升级安全的保证
五、Shim v1 vs v2
5.1 架构对比
| 维度 | Shim v1 | Shim v2 |
|---|---|---|
| 进程模型 | 每个容器一个 shim | 每个容器一个 shim |
| 通信方式 | gRPC over Unix socket | ttrpc over Unix socket |
| 二进制 | containerd-shim | 自定义 shim 二进制 |
| 运行时支持 | 仅 runc | runc/gVisor/Kata/Wasm |
| 配置方式 | 命令行参数 | containerd 配置文件 |
| IO 处理 | FIFO + 外部进程 | 内置 FIFO 处理 |
5.2 Shim v2 的改进
Shim v2 的最大改进是支持自定义运行时——每个运行时可以提供自己的 shim 二进制:
# containerd 配置:注册自定义运行时[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runc] runtime_type = "io.containerd.runc.v2"
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runsc] runtime_type = "io.containerd.runsc.v1" # gVisor shim
[plugins."io.containerd.grpc.v1.cri".containerd.runtimes.kata] runtime_type = "io.containerd.kata.v2" # Kata shim5.3 自定义 Shim 的接口
// shim v2 的接口定义(简化)type Shim interface { // 启动 shim Start(ctx context.Context, id string, opts StartOpts) (Shim, error)
// 容器操作 Create(ctx context.Context, task *TaskConfig) (Task, error) Delete(ctx context.Context, id string) error
// 生命周期 Wait(ctx context.Context, id string) (uint32, error) Kill(ctx context.Context, id string, signal syscall.Signal) error
// IO ResizePty(ctx context.Context, id string, size ConsoleSize) error CloseIO(ctx context.Context, id string) error
// 清理 Shutdown(ctx context.Context) error}六、Shim 的 IO 处理
6.1 容器 IO 的路径
6.2 FIFO 文件的位置
# 查看容器的 FIFO 文件ls /run/containerd/fifo/
# 输出示例:# 5a3b2c1d4e5f-stdin# 5a3b2c1d4e5f-stdout# 5a3b2c1d4e5f-stderr
# shim 负责打开这些 FIFO 并转发 IOshim 进程以 root 权限运行,且每个容器对应一个 shim。如果攻击者通过容器逃逸获得了 shim 进程的控制权,可以利用 shim 的 gRPC 接口操作该容器(kill、exec 等)。在安全要求高的场景下,可以考虑使用 User Namespace 将 shim 也放入非特权上下文中运行。
七、动手实践
7.1 观察 Shim 的行为
#!/bin/bash# 观察 containerd-shim 的行为
# 1. 启动一个容器ctr run -d docker.io/library/nginx:alpine mynginx /bin/sh -c "while true; do echo hello; sleep 1; done"
# 2. 查看 shim 进程ps aux | grep "containerd-shim.*mynginx"
# 3. 查看 shim 的 socketls -la /run/containerd/s/
# 4. 查看容器的 FIFOls -la /run/containerd/fifo/
# 5. 重启 containerdsudo systemctl restart containerd
# 6. 验证容器仍在运行ctr tasks list# TASK PID STATUS# mynginx 12345 RUNNING ← 容器未受影响
# 7. 查看 shim 进程(新的 PID)ps aux | grep "containerd-shim.*mynginx"
# 8. 清理ctr tasks kill mynginxctr containers delete mynginx7.2 用 strace 追踪 Shim 与 runc 的交互
#!/bin/bash# 追踪 shim 调用 runc 的过程
# 1. 找到 shim 进程SHIM_PID=$(pgrep -f "containerd-shim.*mynginx")
# 2. 追踪 shim 的子进程(runc)sudo strace -f -p $SHIM_PID -e trace=execve,clone,wait4 -o /tmp/shim-strace.log &
# 3. 通过 containerd 操作容器ctr tasks kill mynginx SIGUSR1
# 4. 查看追踪结果grep "execve" /tmp/shim-strace.log# 应该能看到 shim 调用 runc kill 的记录八、本章小结
上一章了解了containerd 的架构设计。
| 特性 | 说明 |
|---|---|
| 解耦 | shim 让 containerd 与容器进程解耦 |
| 升级安全 | containerd 升级重启不影响运行中的容器 |
| 进程监督 | shim 是容器进程的父进程,负责收集退出状态 |
| IO 转发 | shim 通过 FIFO 转发容器的 stdin/stdout/stderr |
| 自定义运行时 | shim v2 支持自定义 shim 二进制(gVisor/Kata/Wasm) |
| 通信方式 | shim 通过 gRPC/ttrpc 与 containerd 通信 |
shim 是 containerd 架构中最容易被忽视但最重要的组件。没有 shim,containerd 的升级安全就无法保证,容器的 IO 处理会变得复杂,自定义运行时的支持也会受限。
扩展阅读
参考
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






