第 11 章 服务发现与负载均衡的演进:Endpoints → EndpointSlice

墨:老哥,这章讲一个特别”工程师”的故事——一个对象,因为集群变大,自己把自己玩爆了,然后社区怎么救。 这个故事的价值不在技术细节,在于它完整演示了”规模如何逼出设计演化”。你做架构时,这套思维能直接复用。


11.1 服务发现:Service 背后那张”后端名单”

第 9 章我们说 Service 的 VIP 靠 iptables/ipvs 规则 DNAT 到后端 Pod。那规则里的”后端 Pod IP 列表”从哪来? 从 Endpoints 对象。

每个 Service 有一个同名的 Endpoints 对象(如 Service orders 对应 Endpoints orders),里面列着”当前所有就绪 Pod 的 IP:port”:

# Endpoints orders
subsets:
- addresses: [{ip: 10.244.1.5}, {ip: 10.244.2.7}, ...]   # 就绪 Pod
  ports: [{port: 8080}]

谁维护它? Endpoint 控制器(第 3 章的控制器模式)watch Pod 和 Service,当 Pod Ready 状态变、或 Service 的 selector 匹配的 Pod 集合变,就更新 Endpoints。kube-proxy watch Endpoints,据此重写本节点的转发规则。

墨:这套”Service→Endpoints→规则”的链路,你觉得在 3 个 Pod 时和 3000 个 Pod 时,有本质区别吗? 你先直觉答,再看下一节。


11.2 规模下的爆炸:Endpoints 的三重危机

当 Service 背后有成千上万个端点(比如一个超大的无状态服务,或很多 Service 共享一个大 Endpoint 集合),老 Endpoints 设计开始崩。三重危机:

11.2.1 单对象过大,撑爆 etcd 和 API

一个 Endpoints 对象把所有后端 IP 塞进一个 YAML/JSON。3000 个端点的 Endpoints 对象可能几 MB。而 etcd 单 key 上限约 1.5MB(第 5.5)、API Server 序列化/传输这么大对象也吃力。一个对象几 MB × 成千上万个 Service = etcd 被撑爆。

11.2.2 Watch 风暴:变一个,推全部

这是最致命的。kube-proxy 要 watch Endpoints 来更新规则。问题是:Endpoints 是一个整体对象。某个 Pod 挂了(3000 个里的一个),Endpoints 对象内容变了 → API Server 把整个几 MB 的新 Endpoints 对象推给所有 watch 它的客户端(每个节点的 kube-proxy)。

墨:你品这个放大效应。 一个 Pod 状态变,触发的是”全量几 MB 对象广播给所有节点”。3000 端点、100 节点,每次小变化就是 100 次几 MB 的推送。端点越多、变化越频繁(Pod 重建频繁的服务),这个风暴越猛——API Server 和 etcd 的 watch 带宽被活活吃光,连累整个集群的控制面。

11.2.3 更新抖动:大对象重写成本高

Endpoint 控制器每次变化都要重算并写回整个 Endpoints 对象。3000 端点变 1 个,也得序列化 3000 个、写 etcd、广播。etcd 的 MVCC 历史也因此快速膨胀(每次都是大对象新 revision),compact/defrag 压力陡增。

📦 案例(真实事故原型): 某大厂一个核心 Service 后端 5000+ Pod,因发布频繁滚动,Endpoints 每秒变几次。结果:API Server 的 watch 事件吞吐被打满,其他控制器(调度器、Deployment)的 watch 延迟飙升,整个集群”反应迟钝”,调度变慢、新 Pod 起不来。根因就是”一个大 Endpoints 对象被高频全量广播”。这事故逼出了 EndpointSlice。


11.3 演化:EndpointSlice 把”大名单”切成”小片”

演化(K8s 1.16 Alpha,1.21 GA): 引入 EndpointSlice。核心思想极朴素:把一个大 Endpoints 对象,切成多个小 slice,每个 slice 最多装约 100 个端点。

