DaemonSet 滚动更新导致服务中断——优雅终止与 PodDisruptionBudget

场景: DaemonSet RollingUpdate 过程中旧 Pod 被 SIGTERM → 进程不优雅退出 → Prometheus 采集链路断裂 路径: Pod(优雅终止机制) → CRI(信号转发) → Node(kubelet 驱逐) → 集群(PDB 边界) 版本: K8s v1.25

上篇讲了 DaemonSet 更新策略的选择陷阱——OnDelete 和 RollingUpdate 各有适用场景。这篇我们来看 RollingUpdate 本身的一个更隐蔽的问题:更新过程中服务中断。

给 node-exporter DaemonSet 从 v1.5 升级到 v1.6,滚动更新刚启动,Prometheus 告警来了——"Targets 批量 Down 机"。不是新 Pod 没起来,是旧 Pod 里的 node-exporter 进程收到 SIGTERM 后直接退出,Prometheus 的 HTTP scrape 请求打到了正在退出的进程上,connection refused。DaemonSet controller(在每个节点上运行一个 Pod 的控制器)的默认行为是:新 Pod Ready → 终止旧 Pod。但 Ready 只保证新 Pod 在运行——不保证旧 Pod 的连接已被优雅关闭。

K8s 的 RollingUpdate 只保证 Pod 被替换——不保证连接被优雅关闭。

【坐标】→ 滚动更新期间采集链路断裂

Pod 状态:Terminating 的时间窗口

更新 node-exporter DaemonSet 后,Prometheus 面板上 Targets 开始逐节点变红。

kubectl get pods -n monitoring -w 能清楚看到新旧 Pod 交替的节奏:

$ kubectl get pods -n monitoring -w
NAME                     READY   STATUS        RESTARTS   AGE
node-exporter-abc01      1/1     Running       0          12d
node-exporter-abc01      1/1     Terminating   0          12d    ← DaemonSet controller 终止旧 Pod
node-exporter-abc02      0/1     Pending       0          2s     ← 新 Pod 启动中
node-exporter-abc02      1/1     Running       0          15s    ← 新 Pod Ready
node-exporter-abc01      0/1     Terminating   0          30s    ← 旧 Pod 还在终止中
node-exporter-abc01      0/1     Terminating   0          32s    ← 超过 30s 才消失
^C

关键观察:新 Pod 在 15s 内 Ready,但旧 Pod 从 Running 到完全消失花了 32s。这多出来的 30s 就是 terminationGracePeriodSeconds(Pod 的优雅终止期限)——kubelet 发 SIGTERM 后等进程自行退出,如果 30s 内不退出,直接发 SIGKILL。

DaemonSet controller 的滚动更新逻辑是等新 Pod Ready 后再终止旧 Pod,但它不关心旧 Pod 的优雅退出是否完成。从 Prometheus 的视角看:

  1. 旧 Pod 收到 SIGTERM → node-exporter HTTP server 停止 → scrape 请求 connection refused
  2. Prometheus 标记该 target 为 Down(down 状态持续到下次 scrape 周期)
  3. 新 Pod Ready → Prometheus service discovery 重新发现 → scrape 恢复
  4. 但中间有一段空窗:旧 Pod 已死,新 Pod 还未被 Prometheus 发现

这不是 node-exporter 特有的问题——任何 DaemonSet 用 RollingUpdate 更新时,旧 Pod 的进程如果不处理 SIGTERM,都会出现这个空窗。

【分层】→ 逐层排查优雅终止链路

滚动更新的优雅终止链路涉及 4 层:Pod → CRI → Node → 集群。每层都可能成为瓶颈。

排查顺序表

排查对象 关键命令 重点关注
Pod 层 terminationGracePeriodSeconds / preStop kubectl describe pod Termination Grace Period 值、PreStop Hook 有无
CRI 层 容器运行时信号转发 crictl inspect <cid> PID 1 进程是应用还是 shell、信号能否直达
Node 层 kubelet 优雅终止流程 kubectl describe node 节点上 Pod 驱逐状态、maxUnavailable 生效数
集群层 PodDisruptionBudget kubectl get pdb -o yaml disruptionsAllowed、PDB 是否阻止了终止

