系列专栏《深入剖析 Kubernetes》· 基于张磊极客时间专栏思想整理 · 适合 CSDN 发布
13 容器运行时:CRI、Kata Containers 与 gVisor
前面我们讲了 Pod、Deployment、DaemonSet 这些"调度层"概念。但有一个根本问题一直悬而未决:kubelet 收到"创建一个容器"的指令后,到底是谁真正把那个进程拉起来的?这就是**容器运行时(Container Runtime)**的领域。本讲我们下沉到节点最底部,看清 CRI 这套接口如何把 K8s 和各种运行时解耦,以及为什么在强隔离场景下我们需要 Kata Containers 和 gVisor。
一、容器运行时的两层架构
容器运行时不是一个单一程序,而是分层的:
┌──────────────────────────────────────────────┐ │ 高层运行时 High-level Runtime │ │ docker(旧) / containerd / CRI-O │ │ 职责:镜像管理、解包、调用低层运行时、 │ │ 对接 CRI、管理容器生命周期 │ └───────────────┬──────────────────────────────┘ │ (调用 runc 等) ┌───────────────┴──────────────────────────────┐ │ 低层运行时 Low-level Runtime │ │ runc / runsc(gVisor) / kata-runtime │ │ 职责:真正按 OCI spec 创建 │ │ namespace + cgroups,把进程跑起来 │ └───────────────────────────────────────────────┘- 高层运行时(High-level Runtime):面向 K8s,负责拉镜像、解包、和管理容器的整个生命周期。代表有containerd(Docker 剥离出来的核心)、CRI-O(专为 K8s 打造,Red Hat 主导)。
- 低层运行时(Low-level Runtime):面向内核,严格遵循OCI Runtime Spec(由
runc定义的事实标准),负责调用clone()、unshare()、写 cgroup,最终把容器进程创建出来。代表是runc(默认)、runsc(gVisor)、kata-runtime(Kata)。
二者之间靠OCI Runtime Spec这个标准化接口衔接——这正是"解耦"思想的第一层体现。
二、CRI:让 K8s 不再绑定某一种运行时
早期 K8s 只能对接 Docker,代码里写死了调用docker命令行。这显然不灵活:K8s 想支持更多运行时,就得改自己代码。于是 K8s 在 1.5 引入了CRI(Container Runtime Interface,容器运行时接口)——一套gRPC 定义的、与具体运行时无关的抽象接口。
kubelet │ │ CRI (gRPC: 通用接口) │ ├─ RunPodSandbox / StopPodSandbox │ ├─ CreateContainer / StartContainer │ └─ PullImage / ListImages ... ▼ ┌───────────────┐ │ CRI 插件/垫片 │ (containerd 内置 / CRI-O / 旧 dockershim) └──────┬────────┘ │ OCI Runtime Spec ▼ runc / runsc / kata-runtimeCRI 的核心方法(概念级,不必死记):
RunPodSandbox/StopPodSandbox:Pod 在 CRI 语境下叫 “sandbox”,其实对应的是为 Pod 创建那个共享的 pause 容器 / network namespace。CreateContainer/StartContainer:PodSandbox内真正创建并启动业务容器。PullImage/RemoveImage:镜像管理。
只要一个运行时实现了这套 gRPC 接口,kubelet 就能用--container-runtime-endpoint指向它,无需 K8s 源码有任何改动。这就是 CRI 的价值:K8s 只认接口,不认实现。
dockershim 的废弃:一次"去耦"的必然
很多人疑惑:“我明明装的是 Docker,K8s 怎么用的是 containerd?” 历史是这样的:
- 早期 K8s 为了支持 Docker,在代码里内置了dockershim(一个把 CRI 调用翻译成 Docker API 的"垫片")。
- 这层垫片既丑又重,且 Docker 本身并不实现 CRI。K8s 在1.20 宣布废弃 dockershim,并在1.24 正式移除。
- 移除后,K8s 直接与实现了 CRI 的containerd或CRI-O对话,Docker 不再是 K8s 的必选项。
注意:你用docker build打出来的镜像,依然能在 containerd 上跑——因为镜像格式是 OCI 标准,构建工具与运行时是解耦的。多数人感觉"没变化",只是因为 Docker 桌面版默认把 containerd 也带上了。
三、为什么 runc 还不够:共享内核的风险
默认的runc跑出来的容器,如第 1 讲所述,只是被 namespace 和 cgroups 隔离的进程——它们共享同一个宿主机内核。这对大多数场景够用,但在以下场景存在隐患:
- 安全多租户:云厂商把不同客户的容器放在同一台物理机上,一旦某个客户利用内核漏洞(如脏牛 Dirty COW、脏管道 Dirty Pipe)逃逸,就能打到宿主机和其他客户的容器。
- 不可信负载:运行用户上传的代码、第三方镜像。
- 强合规:金融、政务要求硬隔离。
namespace/cgroups 是"逻辑隔离",不是"物理隔离"。要更强的隔离,就得引入虚拟化或用户态内核。于是 Kata Containers 和 gVisor 登场。
四、Kata Containers:给每个容器一台"轻量虚拟机"
Kata Containers 的思路非常直白:给每个 Pod 起一个极轻量的虚拟机(MicroVM),容器进程跑在 VM 里的独立内核中,而不是宿主机内核。
宿主机内核 │ QEMU / Firecracker (轻量 VMM) ▼ ┌──────── VM (microVM) ────────┐ │ guest 内核 (独立, 真隔离) │ │ ├─ 容器进程 A │ │ └─ 容器进程 B (同 Pod) │ └──────────────────────────────┘- 基于QEMU或更轻的Firecracker(AWS 开源),启动快(百毫秒级)、内存开销小。
- 容器仍能拿到"看起来像普通容器"的接口,对上层完全透明——K8s 通过 CRI 调用 kata-runtime 即可,业务无感知。
- 隔离强度 = 虚拟机级:即使容器逃逸,也只是逃到自己的 guest 内核,碰不到宿主机。
- 代价:每个 Pod 多一份内核内存(约几十 MB)、启动稍慢、某些依赖宿主内核特性的功能受限(如 eBPF 在 guest 内)。
典型场景:公有云 Serverless 容器、多租户 CaaS 平台。
五、gVisor:用"用户态内核"拦截系统调用
gVisor(Google 开源)走了另一条路:它不跑完整 VM,而是在用户态实现一个"假内核"(Sentry),拦截容器进程发出的所有syscall,在用户态模拟内核行为。
宿主机内核 ▲ 只暴露极少量 syscall(经 Go 实现的 runsc 代理) │ ┌──┴── 用户态 ──────────────┐ │ Sentry (gVisor 内核模拟) │ │ 接收 syscall → 模拟返回 │ │ └─ 容器内应用进程 │ └────────────────────────────┘- 低层运行时叫runsc(runtime for gVisor Sandbox Container)。
- 容器进程表面上觉得自己调了
open()/read(),其实这些调用被 Sentry 拦截、在用户态处理,绝大多数根本不会真正进入宿主机内核。攻击面被压缩到 runsc 这一薄层。 - 优点:比 VM 更轻、启动更快、跨平台(不依赖硬件虚拟化)。
- 缺点:系统调用兼容性不是 100%,个别依赖特殊内核特性的程序(如某些高性能网络栈、特定 ioctl)可能跑不了。
典型场景:运行不可信代码的沙箱、CI 构建隔离、多租户轻量隔离。
六、三者选型对照
| 运行时 | 隔离机制 | 隔离强度 | 开销 | 适用场景 |
|---|---|---|---|---|
| runc | namespace + cgroups | 进程级(共享内核) | 最低 | 绝大多数可信业务 |
| Kata | 轻量 VM(独立内核) | 虚拟机级 | 中(多份内核) | 强隔离、多租户、合规 |
| gVisor | 用户态内核拦截 syscall | 接近 VM 级 | 低-中 | 不可信代码沙箱、轻量多租户 |
在 K8s 中,还可以用RuntimeClass让同一个集群混合使用多种运行时:
# 1) 集群管理员定义 runtimeClassapiVersion:node.k8s.io/v1kind:RuntimeClassmetadata:{name:kata}handler:kata# 对应 containerd 里配置的 kata runtime---# 2) 业务 Pod 指定用 kata 隔离apiVersion:v1kind:Podmetadata:{name:tenant-a}spec:runtimeClassName:kata# 该 Pod 走 Kata 强隔离containers:-name:appimage:myrepo/untrusted-user-code:v1spec.runtimeClassName让调度与隔离策略解耦——默认用 runc,敏感租户 Pod 标kata,实现"一套集群、分级隔离"。
小结 / 核心要点
- 运行时分层:高层(containerd/CRI-O)管镜像与生命周期,低层(runc/runsc/kata-runtime)按 OCI spec 真正创建进程;二者靠 OCI Runtime Spec 解耦。
- CRI 是 K8s 与运行时的契约:一套 gRPC 接口,让 kubelet 只认接口、不绑实现;Pod 在 CRI 里对应 “sandbox”,底层是共享的 pause/network namespace。
- dockershim 已废弃(1.24 移除):K8s 直连 containerd/CRI-O;Docker 仍是可用的构建工具,但不再是 K8s 运行时必选项。镜像格式是 OCI 标准,构建与运行天然解耦。
- runc 共享内核 = 逻辑隔离:多租户/不可信负载场景需更强隔离。
- Kata = 轻量 VM(独立内核,真隔离)、gVisor = 用户态内核拦截 syscall(轻量隔离);用RuntimeClass可在同一集群按 Pod 灵活选择隔离级别。
至此,我们从"一个被隔离的进程"出发,一路走到了"节点上真正拉起容器的运行时"——整个容器技术栈的自底向上脉络已经清晰。后续内容将更多聚焦于 K8s 的编排与控制循环本身。