EndpointSlice (service=orders, slice1): 端点 1..100
EndpointSlice (service=orders, slice2): 端点 101..200
...

11.3.1 它怎么解决三重危机

  • 单对象小:每个 slice 最多 ~100 端点,对象小,不撑 etcd/API;
  • watch 精准:某 Pod 变了,只影响它所在的那一个 slice,API Server 只推那个小 slice(几百字节),不是全量几 MB。广播量从”几 MB × 所有节点”降到”几百字节 × 相关节点”,风暴消失;
  • 更新轻:只重算/重写变的那片,不是全量。

墨:这不就是”分片(sharding)“吗?你在哪还见过? 数据库分库分表、Redis 集群 slot、Kafka partition、一致性哈希环——“一个大东西变成一堆小东西,变化只动局部”是分布式系统最通用的解法之一。EndpointSlice 是分片思想在”服务发现对象”上的落地。你记住:凡是遇到”一个对象随规模膨胀、变化引发全局重算/广播”,第一反应就该是”能不能分片”。这是可迁移的架构直觉。

11.3.2 分片还能按拓扑切

EndpointSlice 还能按拓扑(topology)分片:比如把”同可用区”的端点放一个 slice。这样 kube-proxy / 调用方只关心本 AZ 的 slice,进一步减少不必要的跨区域 watch 和流量(就近路由)。这是分片带来的”额外好处”——切片维度可承载调度/拓扑信息。


11.4 双栈(IPv4/IPv6)也顺手解决了

Endpoints 对象早期只存 IPv4。要支持 IPv4/IPv6 双栈,得在对象里同时放两种地址。EndpointSlice 从设计上每个端点可带 addresses 列表(可含 v4 和 v6),天然支持双栈。而老 Endpoints 要改结构、兼容性差。

演化看点: 新数据结构的引入,常常顺手解决了老结构无力承载的新需求(双栈)。这提醒你:当你重构一个爆炸的老对象时,顺便把”已知将来要支持的能力”设计进去,比以后再打补丁省事。EndpointSlice 比 Endpoints 晚出生,所以可以”一开始就把双栈、拓扑、分片都想好”——新方案的优势之一,是它能吸收老方案时代还没出现的需求。


11.5 兼容性:EndpointSlice 来了,老 Endpoints 还在吗

墨:引入 EndpointSlice,K8s 难道把老 Endpoints 删了?那一大票老客户端(旧版 kube-proxy)不就瞎了?

不会。K8s 用 EndpointSlice 控制器自动从 EndpointSlice 镜像生成老 Endpoints 对象(双向同步),保证旧客户端仍能 watch Endpoints。同时新客户端(新版 kube-proxy)直接 watch EndpointSlice,享受分片好处。老 Endpoints 变成”EndpointSlice 的只读投影”,逐步被标记为遗留(legacy)。

这又是一次**“新机制并存 + 自动镜像老格式 + 长期过渡”**的兼容演化(和第 4.6 的 GVK 多版本、etcd v2→v3 同构)。K8s 处理”换底层数据结构”的一贯手法:不破坏旧客户端,新老并行,老的慢慢退场。


11.6 回到 kube-proxy:端点规模对它的真实压力

顺带把第 9 章的 kube-proxy 和本章连起来:kube-proxy watch Endpoint(s/Slice),维护 iptables/ipvs 规则。端点越多:

  • iptables 模式:规则数 ∝ 端点数,且每次 Endpoint 变全量刷规则(第 9.3.2);
  • ipvs 模式:规则哈希,增量更新友好,但仍要处理每个端点的增删。

EndpointSlice 减小了”每次推送的数据量”,但 kube-proxy 本地维护的规则数仍 ∝ 端点数。所以超大规模还有别的招:EndpointSlice 按拓扑分片 + kube-proxy 只关心本节点相关 slice,以及 eBPF 方案(Cilium)直接在 map 里维护端点、不必每节点一大堆 iptables 规则(第 9.4)。层层的优化:数据对象分片(EndpointSlice)→ 只 watch 相关片 → 数据面用更高效结构(ipvs/eBPF)。 每一层都在消化”端点多”的压力。


