WEBKT

深入 Kubelet 与 Containerd 源码:剖析 CRI 通信机制与高并发瓶颈定位

10 0 0 0

在 Kubernetes 集群中,Kubelet 与容器运行时(Containerd)的交互效率直接决定了 Pod 的拉起速度和集群的响应能力。当面对大规模并发调度(如大促弹性扩容、批量批处理作业)时,底层的 gRPC 通信链路往往会成为隐藏的性能瓶颈。

本文将从 Kubernetes 和 Containerd 的源码出发,深度拆解 CRI(Container Runtime Interface)的 gRPC 通信机制,并剖析在高并发场景下,这条链路上最容易发生的瓶颈及排查定位方法。


一、 Kubelet 端 CRI 客户端的初始化与调用机制

Kubelet 作为 gRPC 的客户端,其所有的容器操作最终都会转化为对 Containerd 的 RPC 调用。

1.1 客户端的建立与连接管理

在 Kubelet 启动时,其内部的 CRI 客户端被初始化。核心源码位于 pkg/kubelet/cri/remote/remote_runtime.go。Kubelet 会通过 Unix Domain Socket (UDS) 与 Containerd 通信。

// pkg/kubelet/cri/remote/remote_runtime.go 中的初始化片段
func NewRemoteRuntimeService(endpoint string, connectionTimeout time.Duration, tp oteltrace.TracerProvider) (internalapi.RuntimeService, error) {
    // 解析 UDS 地址,例如 unix:///run/containerd/containerd.sock
    addr, dialer, err := util.GetAddressAndDialer(endpoint)
    if err != nil {
        return nil, err
    }

    // 设置 gRPC 拨号参数,包括连接超时、最大消息接收大小等
    ctx, cancel := context.WithTimeout(context.Background(), connectionTimeout)
    defer cancel()

    conn, err := grpc.DialContext(ctx, addr, 
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithContextDialer(dialer),
        grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(maxMsgSize)),
    )
    if err != nil {
        return nil, fmt.Errorf("connect to DBUS/CRI service failed: %v", err)
    }

    return &RemoteRuntimeService{
        timeout:       connectionTimeout,
        runtimeClient: runtimeapi.NewRuntimeServiceClient(conn),
        // ...
    }, nil
}

关键设计点:

  • 单连接复用(Multiplexing):Kubelet 与 Containerd 之间通常只维护一个 gRPC 连接。通过 HTTP/2 的多路复用能力,所有的 Pod 创建、停止、状态查询请求都在这个连接上的不同 Stream 中并发传输。
  • 超时控制connectionTimeout 默认为 2 分钟,如果高并发下 Containerd 响应过慢,Kubelet 会直接触发 context 截止时间超时(Context Deadline Exceeded)。

1.2 拦截器(Interceptors)埋点

为了监控 CRI 调用的耗时,Kubelet 在 gRPC 客户端挂载了 OpenTelemetry 拦截器,这为我们后续进行链路追踪(Tracing)提供了标准入口:

// 客户端一侧的拦截器会对每个 CRI 方法的执行延迟进行记录
grpc.WithUnaryInterceptor(otelgrpc.UnaryClientInterceptor(otelgrpc.WithTracerProvider(tp)))

二、 Containerd 端 CRI 插件的响应架构

Containerd 自 1.1 版本起,将 CRI 服务作为内部的内置插件(CRI Plugin)运行,避免了额外的进程间通信。

2.1 注册 gRPC 服务

Containerd 启动时,CRI 插件会向 Containerd 的 gRPC Server 注册自己。其核心源码位于 github.com/containerd/containerd/pkg/cri/server(或在新版本中的 plugins/cri)。

// pkg/cri/server/service.go
func (c *criService) Register(s *grpc.Server) error {
    runtimeapi.RegisterRuntimeServiceServer(s, c)
    runtimeapi.RegisterImageServiceServer(s, c)
    return nil
}

criService 实现了 RuntimeServiceServer 接口的所有方法,例如 RunPodSandboxCreateContainerStartContainer 等。

2.2 请求的分发与上下文限制

当 gRPC 收到 Kubelet 的请求时,Go 的 gRPC 框架会为每个请求启动一个 Goroutine 进行处理。

