文章

OCI Runtime 与镜像内部机制

OCI Runtime 与镜像内部机制

概述

上一层的「容器运行时对比」看了 containerd 和 CRI-O 的外部差异。但这层看的是 CRI 适配层——真正干活的是它下面的 OCI Runtime(runc / crun)和 OCI Image Spec。理解这一层才能解释为什么某个镜像 pull 不下来、为什么 overlayfs 磁盘爆炸、为什么 containerd gc 不干活。

一句话:CRI 回答了”什么时候创建 Pod”,OCI Runtime 回答了”怎么创建容器”。两者的关系就像 kube-scheduler 和 kubelet——一个拍板,一个干活。

OCI Runtime Spec:从 JSON 到容器进程

容器 = config.json + rootfs

runc(或 crun、gVisor)只做一件事:读 config.json → 创建命名空间 → pivot_root → exec 用户进程。

config.json:
  ├── ociVersion
  ├── process              # 进程定义
  │   ├── args             # ["nginx", "-g", "daemon off;"]
  │   ├── env              # ["PATH=/usr/sbin:/usr/bin", ...]
  │   ├── cwd              # "/"
  │   ├── capabilities     # CAP_NET_BIND_SERVICE, ...
  │   └── user             # {uid: 101, gid: 101}
  ├── root                 # 根文件系统路径
  │   └── path: "rootfs"   # bundle/rootfs/
  ├── mounts               # 挂载点
  ├── linux                # Linux 特定配置
  │   ├── namespaces       # [pid, net, ipc, uts, mount] + 可选 [user, cgroup]
  │   ├── cgroupsPath      # /sys/fs/cgroup/kubepods/.../pod-xxx/container-yyy
  │   ├── resources        # CPU shares, memory limit, pids limit
  │   ├── seccomp          # 系统调用白名单
  │   ├── maskedPaths      # /proc/kcore(禁止访问)
  │   └── readonlyPaths    # /proc/sys(只读)
  └── hooks                # prestart / poststart / poststop

runc 的执行流程(源码级)

// 简化的执行路径:
main()
startContainer()
createContainer()         // 创建容器(不运行)
prepareRootfs()          // 准备 rootfs(mount /proc, /sys, /dev 等)
setupSeccomp()           // 加载 seccomp profile
setupNamespaces()        // unshare() 创建新 namespace
setupCgroups()           // 写入 cgroup 限制
fork()                   // fork 子进程
        → child: pivot_root()    // 切换根文件系统
        → child: execve()        // 执行用户命令(nginx)
startContainer()
      → 向子进程发送 SIGCONT

关键区别:runc createrunc start。kubelet 通过 CRI 先 RunPodSandboxCreateContainer(此时容器已创建但未运行)→ StartContainer(发送信号启动)。这个两阶段设计让 CNI 插件有机会在容器启动前配置网络。

crun:更快但非默认

crun 用 C 语言实现 OCI Runtime(runc 是 Go),启动延迟更低:

操作runc (Go)crun (C)
创建 + 启动容器~120ms~35ms
内存占用(单容器)~5MB~1MB
功能兼容性参考实现完全兼容 OCI
libcgroup 依赖❌ 自己写 cgroup✅ 使用 libcgroup(更快更标准)

CRI-O 默认支持 crun,containerd 也可以切换。

镜像内部机制

OCI Image Spec:Manifest + Config + Layers

一个镜像 = 一个 Manifest + 一个 Config + N 个 Layer:

alpine:latest
  ├── Manifest (application/vnd.oci.image.manifest.v1+json)
  │   ├── config:
  │   │   mediaType: "application/vnd.oci.image.config.v1+json"
  │   │   digest: sha256:abc123...         ← 指向 Config
  │   │   size: 702
  │   └── layers:
  │       ├── {mediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
  │       │   digest: sha256:def456..., size: 2812345}
  │       └── ...
  ├── Config (application/vnd.oci.image.config.v1+json)
  │   ├── Env: ["PATH=/usr/sbin:/usr/bin"]
  │   ├── Cmd: ["/bin/sh"]
  │   ├── WorkingDir: "/"
  │   └── rootfs.diff_ids: [sha256:def456...]  ← 每层的未压缩 sha256
  └── Layer 1: tar+gzip blob (sha256:def456...)
      Layer 2: tar+gzip blob (sha256:789012...)
      ...