11.7 本章演化线小结

  • Endpoints 是 Service 的后端名单,由控制器维护、kube-proxy watch 用于写规则;
  • 规模危机:单对象过大撑 etcd、变一个推全量(watch 风暴)、大对象重写贵;
  • EndpointSlice 用分片(每片 ~100 端点)化解:对象小、watch 精准、更新轻;
  • 分片思想通用(分库分表/Partition/Slot),可迁移;
  • 顺带支持双栈、按拓扑分片(就近);
  • 兼容:Slice 控制器镜像生成老 Endpoints,旧客户端不瞎,长期过渡;
  • 与 kube-proxy/ipvs/eBPF 层层配合消化端点规模。

11.8 本章思考题

🔧 思考题 1

一个 Service 有 5000 端点,部署在 100 节点集群,每秒因发布变 3 次(每次变 1 个 Pod)。请量化对比”老 Endpoints”和”EndpointSlice(每片100端点)“下,每秒因端点变化产生的 watch 推送总字节量(估算量级即可,说明数量级差异来源)。

【参考答案】 老 Endpoints:每次变 1 个 Pod → 整个 5000 端点对象(假设每端点 ~50 字节 JSON,约 250KB)被重写为新 revision → API Server 推给所有 100 节点的 kube-proxy(每台都 watch 这个 Service 的 Endpoints)→ 每次推送 250KB × 100 = 25MB,每秒 3 次 = 75MB/s 的 watch 带宽,且 etcd 每秒写 3 次 250KB 大对象(MVCC 历史快速膨胀)。EndpointSlice(每片 100 端点 → 50 片):变 1 个 Pod 只影响它所在的 1 片(约 5KB),只推这 1 片给 watch 它的客户端(通常只相关节点,假设拓扑分片后每节点只看本 AZ 几片,但即使全推也是 5KB × 100 = 500KB/次,每秒 3 次 = 1.5MB/s)。数量级差异:75MB/s vs 1.5MB/s,约 50 倍,且 etcd 写的是小对象。差异来源:老方案”变 1 推全量”,新方案”变 1 推 1 片”——分片把广播范围从”整个集合”降到”局部”。这是 EndpointSlice 救集群控制面的核心机制。

🔧 思考题 2

EndpointSlice 按拓扑(如可用区)分片后,kube-proxy 只 watch 本 AZ 的 slice。这带来什么好处和潜在问题?如果一个跨 AZ 的服务想让 A 区 Pod 优先访问 A 区后端、但 A 区后端全挂了要 fallback 到 B 区,分片设计要怎么配合?

【参考答案】 好处:(1) watch 数据量进一步降(只收本 AZ slice);(2) 本节点规则只含本 AZ 端点,规则数少、更新少;(3) 天然支持”就近路由”的拓扑感知。潜在问题:若 kube-proxy 只看本 AZ slice,当本 AZ 端点全失效,本地规则里没有后端,流量会去哪?若没 fallback 机制就全断。解决:分片是”数据组织方式”,fallback 是”路由策略”。需配合:(1) kube-proxy 仍 watch 所有 slice 但优先用本 AZ(或 endpointslice 带 topology 信息,kube-proxy/ipvs 做拓扑感知 LB,本 AZ 优先、跨 AZ 兜底);(2) 应用层/Service 层支持拓扑感知路由(TopologyAwareHints / 后来的流量策略):优先同 AZ,同 AZ 无端点才跨 AZ。注意:分片本身不解决 fallback,它只是”让端点带位置标签、按需订阅”;真正的”就近+兜底”是 LB 逻辑读这些标签后的决策。启示:数据分片解决”规模/带宽”,但”优先级/fallback”是独立要设计的路由语义,不能假设分片自动搞定可用性。