Pod 层:terminationGracePeriodSeconds 与 preStop

[结论] 优雅终止的起点在 Pod spec——terminationGracePeriodSeconds 定义了 kubelet 等多久才发 SIGKILL,preStop hook 决定了进程退出前做什么。默认 30s + 无 preStop = 进程收到 SIGTERM 必须自己优雅退出,否则 SIGKILL 准时到。

[证据]

查看 node-exporter Pod 的终止配置:

$ kubectl describe pod node-exporter-abc01 -n monitoring | grep -E 'Termination|PreStop'
    Termination Grace Period:  30s
    PreStop Hook:              none

默认值——30s 优雅期限,没有 preStop hook。再看 DaemonSet 的 update strategy:

$ kubectl get ds node-exporter -n monitoring -o yaml | grep -A 5 'strategy'
  updateStrategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
    type: RollingUpdate

maxUnavailable: 1 限制了同时不可用的 Pod 数——这是线程安全级别的控制,但不是服务可用性级别的保证。

当 kubelet 收到 DELETE Pod 请求后,执行以下时序(从 K8s 源码 pkg/kubelet/kuberuntime/kuberuntime_container.go 提取):

T+0s   kubelet 收到 Pod 删除请求
T+0s   执行 preStop hook(如果有)
T+0s   向容器 PID 1 发 SIGTERM
T+0~30s 等待进程退出
T+30s  terminationGracePeriodSeconds 到期→发 SIGKILL
T+30s+ Pod 从 API Server 删除

node-exporter 的 PID 1 就是二进制本身,收到 SIGTERM 后默认行为是立刻退出——不等待正在处理的 HTTP 请求完成。Prometheus 的 scrape 请求正好打到这个窗口,就是 connection refused。

[衔接] Pod 层只定义了"等多久"——但信号能不能正确到达进程,还取决于 CRI 层怎么转发。

CRI 层:容器运行时如何转发信号

[结论] containerd(当前 K8s 默认 CRI 实现)会把 kubelet 的 SIGTERM 发给容器内的 PID 1。但如果 entrypoint 是 shell 脚本(sh -c),shell 不会把信号转发给子进程。PID 1 是 shell,收到 SIGTERM 直接退出——子进程变成孤儿进程继续运行,等到 grace period 结束被 SIGKILL 杀死。

[证据]

用 crictl 检查容器内的进程结构:

$ crictl ps --name node-exporter -n k8s.io -o json | \
  python3 -c "import sys,json; d=json.load(sys.stdin); print(d['containers'][0]['id'])"
<container-id>

$ crictl inspect <container-id> | jq '.info.pid'
123456

看宿主上这个容器的进程树:

$ ps -ef | grep 123456
root     123456     1  0  ... node_exporter --collector.disable-defaults

PID 1 是 node_exporter 二进制——没有 shell 包装,信号直达。这是好的情况。

但如果 Dockerfile 写的是 ENTRYPOINT ["sh", "-c", "node_exporter ..."],PID 1 是 shell 而非 node_exporter:

$ ps -ef | grep <cid>
root     123456     1  0  ... sh -c node_exporter --collector.disable-defaults
root     123457  123456  0  ... node_exporter --collector.disable-defaults

PID 1 是 shell(123456),子进程是 node_exporter(123457)。当 SIGTERM 到达 PID 1:

  1. Shell 收到 SIGTERM → 自己退出(不转发信号给子进程)
  2. 子进程 node_exporter 变成孤儿进程 → 被 init 接管
  3. Kubelet 等 30s → grace period 到期 → 发 SIGKILL → 杀死整个 cgroup

