jimyag's Blog

Calico 与 kube-proxy 如何协作:Pod 路由、Service 转发与网络策略

一个 Pod 能访问另一个 Pod,却访问不了它的 Service,该先看 Calico 还是 kube-proxy?如果同节点能通、跨节点不通,排查方向又有什么不同?

这两个问题需要沿数据包实际走过的路径区分。Calico 为 Pod 建立网络接口、路由和策略;kube-proxy 把 Service 地址转换为后端地址。访问 ClusterIP 时,数据包在源节点完成后端选择,随后仍要依靠 Calico 配置的网络抵达那个 Pod。

本文使用 Calico 作为主 CNI,禁用 kindnet,保留 kube-proxy。具体组合是 Kubernetes v1.35.1、Calico v3.33.0、kube-proxy iptables 模式、Calico iptables 数据面,以及 VXLAN Always。BGP、IP-in-IP 和 Calico eBPF 均未启用。这些条件决定了下面看到的规则和路由,不能把图直接套到 Calico eBPF 替代 kube-proxy 的集群。

实验通过 SSH 在 zj 上运行三节点 kind:一个控制面、两个 worker。kind 节点本身是 Docker 容器,节点间互联通过宿主机上的 Docker 网络;图中的节点 eth0 因此是节点容器的网卡。实验验证的是节点网络命名空间中的转发机制,不用这套拓扑推断物理交换网络的性能。

配置、操作说明和原始证据见 k8sdev/pod-network-calico。下面保留与机制解释直接相关的结果。

从访问目标辨认参与的组件

访问方式kube-proxy 在这条路径中的工作Calico 在这条路径中的工作
同节点 Pod IP不需要 Service DNATveth、主机路由和端点策略
跨节点 Pod IP不需要 Service DNAT路由到远端节点,经 VXLAN 传送并交给目标 Pod
ClusterIP选后端,改写目标地址和端口转发到 DNAT 后的 Pod IP,并执行策略
Pod 访问集群外地址目标不是 Service 时,不承担 Service 转发按节点路由出站,按 IP 池条件决定是否 SNAT
集群外访问 NodePort在入口节点选后端,按流量策略决定源地址处理将转换后的流量送到后端 Pod

“不需要 Service DNAT”不表示数据包绕过了 Linux netfilter。Calico 和 kube-proxy 的规则都可能挂在相同 hook 上,区别在于哪些规则匹配并改变了这个包。

本次用 client 和 local 两个 Pod 验证同节点路径,remote 固定在另一台 worker 上。名为 remote 的 Service 只选择跨节点后端,使每次抓包的终点确定。业务进程在 TCP 8080 上返回自己的名字和看到的客户端 IP;ClusterIP 使用 80,NodePort 使用 30080。

本次实测地址如下,后文的路由与抓包均来自这组对象:

对象地址
worker 节点 / worker2 节点172.31.0.2 / 172.31.0.3
client / local10.244.80.65 / 10.244.80.66
remote10.244.158.65
remote Service10.98.235.247:80
worker / worker2 的 VXLAN 端点10.244.80.64 / 10.244.158.64

官方版本要求列出了 Calico 3.33 测试覆盖的 Kubernetes 版本。这里固定具体版本,是为了让源码与运行行为可核对。节点中的 iptables 即使使用 nf_tables 后端,也不能据此说 kube-proxy 已切换为原生 nftables 模式;判断代理模式要看 kube-proxy 配置与生成的规则结构。

Pod 发包前,Calico 已经建立了什么

CNI 插件参与 Pod 网络创建时,并不处理之后的每一个请求。调用链从 kubelet 请求创建 sandbox 开始,容器运行时在网络配置过程中调用 Calico CNI。Calico 分配地址,进入 Pod 的网络命名空间配置接口和路由,再把端点信息交给数据存储。

Calico 的 CmdAddK8s把这几件事连接起来:调用 IPAM 获取地址,通过 DoNetworking 建立数据路径,再以 CreateOrUpdate 保存端点信息。这里的 WorkloadEndpoint 是 Calico 描述工作负载网络的模型,不要把它直接等同于一个独立存储的 Kubernetes CRD。

