ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

深入 kubelet syncLoop:Kubernetes 节点侧状态同步原理与实战

深入 kubelet syncLoop:Kubernetes 节点侧状态同步原理与实战

1. 为什么 kubelet 是 Kubernetes 集群里最沉默却最不可替代的“守门人”

你可能在 k8s 架构图里见过它——那个缩在每个 Node 节点右下角、图标小得几乎被忽略的方块,标着“kubelet”。它不像 apiserver 那样天天出现在日志头行,也不像 scheduler 那样总被拿来讨论“调度策略”,更不似 etcd 那样一出问题就全集群告警。但只要它宕了五分钟,你就会发现:新 Pod 死活起不来,Running 的 Pod 突然变成 Unknown,liveness probe 失效后容器没被重启,node status 直接变 NotReady……而此时 control plane 一切正常,apiserver 响应飞快,kubectl get nodes 还能查到节点,只是状态栏里那个红色的 “NotReady” 像个无声的判决。

这就是 kubelet 的真实地位:它不是集群的“大脑”,而是每个节点上唯一具备“执行权”的实体;它不参与决策,但拥有对本机资源的最终解释权和处置权;它不暴露 API,却每天要跟 apiserver 打上百次“确认电话”,还要同时协调 container runtime、CNI 插件、CSI 驱动、cgroup 控制器、甚至本地磁盘和证书轮换系统。它不声不响,却手握 Pod 生死簿——Pod 的创建、启动、健康检查、终止、清理,全由它一手经办,且全程不经过 control plane 的二次审批。

我带过好几个刚接触 k8s 的某高校实验室学生,在模拟项目X中部署一个含 initContainer 和 volumeMount 的复杂应用时,反复遇到“Pod 一直 Pending 在 ContainerCreating 阶段”。他们第一时间去查 scheduler 日志、看 node selector 是否匹配、翻 cordon 状态……折腾两小时后才发现,是某台 worker 节点上的 kubelet 进程因配置错误卡在了证书加载环节,根本没连上 apiserver。而 kubectl get nodes 显示该节点仍是 Ready——因为 kubelet 自身的 healthz 端口还在响应,但其核心 syncLoop 已停滞。这个细节,90% 的入门文档不会提,但它恰恰是理解 kubelet 本质的第一道门槛:kubelet 的“就绪”不等于“可用”,它的健康是分层的,而最底层的可用性,必须靠你亲手验证。

本文不讲“kubelet 是什么”,而是带你钻进它的 syncLoop 主循环,看清它是如何把一份 YAML 文件变成一台正在运行的容器;拆解它与 containerd 的 socket 通信边界在哪;实测当 cgroup v2 启用后,它如何动态调整 memory.limit_in_bytes;还原一次真实的 image pull 失败排查链路;并告诉你,为什么你在 /var/lib/kubelet/config.yaml 里改了 --cni-bin-dir,却对已运行的 Pod 完全无效——这些都不是“配置错误”,而是 kubelet 设计哲学的自然结果。

提示:本文所有操作均基于 v1.28.x(当前 LTS 版本),所有路径、参数、行为均经某公司生产环境 300+ 节点集群实测验证。文中所有命令、配置片段均可直接复制粘贴使用,但请务必结合你本地环境调整路径与版本号。

2. kubelet 的生命周期真相:它从不“启动 Pod”,只负责“同步期望状态”

很多初学者会误以为 kubelet 收到 Pod 对象后,就立刻调用 containerd 创建容器。这是典型的概念错位。kubelet 的核心职责从来不是“执行动作”,而是“维持状态一致”。它本质上是一个状态同步器(State Reconciler),其工作模型可精准概括为一句话:

kubelet 持续从 apiserver 拉取分配给本节点的所有 Pod 清单(即“期望状态”),再通过本地探针获取当前实际运行的容器、卷挂载、网络配置等信息(即“实际状态”),然后驱动本地组件将实际状态逐步收敛至期望状态。

这个过程不是单次触发,而是一个永不停歇的闭环——syncLoop。理解 syncLoop,是读懂 kubelet 的钥匙。

2.1 syncLoop 的四大支柱:housekeeping、config、status、pod

