第 7 章 · 网络:Service 抽象与 kube-proxy 的演进(含 ipvs 弃用故事)

一句话结论:K8s 的网络设计可以用一句话概括——每个 Pod 一个独立的、集群内可达的 IP,Service 提供「稳定的虚拟 IP」,kube-proxy 负责把「访问 Service 的流量」翻译成「访问某个 Pod 的流量」。而 kube-proxy 的底层实现从 iptables → ipvs → nftables 一路演进,是一个绝佳的「怎么持续重构基础设施」的案例。


1. 网络要解决的两个根本矛盾

K8s 网络设计的起点,是两个「天然冲突」的事实:

  1. Pod 是易变的:扩缩容、重启、故障迁移,Pod 的 IP 会变。消费者不能依赖 Pod 的 IP。
  2. 服务需要稳定入口:别的服务、外部用户,需要一个不变的地址来访问你。

解法就是 Service:一个拥有稳定虚拟 IP(ClusterIP)+ 稳定 DNS 名的抽象,它背后动态地代理一组 Pod。

消费者 ──▶ 访问 Service(稳定 IP: 10.96.0.10)──▶ 被翻译到某个 Pod(易变 IP)

2. 但「翻译」这件事,谁来干?怎么做?

Service 是个「虚拟」的东西,真实网络里并没有一个叫 10.96.0.10 的网卡。把「访问 Service」翻译成「访问具体 Pod」的活,是 kube-proxy 干的。而它怎么做,经历了一段很有启发性的演进:

第一代:userspace 模式(最朴素,最慢)

kube-proxy 自己监听一个端口,流量先打到 kube-proxy 这个用户态进程,再由它转发到 Pod。

  • 问题:流量要经过用户态 ↔ 内核态的多次拷贝,性能极差。很快被淘汰。

第二代:iptables 模式(转到内核,快了很多)

kube-proxy 不再自己转发流量,而是把「翻译规则」写进内核的 iptables,由内核直接做 NAT 转发。

  • 优点:转发在内核完成,性能大幅提升。
  • 问题:iptables 是链式规则匹配,规则一多,复杂度 O(n) 甚至更糟;而且规则是「线性追加」的,更新一条规则往往要全量重建,集群规模一大就卡。

第三代:ipvs 模式(更高效,但埋了隐患)

kube-proxy 改用内核的 ipvs(IP 虚拟服务器)来做负载均衡,它是哈希表结构,O(1) 查找,比 iptables 更适合大规模。

第四代(正在发生):nftables,以及 ipvs 的弃用

这才是本教程想重点讲的「思考案例」。


3. 思考案例:ipvs 为什么被弃用?(KEP-5495)

ipvs 性能好、又是主流,为什么 K8s 在 1.37 开始弃用它、计划 1.43 移除?

官方的理由,诚实得让人佩服:

「内核的 ipvs API 自己,无法完整实现 Kubernetes 的 Service 语义,所以 ipvs 模式一直不得不『在 iptables 之上』打补丁才能跑通。」

这句话背后的工程现实是:

  • ipvs 能做好「负载均衡转发」,但它管不了 K8s Service 需要的其他语义——比如「会话保持(session affinity)」「NodePort 的某些行为」「流量策略」等。
  • 于是 ipvs 模式长期以来是一个「双栈怪物」:核心转发用 ipvs,但边边角角还得靠 iptables 规则兜底。两套规则系统并存,运维复杂度高、行为还可能有细微不一致。

这个弃用故事教给我们什么:

  1. 性能不是唯一标准。ipvs 性能好,但它「语义不完整」这个根本缺陷,决定了它只能是个「打补丁的方案」,不配做长期地基。
  2. 「诚实评估技术债」比「维持现状」更重要。K8s 敢于承认「我们这套 ipvs 方案一直是拼凑的」,并规划好几年的迁移路径(1.37 警告 → 1.40 默认关闭 → 1.43 移除),给足用户缓冲。
  3. 选型要看「这个技术是否能原生承载你的完整需求」,而不是「它能不能勉强凑合」。凑合的东西,最终都会变成技术债,而且越晚还越贵。

类比:ipvs 像「一辆跑得飞快、但方向盘失灵、得靠另一套装置辅助转向的车」。跑得快是优点,但「方向盘失灵」这个根本缺陷意味着你永远要带着那套辅助装置,复杂度翻倍。当技术选型的「根本缺陷」无法靠修修补补解决时,正确决策是承认它、规划替换,而不是继续往里堆补丁。


4. 伪代码:kube-proxy 的「抽象」与「实现」分离

kube-proxy 演进能这么平滑(换实现而不改 API),关键在于它把「要什么行为」和「怎么实现」分开了:

// 抽象:Service 需要的行为(稳定不变)
type Proxy interface {
    Sync(services []Service) error   // 把「服务 → Pod」的映射同步到数据平面
}
 
// 实现一:iptables 模式
type IptablesProxy struct{ /* 把规则写成 iptables */ }
 
// 实现二:ipvs 模式
type IPVSProxy struct{ /* 用 ipvs 做转发 */ }
 
// 实现三:nftables 模式(继任者)
type NftablesProxy struct{ /* 用 nftables */ }
 
// 上层只依赖接口,不关心底层是谁
func Run(p Proxy) {
    for {
        services := watchServices()      // 监听 Service 变化
        p.Sync(services)                 // 让具体实现去同步
    }
}

这个「接口抽象 + 多实现」的设计,正是第 9 章要展开的「插件化」思想的缩影——K8s 能把一个子系统(网络转发)用十几种方式实现、并平滑替换,靠的就是「稳定接口 + 可替换实现」。

同时别忘了数据的源头:kube-proxy 不自己「猜」Service 有哪些,它也是watch etcd(经 API Server),拿到 Service 和 Pod 的最新映射,再同步到数据平面。这和第 4 章「唯一事实源」完全闭环——网络转发规则的唯一真相,也在 etcd 里。


思考题

Q:这一章我们看到 kube-proxy 从 userspace → iptables → ipvs → nftables 一路换实现。那「频繁换底层实现」是不是说明最初的抽象没设计好?反过来,如果为了「再也不改」而一开始就过度设计,又会踩什么坑?

参考答案:

这其实暴露了「抽象设计」里最经典的一对张力——「改底层实现」恰恰说明抽象是对的,而不是错的:

  • 如果最初的抽象没做好(把「用户态转发」这个实现细节焊死在接口里),后面根本换不动——想换 iptables 就得重写所有调用方。
  • 正因为抽象做对了(接口只暴露「同步服务映射」这个行为,不暴露「用哪个内核机制」),所以才能在不惊动上层的前提下,一次次替换底层实现,跟随内核技术的演进。

那「过度设计」的坑是什么?

  • 一开始就为了「未来可能换」而设计十层抽象、预留无数接口,会拖慢开发、增加复杂度,而且很多「预留」永远用不上——这是典型的「过度工程」。
  • 正确的节奏是 「先简单实现,等真实需要出现时再抽象」:K8s 也是先有 userspace 这个简单实现跑起来,等到性能、规模问题真实出现,才一步步引入 iptables、ipvs,并在这个过程中把「行为」和「实现」解耦。

这引出一条极重要的工程原则:「抽象要跟着真实需求长出来,而不是一开始凭空设计出来」。好的抽象是「改出来的」,是在「第二次、第三次需要替换实现」时被提炼出来的,而不是在「第一次写代码」时被臆想出来的。过早抽象和拒不抽象,是设计的两端陷阱,中间的平衡点叫「等第二个用例出现」。