Kubernetes Service 的数据面实现:iptables、nftables 与 eBPF
上一篇沿着数据包回答了两个问题:Service 流量在哪里做 DNAT,源 IP 又为什么会被 SNAT。那还留着一个更底层的问题:同样是把 Service 前端映射到后端 Pod,iptables、nftables、IPVS 和 eBPF 到底把映射存在哪里,又在哪个内核路径执行它?
这不是四种语法的横向罗列。实现位置不同,会改变规则更新成本、可观测入口、负载均衡行为,甚至决定排障时应该先看 iptables-save、nft list ruleset,还是 BPF map。
本文使用五个独立的三节点 kind 集群,给同一份 Service 和工作负载换上不同数据面。所有输出都来自 2026-09-21 在 m6 上执行的实验:
| 集群 | Kubernetes | Service 数据面 | 实现者 |
|---|---|---|---|
svc-iptables | v1.35.0 | iptables | kube-proxy |
svc-ipvs | v1.35.0 | IPVS | kube-proxy |
svc-nftables | v1.35.0 | nftables | kube-proxy |
svc-cilium | v1.35.0 | eBPF | Cilium v1.19.8 |
svc-calico-bpf | v1.35.0 | eBPF | Calico v3.31.7 |
这里没有做性能压测。十二次请求只能帮助识别选择行为和数据面状态,不能推出吞吐量或延迟排名。清单、采集脚本和输出已提交到 k8sdev 6da31f8。
先把控制面和数据面分开
无论使用哪一种实现,输入都大致相同:Service 给出稳定前端,EndpointSlice 给出当前后端。真正变化的是“谁监听这些对象”和“谁把前端改写成后端”。
flowchart LR
API[API Server<br/>Service + EndpointSlice]
KP[kube-proxy]
CIL[Cilium agent]
CAL[Calico Felix]
IPT[iptables chains]
NFT[nftables maps and chains]
IPV[IPVS virtual services]
CBPF[Cilium BPF maps]
FBPF[Calico BPF NAT maps]
API --> KP
API --> CIL
API --> CAL
KP --> IPT
KP --> NFT
KP --> IPV
CIL --> CBPF
CAL --> FBPF
kube-proxy、Cilium agent 和 Calico Felix 都属于控制循环:监听对象变化,再把期望状态编译进节点。它们通常不在用户数据包的每次转发中充当一个进程级代理。数据包真正命中的是 Netfilter、IPVS 或挂在 socket/网络设备上的 BPF 程序。
这也解释了一个常见误区:安装了 Calico 或 Cilium,不自动等于它们已经接管 Service。普通 CNI 模式下,Pod 网络可能由 CNI 提供,Service 仍由 kube-proxy 实现。只有明确启用 kube-proxy replacement,并用运行时证据确认,才能把 Service 数据面归到 eBPF。
公共实验:只替换数据面
五个集群都使用两个 worker。每个 worker 上运行一个 Python HTTP 后端,客户端固定在 worker2。Service 配置如下:
| |
每次实验都检查四层证据:
- 控制组件宣告的运行模式;
- Service 和 EndpointSlice 的实际地址;
- 新建连接命中了哪个后端、后端看到了什么源地址;
- 对应内核数据结构里是否存在此前端和后端。
只看配置文件不够,因为配置可能没有被进程采用;只看请求成功也不够,因为另一个数据面可能仍在工作。
iptables:把每个 Service 编译成链跳转
iptables 模式将 Service 和 Endpoint 编译成 nat 表中的链。一个 ClusterIP 请求通常经历:
flowchart LR
P[Pod packet<br/>dst Service IP:80]
S[KUBE-SERVICES]
C[KUBE-SVC-*<br/>choose endpoint]
E[KUBE-SEP-*<br/>DNAT]
R[Pod IP:8080]
P --> S --> C --> E --> R
KUBE-SERVICES 是总入口,KUBE-SVC-* 代表某个 Service,KUBE-SEP-* 代表某个 Endpoint。后端选择并不是应用层轮询;规则通过 statistic --mode random --probability ... 逐级决定跳到哪个 Endpoint 链,随后执行 DNAT。
上一篇实验中的 Service 10.96.123.219:80 只有一个后端 10.244.1.3:8080,节点上产生了这组真实规则:
| |
只有一个后端时可以直接跳转;多个后端会形成带概率匹配的规则。连接第一次选定后,conntrack 记录 NAT 结果,后续数据包沿用同一映射。因此不能用一个长连接里的连续 HTTP 请求检验“是否轮询”,应当创建多条新连接。
iptables 模式的优点是成熟、普遍、排障资料多。代价是对象规模增长时会生成大量线性规则,规则同步和匹配结构都不如集合、map 直接。这里的“线性”描述的是规则组织方式,不等于每个包都会遍历集群全部 Service:跳转链、连接跟踪和具体入口会缩小实际路径。
实现入口可从 Kubernetes v1.35.0 的 pkg/proxy/iptables/proxier.go 对照:syncProxyRules 根据 Service 和 Endpoint 状态生成链,writeServiceToEndpointRules 写后端选择规则。
nftables:语义相似,数据结构不同
nftables 模式仍由 kube-proxy 监听 Service 和 EndpointSlice,也仍依赖 Netfilter 完成 DNAT/SNAT。它把原先大量兼容 iptables 的链,改为 nftables 原生的 set、map、verdict map,并通过事务更新规则。
实验中的 Service 是 10.96.66.43:80,两个 Endpoint 是 10.244.1.2:8080 和 10.244.2.2:8080。节点上的规则包含:
| |
Service 前端通过拼接键 地址 . 协议 . 端口 直接映射到 verdict。进入具体 Service 链后,numgen 负责选择后端:
| |
Endpoint 链仍然执行 DNAT:
| |
十二条新连接的命中结果是 6:6,但顺序不是严格交替:
| |
这与 numgen random mod 2 相符。样本恰好 6:6 不代表它承诺每 N 个连接绝对均匀。
Kubernetes v1.35 文档将 nftables 模式标为自 v1.33 起稳定,并指出它改善了大规模 Service 规则更新和处理性能。这里仍应区分“数据结构更适合扩展”和“你的工作负载一定更快”:最终收益还受内核版本、Service 数量、连接模型和观测工具影响。实现可对照 v1.35.0 的 pkg/proxy/nftables/proxier.go。
IPVS:虚拟服务负责选择后端,但没有完全离开 iptables
IPVS 模式也由 kube-proxy 编程。它把 ClusterIP 建成 IPVS virtual service,把 Endpoint 建成 real server,并把 Service IP 绑定到虚拟接口 kube-ipvs0。
实验日志明确记录:
| |
Service 10.96.169.181:80 的 /proc/net/ip_vs 状态如下:
| |
十六进制解码后正好对应:
| |
默认 rr 调度器让十二条新连接严格交替命中两个后端:
| |
后面八条保持相同交替顺序。这说明本次实验里选择逻辑来自 IPVS 的 round-robin,而不是 iptables 的随机概率规则。
但 IPVS 模式不是“删除所有 iptables”。实验节点仍有 KUBE-SERVICES、KUBE-NODE-PORT 和 ipset 匹配规则,用来处理捕获、标记、NodePort 和 masquerade 等环节;kube-ipvs0 上则绑定了 Service /32 地址:
| |
因此排查 IPVS 不能只看 ipvsadm 或 /proc/net/ip_vs,还要看 ipset、iptables 和 kube-ipvs0。Kubernetes v1.35 已将 IPVS proxier 标为 deprecated,官方建议迁往 nftables;新部署不应因为“IPVS 听起来更专业”就忽略这个生命周期信号。源码入口是 pkg/proxy/ipvs/proxier.go。
eBPF:把 Service 查表前移到 socket 或设备 hook
eBPF 不是一份统一的 Kubernetes Service 实现。Cilium 和 Calico 都能替换 kube-proxy,但它们的程序组织、map 布局、命令行工具和功能边界不同。共同点是:把前端/后端状态放入 BPF map,让挂载在内核 hook 上的程序查表并改写流量。
flowchart TB
APP[Application opens connection]
SOCK[cgroup socket hook]
TC[TC or TCX device hook]
MAP[BPF service and backend maps]
POD[Backend Pod]
APP --> SOCK
SOCK -->|socket LB applies| MAP
SOCK -->|packet continues| TC
TC --> MAP
MAP --> POD
图里画出两个可能入口,不表示每条连接一定重复做两次负载均衡。具体命中哪个 hook,取决于流量来源、协议、host network、配置和实现。
Cilium:Service 表与 BPF LB map
svc-cilium 创建时禁用了默认 CNI 和 kube-proxy,再通过本地 v1.19.8 Helm chart 安装 Cilium,配置 kubeProxyReplacement=true。运行时状态是:
| |
同时查询 kube-proxy 得到:
| |
状态明确启用了 replacement,集群中又没有 kube-proxy。因此这里的 Service 由 Cilium 接管,不是“Cilium CNI + kube-proxy”的组合。
实验 Service 是 10.96.155.52:80,Endpoint 是 10.0.0.77:8080 和 10.0.1.216:8080。cilium-dbg service list 显示:
| |
cilium-dbg bpf lb list 中又能找到相同映射:
| |
节点的 iptables-save -t nat 没有 KUBE-SVC 或 KUBE-SEP。客户端 10.0.1.141 发起请求,后端记录的是 10.0.1.141:60184,说明这条 Pod 到 ClusterIP 的路径保留了源 IP。
Cilium 官方的 kube-proxy-free 文档 说明了安装前提、API Server 地址和 replacement 验证方式;本次实验使用的 chart 对应 cilium/cilium v1.19.8。
Calico:Felix 编程 BPF NAT map
svc-calico-bpf 先以普通 kube-proxy 引导,再让 Tigera Operator 管理替换过程。安装对象的关键字段是:
| |
不能直接照抄仓库示例的 IPPool:示例使用 192.168.0.0/16,而本次 kind 集群的 Pod CIDR 是 10.244.0.0/16。第一次应用时,Operator 明确拒绝:
| |
将 IPPool 改为 10.244.0.0/16 后,运行时证据收敛为:
| |
kube-proxy DaemonSet 对象还在,但 Operator 给它增加了不会匹配节点的 selector,因此副本数为零:
| |
Calico 的结果和 Cilium 在表现上相似,但内部表和工具不同。Service 10.96.229.201:80 在 calico-node -bpf nat dump 中是:
| |
同一张表还包含节点 172.30.0.16:30081 到这两个后端的映射。客户端 10.244.71.195 发起 ClusterIP 请求,后端看到 10.244.71.195:42264;节点上同样没有 KUBE-SVC/KUBE-SEP。
安装字段可在 projectcalico/calico v3.31.7 的 BPF 示例 中核对,工作原理和前置条件见 Calico 官方的 Enable the eBPF data plane。
四种实现到底差在哪里
| 维度 | iptables | nftables | IPVS | eBPF replacement |
|---|---|---|---|---|
| 控制器 | kube-proxy | kube-proxy | kube-proxy | CNI agent / Felix |
| 主要状态 | Netfilter chains | sets、maps、chains | virtual service + real server | BPF maps |
| 主要执行位置 | Netfilter hooks | Netfilter hooks | IPVS hooks,辅以 Netfilter | socket、TC/TCX、XDP 等实现相关 hook |
| 本实验后端选择 | 概率链 | numgen random | rr | map + 实现自己的选择逻辑 |
| 首选观测入口 | iptables-save | nft list table ip kube-proxy | /proc/net/ip_vs、ipset、iptables | Cilium: cilium-dbg service list、cilium-dbg bpf lb list;Calico: calico-node -bpf nat dump |
| kube-proxy 是否存在 | 是 | 是 | 是 | replacement 模式下否或 0 副本 |
| Kubernetes v1.35 方向 | 成熟兼容 | 稳定、推荐关注 | 已 deprecated | 不属于 kube-proxy 内建模式 |
“eBPF 更快”不是足够的选型理由。它通常能更早处理流量、使用高效 map,并把网络策略、Service 和可观测性结合起来;与此同时,升级边界从 Kubernetes 本身扩展到了 CNI 版本、内核能力和特定 CLI。团队如果无法稳定获取 BPF 状态、理解 hook 和验证回退路径,理论收益可能换来更高的事故处置成本。
新建集群应该怎么选
flowchart TB
A{是否准备采用<br/>Cilium 或 Calico eBPF?}
A -->|是| B{内核、功能矩阵和<br/>运维工具是否已验证?}
B -->|是| C[eBPF replacement]
B -->|否| D[先保留 kube-proxy]
A -->|否| E{Kubernetes 与内核<br/>是否满足 nftables 要求?}
E -->|是| F[nftables]
E -->|否| G[iptables]
H[现有 IPVS 集群] --> I[制定并验证迁移到 nftables]
对新集群,我会按下面顺序判断:
- 已选 Cilium/Calico,且内核、NetworkPolicy、HostPort、NodePort、健康检查、可观测性和回退路径都经过验证:考虑 eBPF replacement。
- 仍使用 kube-proxy,版本和内核支持 nftables:优先验证 nftables,而不是新上已 deprecated 的 IPVS。
- 兼容性约束强,或现有自动化完全围绕 iptables:可以继续使用 iptables,但要实测大规模规则同步时间。
- 已运行 IPVS:不要只因 deprecated 立刻切换生产数据面;先做并行集群验证,比较源 IP、externalTrafficPolicy、健康检查、NodePort、防火墙和监控差异。
数据面切换不是改一行 mode 就结束。连接跟踪中的旧连接、节点滚动顺序、规则清理、监控告警和故障回退都应纳入迁移方案。
排障时先辨认实现,再查流量
同一个“ClusterIP 不通”,在不同数据面下应从不同入口开始。
kube-proxy 三种模式
| |
然后分别检查:
| |
Cilium replacement
| |
Calico eBPF
| |
所有模式都还要回到共同输入:Service、EndpointSlice、Pod readiness、端口协议和连接跟踪。内核表里没有对应前端时,先查控制循环为什么没编程;表里有映射但请求仍失败,再查路由、策略、SNAT、MTU 和返回路径。
实验给出的结论
同一个 Kubernetes Service API,至少可以落成四类内核状态:iptables 链、nftables map、IPVS virtual service 或 BPF map。API 对象没有告诉你当前节点究竟使用哪一种;必须把配置、组件日志、内核状态和真实请求连起来验证。
本次实验还给出三个容易忽略的事实:
- IPVS 负责后端调度,不代表节点上不再需要 iptables;
- 安装 Cilium/Calico 不代表 Service 自动进入 eBPF,replacement 必须有运行时证据;
- eBPF 也不是单一实现,Cilium 和 Calico 的启用方式、map 和诊断入口都不同。
下一篇会把视角从单个数据包和单个节点抬高到生产生命周期:DNS 如何返回 Service 名称、Endpoint 终止时连接如何排空,以及怎样用一套固定顺序定位“解析成功但连接失败”“偶发 502”“节点重启后 NodePort 异常”等问题。
#Kubernetes #Service #Kube-Proxy #Iptables #Nftables #IPVS #EBPF