镜像拉取的全流程

crictl pull alpine:latest

1. DNS 解析 registry → 建立 HTTPS → auth

2. GET /v2/alpine/manifests/latest
   (Accept: application/vnd.oci.image.manifest.v1+json)

3. 解析 Manifest → 得到 config digest + layer digests

4. GET /v2/alpine/blobs/sha256:abc... (Config)

5. for each layer:
     GET /v2/alpine/blobs/sha256:def... (Layer blob)

     解压 → overlayfs 写入 → 验证 sha256

6. containerd: 记录 image metadata → 标记为可用的 image

加速策略:

# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri".registry.mirrors]
  # 1. 镜像 mirror(国内云环境必备)
  [plugins."io.containerd.grpc.v1.cri".registry.mirrors."docker.io"]
    endpoint = ["https://mirror.ccs.tencentyun.com", "https://docker.io"]

  # 2. 跳过 TLS 验证(镜像代理常自签证书)
  [plugins."io.containerd.grpc.v1.cri".registry.configs."mirror.internal".tls]
    insecure_skip_verify = false

# 3. 最大并发拉取(默认 3)
[plugins."io.containerd.grpc.v1.cri"]
  max_concurrent_downloads = 10

Layer 的 overlayfs 挂载

每层在 overlayfs 中是一个 lowerdir,最终容器的 rootfs 是:

lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/5/fs:\
         /var/lib/containerd/.../snapshots/4/fs:\
         /var/lib/containerd/.../snapshots/3/fs
upperdir=/var/lib/containerd/.../snapshots/6/fs    ← 容器可写层
workdir=/var/lib/containerd/.../snapshots/6/work
merged=/run/containerd/.../rootfs                   ← 最终呈现给容器的根文件系统

overlayfs 的引用计数陷阱

# 场景:一个镜像被 10 个容器使用
# 10 个容器的 lowerdir 都指向同一个 layer 目录
# containerd 通过 refcount 管理:使用中的 layer 不能被 gc

# 查看 snapshot 引用关系
ctr -n k8s.io snapshot ls
# KEY      PARENT   KIND
# sha256:a base     Committed
# snap-1   sha256:a Active     ← 某容器正在使用
# snap-2   sha256:a Active     ← 另一个容器也在用同一个 base

Snapshotter 对比:不只是”文件系统”选择

containerd 的 snapshotter 实现了镜像 layer 到容器 rootfs 的映射。不同 snapshotter 的选择直接影响磁盘使用、启动速度和故障恢复。

Snapshotter原理优点缺点适用场景
overlayfs多层联合挂载快、省空间(layer 共享)不能跨文件系统默认,95% 场景
devmapper精简置备的块设备 + 快照支持配额限制(每个容器固定大小)需要独立磁盘分区、预分配慢需要严格按容器限磁盘的场景
btrfs子卷 + CoW 快照原生快照、子卷配额内核 bug 多、社区小实验性
native复制整个目录简单、无依赖慢、占空间不支持 overlayfs 的环境(如 tmpfs)
stargz拉取时懒加载(eStargz)镜像不 pull 完就能启动运行时读取延迟、兼容性超大镜像(GPU 训练镜像 10GB+)

overlayfs 磁盘分析

# 查看 containerd snapshot 磁盘使用
du -sh /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/

