
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 多条 Endpointkube-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 IPendpoint-controller 自动维护iptables / IPVS 规则把发往 ClusterIP 的包 DNAT 到某个 Pod IPkube-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 种连接跟踪conntrackconntrack依赖无需加载 ip_vs 内核模块我们切换的操作本身很简单改 kube-proxy 的 ConfigMap 里mode: ipvs然后重启 DaemonSet。但有两个前置检查不能省一是所有节点要加载ip_vs、ip_vs_rr、ip_vs_wrr、ip_vs_sh、nf_conntrack这几个内核模块我们有 3 台节点是从旧集群迁过来的内核版本 3.10nf_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 GbpsCalico BGP 模式 9.1 GbpsFlannel VXLAN 模式 4.8 Gbps。差距接近一半因为封装解封装吃 CPU而且默认没开硬件 offload。MTU 上这是个特别隐蔽的坑。宿主机网卡 MTU 是 1500VXLAN 封装后需要额外 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 1500VXLAN 下会失败第四层应用层的长连接会让 Service 负载均衡完全失效这一层是我认为最容易被忽视的因为它跟 K8s 本身没关系是 Java 应用自己的行为。Service 的负载均衡是连接级别的一个 TCP 连接建立时选一个后端 Pod之后这条连接上的所有请求都固定发给那个 Pod。而 Java 生态里几乎所有 HTTP 客户端默认都开启 keep-alive 长连接。结果就是服务 A 有 4 个 Pod服务 B 有 20 个 PodA 通过 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 ApplicationListenerContextClosedEvent { 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 数量34000-12扩容后新 Pod 首次接到流量最长 8 分钟60 秒内Pod 间带宽VXLAN → BGP4.8 Gbps9.1 Gbps单次发布总耗时6 分钟9 分钟最后一行是代价每个 Pod 多了 10 秒 preStop20 个 Pod 滚动就是多 3 分钟。这个交换我认为是值得的——发布慢 3 分钟没人在意发布报 3400 个错要写复盘报告。我的取舍判断Service 类型的选择上我不建议对内部服务用 NodePort。它占用节点端口默认 30000-32767 只有 2768 个且流量要多绕一跳进任意节点再转发到目标 Pod。内部调用用 ClusterIP对外暴露用 Ingress 或 LoadBalancer。headless ServiceclusterIP: 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 间 curlCNI→ 跨节点 Pod 间 curlCNI 跨节点→ curl ClusterIPkube-proxy→ curl Service DNS 名CoreDNS。五步走下来基本能定位到具体层比一上来就抓包效率高得多。最后留个问题假设你的集群里有一个 Java 服务它通过 ClusterIP 调用下游下游 Pod 因为节点故障被驱逐。此时 Endpoints 已经摘除了这个 IP但你的服务连接池里那条已建立的 TCP 连接不会立刻感知到——它会一直等到 read timeout 才失败。你会怎么缩短这个感知时间是靠 TCP keepalive 参数、还是靠应用层心跳、或者干脆在连接池里加主动探活三种方案分别有什么代价在容器频繁重建的环境下哪种更实际评论区聊聊你们的配置。