kubelet 的主循环并非线性执行,而是由四个并行运行、职责分明的 goroutine 协同构成,它们共同支撑起整个状态同步体系:

  • Housekeeping Loop(管家循环):每 2 秒执行一次,负责清理僵尸进程、回收已终止容器的 rootfs、检查本地磁盘空间(/var/lib/kubelet 和 /var/log)、更新 cgroup 层级结构。它不依赖 apiserver,纯本地自治。当你看到 kubelet 日志里频繁出现cleaning up orphaned pod或evicting pod due to disk pressure,就是它在干活。

  • Config Loop(配置循环):监听来自 apiserver 的 Pod 变更事件(通过 watch 机制),也接收来自本地文件目录(如/etc/kubernetes/manifests)或 HTTP 端点的静态 Pod 更新。一旦捕获变更,立即触发 syncPod 流程。注意:它本身不解析 YAML,只做事件分发。

  • Status Loop(状态上报循环):默认每 10 秒向 apiserver 上报一次本节点状态(Node.Status)和所有 Pod 的最新运行状况(Pod.Status)。这个上报不是“心跳”,而是完整的状态快照。如果你修改了 kubelet 的--node-status-update-frequency=5s,它并不会让节点“更快变 Ready”,只会让 Node.Status.Conditions 中的Ready条件更新更及时——但真正决定 Ready 的,是 kubelet 内部是否完成了初始化(如 CNI 初始化成功、runtime 连通性验证通过)。

  • Pod Sync Loop(Pod 同步循环):这是最核心的循环,由 Config Loop 触发,执行真正的syncPod()函数。它按优先级队列处理待同步的 Pod,每个 Pod 的同步流程严格遵循以下七步(v1.28 实现):

    1. Admit(准入检查):调用所有注册的 admission plugin(如 DevicePluginManager、TopologyManager),检查硬件资源是否满足(如 GPU 数量、NUMA 绑定)。若失败,Pod 直接卡在 Pending。
    2. Sync Volume(同步存储卷):挂载 emptyDir、hostPath、PersistentVolume(需 CSI driver 配合)、configMap/secret(通过 tmpfs)。此步失败,Pod 卡在 ContainerCreating。
    3. Run Container Runtime(运行时准备):生成 OCI runtime spec(如 config.json),调用 containerd 的 CRI 接口创建 sandbox(pause 容器)。这是容器运行的基石,pause 容器的 PID 1 必须是pause进程,否则后续容器无法共享 network namespace。
    4. Start Init Containers(启动 Init 容器):按顺序逐个启动,任一失败则整个 Pod 重启(受 restartPolicy 影响)。注意:Init 容器的 image pull、volume mount、health check 全部由 kubelet 独立完成,不走 Pod 主容器逻辑。
    5. Start App Containers(启动主容器):并行启动所有主容器。此时才真正调用 containerd 的CreateContainer和StartContainer。
    6. Probe Execution(执行探针):liveness/readiness/startup probe 由 kubelet 内置的 probe manager 调度,独立于容器启动流程。probe 失败后,kubelet 不会通知 apiserver,而是直接调用 runtime KillContainer。
    7. Status Reporting(状态回传):将本次 sync 结果(如容器 PID、IP、端口映射、probe 结果)写入 Pod.Status,供 Status Loop 上报。

这七步不是原子操作,每一步都可能失败并重试。例如,第 3 步 sandbox 创建失败,kubelet 会记录FailedCreatePodSandBox事件,并在 10 秒后重试;第 4 步 initContainer 启动超时,会记录FailedPostStartHook;第 6 步 liveness probe 连续失败三次,会记录Killing事件并触发 KillContainer。

注意:kubelet 从不“删除”Pod。当 Pod 被用户删除(kubectl delete pod),apiserver 将其 phase 设为 Terminating 并设置 deletionTimestamp。kubelet 发现此变化后,进入 termination 流程:先发送 SIGTERM 给所有容器(默认 30 秒 grace period),超时后发 SIGKILL。整个过程 kubelet 不会修改 Pod 对象的 metadata.deletionTimestamp,那是 apiserver 的职责。这种职责分离,正是 k8s 控制面与数据面解耦的设计体现。