这个过程虽然最终进程会被杀,但失去了优雅终止的机会——子进程没有收到 SIGTERM,无法做任何退出前清理。30s 的 grace period 被白白浪费。

[衔接] 信号能正确到达进程后,下一层是 Node 层的 kubelet——它是优雅终止的实际执行者。

Node 层:kubelet 的优雅终止执行者

[结论] kubelet 负责执行 Pod 优雅终止的全流程:接收 DELETE 请求 → 发 SIGTERM → 等 grace period → 发 SIGKILL。它不关心进程是否优雅退出——只关心时限。Pod 的 terminationGracePeriodSecondslifecycle.preStop 是它执行终止的依据。

[证据]

DaemonSet 的 maxUnavailable 控制 Node 层同时终止多少个 Pod。默认值 1——逐节点更新,一次只终止 1 个 Pod,等新 Pod Ready 后再终止下一个。

$ kubectl get ds node-exporter -n monitoring -o yaml | grep -A 5 'rollingUpdate'
  rollingUpdate:
    maxSurge: 1
    maxUnavailable: 1

时间窗口叠加问题:当旧 Pod 进入 Terminating 状态,它算作不可用;新 Pod 还没 Ready 也算不可用。这两个阶段在时间上可能重叠:

从「旧 Pod 停止服务」到「新 Pod 被 Prometheus service discovery 发现」,中间有一个窗口两个 Pod 都不可用。maxUnavailable: 1 保证了这个窗口每次只有 1 个节点受影响——但不能消除它。

[衔接] Node 层的 maxUnavailable 控制了并发数——但它不区分 Pod 终止的原因。对于自愿中断(滚动更新算自愿中断),集群层的 PDB 提供了额外的保护语义。

集群层:PodDisruptionBudget 的边界

[结论] PodDisruptionBudget(PDB,Pod 中断预算)是 K8s 提供的自愿中断保护机制。它限制通过 eviction 子资源pods/eviction)发起的 Pod 终止操作。但 DaemonSet controller 的 RollingUpdate 使用的是直接 DELETE API——不走 eviction 子资源,PDB 拦不住。

这是最常见的误解之一:很多人以为 PDB 能保护 DaemonSet 滚动更新,但它不能。

[证据]

查看当前集群中是否配置了 PDB:

$ kubectl get pdb -A
NAMESPACE    NAME               MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
monitoring   node-exporter-pdb  N/A             1                 1                     30d

PDB 的生效路径:

请求类型              → 检查 PDB? → 适用场景
pods/eviction API    → ✅ 是      → kubectl drain、Cluster Autoscaler、手动 evict
pods/delete API      → ❌ 否      → DaemonSet RollingUpdate、Deployment 更新、直接删除

为什么 PDB 不拦 DaemonSet 的 DELETE?因为 DaemonSet controller 用的是普通的 Pod DELETE API——API Server 不会对 DELETE 请求做 PDB 检查。PDB 只在 eviction 子资源上做准入检查。

PDB 的真正作用:当你对节点执行 kubectl drain(排空节点)或 Cluster Autoscaler 尝试缩容时,这些操作使用 eviction API——PDB 会检查 allowed disruptions 是否够用,不够就阻止终止。PDB 防止的是"一次性太多 Pod 被自愿中断",但滚动更新的并发控制已经由 DaemonSet 的 maxUnavailable 完成了。

PDB 的边界总结:

场景 PDB 是否生效 理由
DaemonSet RollingUpdate ❌ 不生效 使用 DELETE API,不走 eviction
Deployment 滚动更新 ❌ 不生效 同样使用 DELETE API
kubectl drain 排空节点 ✅ 生效 使用 eviction API
Cluster Autoscaler 缩容 ✅ 生效 使用 eviction API
手动 kubectl evict ✅ 生效 使用 eviction API

[衔接] 四层排查下来,核心结论浮出水面:DaemonSet 滚动更新的服务中断不是 PDB 能解的——它是 Pod 层优雅终止机制的问题。

