kube-proxy 换成 IPVS 后规则同步从 47 秒降到 1.2 秒:Service 转发链路的 4 层拆解
title: kube-proxy 换成 IPVS 后规则同步从 47 秒降到 1.2 秒:Service 转发链路的 4 层拆解
tags: [Kubernetes, Service, kube-proxy, IPVS, 容器网络]
category: 后端
那次滚动发布,订单服务 20 个 Pod 逐个替换,理论上流量应该平滑迁移。实际结果是:发布过程中上游网关报了 3400 多次Connection refused,持续了将近一分钟。
奇怪的是 Pod 的 readiness 探针配置没问题,新 Pod 就绪后旧 Pod 才被删除,这个顺序是对的。问题出在旧 Pod 已经被 kill 了,但 Node 上的 iptables 规则还没更新完。我们的集群有 180 个 Service、总计 6000 多条 Endpoint,kube-proxy 每次全量刷新 iptables 规则链要 47 秒。这 47 秒里,部分节点的转发表还指向已经死掉的 Pod IP。
排查这个问题让我把 Service 从 ClusterIP 到 Pod 之间的整条链路重新读了一遍。这篇按四层拆开写:Service 抽象层、kube-proxy 转发层、CNI 网络层、以及最容易被忽视的应用层连接复用。
第一层:Service 到底是什么,它不是一个进程
刚接触 K8s 的时候我以为 Service 是个负载均衡器进程,找了半天没找到它在哪台机器上跑。实际上Service 是一条虚拟规则,没有任何进程在监听 ClusterIP。
验证很简单:在集群里随便找台节点,ping一个 ClusterIP,不通;tcpdump抓 ClusterIP 的包,抓不到。因为这个 IP 从来没有出现在任何网卡上,它只存在于内核的 netfilter 规则里。
Service 的完整链路是三个对象串起来的:
| 对象 | 职责 | 谁维护 |
|---|---|---|
| Service | 定义 ClusterIP、端口、selector | 用户创建 |
| Endpoints / EndpointSlice | 记录 selector 匹配到的所有 Pod IP | endpoint-controller 自动维护 |
| iptables / IPVS 规则 | 把发往 ClusterIP 的包 DNAT 到某个 Pod IP | kube-proxy 自动维护 |
开头那个事故的链条就在这里:Pod 被删 → kubelet 停止容器 → endpoint-controller 从 EndpointSlice 移除该 IP → kube-proxy 监听到变更 → 刷新节点规则。前三步是秒级的,第四步在我们的规模下要 47 秒。
EndpointSlice这个对象值得单独提一句。K8s 1.19 之后默认启用,它把原来一个 Endpoints 对象里塞几千个 IP 的做法,改成按 100 个一组切片。这个改动的意义在于:原来任何一个 Pod 变化都要把整个包含几千个 IP 的对象重新推送给所有节点,现在只推变化的那个切片。我们从 1.18 升到 1.21 后,apiserver 的出流量降了 60%。
第二层:kube-proxy 的两种模式,规模上去后差距是数量级的
iptables 模式的原理是给每个 Service 生成一条KUBE-SERVICES链的规则,再为每个 Endpoint 生成一条概率跳转规则。三个 Pod 的 Service,规则大概长这样:
# 第一条:1/3 概率跳到 Pod1 -A KUBE-SVC-XXX -m statistic --mode random --probability 0.33333 -j KUBE-SEP-AAA # 第二条:剩下的 2/3 里取 1/2,即总体 1/3 跳到 Pod2 -A KUBE-SVC-XXX -m statistic --mode random --probability 0.50000 -j KUBE-SEP-BBB # 第三条:兜底,剩下的 1/3 跳到 Pod3 -A KUBE-SVC-XXX -j KUBE-SEP-CCC注意第二条的概率是 0.5 不是 0.333——因为 iptables 规则是顺序匹配的,走到第二条时已经排除了 1/3 的流量。这个细节解释了 iptables 模式的核心问题:规则匹配是 O(n) 线性遍历。Service 数量翻倍,最坏情况下的匹配次数也翻倍。
更要命的是更新。iptables 的规则更新不支持增量,kube-proxy 每次都要用iptables-save导出全量、修改、再iptables-restore写回。规则量大的时候,这个过程本身就是几十秒。
IPVS 模式换了个思路,它用内核的 LVS 模块,规则存在哈希表里:
| 对比项 | iptables 模式 | IPVS 模式 |
|---|---|---|
| 规则查找 | O(n) 线性遍历 | O(1) 哈希 |
| 规则更新 | 全量 save/restore | 增量 API 调用 |
| 我们 6000 Endpoint 下同步耗时 | 47 秒 | 1.2 秒 |
| 负载均衡算法 | 只有随机 | rr / lc / sh / dh 等 8 种 |
| 连接跟踪 | conntrack | conntrack |
| 依赖 | 无 | 需加载 ip_vs 内核模块 |
我们切换的操作本身很简单,改 kube-proxy 的 ConfigMap 里mode: "ipvs",然后重启 DaemonSet。但有两个前置检查不能省:一是所有节点要加载ip_vs、ip_vs_rr、ip_vs_wrr、ip_vs_sh、nf_conntrack这几个内核模块,我们有 3 台节点是从旧集群迁过来的,内核版本 3.10,nf_conntrack模块名还是老的nf_conntrack_ipv4,切换后这 3 台上的 Pod 全部网络不通;二是 IPVS 模式会在节点上创建一个kube-ipvs0的 dummy 网卡,把所有 ClusterIP 都绑上去,如果你有监控脚本按网卡统计 IP,会突然看到几百个 IP。
规模小的时候两种模式没有明显差别,我的经验分界线大概是 Service 数量超过 100 或 Endpoint 总数超过 1000。低于这个量级,iptables 的同步耗时在 1 秒以内,换 IPVS 的收益抵不上引入内核模块依赖的运维风险。
第三层:CNI 决定了 Pod 之间怎么通
Service 只解决了"找到哪个 Pod",包真正从节点 A 的 Pod 送到节点 B 的 Pod,靠的是 CNI 插件。
我们用过 Flannel 的 VXLAN 模式和 Calico 的 BGP 模式,差别体现在两个地方。
性能上,VXLAN 要做封包解包,每个包加 50 字节的外层头。我们实测同机房节点间的 iperf3 带宽:宿主机直连 9.4 Gbps,Calico BGP 模式 9.1 Gbps,Flannel VXLAN 模式 4.8 Gbps。差距接近一半,因为封装解封装吃 CPU,而且默认没开硬件 offload。
MTU 上,这是个特别隐蔽的坑。宿主机网卡 MTU 是 1500,VXLAN 封装后需要额外 50 字节,所以 Pod 网卡的 MTU 必须设成 1450。如果忘了改,小包没问题、大包会被丢弃——表现出来就是"接口偶尔超时,重试就好了",而且只在传输大响应体时出现。
我们真实遇到的场景是:一个返回商品列表的接口,返回 20 条时正常,返回 100 条时(响应体超过 1500 字节)稳定超时。抓包看到 SYN、ACK 都正常,数据包发出去就没了。查了整整一天才想到 MTU。
排查这类问题的一个实用手段是用不带分片的 ping 试探:
# 在 Pod 里执行,-M do 表示禁止分片,-s 指定负载大小 ping -M do -s 1422 <对端 Pod IP> # 1422 + 28 字节头 = 1450,应该通 ping -M do -s 1472 <对端 Pod IP> # 1472 + 28 = 1500,VXLAN 下会失败第四层:应用层的长连接,会让 Service 负载均衡完全失效
这一层是我认为最容易被忽视的,因为它跟 K8s 本身没关系,是 Java 应用自己的行为。
Service 的负载均衡是连接级别的:一个 TCP 连接建立时选一个后端 Pod,之后这条连接上的所有请求都固定发给那个 Pod。而 Java 生态里几乎所有 HTTP 客户端默认都开启 keep-alive 长连接。
结果就是:服务 A 有 4 个 Pod,服务 B 有 20 个 Pod,A 通过 Service 调 B。A 的每个 Pod 维持 10 个长连接,总共 40 条连接分散到 20 个 B Pod 上,看起来还行。但如果 B 扩容到 40 个 Pod,那 40 条已建立的连接不会重新分配,新加的 20 个 Pod 一个请求也收不到。
我们遇到的具体场景是大促扩容:把下游服务从 20 个 Pod 扩到 60 个,监控上看新 Pod 的 CPU 全是 3% 以下,而老 Pod 依然打在 80%。
有几种处理方式,我们最终用的是给连接加最大存活时间:
@Configuration public class HttpClientConfig { @Bean public CloseableHttpClient httpClient() { PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(); cm.setMaxTotal(200); cm.setDefaultMaxPerRoute(50); return HttpClients.custom() .setConnectionManager(cm) // 关键:限制单条连接的最大存活时间为 60 秒 // 到点后连接被主动关闭,下次请求重新建连,重新经过 Service 负载均衡 .setConnectionTimeToLive(60, TimeUnit.SECONDS) // 每 30 秒清理一次空闲超过 30 秒的连接,避免持有已被服务端关闭的死连接 .evictIdleConnections(30, TimeUnit.SECONDS) .evictExpiredConnections() .build(); } }setConnectionTimeToLive是这里的核心。它和evictIdleConnections的区别经常被搞混:后者清理的是空闲连接,一条持续有流量的热连接永远不会被它清掉;而 TTL 是绝对时间,不管这条连接多忙,到 60 秒就关。要打散负载必须用 TTL。
60 秒这个值是权衡出来的:设得太短(比如 5 秒)会导致频繁建连,我们压测时 TLS 握手开销让 P99 涨了 12ms;设得太长(比如 10 分钟)扩容后要等 10 分钟才能均衡。60 秒对应的建连开销大约是每秒 0.7 次,可以忽略。
如果用的是 Spring Cloud OpenFeign,配置在客户端侧:
@Bean public OkHttpClient okHttpClient() { return new OkHttpClient.Builder() // OkHttp 的连接池:最多 50 个空闲连接,空闲超过 60 秒清理 // 注意 OkHttp 没有直接的 TTL 配置,需要靠 connectionPool 的 // keepAliveDuration 间接控制,它的语义是"空闲多久后关闭" .connectionPool(new ConnectionPool(50, 60, TimeUnit.SECONDS)) .connectTimeout(2, TimeUnit.SECONDS) .readTimeout(3, TimeUnit.SECONDS) .build(); }OkHttp 这里要注意,它的keepAliveDuration语义是"空闲多久后关闭",不是绝对 TTL。对高频调用的链路来说连接几乎不会空闲,所以这个配置解决不了负载不均的问题。我们的做法是高频链路干脆不走 Service,改用客户端负载均衡(Spring Cloud LoadBalancer 直接从注册中心拿 Pod IP 列表),这样扩容后新实例能立刻被感知。
回到开头:发布期间的 Connection refused 怎么根治
换 IPVS 把规则同步从 47 秒压到 1.2 秒,只解决了一半。剩下的 1.2 秒里仍然有请求会打到正在关闭的 Pod。
彻底解决要靠两件事配合。第一是 preStop 钩子,让 Pod 收到删除信号后先「装死」一段时间:
lifecycle: preStop: exec: # 睡 10 秒,这期间 Pod 已从 Endpoints 摘除,但进程还活着继续处理存量请求 command: ["sh", "-c", "sleep 10"]第二是应用层的优雅停机,Spring Boot 2.3+ 直接支持:
// application.yml 里开启 // server.shutdown: graceful // spring.lifecycle.timeout-per-shutdown-phase: 20s @Component public class GracefulShutdownHook implements ApplicationListener<ContextClosedEvent> { @Resource private ThreadPoolTaskExecutor bizExecutor; @Override public void onApplicationEvent(ContextClosedEvent event) { // Spring 的 graceful shutdown 只管 Tomcat 的请求线程, // 自己创建的业务线程池需要手动等待,否则异步任务会被强杀 bizExecutor.shutdown(); try { if (!bizExecutor.getThreadPoolExecutor().awaitTermination(15, TimeUnit.SECONDS)) { log.warn("业务线程池未在 15 秒内结束,剩余任务数={}", bizExecutor.getThreadPoolExecutor().getQueue().size()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }那个"Spring 的 graceful shutdown 只管 Tomcat 线程"是我们踩过的。开启server.shutdown: graceful之后 HTTP 请求确实平滑了,但我们有个异步的库存回滚任务跑在自建线程池里,Pod 被删时这个池直接被 JVM 退出干掉,留下了 17 条状态卡在中间的记录。
还有一个顺序问题要注意:terminationGracePeriodSeconds必须大于preStop 时长 + 应用优雅停机时长。我们的配置是 preStop 10 秒 + 应用最多 20 秒 = 30 秒,而terminationGracePeriodSeconds设的是 45 秒,留 15 秒余量。如果这个值设小了(默认 30 秒),kubelet 会在超时后直接 SIGKILL,前面所有的优雅停机都白做。
复盘:改造前后的数字
| 指标 | 改造前 | 改造后 |
|---|---|---|
| kube-proxy 规则同步耗时 | 47 秒 | 1.2 秒 |
| 滚动发布期间 5xx 数量 | 3400+ | 0-12 |
| 扩容后新 Pod 首次接到流量 | 最长 8 分钟 | 60 秒内 |
| Pod 间带宽(VXLAN → BGP) | 4.8 Gbps | 9.1 Gbps |
| 单次发布总耗时 | 6 分钟 | 9 分钟 |
最后一行是代价:每个 Pod 多了 10 秒 preStop,20 个 Pod 滚动就是多 3 分钟。这个交换我认为是值得的——发布慢 3 分钟没人在意,发布报 3400 个错要写复盘报告。
我的取舍判断
Service 类型的选择上,我不建议对内部服务用 NodePort。它占用节点端口(默认 30000-32767 只有 2768 个),且流量要多绕一跳(进任意节点再转发到目标 Pod)。内部调用用 ClusterIP,对外暴露用 Ingress 或 LoadBalancer。
headless Service(clusterIP: None)在有状态服务上比普通 Service 更合适。它不做负载均衡,DNS 直接返回所有 Pod IP,客户端自己选。Kafka、Redis Cluster、Elasticsearch 这类需要感知具体节点的中间件,都应该用 headless。我们早期给 Redis Cluster 配了普通 Service,结果客户端拿到的 MOVED 重定向指向的是 Pod IP,而客户端只能访问 ClusterIP,整个集群模式直接不可用。
在 K8s 里跑 Java 服务,最该重新审视的是连接池配置。传统物理机时代后端 IP 是固定的,长连接一建就是几天,这个假设在 K8s 里完全不成立——Pod IP 随时会变。连接 TTL、健康检查、失败重试这三项配置需要按容器环境重新定一遍。
排查网络问题时,先确认问题出在哪一层再动手。我的顺序是:Pod 内 curl localhost(应用层)→ 同节点 Pod 间 curl(CNI)→ 跨节点 Pod 间 curl(CNI 跨节点)→ curl ClusterIP(kube-proxy)→ curl Service DNS 名(CoreDNS)。五步走下来基本能定位到具体层,比一上来就抓包效率高得多。
最后留个问题
假设你的集群里有一个 Java 服务,它通过 ClusterIP 调用下游,下游 Pod 因为节点故障被驱逐。此时 Endpoints 已经摘除了这个 IP,但你的服务连接池里那条已建立的 TCP 连接不会立刻感知到——它会一直等到 read timeout 才失败。你会怎么缩短这个感知时间?是靠 TCP keepalive 参数、还是靠应用层心跳、或者干脆在连接池里加主动探活?三种方案分别有什么代价,在容器频繁重建的环境下哪种更实际?
评论区聊聊你们的配置。