在本文的 Kubernetes datastore 模式下,WorkloadEndpoint 的后端实现会修改 Pod 的 Calico annotations,并由 Pod 数据构造端点视图。源码解释了使用 annotations 的两个原因:避免直接修改 PodStatus 与 kubelet 更新竞争,以及尽早把地址信息送到 Felix。

  sequenceDiagram
    participant K as kubelet
    participant R as CRI 运行时
    participant C as Calico CNI / IPAM
    participant L as 节点 Linux 内核
    participant A as API Server
    participant F as Felix
    K->>R: RunPodSandbox
    R->>C: CNI ADD:容器标识与 netns
    C->>A: 查询 Pod、分配 IPAM 地址
    C->>L: 创建 veth,配置 Pod 地址与路由
    C->>L: 配置宿主机端路由和 sysctl
    C->>A: 更新 Pod 的 Calico 端点 annotations
    par Felix 异步协调
        A-->>F: watch:Pod、节点、IPAM、策略变化
        F->>L: 协调端点策略、远端路由与隧道状态
    and 运行时继续创建
        C-->>R: 返回 CNI result
        R-->>K: 返回 sandbox 结果
    end
    Note over C,F: 控制面更新与节点协调异步推进;图不表示 CNI 返回前所有远端节点都已收敛

CNI 配置完成和全网状态收敛之间仍有区别。失败定位时,需要分别确认本地接口是否建立、Felix 是否收到端点、远端路由是否已编程,而不是只看 Pod 已有 podIP。

/32 地址和 169.254.1.1 网关如何工作

Calico 的普通 IPv4 workload 接口使用 /32 地址。Pod 看不到一个包含其他 Pod 的直连子网,发往其他地址的包交给默认路由。

DoWorkloadNetnsSetUp先给 169.254.1.1/32 添加 link scope 路由,再把需要的 IPv4 路由指向这个下一跳。这样,即使 Pod 地址是 /32,内核仍知道下一跳可以通过 eth0 直接到达。

Pod 为 169.254.1.1 发 ARP 时,宿主机侧 veth 开启的 proxy_arp 负责回应。这个网关地址不需要逐一配置在宿主机 veth 上。configureSysctls 还把 proxy_delay 设为 0,并开启接口转发;源码注释明确指出,使用 proxy ARP 可以少维护一份地址,也少一个发生地址冲突的地方。

客户端网络命名空间中的实际配置是:

1
2
3
eth0: inet 10.244.80.65/32,MTU 1450
default via 169.254.1.1 dev eth0
169.254.1.1 dev eth0 scope link

对应宿主机接口 cali747ff840379 的 proxy_arp=1、proxy_delay=0、forwarding=1,而 ip -4 addr show dev cali747ff840379 没有输出 IPv4 地址。这与源码中的虚拟下一跳设计相吻合。

这里的两张表各管一段:Pod 的路由表决定把包交给宿主机,宿主机的路由表决定从哪一个接口继续转发。知道虚拟网关地址,并不意味着所有 Pod 都在同一个二层广播域中。

同节点 Pod 通过主机路由相遇

客户端包从 Pod eth0 进入宿主机侧 cali... veth。目标 Pod 也有对应的宿主机 veth 和主机路由,Linux 根据目的 Pod IP 选中那个接口,把包送进目标网络命名空间。

SetupRoutes负责为普通 workload 地址建立宿主机端路由。这条路径不要求把两个 veth 接到 Linux bridge 上,也不需要经 VXLAN 绕到远端再回来。

源节点实际安装了下面两条主机路由:

1
2
10.244.80.65 dev cali747ff840379 scope link metric 1024
10.244.80.66 dev calid5f5981a8e0 scope link metric 1024

client 请求 local 后得到 {"server":"local","peer":"10.244.80.65"},后端确实看到了客户端 Pod 地址。

  flowchart LR
    subgraph CP[客户端 Pod]
        C[进程] --> CE[eth0]
    end
    subgraph HOST[同一节点的网络命名空间]
        CV[cali 客户端接口] --> ROUTE[路由与转发策略]
        ROUTE --> SV[cali 服务端接口]
    end
    subgraph SP[服务端 Pod]
        SE[eth0] --> S[监听 8080 的进程]
    end
    CE --> CV
    SV --> SE

