在 Kubernetes 集群进行滚动更新或缩容时,我们经常会遇到一个棘手的问题:尽管配置了优雅停机,NodePort 类型的服务在 Pod 停止瞬间仍会出现短暂的连接失败(如 502 Bad Gateway 或 Connection Refused)。
这个问题的核心在于**流量摘除(Traffic Draining)**的滞后性。当 Pod 停止时,Kubernetes 的网络层是否能足够快地切断流向该 Pod 的流量?本文将深入 kube-proxy 的黑盒,从原理到实践,剖析 NodePort 服务下的流量摘除机制及其优化方案。
一、基石:NodePort 流量路径与 kube-proxy
要理解流量为何无法及时摘除,首先需要理解流量是如何到达 Pod 的。
kube-proxy 是运行在每个节点上的网络代理,它通过监听 API Server 来维护节点上的网络规则。对于 NodePort 服务,kube-proxy 负责在所有节点上开放端口,并将流量转发到后端的 Pod。
流量转发架构
当外部流量访问 <节点IP>:<NodePort> 时,数据包的流向如下:
在这个过程中,kube-proxy 是被动的。它必须等待 API Server 通知它“某个 Pod 已经不存在了”,然后才会去修改节点上的 iptables 或 IPVS 规则。
二、动态过程:Pod 删除时的时序竞态
流量丢失通常发生在 Pod 被删除的瞬间。这是一个典型的分布式系统一致性问题:
在 kubectl delete pod 时, Pod 会在 API Server 被标记为 Terminating 状态(可以通过 kubectl describe 查看到),然后在容器侧和网络侧会同时进行下面2个流程:
应用流(容器侧)
- kubelet 开始本地优雅关闭
- 节点上的 kubelet 发现 Pod 被标记为 terminating 后,开始关闭流程。
- 执行
preStop(如果配置且 grace period 不为 0)- kubelet 在容器内执行
preStophook。 - 同时开始计算优雅停止等待开始时间
terminationGracePeriodSeconds, 因此preStop不应该大于terminationGracePeriodSeconds。如果确实需要更长的preStop, 需要同时调大terminationGracePeriodSeconds
- kubelet 在容器内执行
- 发送 SIGTERM
preStop执行结束后,kubelet 让容器运行时给每个容器的 PID 1 发送 SIGTERM。
- 优雅期结束:强制终止
terminationGracePeriodSeconds到期仍有容器在运行, 对仍存活进程发送 SIGKILL。
控制流(网络侧)
- 控制面并行进行“摘流/下线”评估
- kubelet 开始优雅关闭的同时,控制面会评估是否把该 Pod 从对应 Service 的 EndpointSlice 中移除。
- 工作负载控制器(如 ReplicaSet)不再把该 Pod 视为“可用副本”。
- EndpointSlice 中的 terminating 端点表现
- terminating 的 endpoints 不会立刻从 EndpointSlice 消失,而是暴露“正在终止”的状态。
- 这些 terminating endpoints 的
ready会被置为 false(兼容 1.26 之前逻辑),因此负载均衡/Service 通常不会再把“常规流量”转发给它。 - 若需要更精细的连接/会话 draining,可看
serving等条件,并参考官方教程实现 connection draining。
致命的时间差
在默认配置下,这两个流程往往存在竞态条件(Race Condition):
也就是说,应用流已经停止服务了,但节点上的转发规则还在。在这段“时间差”内进入 NodePort 的流量,会被转发到一个已经关闭的 Pod,导致请求失败。
三、深入内核:iptables 与 IPVS 的差异
kube-proxy 的工作模式(iptables vs IPVS)直接影响这个“时间差”的长短。
两种模式的流量路径对比
摘流性能对比
| 特性 | iptables 模式 | IPVS 模式 | 摘流影响 |
|---|---|---|---|
| 规则更新机制 | 全量刷新 (O(N)) | 原子增量更新 (O(1)) | IPVS 更快 |
| 大规模性能 | 规则多时更新慢,CPU 飙升 | 几乎无延迟 | IPVS 更稳 |
| 连接追踪 | 依赖 conntrack | 内置连接状态表 | IPVS 处理更精细 |
在 iptables 模式下,如果集群 Service 数量较多,kube-proxy 刷新一条规则可能需要消耗数秒甚至更久,这会极大地拉长“流量黑洞”的窗口期。而 IPVS 模式 得益于哈希表结构和增量更新,能更快地完成流量摘除。
四、瓶颈揭示:延迟从何而来?
除了 kube-proxy 的处理模式,NodePort 场景下还有其他延迟来源:
- 事件传播延迟:Pod 状态变化 -> API Server -> EndpointSlice Controller -> API Server -> kube-proxy。这个链路在繁忙的集群中可能有显著延迟。
- 跨节点转发延迟:NodePort 的流量可能被转发到另一台机器上的 Pod。这意味着涉及两台机器上的 kube-proxy 规则同步。如果入口节点的规则没更新,它仍会将流量发往目标节点;如果目标节点的 Pod 已死,流量就会被丢弃。
五、最佳实践:如何实现零停机摘流?
既然网络规则的更新总是有延迟的,解决思路就是:让 Pod 等待网络规则更新完毕后再真正停止。
1. 核心策略:PreStop Hook 缓冲
这是最有效的方法。在 Pod 收到 SIGTERM 之前,先执行一个钩子脚本。
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]原理:
- 当 Pod 进入 Terminating 状态时,Kubernetes 同时启动“从 Endpoint 移除 Pod”和“执行 PreStop”两个动作。
sleep 10强行让 Pod 保持 Running 状态 10 秒。- 在这 10 秒内,kube-proxy 有充足的时间收到通知并更新 iptables/IPVS 规则,将该 Pod 从负载均衡池中摘除。
- 10 秒后,流量不再分发给该 Pod,此时应用再真正停止,就不会有新流量受损。
2. 应用层:优雅关闭 (Graceful Shutdown)
PreStop 解决了“新流量不进”的问题,应用层代码则需要解决“旧流量处理完”的问题。
// Go 示例:收到 SIGTERM 后,停止接受新请求,但处理完存量请求
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM)
go func() {
<-sigChan
// 1. 此时 PreStop 已经执行完毕(流量已切断)
// 2. 关闭监听 socket,不再 accept 新连接
server.SetKeepAlivesEnabled(false)
// 3. 等待现有连接处理完成(设置超时)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
server.Shutdown(ctx)
}()3. 基础设施优化
- 启用 IPVS 模式:对于规模较大的集群,切换到 IPVS 模式能显著降低规则同步的延迟。yaml
# kube-proxy ConfigMap mode: "ipvs" - 调整同步周期 (慎用):可以适当减小
iptables.syncPeriod(默认 30s)或minSyncPeriod,但这会增加 CPU 负载。
六、观测与排查:验证摘流效果
在实施优化后,可以通过以下工具验证流量摘除是否符合预期:
监控 iptables 规则变化: 在 Pod 删除期间,观察节点上规则被移除的时间点。
bashwatch -n 0.5 "iptables-save | grep <service-name>"查看连接状态: 确认是否还有连接指向即将删除的 Pod IP。
bashconntrack -L | grep <pod-ip>kube-proxy 日志: 检查 kube-proxy 收到 Endpoint 变更事件的时间戳,与 Pod 删除时间戳对比。
bashkubectl logs -n kube-system -l k8s-app=kube-proxy --tail=100 -f
七、总结
NodePort 服务下的流量摘除问题,本质上是网络配置收敛速度与应用退出速度之间的赛跑。
- kube-proxy 负责维护转发规则,但它的更新是被动且有延迟的。
- IPVS 比 iptables 更快,但无法消除分布式系统的传播延迟。
- PreStop Hook (
sleep) 是最简单且成本最低的解决方案,它为网络规则的收敛争取了宝贵的时间。
通过组合使用 PreStop Hook(阻断新流量进入)和 应用层 Graceful Shutdown(处理存量流量),我们可以确保 NodePort 服务在任何变更场景下都能保持零停机。