jimyag's Blog

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 上执行的实验:

集群KubernetesService 数据面实现者
svc-iptablesv1.35.0iptableskube-proxy
svc-ipvsv1.35.0IPVSkube-proxy
svc-nftablesv1.35.0nftableskube-proxy
svc-ciliumv1.35.0eBPFCilium v1.19.8
svc-calico-bpfv1.35.0eBPFCalico 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 配置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 为两个后端 Pod 提供 ClusterIP 和固定 NodePort。
apiVersion: v1
kind: Service
metadata:
  name: echo-server
  namespace: service-dataplane
spec:
  # ClusterIP 和 NodePort 共用同一组后端,便于比较不同入口。
  type: NodePort
  selector:
    app.kubernetes.io/name: echo-server
  ports:
    # Service 80 端口转发到 Pod 的 8080 端口。
    - name: http
      port: 80
      targetPort: 8080
      nodePort: 30081

每次实验都检查四层证据:

  1. 控制组件宣告的运行模式;
  2. Service 和 EndpointSlice 的实际地址;
  3. 新建连接命中了哪个后端、后端看到了什么源地址;
  4. 对应内核数据结构里是否存在此前端和后端。

只看配置文件不够,因为配置可能没有被进程采用;只看请求成功也不够,因为另一个数据面可能仍在工作。

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,节点上产生了这组真实规则:

1
2
3
4
5
6
7
-A KUBE-SVC-HMXYGTJUZB5GQ45C \
  -m comment --comment "service-path/source-ip:http -> 10.244.1.3:8080" \
  -j KUBE-SEP-MGYZ72N52QLOHZGY

-A KUBE-SEP-MGYZ72N52QLOHZGY -p tcp \
  -m comment --comment "service-path/source-ip:http" \
  -m tcp -j DNAT --to-destination 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。节点上的规则包含:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
map service-ips {
  type ipv4_addr . inet_proto . inet_service : verdict
  elements = {
    10.96.66.43 . tcp . 80 : goto service-AKD4XF6G-service-dataplane/echo-server/tcp/http
  }
}

map service-nodeports {
  type inet_proto . inet_service : verdict
  elements = {
    tcp . 30081 : goto external-AKD4XF6G-service-dataplane/echo-server/tcp/http
  }
}

Service 前端通过拼接键 地址 . 协议 . 端口 直接映射到 verdict。进入具体 Service 链后,numgen 负责选择后端:

1
2
3
4
5
6
7
chain service-AKD4XF6G-service-dataplane/echo-server/tcp/http {
  ip daddr 10.96.66.43 ip saddr != 10.244.0.0/16 jump mark-for-masquerade
  numgen random mod 2 vmap {
    0 : goto endpoint-Q7LEI3H3-...__10.244.1.2/8080,
    1 : goto endpoint-JHFC3PET-...__10.244.2.2/8080
  }
}

Endpoint 链仍然执行 DNAT:

1
2
3
4
chain endpoint-Q7LEI3H3-...__10.244.1.2/8080 {
  ip saddr 10.244.1.2 jump mark-for-masquerade
  meta l4proto tcp dnat to 10.244.1.2:8080
}

十二条新连接的命中结果是 6:6,但顺序不是严格交替:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
echo-server-jp7pk
echo-server-4q5nm
echo-server-jp7pk
echo-server-4q5nm
echo-server-jp7pk
echo-server-jp7pk
echo-server-jp7pk
echo-server-4q5nm
echo-server-4q5nm
echo-server-jp7pk
echo-server-4q5nm
echo-server-4q5nm

这与 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。

实验日志明确记录:

1
2
3
"Using ipvs Proxier"
"The ipvs proxier is now deprecated and may be removed in a future release. Please use 'nftables' instead."
"IPVS scheduler not specified, use rr by default"

Service 10.96.169.181:80 的 /proc/net/ip_vs 状态如下:

1
2
3
TCP  0A60A9B5:0050 rr
  -> 0AF40202:1F90      Masq    1      0          0
  -> 0AF40102:1F90      Masq    1      0          0

十六进制解码后正好对应:

1
2
3
0A60A9B5:0050 = 10.96.169.181:80
0AF40202:1F90 = 10.244.2.2:8080
0AF40102:1F90 = 10.244.1.2:8080

默认 rr 调度器让十二条新连接严格交替命中两个后端:

1
2
3
4
echo-server-wdjwh
echo-server-4khtv
echo-server-wdjwh
echo-server-4khtv

后面八条保持相同交替顺序。这说明本次实验里选择逻辑来自 IPVS 的 round-robin,而不是 iptables 的随机概率规则。

但 IPVS 模式不是“删除所有 iptables”。实验节点仍有 KUBE-SERVICES、KUBE-NODE-PORT 和 ipset 匹配规则,用来处理捕获、标记、NodePort 和 masquerade 等环节;kube-ipvs0 上则绑定了 Service /32 地址:

1
kube-ipvs0 DOWN 10.96.0.1/32 10.96.0.10/32 10.96.169.181/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。运行时状态是:

1
2
3
4
5
6
Kubernetes:             Ok         1.35 (v1.35.0) [linux/amd64]
KubeProxyReplacement:   True
Attach Mode:            TCX
Device Mode:            veth
  Socket LB:            Enabled
  Mode:                 SNAT

同时查询 kube-proxy 得到:

1
Error from server (NotFound): daemonsets.apps "kube-proxy" not found

状态明确启用了 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 显示:

1
2
3
4
5
ID   Frontend               Service Type   Backend
6    0.0.0.0:30081/TCP      NodePort       1 => 10.0.0.77:8080/TCP (active)
                                           2 => 10.0.1.216:8080/TCP (active)
8    10.96.155.52:80/TCP    ClusterIP      1 => 10.0.0.77:8080/TCP (active)
                                           2 => 10.0.1.216:8080/TCP (active)

cilium-dbg bpf lb list 中又能找到相同映射:

1
2
10.96.155.52:80/TCP (1)   10.0.0.77:8080/TCP (8) (1)
10.96.155.52:80/TCP (2)   10.0.1.216:8080/TCP (8) (2)

节点的 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 管理替换过程。安装对象的关键字段是:

1
2
3
4
5
6
7
8
# 让 Calico eBPF 接管 Linux 数据面和 kube-proxy 生命周期。
calicoNetwork:
  # 选择 BPF 数据面。
  linuxDataplane: BPF
  # 在替换早期维持到 Kubernetes API 的连通性。
  bpfNetworkBootstrap: Enabled
  # eBPF 就绪后由 Operator 停用 kube-proxy。
  kubeProxyManagement: Enabled

不能直接照抄仓库示例的 IPPool:示例使用 192.168.0.0/16,而本次 kind 集群的 Pod CIDR 是 10.244.0.0/16。第一次应用时,Operator 明确拒绝:

1
IPPool 192.168.0.0/16 is not within the platform's configured pod network CIDR(s) [10.244.0.0/16]

将 IPPool 改为 10.244.0.0/16 后,运行时证据收敛为:

1
2
3
4
5
calico              True        False         False      All objects available
ippools             True        False         False      All objects available
kubeproxy-monitor   True        False         False      All objects available
linuxDataplane=BPF bpfNetworkBootstrap=Enabled kubeProxyManagement=Enabled
bpfEnabled=true bpfConnectTimeLoadBalancing=TCP

kube-proxy DaemonSet 对象还在,但 Operator 给它增加了不会匹配节点的 selector,因此副本数为零:

1
2
NAME         DESIRED   CURRENT   READY   NODE SELECTOR
kube-proxy   0         0         0       ...,operator.tigera.io/disable-kube-proxy=true

Calico 的结果和 Cilium 在表现上相似,但内部表和工具不同。Service 10.96.229.201:80 在 calico-node -bpf nat dump 中是:

1
2
3
10.96.229.201 port 80 proto 6 id 7 count 2 local 1
    7:0  10.244.71.194:8080
    7:1  10.244.159.66:8080

同一张表还包含节点 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。

四种实现到底差在哪里

维度iptablesnftablesIPVSeBPF replacement
控制器kube-proxykube-proxykube-proxyCNI agent / Felix
主要状态Netfilter chainssets、maps、chainsvirtual service + real serverBPF maps
主要执行位置Netfilter hooksNetfilter hooksIPVS hooks,辅以 Netfiltersocket、TC/TCX、XDP 等实现相关 hook
本实验后端选择概率链numgen randomrrmap + 实现自己的选择逻辑
首选观测入口iptables-savenft list table ip kube-proxy/proc/net/ip_vs、ipset、iptablesCilium: 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 三种模式

1
2
3
4
5
6
7
# 确认配置声明的模式。
kubectl -n kube-system get configmap kube-proxy \
  -o jsonpath='{.data.config\.conf}' | grep '^mode:'

# 确认进程实际选择的 proxier。
kubectl -n kube-system logs daemonset/kube-proxy --tail=200 | \
  grep -E 'Using .* Proxier|deprecated'

然后分别检查:

1
2
3
4
5
6
7
8
9
# iptables
iptables-save -t nat | grep -E 'KUBE-SVC|KUBE-SEP'

# nftables
nft list table ip kube-proxy

# IPVS;没有 ipvsadm 时仍可读取内核状态。
cat /proc/net/ip_vs
ip -brief address show kube-ipvs0

Cilium replacement

1
2
3
cilium-dbg status --verbose
cilium-dbg service list
cilium-dbg bpf lb list

Calico eBPF

1
2
3
kubectl get tigerastatus
kubectl get felixconfiguration default -o yaml
calico-node -bpf nat dump

所有模式都还要回到共同输入:Service、EndpointSlice、Pod readiness、端口协议和连接跟踪。内核表里没有对应前端时,先查控制循环为什么没编程;表里有映射但请求仍失败,再查路由、策略、SNAT、MTU 和返回路径。

实验给出的结论

同一个 Kubernetes Service API,至少可以落成四类内核状态:iptables 链、nftables map、IPVS virtual service 或 BPF map。API 对象没有告诉你当前节点究竟使用哪一种;必须把配置、组件日志、内核状态和真实请求连起来验证。

本次实验还给出三个容易忽略的事实:

  1. IPVS 负责后端调度,不代表节点上不再需要 iptables;
  2. 安装 Cilium/Calico 不代表 Service 自动进入 eBPF,replacement 必须有运行时证据;
  3. eBPF 也不是单一实现,Cilium 和 Calico 的启用方式、map 和诊断入口都不同。

下一篇会把视角从单个数据包和单个节点抬高到生产生命周期:DNS 如何返回 Service 名称、Endpoint 终止时连接如何排空,以及怎样用一套固定顺序定位“解析成功但连接失败”“偶发 502”“节点重启后 NodePort 异常”等问题。

#Kubernetes #Service #Kube-Proxy #Iptables #Nftables #IPVS #EBPF