这是普通 Pod IP 通信:目的地址不变,后端看到的源地址仍是客户端 Pod IP。若这条路径已经不通,应先确认本地 veth、Pod 路由和 Calico 策略,跨节点隧道通常还不是第一检查点。

跨节点多出的三层映射

跨节点时,源节点没有目标 Pod 的本地 veth,需要把包交给目标所在节点。本实验使用 VXLAN,涉及三组不同的地址:

  • Pod IP:业务请求的源地址与目的地址。
  • VTEP 地址:VXLAN 隧道端点地址,用作经 vxlan.calico 转发的下一跳。
  • 节点地址:外层 UDP 包的源地址和目的地址,底层网络实际负责路由这一层。

路由表把远端 Pod 网段指向远端 VTEP;邻居表关联 VTEP IP 与隧道 MAC;FDB 再关联隧道 MAC 与远端节点地址。FDB 在这里是 VXLAN 设备使用的转发表,不代表 Calico 把所有 Pod 接在一个普通 Linux bridge 上。

对远端后端 10.244.158.65,源节点上的对应关系是:

1
2
3
4
路由:10.244.158.64/26 via 10.244.158.64 dev vxlan.calico onlink mtu 1450
邻居:10.244.158.64 lladdr 66:b0:94:1d:fb:92 PERMANENT
FDB: 66:b0:94:1d:fb:92 dst 172.31.0.3 self permanent
设备:vxlan id 4096 local 172.31.0.2 dev eth0 dstport 4789 nolearning

这里是控制面编程的邻居与 FDB,不依赖业务包先经过广播学习。源节点还存在本地 IPAM block 的 blackhole 10.244.80.64/26,实际 Pod 则有更精确的主机路由。按最长前缀匹配,已分配 Pod 会命中主机路由,块内没有更精确路由的目标会被 blackhole 截住,而不是沿默认路由发到节点外。

L3RouteResolver处理 IPAM block、节点和端点信息,计算需要发送给数据面的路由。VXLANResolver整理隧道地址、MAC 和节点地址,发出 VTEP 更新;Linux 数据面的 vxlanManager把这些信息转换为内核设备、路由和邻居/FDB 状态。

tunnelRoute 有一个值得注意的分支:路由对应的远端 VTEP 尚未到达时,它返回空结果,等待 VTEP 更新再次触发计算。路由信息和节点隧道信息不必同时到达,数据面通过重新协调补齐依赖。排查“IPAM 已分配但远端不通”时,不能只看地址池而不看 VTEP。

  sequenceDiagram
    participant C as 客户端 Pod
    participant N1 as 源节点 Linux
    participant U as 节点间网络
    participant N2 as 目标节点 Linux
    participant S as 服务端 Pod
    C->>N1: 内层 IP 包:client Pod IP → remote Pod IP
    N1->>N1: 路由查找:远端网段 → vxlan.calico
    N1->>N1: 邻居表与 FDB:VTEP → 远端节点
    N1->>U: 外层节点 IP → 节点 IP,UDP 4789<br/>携带 VXLAN 与原始业务包
    U->>N2: 按外层节点地址送达
    N2->>N2: VXLAN 解封装,查本地 Pod 路由<br/>执行目标端点 ingress 策略
    N2->>S: 原始 client Pod IP → remote Pod IP
    S-->>N2: 响应包
    N2-->>N1: 反向 VXLAN 转发
    N1-->>C: 响应送入客户端 veth

Always 表示本地址池的跨节点通信使用 VXLAN,不表示同节点通信也必须封装。VXLAN 封装增加外层 IP、UDP、VXLAN 和内层以太网头;因此要同时核对 Pod 与隧道 MTU,不能只检查节点网卡的 MTU。