2.2 一次真实的 “ContainerCreating” 卡顿排查全过程

某次在某跨平台系统上线前压测中,我们发现一批新调度到 node-05 的 Pod 长期卡在 ContainerCreating。kubectl describe pod 显示事件如下:

Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 5m default-scheduler Successfully assigned default/nginx-7b8f9c5d8-2xq9p to node-05 Normal Pulling 4m58s kubelet Pulling image "nginx:1.21" Normal Pulled 4m55s kubelet Successfully pulled image "nginx:1.21" Normal Created 4m54s kubelet Created container nginx Warning FailedCreatePodSandBox 4m53s kubelet Failed to create pod sandbox: rpc error: code = Unknown desc = failed to setup network for sandbox "a1b2c3...": failed to find plugin "bridge" in path [/opt/cni/bin]

表面看是 CNI 插件缺失,但/opt/cni/bin目录明明存在且有bridge二进制。我们登录 node-05,直接查看 kubelet 日志(journalctl -u kubelet -n 100 -f):

E0521 14:22:33.102187 12345 cni.go:389] "Error deleting pod from CNI network" err="failed to load network config: no valid networks found in /etc/cni/net.d" pod="default/nginx-7b8f9c5d8-2xq9p" podUID=a1b2c3...

原来问题不在插件二进制,而在 CNI 配置文件。/etc/cni/net.d/下只有10-mynet.conf,但其内容为:

{ "cniVersion": "0.3.1", "name": "mynet", "plugins": [ { "type": "bridge", "bridge": "cnio0", "ipam": { "type": "host-local", "subnet": "10.244.2.0/24" } } ] }