# 查看每个 snapshot 的大小
for snap in /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/*; do
  echo "$(basename $snap): $(du -sh $snap/fs 2>/dev/null | cut -f1)"
done | sort -t: -k2 -hr | head -20

# 大的 snapshot 通常来自日志或临时文件
# 容器内写入了大量数据但没有及时清理

stargz:超大镜像的解决方案

GPU 训练镜像(CUDA + PyTorch + 训练代码)动辄 15-20GB。传统 crictl pull 需要完全下载 + 解压才能启动容器,stargz(eStargz / lazy pulling)允许边拉取边启动

# 转换镜像为 eStargz 格式
nerdctl pull alpine:latest
nerdctl push --estargz alpine:latest registry.example.com/alpine:estargz

# containerd 配置 stargz snapshotter
# 容器启动时间:15GB 镜像从 3 分钟降到 15 秒

containerd Content Store 与 GC

Content Store 三张表

containerd 内部用 Content Store 管理所有 blob:

Content Store (metadata DB):
  ┌────────────┐     ┌─────────────┐     ┌──────────┐
  │ blobs       │ ←── │ images       │ ←── │ containers│
  │ (原始数据)   │     │ (镜像元数据)  │     │ (容器引用) │
  └────────────┘     └─────────────┘     └──────────┘

                         GC 从 images 出发
                         标记可达的 blob
                         其余 → 删除

GC 触发条件

containerd 的 GC 不是后台定时任务,而是事件驱动

触发事件动作
删除 image(crictl rmi立即检查该 image 的 blob 是否还有其他 image 引用
删除容器(container stop + remove)减少 snapshot refcount
手动触发ctr -n k8s.io content gc
kubelet 镜像 GC磁盘使用 > --image-gc-high-threshold → 删除最久未使用的 image
# kubelet 镜像 GC 配置
# /var/lib/kubelet/config.yaml
imageGCHighThresholdPercent: 85   # 磁盘使用 > 85% 触发
imageGCLowThresholdPercent: 80    # 回收到 80%
imageMinimumGCAge: 2m             # 镜像至少存在 2 分钟才能被回收

# containerd GC 配置
# /etc/containerd/config.toml
[plugins."io.containerd.grpc.v1.cri"]
  discard_unpacked_layers = true   # pull 后释放解压的 layer(节省空间)

磁盘满了的紧急处理

# 1. 查明是谁在吃磁盘
du -sh /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/
du -sh /var/lib/containerd/io.containerd.content.v1.content/

# 2. 列出所有镜像(按大小)
crictl images | sort -k4 -hr | head -20

# 3. 安全清理
crictl rmi --prune                              # 清理未使用镜像
ctr -n k8s.io content gc                        # 触发 containerd GC

# 4. 如果 snapshot 泄漏(删除容器后 snapshot 未释放)
ctr -n k8s.io snapshot ls | grep -v "Active\|Committed" | awk '{print $1}' | xargs -I {} ctr -n k8s.io snapshot rm {}

# 5. 终极手段:重启 containerd(会强制清理)
systemctl restart containerd

cgroup v2 运行时集成细节

从 Pod Spec → cgroup path 的完整映射

# Pod spec
apiVersion: v1
kind: Pod
metadata:
  uid: abc-123
spec:
  containers:
    - name: app
      resources:
        requests: {cpu: "2", memory: "4Gi"}
        limits:   {cpu: "4", memory: "8Gi"}

映射到 cgroup v2:

/sys/fs/cgroup/kubepods.slice/
  └── kubepods-burstable.slice/              ← QoS: Burstable
      └── kubepods-burstable-podabc_123.slice/  ← Pod cgroup
          ├── cpu.weight: 205                  ← requests.cpu 的映射
          ├── cpu.max: 400000 100000            ← limits.cpu = 4 cores
          ├── memory.max: 8589934592            ← limits.memory = 8GiB
          ├── memory.high: 4294967296           ← requests.memory = 4GiB
          └── cgroup.procs                      ← pause + app 进程

QoS 对应的 cgroup 路径:

QoS Classcgroup path
Guaranteedkubepods.slice/kubepods-pod<uid>.slice/
Burstablekubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/
BestEffortkubepods.slice/kubepods-besteffort.slice/kubepods-besteffort-pod<uid>.slice/

CPU Manager + cpuset 集成

当 CPU Manager static policy 分配独占 CPU 时,containerd 需要:

  1. kubelet → CRI → UpdateContainerResources(linux.cpuset_cpus="4-7")
  2. containerd → runc → 写入 cpuset.cpus 到容器的 cgroup
  3. 容器进程只能用 CPU 4-7

如果 containerd 的 SystemdCgroup 设为 false,这些 cgroup 操作会走 cgroupfs driver,可能与 systemd 的 cgroup 树冲突。这就是为什么 K8s v1.31+ 强制 SystemdCgroup=true

关联知识

参考资源

学习时间

阶段时间备注
OCI 深入2026-07-02OCI Runtime Spec、runc 执行流程、镜像 Manifest/Layer、Snapshotter、Content Store GC、cgroup v2 映射

状态: 🌱 学习中 下次复习日期: 2026-07-09