【路径】→ 🔍 核心排查命令与异常判断

遇到 DaemonSet 滚动更新导致服务中断时,按以下顺序排查:

命令 1:查 DaemonSet 更新策略

$ kubectl get ds node-exporter -n monitoring -o yaml | grep -A 6 'updateStrategy'
  updateStrategy:
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 1
    type: RollingUpdate

正常type: RollingUpdatemaxUnavailable 为 1 或合理百分比(每次只中断 1 个 Pod) ❌ 异常maxUnavailable: 00%(阻止一切更新),或 type: OnDelete 但预期是滚动更新

命令 2:查 Pod 优雅终止配置

$ kubectl describe pod node-exporter-xxxxx -n monitoring | grep -E 'Termination|PreStop'
    Termination Grace Period:  30s
    PreStop Hook:              none

正常terminationGracePeriodSeconds ≥ 应用排空所需时间,且有 preStop hook(Sleep 或 drain 逻辑) ❌ 异常:原值 30s(默认)且无 preStop hook——如果应用排空需要 60s,30s 一到就被 SIGKILL

命令 3:查 PDB 配置

$ kubectl get pdb -n monitoring -o yaml | grep -A 3 'disruptionsAllowed\|currentHealthy\|expectedPods'
status:
  disruptionsAllowed: 1
  currentHealthy: 20
  expectedPods: 20

正常disruptionsAllowed ≥ 1——PDB 允许执行终止操作 ❌ 异常disruptionsAllowed: 0——PDB 阻止了 eviction 操作(如果你用的是 drain 而非 RollingUpdate) 📌 注意:即使 disruptionsAllowed: 1,也不保证 DaemonSet RollingUpdate 不出问题——PDB 对 DELETE 请求无效

【定位】→ 最常误判

❌ 错误排查方向

"滚动更新导致服务中断?给 DaemonSet 加个 PDB 就行。"

为什么这是错的:PDB 只检查 eviction API,不检查 DELETE API。DaemonSet controller 的 RollingUpdate 走 DELETE 路径——PDB 管不到。加了 PDB 但问题依旧——因为 PDB 根本就没参与到 RollingUpdate 的流程中。

更隐蔽的误判:把"并发"问题当成了"优雅退出"问题

当更新导致服务中断时,第一反应通常是"更新节奏太快了,一次终止太多 Pod"。于是调小 maxUnavailable(从 0% 调整到 10%?但原来就是 1)或者加 PDB。如果根因是"单个 Pod 退出时进程不优雅",减速只能拉长问题窗口——不能消除它。

❌ 错误归因:"同时终止的 Pod 太多了,服务扛不住"
→ 修复:加 PDB / 调小 maxUnavailable
→ 结果:问题还在,只是发生频率降低

✅ 正确归因:"单个 Pod 退出时进程不处理 SIGTERM,连接没排空就被杀"
→ 修复:加 preStop hook / 进程注册 SIGTERM handler / 调大 grace period
→ 结果:每个 Pod 退出时连接被优雅排空,问题消除

✅ 正确排查思路

排查顺序清晰——从进程开始,逐层往上:

排查顺序:
① Pod 层:terminationGracePeriodSeconds 够不够?有没有 preStop hook?
② CRI 层:PID 1 是应用进程还是 shell?信号能不能直达?
③ Node 层:maxUnavailable 是否合理?是否需要调小?
④ 集群层:PDB 是否已配置?用于保护节点 drain 等场景

三层优雅终止 + PDB 是最后一道防线。PDB 保护的是"节点 drain"场景,不是 RollingUpdate 场景。

一句话金句:PDB 是交通管制——它不让太多车同时上路,但不保证每辆车安全到达终点。进程不处理 SIGTERM,PDB 也拦不住 SIGKILL。

【标点】→ 修复 + Check-list

修复方案 A:preStop hook + 调大 grace period(推荐,0 代码改动)