BGP 和 VXLAN 也不是两种等价的“隧道协议”:BGP 用来交换路由,VXLAN 用来封装数据。本实验的 VXLAN 集群路由由 Felix 管理,所以禁用了 BIRD。若换成无封装或 IP-in-IP 配置,路由分发与底层可达性条件需要重新分析,不能只删掉图中的 VXLAN 方框。

Service 在源节点把目的地址改成 Pod IP

客户端访问 ClusterIP 时,Pod 的网络配置没有变化。它仍沿默认路由把包交给宿主机;变化发生在源节点的 Service 规则中。

控制面首先要把“哪些 Pod 是这个 Service 的后端”变成 EndpointSlice。这个工作由 kube-controller-manager 中的 EndpointSlice 控制器协调:读取 Service 和 Pod 状态,写回 EndpointSlice。它不直接给 kube-proxy 发配置,也不亲自转发请求。

  sequenceDiagram
    participant A as API Server
    box kube-controller-manager
        participant E as EndpointSlice 控制器
    end
    participant K as 节点 kube-proxy
    participant L as 节点 Linux
    A-->>E: informer:Service 与 Pod 状态变化
    E->>A: 协调 EndpointSlice
    par 服务定义
        A-->>K: watch:Service
    and 后端集合
        A-->>K: watch:EndpointSlice
    end
    K->>K: 更新 change tracker,合并待处理变化
    K->>K: syncProxyRules 计算规则
    K->>L: iptables-restore 更新 Service 规则
    Note over K,L: 后续数据包由内核转发,不逐包请求 kube-proxy

在 iptables proxier 中,OnServiceUpdate 和 OnEndpointSliceUpdate 记录变化并触发同步;syncProxyRules 在初始 Service 与 EndpointSlice 缓存都完成同步后才生成规则。先等待两类缓存,避免只看到 Service、还没看到已有后端时,就按不完整视图编程。

规则更新也不是每来一个 watch 事件就重建整个数据面。同步 runner 合并变化,syncProxyRules 区分局部更新与全量同步;若恢复规则失败,会标记下一次需要全量同步,因为已消费的变化集合不足以继续做可靠的局部更新。

对于普通 ClusterIP,核心关系是:

1
2
3
4
5
nat PREROUTING
  → KUBE-SERVICES:匹配 ClusterIP:80
  → KUBE-SVC-...:选择可用后端
  → KUBE-SEP-...:DNAT 到后端 Pod IP:8080
  → 按改写后的目的地址查路由

本实验后端只有一个,所以不会观察到多个 endpoint 之间的随机选择。多个后端时,proxier 会为候选 endpoint 生成概率匹配规则;那是新连接进入 NAT 路径时的选择,不是每个 HTTP 请求都重新抽签。

如果被选中的是远端 Pod,DNAT 后的包继续走前一节的 VXLAN 路径。因此抓源节点 eth0 上的 VXLAN 包,内层目的地址应该已经是后端 Pod IP 和 8080,不再是 ClusterIP 和 80。

实测抓包确认了这个变化。客户端发往 10.98.235.247:80 的请求,离开源节点时已成为:

1
2
3
外层:172.31.0.2:<UDP 源端口> → 172.31.0.3:4789,VNI 4096
内层:10.244.80.65:<TCP 源端口> → 10.244.158.65:8080
HTTP:Host: 10.98.235.247

HTTP Host 仍然是应用最初访问的地址。kube-proxy 的这条路径做的是网络层和传输层地址转换,并没有像 HTTP 反向代理那样接收请求后重新组织应用层消息。后端返回的 peer 仍为 10.244.80.65,说明这次 Pod 到 ClusterIP 的路径只需要 DNAT,没有把客户端地址一并隐藏。

  flowchart TB
    A[Pod 发出目标为 ClusterIP:80 的包] --> B[源节点 nat PREROUTING<br/>KUBE-SERVICES → SVC → SEP]
    B --> C[DNAT:目标改成后端 Pod IP:8080]
    C --> D{路由指向哪里?}
    D -->|本地后端| E[目标节点执行端点策略]
    D -->|远端后端| F[vxlan.calico 封装,送往目标节点]
    F --> G[目标节点解封装,查本地 Pod 路由]
    G --> E
    E --> H[经目标 cali 接口送入 Pod 业务进程]