func (c *criService) RunPodSandbox(ctx context.Context, r *runtimeapi.RunPodSandboxRequest) (*runtimeapi.RunPodSandboxResponse, error) {
    // 1. 构造 Sandbox 的元数据
    // 2. 调用底层 runtime (如 runc) 创建 network namespace
    // 3. 写入 metadata 数据库 (BoltDB)
    // 4. 触发 OCI Runtime 启动 pause 容器
}

由于 Go 协程(Goroutine)的轻量化特性,Containerd 理论上可以并发接收成百上千个请求,但真实的物理资源及锁争抢会将高并发请求死死卡住。


三、 高并发场景下的瓶颈定位与根因剖析

在大规模并发拉起容器时,集群通常会遇到 Kubelet 报 CRI request runtime timeoutcontext deadline exceeded 错误,Pod 处于 ContainerCreating 状态不退。

结合源码与工程实践,瓶颈通常发生以下四个核心区域:

+------------------+                   +------------------------+
|   Kubelet        |                   | Containerd (CRI)       |
|                  |                   |                        |
|  [gRPC Client] --+---(UDS Socket)--->+-- [gRPC Server]        |
+------------------+                   +-----------+------------+
                                                   |
                             +---------------------+---------------------+
                             |                     |                     |
                  +----------v----------+ +--------v--------+ +----------v----------+
                  |  1. BoltDB Tx Lock  | |  2. Shim Fork   | | 3. Systemd D-Bus  |
                  +---------------------+ +-----------------+ +---------------------+

3.1 瓶颈一:BoltDB 的读写事务锁(Transaction Serialization)

Containerd 内部使用 BoltDB(或其变种 bbolt)来持久化容器和镜像的元数据。

  • 源码机制:BoltDB 支持多个并发读事务(Read-Only Transactions),但只支持一个并发写事务(Read-Write Transaction)。当多个 RunPodSandboxCreateContainer 请求并发到达时,它们必须排队获取写锁。
  • 高并发表现:如果磁盘 I/O 出现抖动(Io Wait 高),写事务的提交(tx.Commit())变慢,就会导致大量的 Goroutine 阻塞在等待 BoltDB 锁的分配上。

3.2 瓶颈二:Containerd-Shim 进程 Fork 限制

每创建一个容器,Containerd 都会调用 containerd-shim 进程,由 Shim 进程去调用 runc,并作为容器内 init 进程的父进程。

  • 源码机制:创建 Shim 是通过 os/exec 执行系统调用 clone()(Go 中的 fork)实现的。
  • 高并发表现:在 Linux 内核中,大并发的 fork 系统调用会导致严重的物理 CPU 锁争抢(尤其是在内存页表复制和 namespaces 隔离区初始化时)。此外,如果主机的 pids.max(进程数上限)或系统的 nofile 限制过小,会导致 Shim 创建直接失败。

3.3 瓶颈三:Systemd cgroup 驱动的 D-Bus 单线程瓶颈

在生产环境,我们通常配置 Containerd 和 Kubelet 使用 systemd 作为 cgroup 驱动(Cgroup Driver)。

  • 源码机制:使用 systemd 意味着每次创建 Pod 隔离区或限制容器资源时,Containerd 都会通过 D-Bus 接口向系统的 systemd 服务发送请求,让其创建 .slice.scope 单元。
  • 高并发表现systemd 处理 D-Bus 请求在很大程度上是单线程循环的。当数百个容器并发请求创建时,D-Bus 消息队列会产生积压,造成响应延迟从几毫秒飙升至数秒甚至数十秒,直接导致 Kubelet 通信超时。

3.4 瓶颈四:UDS 监听队列积压(Listen Backlog)

gRPC 跑在 Unix Domain Socket 之上。在高并发瞬间,如果 Containerd 反应不过来,内核中 UDS 的接收队列就会被塞满。

  • 根因:Linux 内核对 Socket 监听队列有 backlog 限制(受 /proc/sys/net/core/somaxconn 约束)。

四、 生产实战:如何精准定位瓶颈

当遇到高并发瓶颈时,通过常规的 kubectl describe 往往只能看到超时结果,我们需要使用以下工具链在节点侧进行深度诊断。

4.1 第一步:开启 Containerd 性能分析入口(pprof)

