13. 标准接口:CNI / CSI / CRI
K8S 本体到底管什么
经过了前面 12 章,你心里应该有个轮廓了:K8S 管的是 Pod 的编排——决定 Pod 在哪个 Node 上跑、维持几个副本、网络设备怎么配。但每一个具体环节的操作,K8S 本身不直接执行。
Pod 里面的容器是谁起的?有人说是 kubelet,但 kubelet 调的是 CRI 接口。 Pod 的 IP 是谁分配的?有人说是 kubelet,但 kubelet 调的是 CNI 接口。 PV 背后的云盘是谁创建的?有人说是 StorageClass,但 StorageClass 调的是 CSI 接口。
K8S 的真正角色——定义接口,调用外部实现。这一章把三个接口摊开看。
CRI:容器运行时接口
CRI 的概念在第 4 章 Pod 那期已经提到过。这里把它补全。
kubelet 跟 container runtime 之间通过 gRPC 通信。接口定义在 k8s.io/cri-api 仓库:
// k8s.io/cri-api/pkg/apis/runtime/v1/api.proto
service RuntimeService {
rpc RunPodSandbox(RunPodSandboxRequest) returns (RunPodSandboxResponse);
rpc StopPodSandbox(StopPodSandboxRequest) returns (StopPodSandboxResponse);
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse);
rpc StartContainer(StartContainerRequest) returns (StartContainerResponse);
rpc StopContainer(StopContainerRequest) returns (StopContainerResponse);
rpc RemoveContainer(RemoveContainerRequest) returns (RemoveContainerResponse);
rpc ListContainers(ListContainersRequest) returns (ListContainersResponse);
rpc ContainerStatus(ContainerStatusRequest) returns (ContainerStatusResponse);
}
service ImageService {
rpc PullImage(PullImageRequest) returns (PullImageResponse);
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
rpc ImageStatus(ImageStatusRequest) returns (ImageStatusResponse);
}
两个 service:
- RuntimeService:管理 Pod Sandbox 和容器的生命周期
- ImageService:管理镜像的拉取和清理
任何实现了这两个 gRPC 服务的进程,都能当 K8S 的容器运行时。kubelet 启动时通过 /var/run/dockershim.sock 或其他 unix socket 连接。socket 文件就像是 kubelet 和 CRI 实现之间的"握手标识"。
CRI 的实现们
| 实现 | 编程语言 | 特点 |
|---|---|---|
| containerd | Go | 最主流,K3S 内置,K8S 社区首选 |
| CRI-O | Go | 专为 K8S 设计,更轻量,支持 OCI 原生 |
| cri-dockerd | Go | Docker 到 CRI 的适配层(K8S 1.24 后替代 dockershim) |
containerd 的 CRI 插件代码在 containerd/pkg/cri/ 下:
// containerd/pkg/cri/server/service.go
func (c *criService) RunPodSandbox(ctx context.Context, r *runtime.RunPodSandboxRequest) (*runtime.RunPodSandboxResponse, error) {
// 1. 创建 net namespace (调 CNI)
// 2. 起一个 pause 容器(sandbox)
// 3. 设置容器的 cgroup
// 4. 返回 Pod Sandbox 的 ID
}
K3S 用的就是这段代码,跟 K8S 的标准 containerd 调用路径完全一致。
K3S 的 CRI 选择
K3S 的 CRI 实现是 containerd 的内置版本——不是系统级的 containerd,而是跟随 K3S 二进制编译进去的一份:
// k3s/pkg/agent/containerd/containerd.go
func Run(ctx context.Context, cfg *config.Node) error {
// containerd 在 K3S 进程内启动
client, err := containerd.New(cfg.Containerd.Address)
// 注册 CRI 插件
cri.Register(client, cfg)
}
这意味着你不需要单独安装、配置 containerd。K3S 启动后,CRI 的 unix socket 已经在那了。kubelet 拿到 Pod 调的就是这个内置的 containerd。
思考题:K8S 1.24 去掉 dockershim 之后,kubelet 的
--container-runtime-endpoint只能指一个 socket——但集群里同时有 Linux 和 Windows 容器,它们需要不同的运行时,这怎么解决?
CNI:容器网络接口
CNI 的职责是给 Sandbox 分配网络——IP、网卡、路由。接口定义在 github.com/containernetworking/cni:
// CNI plugin 的核心行为
type CNI interface {
// 给一个容器(net namespace)添加网络
AddNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) (types.Result, error)
// 从容器中移除网络
DelNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error
// 检查网络配置是否还在
CheckNetwork(ctx context.Context, net *NetworkConfig, rt *RuntimeConf) error
}
CNI 不是系统调用——它是一个可执行文件协议。kubelet 调用 /opt/cni/bin/bridge 这样的可执行文件,通过 stdin 传配置 JSON,stdout 读返回结果:
# kubelet 调用 CNI 插件的实际方式
echo '{"cniVersion":"1.0.0","name":"mynet","type":"bridge"}' | \
CNI_COMMAND=ADD CNI_CONTAINERID=abc CNI_NETNS=/var/run/netns/xxx \
/opt/cni/bin/bridge
Flannel、Calico、Cilium 都是 CNI 可执行文件的不同"品牌"。kubelet 不关心 /opt/cni/bin/ 下面放的是哪个——它只管按标准 JSON 格式沟通。
K3S 的默认 CNI
K3S 内置了 Flannel 作为 CNI 插件。启动 K3S 后,/var/lib/rancher/k3s/agent/etc/cni/net.d/ 下已经有 Flannel 的配置文件。这是 K3S 跟 K8S 的一大差异——K8S 上你得手动部署 CNI 才能让 Pod 有 IP。
// k3s/pkg/agent/flannel/flannel.go
func Run(ctx context.Context) error {
// 从 kube-proxy 的配置里拿到集群 CIDR
// 写 flannel 的 CNI config 到 /var/lib/rancher/k3s/agent/etc/cni/net.d/
}
思考题:默认一个 Pod 只有一张网卡(eth0)——但如果你想给 Pod 挂第二张网卡走另一个网络平面(比如一张走业务流量、一张走存储流量),K8S 能做到吗?需要什么额外的 CNI 插件?
CSI:容器存储接口
CSI 的职责是挂载存储到 Pod 里。它的接口比 CRI 和 CNI 都复杂,因为存储操作本身就很繁琐:
// github.com/container-storage-interface/spec/blob/master/csi.proto
service Identity {
rpc GetPluginInfo(GetPluginInfoRequest) returns (GetPluginInfoResponse);
rpc GetPluginCapabilities(GetPluginCapabilitiesRequest) returns (GetPluginCapabilitiesResponse);
rpc Probe(ProbeRequest) returns (ProbeResponse);
}
service Controller {
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
rpc DeleteVolume(DeleteVolumeRequest) returns (DeleteVolumeResponse);
rpc ControllerPublishVolume(ControllerPublishVolumeRequest) returns (...);
rpc ControllerUnpublishVolume(ControllerUnpublishVolumeRequest) returns (...);
}
service Node {
rpc NodeStageVolume(NodeStageVolumeRequest) returns (NodeStageVolumeResponse);
rpc NodePublishVolume(NodePublishVolumeRequest) returns (NodePublishVolumeResponse);
rpc NodeUnpublishVolume(NodeUnpublishVolumeRequest) returns (...);
}
分成了三层:
- Identity:插件是谁(名字、版本)
- Controller:创建 / 删除 / 挂载 / 卸载存储(通常跑在控制面)
- Node:在具体 Node 上把存储 format、mount 到 Pod 的 namespace 里
Cloud Provider 实现 CSI 的例子——AWS EBS CSI Driver 的 ControllerServer.CreateVolume:
// aws-ebs-csi-driver/pkg/cloud/cloud.go 简化
func (c *Cloud) CreateDisk(ctx context.Context, volumeName string, diskOptions *DiskOptions) (*Disk, error) {
// 1. 调 AWS API 创建 EBS 盘
vol, err := c.ec2.CreateVolume(aws.String(diskOptions.CapacityBytes), ...)
// 2. 等待 EBS 盘状态变为 available
c.waitForVolume(vol.VolumeID, "available")
// 3. 返回这个卷的信息
return &Disk{VolumeID: *vol.VolumeID, Capacity: vol.Size}, nil
}
然后在 NodePublishVolume 里把 EBS 盘 mount 到 Pod 的 /var/lib/mysql 路径。
K3S 的 CSI
K3S 自带了一个 local-path-provisioner(基于 hostPath 的 CSI 插件)。这意味着在不装任何额外插件的情况下,你 kubectl apply 一个 PVC,就能自动创建 hostPath 目录作为存储。这是 K3S 又一处"帮你选好了默认实现"的地方。
# K3S 默认 StorageClass:local-path
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-path
annotations:
storageclass.kubernetes.io/is-default-class: "true" # 默认
provisioner: rancher.io/local-path
reclaimPolicy: Delete
思考题:CSI 的 Controller 服务跑在集群里作为一个 Pod——如果这个 Pod 挂了,已挂载到 Node 上的 PV 还能读写吗?新 PVC 还能创建吗?
三个接口的共同设计
三个接口都有自己的扩展机制,但它们共享同一个设计哲学:
| 共同点 | 例子 |
|---|---|
| 解耦 | K8S 只调用接口,不直接依赖 containerd 或 Flannel |
| 多实现 | CRI 有 containerd/CRI-O/cri-dockerd,CNI 有 Flannel/Calico/Cilium |
| 标准协议 | CRI 和 CSI 是 gRPC,CNI 是 JSON-over-stdin/stdout |
K8S 不关心谁实现了这些接口——它只定义"这个容器里发生的行为,需要对标什么界面"。
思考题:CRI 和 CSI 用 gRPC,CNI 却用 stdin/stdout 传 JSON——都是"标准接口",为什么设计不一样?CNI 是调一次就完事还是长期保持连接?这跟 gRPC 的"长连接"模型有什么冲突?
思考题:K8S 对 IPv6 的支持从什么时候开始的?一个纯 IPv6 的集群,Service、Pod、CNI 分别要配什么?
小结
┌──────────┐ ┌──────────┐ ┌──────────┐
│ kubelet │ │ kubelet │ │ kubelet │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ CRI │ CNI │ CSI
┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐
│containerd │ │ Flannel │ │ EBS CSI │
│ CRI-O │ │ Calico │ │ Ceph CSI │
│cri-dockerd│ │ Cilium │ │ NFS CSI │
└───────────┘ └───────────┘ └───────────┘
- CRI:管容器的创建/启停/镜像拉取,containerd 是 K8S 和 K3S 的默认选择
- CNI:管 Pod 的网络(IP 分配、路由),K3S 内置 Flannel
- CSI:管 Pod 挂载存储的创建和挂载,K3S 默认 local-path-provisioner
- 所有接口背后都是多实现——K8S 在乎的是接口签名的约定,不在乎后面跑的是谁
解答
1. 为什么 CNI 不用 gRPC,而是 stdin/stdout?
因为 CNI 的调用场景跟 CRI/CSI 完全不一样。
gRPC 的优势是长连接 + 流式通信——适合 kubelet 和 containerd 这种"24/7 一直跑、随时互相调用"的场景。kubelet 每次 syncPod 都要调 CRI 的多个方法(RunPodSandbox → CreateContainer → StartContainer),长连接能复用 TLS 握手、维持连接池。
CNI 的调用场景是一次性的——Pod 创建时调 ADD,Pod 销毁时调 DEL。两次调用之间可能隔几天。如果 CNI 也用 gRPC + 长连接,kubelet 就得跟每个 CNI 插件维持一个"隔几天才用一次"的连接——资源浪费,而且插件如果是一个简单的 shell 脚本(很多早期的 CNI 脚本确实是 shell 写的),gRPC 的复杂度完全不值得。
CNI 选择 stdin/stdout 的理由:
| 理由 | 解释 |
|---|---|
| 无状态 | 每次调用都是独立进程,ADD 跑完进程退出,不需要内存常驻 |
| 简单 | 任何语言都能写——echo '{...}' | my-plugin 就能测试 |
| 安全 | 每次启动新进程,进程在独立空间跑,不共享内存 |
| 不需要长连接 | 调一次就完事,一年调不了一次也 OK |
代价是每调一次 CNI 都要 fork 一个新进程——这比 gRPC 调一个已运行的服务多了进程创建开销。但 Pod 的创建频率(每分钟几十个 Pod)相对于 gRPC 维护十几条闲置连接的开销来说,stdin/stdout 的方案更合理。
类比:CRI 像你去楼下便利店——每天都去,熟了连门都懒得敲;CNI 像你一年去一次邮局——不差那走两步路的时间。
2. 一个 Node 能跑多个 CRI 实现吗?
不能。kubelet 启动时 --container-runtime-endpoint 只能指一个 socket——一个 Node 上只有一个 CRI 实现托管所有容器。
但 K8S 社区在推进 多 CRI 支持来应对混合工作负载场景——比如 Linux 和 Windows 容器混跑在同一 Node 上。containerd 本身就内置了多运行时支持——可以通过 runtime handler annotation 在单个 CRI 实现下切换不同的 OCI 运行时:
apiVersion: v1
kind: Pod
metadata:
annotations:
io.containerd.runtime.v2.task: "windows" # 指定运行时
这是通过 containerd 的 runtime v2 API 做到的——kubelet 调 CreateContainer 时带上 runtime class annotation,containerd 根据注解决定用 runc(Linux)、runhcs(Windows)还是其他 OCI 运行时。对外只有一个 CRI socket,对内切了多个 OCI 运行时。
另外,如果你想在 Node 上跑非容器的 Pod(比如虚拟机——Kata Containers、Firecracker),也是走同一个 CRI 接口 + RuntimeClass:
apiVersion: node.k8s.io/v1
kind: RuntimeClass
metadata:
name: kata-containers
handler: kata
---
spec:
runtimeClassName: kata-containers # 这个 Pod 用 Kata VM 跑
即使是像 gVisor(进程级沙箱)这样的非容器运行时,也通过实现 CRI 的 gRPC 接口来集成——对 kubelet 来说它跟 containerd 长得一模一样。
所以答案:kubelet 层面一个 Node 只有一个 CRI socket,但在 containerd 内部可以切多个 OCI 运行时。真正需要多个 CRI 的场景(比如你想把部分 Pod 交给 cri-o、部分交给 containerd)目前还没官方支持,但社区在讨论 "dynamic CRI plugin" 的方向。
3. Pod 能有多张网卡吗?
能,需要 Multus CNI。
默认 CNI 只给 Pod 创建一张网卡(eth0),挂到集群的 Pod 网络。但有些场景需要多个网络平面:
- 一张网卡走业务流量,另一张走存储流量(iSCSI/FC 网络)
- 一张网卡是 K8S 的 overlay,另一张是物理网络直通(SR-IOV)——低延迟
- NFV(网络功能虚拟化)场景——多个 VLAN 网口
Multus 是一个"meta CNI"——它不是具体的网络实现,而是给一个 Pod 调多个 CNI。Pod 创建时,Multus 先在 CRI 的 RunPodSandbox 阶段被 kubelet 调用,然后它依次调用你指定的底层 CNI 们:
kubelet → Multus (meta CNI)
├─ CNI 1: Flannel → eth0 (K8S 主网络)
├─ CNI 2: macvlan → net1 (物理网络直通)
└─ CNI 3: ipvlan → net2 (独立 VLAN)
配置 NetworkAttachmentDefinition:
apiVersion: k8s.cni.cncf.io/v1
kind: NetworkAttachmentDefinition
metadata:
name: storage-net
spec:
config: '{"cniVersion":"1.0.0","type":"macvlan","master":"eth1","mode":"bridge"}'
Pod 声明使用:
metadata:
annotations:
k8s.v1.cni.cncf.io/networks: storage-net
进入容器后 ip link 能看到 eth0(Flannel)和 net1(macvlan)两张网卡,各自走不同的路由。
4. CSI Controller Pod 挂了,PV 还能用吗?
已挂载的——不受影响。新建的——不行。
CSI 分两层,各自独立:
- Controller 服务(一般以 Deployment 跑):负责创建/删除/attach/detach 云盘。这调的是云 API。
- Node 服务(以 DaemonSet 跑在每台 Node 上):负责 format / mount / unmount 到 Pod 路径。这是本地操作。
PV 已经 attach + mount 到 Node 上之后,kubelet 和内核接管了这个挂载点——容器内的读写直接走内核的文件系统和块设备驱动,不经过 CSI Controller。所以 Controller Pod 挂了,已挂载的 PV 照常读写。
但当 PVC 需要创建新 PV(CreateVolume)或 Pod 被调度到新 Node 需要 attach 云盘(ControllerPublishVolume)时——这些操作需要调 CSI Controller 的 gRPC 方法。Controller 挂了,新创建/新挂载全部卡住。
生产建议:CSI Controller 用 Deployment ≥ 2 副本 + anti-affinity 防单点。因为它是无状态服务(只调用云 API),多副本不会冲突——同一个 volume 的创建请求幂等。
5. K8S 对 IPv6 的支持
从 K8S 1.16 开始支持 dual-stack(IPv4 + IPv6 双栈),1.20 后 stable。纯 IPv6 集群在 1.27 达到 GA。
改造三处:
# API Server / Controller Manager / kubelet 启动参数
--service-cluster-ip-range=10.96.0.0/12,fd00::/108 # IPv4 + IPv6 段
# kube-proxy - 无需改,iptables/IPVS 都支持 v6
# 但 kube-proxy 需要在 IPv6 上绑定端口
# CNI - 必须支持双栈分配
# Calico: IP_AUTODETECTION_METHOD + IP6_AUTODETECTION_METHOD
# Cilium: ipam.mode=kubernetes + enableIPv6=true
Pod 网络的内核参数需要开启 IPv6 forwarding:
sysctl -w net.ipv6.conf.all.forwarding=1
Service 双栈示例:
apiVersion: v1
kind: Service
spec:
ipFamilyPolicy: RequireDualStack
ipFamilies:
- IPv4
- IPv6
selector:
app: nginx
Pod 拿到四个 DNS 记录——A 记录(IPv4)和 AAAA 记录(IPv6)同时存在。
K3S 的 IPv6 支持更快——因为内置 Flannel 同时支持 v4/v6,不需要额外装 CNI。k3s server --cluster-cidr=10.42.0.0/16,fd42::/48 --service-cidr=10.43.0.0/16,fd43::/108 就通了。
下一章,Pod 已经创建完了、网络和存储就绪——调度器怎么决定 Pod 去哪台节点。