这个图突出后端选择与路由的先后关系,具体端点策略还会经过源端 egress、目标端 ingress 等检查,不是只有最后一个方框才可能丢包。宿主机进程自己访问 Service 时,则从本机 OUTPUT 路径进入 Service NAT,不能照搬 Pod 入站到节点的 PREROUTING 入口。

使用 remote.pod-network.svc.cluster.local 访问时,还要先完成另一条网络请求:客户端解析器向集群 DNS 发查询,DNS Service 的流量同样经过 Service 转发和 Pod 网络,CoreDNS 返回业务 Service 的 ClusterIP,应用才连接这个地址。本次域名访问也返回 remote 和客户端 Pod IP。因此“域名访问不通,但 ClusterIP 通”时,应先拆开 DNS 查询与业务连接,不能把两次访问合成一次失败来解释。

返回包如何重新变回 Service 地址

后端监听的是自己的 Pod IP:8080,但客户端认为自己连接的是 ClusterIP:80。两端之所以能维持这个视图,依靠的是 conntrack 记录的连接状态及 NAT 映射。

请求方向建立 DNAT 映射后,同一连接的后续包使用已有状态;返回方向经过相应节点时,内核应用反向转换。客户端最终看到的响应来源恢复为它访问的 Service 地址。只抓目标 Pod 的网卡,看不到“响应源地址是 ClusterIP”,并不表示 Service 转发异常:反向转换还没有在返回路径上完成。

这也解释了两个常见现象。NAT 链的计数不能直接当成所有业务包的计数;修改 Service 后端,也不意味着既有 TCP 连接立即重新选端点。判断规则是否生效时,实验应主动建立新连接,避免把复用连接的结果误判成规则更新失败。

本次 conntrack 中的一条连接同时记录了这两组方向,下面只保留地址与端口字段:

1
2
原始方向:src=10.244.80.65 dst=10.98.235.247 sport=45266 dport=80
回复方向:src=10.244.158.65 dst=10.244.80.65 sport=8080 dport=45266

两组地址不完全对称正是 DNAT 映射的表现,不是后端回应错了地址。

Calico 出站 NAT 与 kube-proxy SNAT 各自解决什么

Pod 访问地址池之外的目标时,底层网络未必知道如何把响应送回 Pod 网段。Calico 可以在出站时改写源地址,让响应先回到节点,再按连接状态送回 Pod。

makeNATOutgoingRuleIPTables的关键条件是:源地址属于需要 masquerade 的地址池,目的地址不属于 Calico 的网络地址池集合。默认动作是 MASQUERADE,配置了专用出站地址时也可以生成 SNAT。判断依据是 IP 池集合,不是“目标是不是公网 IP”;一个集群外私网地址也可以满足出站 NAT 条件。

kube-proxy 的 SNAT 条件则围绕 Service 转发。例如 externalTrafficPolicy: Cluster 允许入口节点把外部请求发给其他节点上的后端,proxier 会给这条外部路径打 masquerade 标记,以保证回复经过持有 NAT 状态的路径。另一个特殊场景是后端 Pod 经 Service 访问到自己,也就是 hairpin,endpoint 规则同样会标记 SNAT。

这两类 NAT 要分别查看 cali-nat-outgoing 和 KUBE-MARK-MASQ / KUBE-POSTROUTING。看到后端源地址变化后,不能仅凭“集群装了 Calico”就把所有 SNAT 归到 Calico。

三组实测结果能把这件事区分开:

请求服务端看到的源地址对应路径
client 访问集群外容器 172.31.0.5:8080172.31.0.2Calico 出站 masquerade,源节点从 eth0 发出
外部容器访问 172.31.0.2:30080,后端在 worker210.244.80.64kube-proxy 为外部流量标记 masquerade,DNAT 后从 vxlan.calico 发出
remote 经 ClusterIP 访问到自己172.31.0.3endpoint 的 hairpin masquerade