Containerd 内置了 Go pprof 调试接口,但默认不开启。编辑 /etc/containerd/config.toml

[debug]
  address = "/run/containerd/debug.sock"
  uid = 0
  gid = 0
  level = "debug"

重启 Containerd。然后通过 UDS 将 pprof 映射为本地 HTTP 端口进行采集:

# 使用 socat 将 UDS 暴露至 TCP 端口
socat TCP-LISTEN:6060,fork UNIX-CONNECT:/run/containerd/debug.sock &

# 获取 Goroutine 堆栈图,观察是否有大量的锁等待
go tool pprof http://localhost:6060/debug/pprof/goroutine

分析技巧:
如果在 pprof 的 svg 图或 text 视图中发现大量协程阻塞在 github.com/etcd-io/bbolt(*DB).Begin(*Tx).Commit,说明瓶颈卡在磁盘 I/O 或 BoltDB 的元数据写入上

4.2 第二步:利用 eBPF(bpftrace)诊断 UDS 队列延迟

可以使用 eBPF 监控 unix socket 的连接排队与处理延迟,避免应用层超时的干扰。以下 bpftrace 脚本可以实时统计 UDS 接收路径上的耗时分布:

# 查看 unix domain socket 的写延迟
kprobe:unix_stream_sendmsg {
    @start[tid] = nsecs;
}

kretprobe:unix_stream_sendmsg /@start[tid]/ {
    @latency_us = lhist((nsecs - @start[tid]) / 1000, 0, 10000, 1000);
    delete(@start[tid]);
}

若低时延区间(如 < 1000us)占比极低,说明内核态的 Socket 通信本身已经发生了排队阻塞。

4.3 第三步:追查 Systemd 响应时延

若怀疑是 cgroup systemd 驱动导致的变慢,可以通过监控 dbus-daemon 的 CPU 占用,或使用 busctl 抓取消息流:

# 监控 D-Bus 消息流量
busctl monitor org.freedesktop.systemd1

如果在执行 RunPodSandbox 时,控制台输出卡在创建 Scope 单元的消息上久久不返回,即可判定是 systemd 的 cgroup 写入瓶颈。


五、 针对性性能调优与最佳实践

定位到瓶颈后,我们可以从系统级、运行时级和集群管理级进行深度调优。

5.1 系统级调优(Sysctl & Systemd)

提升内核对 UDS 和并发进程的处理承载力:

# 增大 UDS 的最大监听队列长度
sysctl -w net.core.somaxconn=32768

# 增加全局文件描述符上限
sysctl -w fs.file-max=2097152

修改 systemd 的默认配置(/etc/systemd/system.conf),适当增加 DefaultTasksMax,防止因进程数限制导致 fork 失败:

DefaultTasksMax=infinity

5.2 Containerd 配置调优

编辑 /etc/containerd/config.toml,优化 CRI 相关的并发配置:

[plugins."io.containerd.grpc.v1.cri"]
  # 限制最大的容器创建并发数,防止瞬间打垮 runtime-shim
  max_container_ops_per_sandbox = 10
  
  # 对镜像拉取和元数据操作的超时进行合理配置
  registry_pull_timeout = "5m"

[plugins."io.containerd.grpc.v1.cri".containerd]
  # 选用性能更好的 runtime
  default_runtime_name = "runc"

5.3 Kubelet 通信调优

调整 Kubelet 与 CRI 通信的流控参数,避免因客户端请求发送过急导致服务端崩溃。在 Kubelet 启动参数中:

  • --registry-burst=20 (适当调大,允许突发拉取镜像)
  • --registry-qps=10
  • --serialize-image-pulls=false (如果网络和磁盘条件允许,开启并行镜像拉取,减少拉取阻塞导致的 CRI 通信积压)

5.4 调度侧削峰平谷

在高并发作业(如 Spark on K8s)分发时,不推荐在同一时刻将数百个 Pod 调度到单个工作节点上。可以通过配置 PodTopologySpreadConstraints(拓扑分布约束)或 Descheduler,让大并发请求在集群维度均匀分散开,从物理上绕过单节点 Kubelet/Containerd 的并发极限。

云原生深究者 KubernetesContainerdgRPC

评论点评