🔧 思考题 3

EndpointSlice 控制器”从 Slice 镜像生成老 Endpoints”。如果某时刻 Slice 和老 Endpoints 不一致(比如控制器慢了),客户端用老 Endpoints 的会看到过期后端吗?这违反”一致性”吗?怎么看这种”最终一致”?

【参考答案】 会短暂看到不一致:Slice 是权威,老 Endpoints 是镜像投影,镜像有同步延迟(控制器下一轮才更新投影)。用老 Endpoints 的客户端可能短暂看到”刚被删的端点还在”或”新端点还没”。这不违反”强一致”——因为老 Endpoints 本身已被标记为 legacy/只读投影,K8s 的承诺是”新客户端用 Slice 得实时一致,老客户端用投影得最终一致”。这是兼容过渡期的有意取舍:为不破坏旧客户端,允许投影有秒级延迟。系统设计启示:引入新机制又想兼容老客户端时,“权威源 + 投影”是常见模式,投影的一致性可以弱于权威源(因为旧客户端本来就该迁移)。你做系统也可以这么干:新 API 强一致,旧 API 作为派生视图最终一致,给旧用户缓冲期。这和第 4 章”慢废弃长过渡”、etcd”老版本最终被 compact”一脉相承——兼容优先,过渡期内接受短暂不一致。

🔧 思考题 4(进阶)

Endpoints 爆炸的本质是”一个可变集合被建模成单个大对象”。除了分片,还有别的架构思路解决”大规模服务发现”吗?对比 EndpointSlice 的分片法,举一个业界其他系统(如 Consul/etcd 自己/DNS)的做法,说明思路异同。

【参考答案】 其他思路:(1) 只推送增量事件而非全量对象(如 etcd watch 本身发增量,但老 Endpoints 把增量又合并回单个大对象再推,导致”增量进来、全量出去”——所以光增量不够,还得对象小,EndpointSlice 是”增量 + 分片”双管);(2) Consul 的做法:每个服务注册多个小的 health check 条目,watch 时按服务分 key,变化推对应 key(天然分片,因为 key 粒度细);(3) DNS-based 发现:根本不发”列表对象”,调用方每次查 DNS 拿到一部分记录(DNS 本身分片+缓存),但 DNS 有 TTL 缓存导致收敛慢(最终一致、延迟高),不适合秒级变化;(4) gRPC/Envoy xDS:控制面推”增量资源更新(ADS/EDS)“,每个 endpoint 是独立资源,变哪个推哪个,类似细粒度分片+增量。对比 EndpointSlice:它选的是”把大对象切成有上限的小对象 + 每片独立 watch 版本”,既保留”列表式拉取”的简单性,又获得”变化局部化”的好处,且对现有 kube-proxy 改造成本低。共同点:都认同”变化要局部化、别全量广播”。差异:DNS 用缓存换简单但收敛慢;xDS 用独立资源粒度最细但要求客户端支持流式增量协议。K8s 选 EndpointSlice 是”在现有 watch 框架内最小侵入地解决”,而非换整套发现协议——又见”最小侵入演化”的取向。


11.9 小结

  • Endpoints 是 Service 后端名单,规模下三重爆:单对象大、变一推全、重写贵;
  • EndpointSlice 用分片(~100/片)化解,是分片思想在发现对象上的落地,可迁移;
  • 顺带支持双栈、按拓扑分片(就近路由);
  • 兼容:Slice 镜像生成老 Endpoints,旧客户端不瞎,长期过渡;
  • 与 kube-proxy/ipvs/eBPF 层层配合消化端点规模;
  • 演化方法论:规模逼出分片,换结构时顺手吸收新需求(双栈),兼容靠”权威源+投影”。

下一章讲配置与密钥——ConfigMap/Secret。这块看着简单,但”配置热更新陷阱""Secret 明文躺平”是生产里真咬人的坑,而且它的演化(加密静态存储、外部密钥)最能说明”声明式配置”的边界。