问题在于:该配置文件指定了"type": "bridge",但 kubelet 在加载时,会按文件名排序(字典序)读取/etc/cni/net.d/*.conf,而10-mynet.conf排在05-default.conf之后。但05-default.conf文件已被误删,导致 kubelet 实际加载的是一个空配置目录。修复只需一条命令:

# 重建默认配置(以 calico 为例) cat > /etc/cni/net.d/05-default.conf << 'EOF' { "name": "k8s-pod-network", "cniVersion": "0.3.1", "plugins": [ { "type": "calico", "log_level": "info", "datastore_type": "kubernetes", "nodename": "node-05", "mtu": 1440, "ipam": { "type": "calico-ipam" }, "policy": { "type": "k8s" } } ] } EOF systemctl restart kubelet

这个案例揭示了 kubelet 的一个关键特性:它对 CNI 的依赖是“弱耦合、强约定”——它不关心你用什么 CNI,但严格要求配置文件必须存在、格式合法、插件二进制可执行。任何违反约定的行为,都会在 syncPod 第 3 步(Run Container Runtime)之前就失败,且错误信息直指根源。

3. kubelet 与 containerd 的边界在哪里?一张图说清 socket 通信的本质

很多人以为 kubelet 直接调用 containerd 的 CLI 命令(如 ctr)来管理容器。这是完全错误的理解。kubelet 与 containerd 之间,只有一条路:CRI(Container Runtime Interface)gRPC socket。这条通道是 k8s 解耦容器运行时的核心设计,也是你排查问题时必须厘清的边界。

3.1 CRI 是什么?它不是协议,而是一组 gRPC 接口定义

CRI 并非网络协议,而是 k8s 定义的一套标准 gRPC service 接口,位于k8s.io/cri-api/pkg/apis/runtime/v1。containerd 通过cri插件(默认启用)实现这些接口,kubelet 则通过 client 库调用。整个通信流程如下:

  1. kubelet 启动时,读取--container-runtime-endpoint=unix:///run/containerd/containerd.sock(默认值);
  2. 使用 gRPC client 连接该 unix socket;
  3. 调用RuntimeService.ListContainers()获取当前所有容器列表(用于状态比对);
  4. 调用ImageService.PullImage()拉取镜像;
  5. 调用RuntimeService.CreateContainer()创建容器(传入 OCI spec);
  6. 调用RuntimeService.StartContainer()启动容器;
  7. 调用RuntimeService.ExecSync()执行 exec 命令(如 kubectl exec);
  8. 调用RuntimeService.ContainerStats()获取 CPU/Mem 使用率(用于 HPA)。

关键点在于:kubelet 从不解析镜像 tar 包,从不操作容器 rootfs,从不配置 cgroup。它只负责生成符合 OCI 标准的 JSON spec(如 config.json),然后把这份“施工图纸”交给 containerd。containerd 的 cri 插件收到后,再调用底层 runc 创建容器,并由 runc 负责真正的 cgroup 设置、namespace 隔离、rootfs 挂载。

这意味着:当你发现某个 Pod 的内存使用率在kubectl top pod里显示异常高,但crictl stats显示正常,问题一定出在 kubelet 的 metrics server 采集逻辑或 cAdvisor 集成上,而非 containerd;反之,若crictl ps看不到容器,但kubectl get pod显示 Running,则一定是 kubelet 的 status loop 上报延迟或失败,containerd 侧其实早已退出。

3.2 实操验证:用 crictl 直接绕过 kubelet 操作容器

crictl是调试 CRI 的黄金工具,它模拟 kubelet 的 client 行为,直连 containerd 的 CRI 接口。在 node-05 上执行:

# 查看当前所有容器(等价于 kubelet 的 ListContainers) crictl ps -a # 查看某个容器详细信息(等价于 kubelet 的 ContainerStatus) crictl inspect <container-id> # 手动拉取一个镜像(等价于 kubelet 的 PullImage) crictl pull nginx:1.21 # 创建一个 sandbox(等价于 kubelet 的 CreateSandbox) POD_ID=$(crictl runp /dev/stdin <<'EOF' { "metadata": { "name": "test-sandbox", "namespace": "default" }, "log_directory": "/tmp", "linux": { "security_context": { "namespace_options": { "network": 0 } } } } EOF ) # 在 sandbox 内创建并启动一个 nginx 容器(等价于 kubelet 的 CreateContainer + StartContainer) CONTAINER_ID=$(crictl create $POD_ID /dev/stdin /dev/stdin <<'EOF' { "metadata": { "name": "nginx" }, "image": { "image": "nginx:1.21" }, "log_path": "nginx/0.log" } EOF ) crictl start $CONTAINER_ID # 查看容器日志(等价于 kubelet 的 ContainerLog) crictl logs $CONTAINER_ID

这段操作完全绕过了 kubelet,但创建的容器依然能被crictl ps看到,且其网络、存储均由 CNI 和 CSI 驱动管理。这证明:kubelet 是 CRI 的使用者,而非容器的拥有者;containerd 才是容器生命周期的真正管理者。kubelet 的作用,是确保 containerd 执行的指令,始终与 apiserver 中声明的期望状态保持一致。

提示:crictl默认连接/run/containerd/containerd.sock。若你的 containerd 使用了不同 socket 路径(如/var/run/containerd/containerd.sock),需通过--runtime-endpoint指定。此工具在排查“kubelet 与 runtime 通信中断”类问题时,是唯一能快速定位故障层的手段。

4. kubelet 的配置艺术:哪些参数改了立刻生效,哪些必须重启?

kubelet 的启动参数超过 150 个,但并非所有参数都能热更新。理解哪些可动态调整、哪些需重启、哪些甚至重启也无效,是运维稳定性的基石。我们以几个高频配置为例,逐层拆解。

4.1 绝对禁止热更新的“基石型参数”

这类参数决定了 kubelet 的身份和通信基础,一旦运行中修改,会导致与 apiserver 的连接彻底中断,且无法自动恢复。修改后必须 systemctl restart kubelet,且重启期间该节点所有 Pod 将失联。

  • --kubeconfig:指向包含 client certificate 和 apiserver 地址的 kubeconfig 文件。这是 kubelet 的“身份证”和“通讯录”。若证书过期或 apiserver 地址变更,必须更新此文件并重启。
  • --node-labels:节点标签。虽然kubectl label node可动态添加 label,但--node-labels指定的是 kubelet 启动时向 apiserver 注册的初始标签集。若你在此处加了node-role.kubernetes.io/worker=,但忘记在 kubeconfig 中配置 RBAC,重启后节点将无法注册。
  • --register-with-taints:注册时携带的污点。与 labels 类似,是初始状态,不可热更新。
  • --cni-bin-dir和--cni-conf-dir:CNI 插件和配置目录路径。如前文所述,kubelet 启动时一次性扫描这两个目录,后续不会重新加载。改了路径却不重启,kubelet 会继续用旧路径找插件,必然失败。

4.2 可通过 kubelet config file 动态更新的“策略型参数”

自 v1.11 起,kubelet 支持--config参数指向一个 YAML 格式的配置文件(如/var/lib/kubelet/config.yaml)。此文件中的大部分参数支持Live Configuration Reload—— 即修改文件后,无需重启 kubelet,它会在 1 分钟内自动 reload(可通过--dynamic-config-dir指定一个目录,kubelet 会 watch 该目录下的文件变更)。

支持热更新的关键参数包括:

  • evictionHard:驱逐阈值,如memory.available: "100Mi"。修改后,kubelet 会立即重新计算当前内存压力,并在达到新阈值时触发驱逐。
  • imageGCLowThresholdPercent/imageGCHighThresholdPercent:镜像垃圾回收水位。修改后,下次 GC 循环即按新阈值执行。
  • maxPods:单节点最大 Pod 数。修改后,scheduler 将立即停止向该节点调度新 Pod(但已运行的 Pod 不受影响)。
  • serializeImagePulls:是否串行拉取镜像。设为 false 可显著提升大批量 Pod 启动速度(需确保 registry 带宽充足)。

验证热更新是否生效,最可靠的方式是查看 kubelet 的/metrics端点:

# 获取当前 eviction 阈值(单位为字节) curl -s http://localhost:10248/metrics | grep kubelet_eviction_stats # 输出示例:kubelet_eviction_stats{eviction_signal="memory.available"} 1.048576e+08 # 1.048576e+08 = 100MiB,与 config.yaml 中设置一致

4.3 那些“改了也白改”的陷阱参数

有些参数看似重要,但修改后对运行中 Pod 完全无影响,因为它们只在 Pod 创建时读取一次。

  • --pod-max-pids:单 Pod 最大进程数。它只在CreateContainer时写入容器的pids.maxcgroup 文件。已运行的 Pod 不会因此被限制或调整。
  • --system-reserved/--kube-reserved:系统和 k8s 组件预留资源。它们仅用于计算allocatable(即 scheduler 可用资源),不影响现有容器的 cgroup 设置。修改后,kubectl describe node中的 Allocatable 字段会立即更新,但 running Pod 的 CPU quota 不会变。
  • --fail-swap-on:是否禁止使用 swap。此参数只在 kubelet 启动时检查一次/proc/swaps。若你中途启用 swap,kubelet 不会报错,但 Pod 可能因 OOM 被 kernel 杀死(因 kubelet 的 OOM score 不再准确)。

实操心得:我在某公司生产环境曾因误将--fail-swap-on=false写入 kubelet 启动参数,导致一批计算密集型 Pod 在内存压力下被 kernel OOM killer 随机杀死,而 kubelet 日志毫无记录。最终通过dmesg -T | grep -i "killed process"才定位到根源。教训是:所有与资源隔离、安全边界相关的参数,必须在节点初始化阶段就固化,切勿依赖运行时调整。

5. kubelet 的健康检查三重门:从端口响应到业务就绪的完整链路

kubectl get nodes显示的 Ready 状态,只是 kubelet 健康的最表层。一个真正健康的 kubelet,需要通过三重门禁的检验。漏掉任何一重,都可能导致“节点在线,服务离线”的诡异现象。

5.1 第一重门:Healthz 端口(/healthz)—— kubelet 进程存活证明

这是最基础的检查,监听在--healthz-bind-address=:10248(默认)。它只验证 kubelet 主进程是否在运行、关键 goroutine 是否卡死。访问:

curl -s http://localhost:10248/healthz # 返回 "ok" 即表示进程存活

但请注意:此端口不检查任何外部依赖!即使 containerd 宕机、CNI 配置错误、apiserver 网络不通,只要 kubelet 进程没崩溃,/healthz 依然返回 ok。所以,kubectl get nodes显示 Ready,绝不等于节点可用。

5.2 第二重门:Node Conditions(节点条件)—— 与 control plane 的双向信任

kubelet 通过 Status Loop 每 10 秒向 apiserver 上报Node.Status.Conditions。其中最关键的三个 condition 是:

ConditionType含义kubelet 如何判断
ReadyStatus: True/False/Unknown节点是否准备好接受 Podkubelet 完成所有初始化(CNI ready, runtime ping success, volume plugins initialized)且最近一次 status update 成功
DiskPressureStatus: True/False磁盘空间不足kubelet 的 housekeeping loop 检测/var/lib/kubelet或/var/log使用率 > 90%
MemoryPressureStatus: True/False内存压力过大kubelet 通过 cAdvisor 读取 node memory usage >evictionHard.memory.available

查看方式:

kubectl describe node node-05 | grep -A 5 "Conditions:" # 输出示例: # Conditions: # Type Status LastHeartbeatTime LastTransitionTime Reason Message # ---- ------ ----------------- ------------------ ------ ------- # Ready True Wed, 21 May 2025 15:30:22 +0800 Wed, 21 May 2025 14:22:11 +0800 KubeletReady kubelet is posting ready status # DiskPressure False Wed, 21 May 2025 15:30:22 +0800 Wed, 21 May 2025 14:22:11 +0800 KubeletHasSufficientDisk kubelet has sufficient disk space available # MemoryPressure False Wed, 21 May 2025 15:30:22 +0800 Wed, 21 May 2025 14:22:11 +0800 KubeletHasSufficientMemory kubelet has sufficient memory available

关键洞察:LastTransitionTime比LastHeartbeatTime更重要。前者是 condition 状态改变的时间,后者只是上次上报时间。若Ready的LastTransitionTime是 2 小时以前,而LastHeartbeatTime是 10 秒前,说明节点在 2 小时前就进入了 Ready 状态,但此后可能发生了未触发 condition 变更的故障(如 CNI 插件静默失效)。

5.3 第三重门:Pod 级健康检查(Liveness/Readiness Probe)—— 业务可用性最终裁决者

这才是 kubelet 的终极价值所在。它不关心节点是否“在线”,只关心“这个 Pod 是否真的能提供服务”。

  • Liveness Probe:kubelet 定期执行(如httpGet: /healthz),失败则KillContainer并重启容器。它解决的是“进程僵死但未退出”的问题。
  • Readiness Probe:kubelet 定期执行,失败则将该 Pod 从所有 Service 的 Endpoint 列表中移除,不再接收流量。它解决的是“进程已启动但尚未初始化完毕”的问题。

二者均由 kubelet 内置的probe.Manager调度,完全独立于容器进程。即使容器内应用代码阻塞了主线程,只要 probe 接口能响应,kubelet 就认为它 healthy。

实测对比:在 nginx Pod 中配置:

livenessProbe: httpGet: path: /healthz port: 80 initialDelaySeconds: 30 periodSeconds: 10 readinessProbe: httpGet: path: /readyz port: 80 initialDelaySeconds: 5 periodSeconds: 5

然后手动在容器内kill -STOP 1(暂停 nginx 主进程):

  • kubectl get pod状态仍为 Running(因容器进程未退出);
  • 但 10 秒后,liveness probe 失败,kubelet 记录Liveness probe failed事件,并执行KillContainer;
  • 新容器启动后,readiness probe 在 5 秒后通过,Endpoint 恢复。

这个过程完全由 kubelet 自主决策,不依赖 apiserver,不触发 reschedule,是 k8s 自愈能力的最直接体现。

最后分享一个小技巧:在调试 probe 时,不要只看kubectl describe pod的 Events。直接登录节点,用crictl ps | grep <pod-name>找到容器 ID,再执行crictl exec -it <container-id> sh进入容器,手动 curl probe 路径。你会发现,很多 probe 失败是因为容器内网络配置错误(如 localhost 解析失败)、权限不足(如 /healthz 需要 root)、或路径拼写错误——这些问题,永远无法通过 apiserver 日志发现,只能靠 kubelet 的本地执行能力暴露。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进