先把结论放这儿
下表是这次演练的实测结果速览:
| 项 | 实测值 |
|---|---|
| 场景 A · 计划内维护 | kubectl cordon + drain 一个节点 |
| 优雅驱逐 | 31 秒清空节点,6 个 Pod 全部迁走,业务零中断 |
| 场景 B · 计划外故障 | 停掉 node01 的 kubelet + 容器运行时(模拟宕机) |
| 节点被判 NotReady | 26~44 秒 (默认 node-monitor-grace-period = 40 秒) |
| Pod 开始被驱逐 | 331 秒后 |
| 331 的构成 | 40 秒(判死)+ 300 秒(默认容忍期)≈ 340 秒 |
| 最值钱的发现 | 被 nodeSelector 钉死的负载,替换 Pod 永远 Pending |
31 秒和 331 秒差了 10 倍,但这两个数字都不是重点。
331 秒看一眼参数就能算出来,那只是默认计时的必然结果。真正让这次演练值得写出来的,是那两个已经被驱逐、却永远回不来的 Pod ------ 它们暴露的不是 K8s 的 bug,而是我自己写法上的一个可用性单点。
环境:VMware 上自建的 3 节点实验集群,K8s v1.34.10,单 master,每台 4C/4G。这是 lab,不是生产。(这次演练时集群还在用 cri-dockerd + Docker,后来才迁到 containerd ------ 那是另一篇的事。)
还有一处必须先坦白:我的"硬故障"是停掉节点上的 kubelet 和容器运行时模拟的,机器并没有真的断电。这个差别会实质性影响结论,放到第八节单独说。
一、节点故障,是运维里最考验判断力的场景
因为它压着一个根本矛盾:
你想尽快把故障节点上的 Pod 挪走,但你没法确定这个节点是不是真的死了。
网络抖动 10 秒、交换机重启、控制平面自己卡了 ------ 这些都会让节点"看起来像死了"。如果你在这个状态下立刻把 Pod 迁到别处,原节点"复活"之后就会出现两份同名副本,而如果它们都在写同一个数据库,坏掉的就不只是可用性了。
这 331 秒存在的全部理由就是它。 记住这句话,比记住那个数字有用得多。
节点故障分两类,处理方式完全不同,很多人在这里混:
| 自愿中断(voluntary) | 非自愿中断(involuntary) | |
|---|---|---|
| 触发者 | 你:drain、升级节点、缩容 |
节点自己:宕机、断网、kubelet 挂掉 |
| 机制 | 调用 Eviction 子资源,要过 PDB | 打 NoExecute 污点,等容忍期到期 |
| 能通知应用吗 | 能(SIGTERM + 优雅关闭) | 不能(节点都死了,通知谁) |
| 实测耗时 | 31 秒 | 331 秒 |
这篇把两条路都真走一遍。
二、动手之前,先搞清三件事
2.1 cordon / drain / uncordon 的区别
| 动作 | 作用 | 驱逐已有 Pod? |
|---|---|---|
cordon |
标记节点不可调度,只挡新 Pod | ❌ |
drain |
cordon + 驱逐现有 Pod | ✅ |
uncordon |
恢复可调度 | --- |
我习惯先 cordon 停几分钟观察,确认没有新 Pod 再落上来,然后再 drain。
2.2 节点"死了"是怎么判定的?不是 10 秒
kubectl get nodes 里那行 Ready 只是摘要,真实状态是一组 Condition。判定"心跳断了"靠的是 NodeLease:
| 参数 | 默认值 | 作用 |
|---|---|---|
nodeStatusUpdateFrequency |
10s | kubelet 上报完整 Node 状态的间隔 |
nodeMonitorGracePeriod |
40s | 控制平面容忍多久收不到心跳 |
nodeMonitorPeriod |
5s | 控制平面检查心跳的间隔 |
判定 NotReady ≈ 40 秒,不是 10 秒。 10 秒是上报频率,不是阈值 ------ 这两个数答混了,一听就知道没实际调过。
2.3 那为什么 NotReady 之后 Pod 还不走?
因为还有一段"容忍期"。节点被判 NotReady 后,node controller 会给它打上污点:
node.kubernetes.io/unreachable:NoExecute
NoExecute 的意思是"不容忍这个污点的 Pod 就得走"。那为什么不是立刻走?因为 apiserver 的 DefaultTolerationSeconds 准入插件会自动给每个 Pod加一段容忍:
yaml
tolerations:
- key: node.kubernetes.io/unreachable
operator: Exists
effect: NoExecute
tolerationSeconds: 300 # ← 默认 5 分钟
于是:
0s ───────────── 40s ───────────────────────── 340s
│ │ │
节点失联 判 NotReady Pod 被驱逐
(grace period) (300s 容忍期到期)
331 秒 = 40 秒 + 300 秒,再减掉一点调度抖动。 实测落在预期区间内。
三、场景 A:优雅驱逐(计划内维护)
3.1 还没开始就踩了一个坑:反亲和性不是越硬越好
先部署测试负载:一个 4 副本的 Deployment,要求 Pod 尽量分散到不同节点。
第一版我用的是硬反亲和 (requiredDuringScheduling...)------ 每台机器最多放一个同类 Pod。而我的集群只有 2 个可调度节点(master 带 control-plane 污点),4 个副本必然有无家可归的:
FailedScheduling: 0/3 nodes are available:
1 node(s) had untolerated taint(s),
2 node(s) didn't match pod anti-affinity rules
硬反亲和 + 副本数 > 可调度节点数 = Pod 永久 Pending。 这是个很实在的设计陷阱。
改成软反亲和(preferred...,"尽量分散"而不是"必须分散")之后就正常了:2 个副本落在 node01,2 个落在 node02。
3.2 drain 实录
bash
kubectl cordon k8s-node01
kubectl drain k8s-node01 --ignore-daemonsets --delete-emptydir-data
node/k8s-node01 cordoned
Warning: ignoring DaemonSet-managed Pods:
kube-system/calico-node-pkv78, kube-system/kube-proxy-jfv68
evicting pod prod-app/web-5c4bbcf55-2j56z
evicting pod default/rc-demo-tppqg
evicting pod default/my-app-6b6fb547ff-lwl5s
evicting pod default/rc-demo-p9q2n
evicting pod drill-ns/web-6745984cb5-wx5nh
evicting pod drill-ns/web-6745984cb5-hfjxm
pod/web-6745984cb5-hfjxm evicted
pod/web-6745984cb5-wx5nh evicted
pod/web-5c4bbcf55-2j56z evicted
pod/my-app-6b6fb547ff-lwl5s evicted
pod/rc-demo-tppqg evicted
pod/rc-demo-p9q2n evicted
node/k8s-node01 drained
★ 驱逐耗时: 31 秒
结果:
| 观测项 | 结果 |
|---|---|
| 驱逐 Pod 数 | 6 个 |
| 驱逐耗时 | 31 秒 |
| Pod 去向 | 全部在 node02 重建 |
| DaemonSet | 保留 (calico-node、kube-proxy 没被动) |
| 业务中断 | 无 |
3.3 三个必须知道的细节
① 为什么必须加 --ignore-daemonsets。 DaemonSet 的语义是"每个节点必须有且仅有一个"。赶走 calico-node,这个节点的 Pod 网络就瘫了;赶走 kube-proxy,Service 转发就失效。所以 drain 默认拒绝执行,必须你显式声明"我知道,忽略它们"。
② 31 秒花在哪。 几乎全在"等每个 Pod 走完优雅关闭":发 SIGTERM → 等进程退出 → 超时则 SIGKILL。我的应用是 nginx,对 SIGTERM 响应很快,所以 6 个 Pod 加起来才 31 秒。如果应用不处理 SIGTERM,每个 Pod 都要等满 terminationGracePeriodSeconds(默认 30 秒),时间会成倍涨。
③ uncordon 不会把 Pod 送回原节点。 这是最常见的一厢情愿:Pod 早就在新节点重建完了,uncordon 只是允许未来的新 Pod 调度上来。
四、场景 B:硬故障(计划外)
4.1 怎么"杀死"一个节点
我在 node01 上直接停掉三件套:
bash
systemctl stop kubelet cri-docker docker
# 停止前:10 个容器运行中 → 停止后:0 个
4.2 实测时序
17:28:50 停止 node01 的 kubelet / cri-docker / docker
│
│ 约 26~44 秒(默认 40 秒阈值,详见 4.3「计时起点」)
▼
17:29:34 节点变 NotReady(status=Unknown)
Conditions 全部转为 Unknown:
MemoryPressure / DiskPressure / PIDPressure / Ready
自动打上污点:
node.kubernetes.io/unreachable:NoExecute
node.kubernetes.io/unreachable:NoSchedule
⚠️ 此刻 Pod 仍然是 Running ------ 并没有被驱逐
│
│ 331 秒
▼
17:35:05 TaintManagerEviction 触发:
"Marking for deletion Pod drill-ns/pinned-xxx"
3 个 Pod 进入 Terminating,ReplicaSet 开始创建替换 Pod
│
▼
★ 替换 Pod 全部 Pending ------ 调度不上去
NotReady 那一瞬间,节点上的业务 Pod 状态是这样的:
NAME READY STATUS AGE IP NODE
pinned-6c798fc8d7-7hhhm 1/1 Running 53s 10.244.85.232 k8s-node01
pinned-6c798fc8d7-9z4fr 1/1 Running 53s 10.244.85.231 k8s-node01
pinned-6c798fc8d7-fm888 1/1 Running 53s 10.244.85.230 k8s-node01
web-6745984cb5-gcxxh 1/1 Running 2m35s 10.244.58.227 k8s-node02
web-6745984cb5-jgqk7 1/1 Running 2m14s 10.244.58.232 k8s-node02
节点已经"失联"了,Pod 却还显示 Running。 因为 apiserver 手上只有最后一次上报的快照,它无从判断这些容器现在是死是活。这也算是"为什么要有容忍期"的另一个视角:控制平面看到的信息,本来就是过期的。
4.3 一个关于"计时起点"的坑
这次演练里,NotReady 我先后得到过两个数:从脚本启动那一刻算是 26 秒,从真正停 kubelet 那一刻算是 44 秒。差的 20 秒,是因为观察脚本比故障注入晚了 20 秒才启动。
两个数都没错,但报出去的结论会差近一倍。做这类计时,第一步是先定"时间原点",并且写清楚原点是什么。我现在的习惯是:把故障注入命令和计时器写在一起。
五、★ 最值钱的发现:我用 nodeSelector 把可用性钉成了单点
驱逐本身完全正常。问题出在 331 秒之后。
为了模拟"钉死在某个节点上"的负载(GPU 节点、特定硬件、本地数据盘这类),我建了 3 个 Pod,加了约束:
yaml
nodeSelector:
kubernetes.io/hostname: k8s-node01
节点失联 → 331 秒后 Pod 被正常驱逐 → ReplicaSet 照常创建了 3 个替换 Pod。然后:
pinned-6c798fc8d7-znct4 0/1 Pending ← 永远调度不上去
pinned-6c798fc8d7-k4zkr 0/1 Pending
pinned-6c798fc8d7-4bjbn 0/1 Pending
事件里只有一句:
0/3 nodes are available:
1 node(s) didn't match Pod's node affinity/selector,
2 node(s) had untolerated taint...
这个 Deployment 会永远 Pending 下去。ReplicaSet 没有超时机制 ------ 它会一直重试到天荒地老。
对比一下更刺眼:同一时刻、同一命名空间里没有节点约束 的那 4 个 w