第二行尤其容易被“源地址变成 Node IP”这个说法掩盖。这里 ip route get 10.244.158.65 选择 vxlan.calico,源地址为 10.244.80.64,与后端看到的地址一致。kube-proxy 生成的是 MASQUERADE 动作,最终使用什么地址还取决于出接口与内核地址选择,并非把某一个 Node 对象里的地址写死在规则中。

这组 NodePort 结果不能推广到 externalTrafficPolicy: Local。本文使用默认 Cluster 策略,Local 对后端选择和源地址保留的影响见 Service 的流量路径。

NetworkPolicy 限制的是端点流量

Calico 除了提供路由,也把策略转换成端点上的过滤规则。在本配置中,Felix 的 WorkloadEndpointToIptablesChains分别生成去往端点和来自端点的链。网络连通所需的路由存在,不代表策略一定允许包通过。

本文给 remote 后端添加一条 ingress 策略,只允许带 allowed=true 标签的同命名空间 Pod 访问 TCP 8080。客户端初始不带标签:直接请求后端 Pod IP 与请求 ClusterIP 都应该被阻止;给客户端添加标签后,两条路径都应该恢复。

Service 的 80 已在源节点转换成后端的 8080,目标端点策略判断的是这条实际到达后端的流量。ClusterIP 不提供绕过后端 ingress 策略的捷径。这个结论针对本文的标准数据面与 Pod 间 Service 路径;外部流量经过 SNAT 后,源身份已经改变,需要结合相应流量策略另行判断。

  flowchart TB
    A[请求到达 remote 端点] --> B{remote 是否被 ingress 策略选择?}
    B -->|本实验:是| C{源 Pod 标签匹配 allowed=true<br/>且目标为 TCP 8080?}
    C -->|是| D[允许新连接]
    C -->|否| E[丢弃,新连接超时]
    B -->|未被隔离| F[按其他适用规则处理]

这里刻意使用新建连接测试。Calico 的端点规则包含连接状态处理,不能拿一个已经建立的连接继续发送数据,就推断后来添加的策略没有生效。

实际结果与路径判断一致:未加标签时,两种访问都超时;同一客户端访问不受该策略选择的 local 后端仍然成功。目标节点端点链的“没有策略放行则丢弃”规则计数增加。给 client 添加 allowed=true 后,Pod IP 与 ClusterIP 访问都恢复,后端再次返回客户端 Pod IP。

这个标签变更没有修改 NetworkPolicy 的端口条件,实际变化出现在策略引用的 IP set:原本为空的源地址集合加入了 10.244.80.65。规则用集合表达 selector 当前选中的地址,所以 Pod 标签变化可以通过更新集合改变匹配结果,而不必把所有源地址逐条展开成规则。这是看策略时需要同时检查 iptables 与 IP set 的原因。

按断点定位故障

排查时先保留实际五元组和 Pod 所在节点,再沿发生变化的那一层缩小范围。

观察到的差异下一步核对的状态
同节点 Pod IP 也不通Pod 的 /32 地址和默认路由、veth、宿主机本地路由、端点策略
同节点通,跨节点不通远端 Pod 网段路由、VTEP、邻居与 FDB、节点之间 UDP 4789、MTU
后端 Pod IP 通,ClusterIP 不通Service 端口与 selector、EndpointSlice 的地址与就绪状态、源节点 kube-proxy 规则、是否在测试新连接
ClusterIP 通,域名不通DNS 解析链路、CoreDNS 的 Service 与后端、客户端 DNS 配置和 DNS 流量策略
包已到目标节点但进不了 Pod解封装后的目的地址、本地 veth 路由、目标 ingress 策略及丢包计数
Pod 能出站但对端看不到 Pod IPCalico 出站 NAT 条件、源节点出接口,以及沿途其他设备的 NAT
NodePort 后端看到的源地址不同externalTrafficPolicy、入口节点、实际出接口和 kube-proxy 的 masquerade 规则

一次连接可能同时经过 kube-proxy 的目标地址转换、Calico 的路由与策略,以及内核 conntrack 的返回转换。把 Service 地址、后端地址、隧道地址和节点地址分别记录下来,才能知道下一张表应该查什么。

#Kubernetes #Calico #Cni #网络