不改应用代码,在 DaemonSet 的 Pod spec 加 preStop hook,让 kubelet 在发 SIGTERM 之前先等一段时间,给现存连接排空时间:

apiVersion: apps/v1
kind: DaemonSet
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 60    # 从 30s 提到 60s
      containers:
      - name: node-exporter
        lifecycle:
          preStop:
            exec:
              command:
              - /bin/sh
              - -c
              - "sleep 10 && /bin/kill -TERM 1"

原理:preStop hook 在 SIGTERM 之前执行——先等 10s,让 Prometheus 完成当前 scrape 周期,再手动发 SIGTERM 给 PID 1。terminationGracePeriodSeconds 从 30s 提到 60s,给排空留足够余量。

修复方案 B:应用进程注册 SIGTERM handler(需改应用代码)

应用注册 SIGTERM 信号处理器,收到信号后按序执行: 1. 停止接受新请求(deregister from service discovery / close listener) 2. 排空连接池(等待 in-flight 请求完成) 3. 刷写最终数据(flush buffer / write checkpoint) 4. 正常退出

// 伪代码示例
sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGTERM)

go func() {
    <-sigs
    log.Println("收到 SIGTERM,开始优雅退出...")
    server.Shutdown(context.WithTimeout(ctx, 20*time.Second))
    db.Close()
    os.Exit(0)
}()

方案 A(preStop)是快速修复,方案 B(SIGTERM handler)是根本解法。

修复方案 C:PDB 联合防护(保护节点 drain 场景)

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: node-exporter-pdb
  namespace: monitoring
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: node-exporter

PDB 虽然不拦 RollingUpdate,但拦节点 drain。组合拳:preStop + terminationGracePeriodSeconds 保证单个 Pod 优雅退出,PDB 保证大规模节点维护时的中断可控。

Check-list(每条对应一个 kubectl 命令)

□ kubectl get ds <name> -n <ns> -o yaml | grep -A 6 'updateStrategy'
  → 确认 type 为 RollingUpdate,maxUnavailable 不为 0
□ kubectl describe pod <name> -n <ns> | grep -E 'Termination|PreStop'
  → 确认 terminationGracePeriodSeconds ≥ 排空所需时间
  → 确认有 preStop hook(sleep / drain 逻辑)
□ kubectl get pdb -A -o wide
  → 确认 DaemonSet 对应的 PDB 存在
□ kubectl get pdb <name> -n <ns> -o yaml | grep disruptionsAllowed
  → 确认 disruptionsAllowed ≥ 1
□ kubectl rollout status ds <name> -n <ns>
  → 确认滚动更新正常推进,无卡住

附:完整命令清单

# DaemonSet 更新策略
kubectl get ds <name> -n <ns> -o yaml | grep -A 6 'updateStrategy'

# Pod 优雅终止配置
kubectl describe pod <name> -n <ns> | grep -E 'Termination|PreStop'

# PDB 配置查询
kubectl get pdb -A -o wide
kubectl get pdb <name> -n <ns> -o yaml

# 容器进程结构检查(确认 PID 1 不是 shell)
crictl ps --name <name> -n k8s.io -o json | \
  python3 -c "import sys,json; d=json.load(sys.stdin); print(d['containers'][0]['id'])"
crictl inspect <container-id> | jq '.info.pid'

# Pod 状态实时观察
kubectl get pods -n <ns> -w

# DaemonSet 滚动更新状态
kubectl rollout status ds <name> -n <ns>

# DaemonSet 滚动更新历史
kubectl rollout history ds <name> -n <ns>

故障排查的终点不是修好了——是把排查路径写成 check-list。滚动更新导致服务中断时,先别急着加 PDB,先查 Pod 里的进程有没有好好说再见。

下篇我们聊 Job/CronJob 未按预期执行的问题——时区配置错误导致定时任务跑错时间,并发策略设错导致多个 Job 同时执行。

📺 公众号「Ai拆代码的曹操」 🌟 知识星球「Ai拆代码的曹操」