土法炼钢兴趣小组的算法知识备份

Kubernetes 网络模型:三条规则统治一切

目录

你把应用部署到 Kubernetes 集群里,kubectl exec 进去 curl 另一个 Pod 的 IP,通了。换个节点上的 Pod,也通了。一切看起来理所当然。

但你有没有想过:这个”理所当然”是怎么来的?两个 Pod 分别跑在不同物理机上,各自有独立的 network namespace,中间隔着物理交换机甚至跨机房的路由器,为什么 curl 10.244.2.5 就能直接通?没有 NAT,没有端口映射,就好像它们在同一个二层网络里一样。

这不是魔法。这是 Kubernetes 对所有 CNI 插件提出的一组硬性约束——三条不可违背的网络规则。所有 CNI 插件(Flannel、Calico、Cilium、AWS VPC CNI……)的全部工作,就是想尽办法满足这三条规则。满足不了?那你就不是一个合格的 K8s 网络方案。

本文要做的事:

  1. 把三条规则讲透——理解每条规则背后的设计动机
  2. 解释为什么 Docker 默认的 bridge + NAT 模式不满足这个模型
  3. 拆解 pause 容器的真实作用——网络命名空间的锚点
  4. 走一遍 Pod 网络命名空间从 kubelet 到 CNI plugin 的创建全过程
  5. 同节点通信 vs 跨节点通信的路径差异
  6. 在 kind 集群里用 nsenter 手工追踪一个包的完整路径

本文基于 Kubernetes 1.30, containerd 1.7, Linux 6.x 内核。 实验环境:kind v0.22, Docker 26.x, Ubuntu 22.04


一、三条铁律:Kubernetes 网络模型的全部内容

Kubernetes 的网络模型定义在官方文档的 Cluster Networking 章节里,核心就三条规则。这三条规则不规定实现方式,只规定最终效果。你用 VXLAN 隧道也好,BGP 路由也好,eBPF 也好,只要结果满足这三条,K8s 就认你。

规则一:每个 Pod 有独立 IP,Pod 之间可以直接通信,不经 NAT

Pod A (10.244.1.2) ---[直接通信,不经 NAT]---> Pod B (10.244.2.5)

Pod A 发出的包:
  src: 10.244.1.2
  dst: 10.244.2.5

Pod B 收到的包:
  src: 10.244.1.2    <-- 没有被 SNAT 改掉
  dst: 10.244.2.5    <-- 没有被 DNAT 改掉

这条规则要求:

为什么要强调”不经 NAT”?因为 NAT 会破坏端到端的可追溯性。如果中间有 SNAT,Pod B 看到的源 IP 不是 Pod A 的真实 IP,对于服务发现、网络策略、日志审计来说都是灾难。

规则二:Node 上的进程可以和所有 Pod 通信

kubelet (Node 192.168.1.10) ---[直接通信]---> Pod A (10.244.1.2)
kubelet (Node 192.168.1.10) ---[直接通信]---> Pod C (10.244.2.3, 在另一个 Node 上)

这条规则保证了 kubelet、kube-proxy、节点级别的监控 agent(如 node-exporter、datadog-agent)等宿主机进程可以正常访问 Pod。如果宿主机进程连本节点的 Pod 都访问不了,健康检查(liveness/readiness probe)就没法做了。

注意:这条规则是双向的。Pod 也可以访问 Node 上的进程。这对于 Pod 访问节点上的 kubelet API(比如拿 metrics)或者访问节点本地的 DNS cache 是必要的。

规则三:Pod 看到的自己的 IP 和别人看到它的 IP 一致

# 在 Pod A 内部执行
hostname -I
# 10.244.1.2

# 在 Pod B 内部执行,访问 Pod A
# Pod B 看到的 Pod A 的 IP 也是 10.244.1.2

这条规则是第一条的补充。它要求 Pod 内部看到自己的 IP 地址(通过 hostname -I 或者 ip addr)和外部看到的必须一致。不能出现 Pod 内部以为自己是 172.17.0.2(Docker bridge 网段),但外部看到的却是 192.168.1.10(宿主机 IP)的情况。

为什么要强调这一点?因为很多应用会把自己的 IP 注册到服务发现系统里(Eureka、Consul 等)。如果 Pod 以为自己的 IP 是 A 但外部看到的是 B,注册的地址就是错的,其他服务根本连不上。

三条规则的本质:扁平网络

把三条规则合在一起,它们定义的其实就是一个扁平网络(flat network)

这个模型的好处是巨大的:应用不需要知道自己跑在容器里。传统微服务搬到 K8s 里,网络行为和物理机上几乎一样——直接用 IP 互联,不处理端口映射,不关心 NAT 穿越。

Kubernetes 网络模型示意图

二、Docker 的 bridge + NAT:为什么不满足 K8s 模型

在进入 Kubernetes 网络之前,先看看 Docker 默认的网络模型。理解它为什么不行,才能理解 K8s 为什么要提出那三条规则。

Docker 默认网络:docker0 bridge + MASQUERADE

Docker 默认创建一个 docker0 网桥,每个容器通过 veth pair 连到这个网桥上。容器的 IP 是 172.17.0.0/16 网段的私有地址。

# 宿主机上查看 docker0
ip addr show docker0
# docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
#     inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0

# 容器内部
docker exec container1 ip addr show eth0
# eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
#     inet 172.17.0.2/16 scope global eth0

当容器要访问外部网络时,Docker 通过 iptables 做 MASQUERADE(SNAT 的一种):

iptables -t nat -L POSTROUTING -n -v
# Chain POSTROUTING (policy ACCEPT)
# target     prot opt source               destination
# MASQUERADE  all  --  172.17.0.0/16        0.0.0.0/0

这意味着:容器 172.17.0.2 发出去的包,经过 MASQUERADE 后,源 IP 被改成宿主机的 IP(比如 192.168.1.10)。外部看到的源 IP 不是容器的 IP。

三条规则逐一对照

规则一:Pod 之间直接通信不经 NAT —— 违反。

Docker 的容器跨主机通信必须经过宿主机的 NAT。容器 A(172.17.0.2 on Host1)要访问容器 B(172.17.0.3 on Host2),包的源 IP 会被改成 Host1 的 IP。容器 B 看到的源 IP 是 Host1,不是容器 A。

容器 A (172.17.0.2) --SNAT--> Host1 (192.168.1.10) --> Host2 --DNAT--> 容器 B (172.17.0.3)

更严重的是:两台主机上的容器 IP 可能冲突——Docker bridge 网段是宿主机本地的,不是全局唯一的。

规则二:Node 上的进程可以和所有 Pod 通信 —— 部分满足。 Docker 容器和本机宿主机可以通信,但跨主机访问远端容器需要端口映射 -p 8080:80

规则三:Pod 看到的自己的 IP 和别人看到的一致 —— 违反。

容器内部看到自己是 172.17.0.2,但跨主机通信后,对方看到的源 IP 是宿主机 IP。IP 不一致。

Docker 模型的根本问题

Docker 的网络模型是为单机场景设计的。Kubernetes 需要一个跨节点的扁平网络,而 Docker 给的是单节点隔离网络 + NAT 出口。这就是 K8s 要定义自己的网络模型,并通过 CNI 插件来实现的原因。

如果你对 veth pair、bridge、NAT 这些底层原语还不熟悉,建议回头看 虚拟网络设备netfilter 与 iptables


三、pause 容器:网络命名空间的锚点

你一定在 Kubernetes 节点上见过 pause 容器。每个 Pod 都有一个。它跑的是一个极简的二进制程序,整个程序就几行汇编,启动后什么都不干,只是 pause() 系统调用挂起。

很多人以为 pause 容器是”占位用的”,或者”K8s 的历史遗留”。大错特错。pause 容器是 Pod 网络模型的核心基础设施

pause 容器到底干了什么

pause 容器的作用只有一个,但极其关键:创建并持有 Pod 的 network namespace

回忆一下 Linux network namespace 的生命周期:namespace 与创建它的进程绑定,进程退出 namespace 就销毁(除非 mount 到某个路径上)。

在 Pod 里,应用容器可能随时 crash 并被 kubelet 重启。如果 network namespace 是应用容器创建的,那应用容器一崩,namespace 就没了——IP、路由表、veth pair 全部丢失。

pause 容器解决了这个问题:pause 是 Pod 中第一个被创建的容器,它持有 Pod 的 network namespace。所有其他容器共享它的 namespace(加入而非新建)。pause 几乎不可能崩溃(就一个 pause() 系统调用),所以 namespace 是稳定的。即使应用容器崩溃重启,加入的还是同一个 namespace,IP、路由、veth 全部不变。

# 在 K8s 节点上查看 pause 容器
crictl ps | grep pause
# CONTAINER   IMAGE                                       STATE    NAME    POD ID
# a1b2c3d4e   registry.k8s.io/pause:3.9                   Running  pause   x9y8z7...

# 查看 pause 容器的 PID
crictl inspect a1b2c3d4e | grep pid
# "pid": 12345

# 查看 pause 容器持有的 namespace
ls -la /proc/12345/ns/net
# lrwxrwxrwx 1 root root 0 ... /proc/12345/ns/net -> 'net:[4026532456]'

# 查看同一个 Pod 中的应用容器,namespace 一样
crictl inspect <app-container-id> | grep pid
# "pid": 12400
ls -la /proc/12400/ns/net
# lrwxrwxrwx 1 root root 0 ... /proc/12400/ns/net -> 'net:[4026532456]'
#                                                       ^^^^^^^^^^^^^^^^^
#                                                       同一个 namespace!

pause 容器的源码

pause 容器的代码极其简单。Kubernetes 仓库里的实现(build/pause/linux/pause.c)核心逻辑就几行:

#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>

static void sigdown(int signo) {
    exit(0);
}

int main() {
    /* 处理 SIGINT 和 SIGTERM,收到就退出 */
    signal(SIGINT,  sigdown);
    signal(SIGTERM, sigdown);

    /* 无限挂起,等待信号 */
    for (;;)
        pause();

    return 42; /* 永远不会到达 */
}

就这么多。pause() 系统调用让进程挂起直到收到信号。这个进程几乎不占 CPU、不占内存(RSS 通常 100KB 以下),但它保证了 network namespace 的稳定性。

Pod 内的网络共享模型

因为 Pod 内所有容器共享同一个 network namespace,所以:

# Pod 有两个容器:app 和 sidecar
# 在 app 容器里
curl localhost:9901  # 访问 sidecar 的端口,直接通

# 在 sidecar 容器里
curl localhost:8080  # 访问 app 的端口,直接通

# 两个容器看到的 IP 一样
kubectl exec pod-name -c app -- hostname -I
# 10.244.1.5
kubectl exec pod-name -c sidecar -- hostname -I
# 10.244.1.5

这就是为什么 Istio 的 sidecar 代理可以用 localhost 拦截应用流量——同一个 network namespace 里,iptables 规则对两者一视同仁。


四、Pod 网络命名空间的创建过程

现在我们知道了 pause 容器的作用,接下来看一个 Pod 的网络从无到有是怎么建立的。这个过程涉及四个组件的协作:kubelet、containerd(CRI runtime)、runc(OCI runtime)、CNI plugin。

完整调用链

kubectl apply -f pod.yaml
         |
         v
    kube-apiserver (写入 etcd)
         |
         v
    kube-scheduler (调度到 Node)
         |
         v
    kubelet (监听到 Pod 被调度到本节点)
         |
         v
    kubelet --CRI--> containerd: RunPodSandbox()
         |
         v
    containerd --OCI--> runc: 创建 sandbox 容器 (pause)
         |                     - unshare(CLONE_NEWNET) 创建 network namespace
         |                     - mount namespace 到 /var/run/netns/xxx
         |
         v
    containerd --CNI--> CNI plugin: ADD
         |                     - 创建 veth pair
         |                     - 把 veth 一端放入 Pod 的 netns
         |                     - 把 veth 另一端连到 host 的 bridge (cni0)
         |                     - IPAM: 分配 IP 地址
         |                     - 配置路由表和 ARP
         |
         v
    kubelet --CRI--> containerd: CreateContainer() (创建应用容器)
         |                     - 应用容器加入 pause 容器的 namespace
         |
         v
    kubelet --CRI--> containerd: StartContainer()
         |
         v
    Pod Running, 网络就绪

第一步:RunPodSandbox —— 创建沙箱

kubelet 通过 CRI(Container Runtime Interface)调用 containerd 的 RunPodSandbox() RPC。这一步会:

  1. 拉取 pause 镜像(如果本地没有)
  2. 调用 runc 创建 pause 容器
  3. runc 在创建过程中通过 clone(CLONE_NEWNET | CLONE_NEWUTS | CLONE_NEWIPC | ...) 创建新的 namespace 集合
  4. 其中 CLONE_NEWNET 就是创建了 Pod 的 network namespace

此时 Pod 的 network namespace 已经存在了,但里面只有一个 lo 回环接口。没有 eth0,没有 IP 地址,没有路由表。

# 此时如果进入 namespace 查看
nsenter -t <pause-pid> -n ip addr
# 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
#     inet 127.0.0.1/8 scope host lo
# 就这些,空空如也

第二步:CNI ADD —— 配置网络

containerd 在创建完 sandbox 后,调用 CNI 插件来配置网络。这个调用遵循 CNI 规范

# containerd 实际执行的命令(简化表示)
CNI_COMMAND=ADD \
CNI_CONTAINERID=abc123 \
CNI_NETNS=/var/run/netns/cni-xxxx \
CNI_IFNAME=eth0 \
/opt/cni/bin/bridge < /etc/cni/net.d/10-bridge.conflist

以最常见的 bridge 插件为例,CNI ADD 做了这些事:

1. 创建 veth pair
   ip link add veth-pod-abc type veth peer name eth0

2. 把 eth0 放入 Pod 的 netns
   ip link set eth0 netns /var/run/netns/cni-xxxx

3. 把 veth-pod-abc 连到 cni0 网桥
   ip link set veth-pod-abc master cni0
   ip link set veth-pod-abc up

4. 在 Pod netns 内配置 IP
   nsenter -n ip addr add 10.244.1.5/24 dev eth0
   nsenter -n ip link set eth0 up
   nsenter -n ip route add default via 10.244.1.1  # 网关是 cni0 的 IP

5. IPAM 记录 IP 分配
   (host-local 插件写入 /var/lib/cni/networks/...)

完成后,Pod 的 network namespace 里就有了:

nsenter -t <pause-pid> -n ip addr
# 1: lo: <LOOPBACK,UP,LOWER_UP>
#     inet 127.0.0.1/8 scope host lo
# 2: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP>
#     inet 10.244.1.5/24 scope global eth0

nsenter -t <pause-pid> -n ip route
# default via 10.244.1.1 dev eth0
# 10.244.1.0/24 dev eth0 proto kernel scope link src 10.244.1.5

第三步:创建应用容器

网络配置完成后,kubelet 才开始创建真正的应用容器。应用容器通过 --net=container:<pause-container-id> 的方式加入 pause 容器的 network namespace,从而共享 IP 地址和路由。

# runc 创建应用容器时的 namespace 配置(OCI spec 中的 linux.namespaces)
{
  "type": "network",
  "path": "/proc/<pause-pid>/ns/net"  # 加入 pause 的 netns,而不是创建新的
}

这就是为什么应用容器和 pause 容器看到的网络是完全一样的——它们字面意义上在同一个 namespace 里。


五、同节点 Pod 通信:bridge 转发

现在我们来看 Pod 之间的实际通信路径。先看最简单的情况:两个 Pod 在同一个节点上。

网络拓扑

Pod A (10.244.1.2)                Pod B (10.244.1.3)
  netns-a                           netns-b
    |                                 |
  eth0                              eth0
    |                                 |
  veth-a (host netns)              veth-b (host netns)
    |                                 |
    +------- cni0 (bridge) ----------+
                 |
            10.244.1.1

包的完整路径

Pod A 的应用发一个包给 Pod B:

1. Pod A 的应用调用 send(),包:src=10.244.1.2, dst=10.244.1.3
2. 内核查 Pod A netns 的路由表:
   10.244.1.0/24 dev eth0  →  目的地在同一子网,直接 L2 转发
3. ARP 解析 10.244.1.3 的 MAC 地址
   (cni0 bridge 会做 ARP proxy 或者直接广播)
4. 包从 eth0 发出,通过 veth pair 到达 host netns 的 veth-a
5. veth-a 是 cni0 bridge 的一个 port
6. cni0 根据 MAC 地址表,把包转发到 veth-b
7. 包通过 veth pair 到达 Pod B netns 的 eth0
8. Pod B 内核的协议栈处理,最终交给应用的 recv()

关键点:整个过程不经过 IP 路由(因为在同一子网内),是纯粹的 L2 转发。cni0 bridge 就像一个虚拟交换机,根据 MAC 地址做转发。

验证:在节点上看 bridge 的 MAC 地址表

# 在宿主机上查看 cni0 的 FDB (forwarding database)
bridge fdb show dev cni0
# 或者用 brctl(如果安装了 bridge-utils)
brctl showmacs cni0
# port no    mac addr            is local?    ageing timer
#   1     da:2b:xx:xx:xx:xx   no           3.45
#   2     f6:8a:xx:xx:xx:xx   no           1.23

每个 veth 对应一个 port,bridge 通过学习到的 MAC 地址表进行精确转发,不需要广播。这和物理交换机的行为完全一样。

性能:同节点通信的开销

同节点 Pod 通信只经过 veth pair + bridge,不涉及隧道封装、路由查找(L3 意义上的)、或者物理网卡。延迟通常在 10-50 微秒级别,比跨节点通信快一个数量级。

# 同节点 Pod 之间 ping
kubectl exec pod-a -- ping -c 5 10.244.1.3
# rtt min/avg/max/mdev = 0.030/0.045/0.060/0.010 ms

如果你对 bridge 的工作原理还不清楚,回顾 虚拟网络设备 中关于 Linux bridge 的章节。


六、跨节点 Pod 通信:隧道或路由

当两个 Pod 不在同一个节点上时,事情就复杂了。包必须离开一个节点的内核,经过物理网络,到达另一个节点。问题是:物理网络不认识 Pod IP(10.244.x.x),它只认识节点 IP(192.168.1.x)。

这就是 CNI 插件的核心价值所在:它要想办法把 Pod IP 的包通过物理网络传到目标节点。主流方案有两种。

方案一:Overlay(隧道封装)

代表:Flannel VXLAN、Calico IPIP

原理:把 Pod IP 的包封装在一个外层包里,外层包的源/目标 IP 是节点 IP,物理网络只看外层包。

Pod A (10.244.1.2, Node1) --> Pod C (10.244.2.3, Node2)

原始包:
  [Eth] [IP: 10.244.1.2 -> 10.244.2.3] [TCP] [Data]

VXLAN 封装后:
  [Eth] [IP: 192.168.1.10 -> 192.168.1.20] [UDP:4789] [VXLAN hdr] [原始包]
         ^                                                           ^
         物理网络认识这个 IP                                           原始包完整保留

IPIP 封装后:
  [IP: 192.168.1.10 -> 192.168.1.20] [IP: 10.244.1.2 -> 10.244.2.3] [TCP] [Data]
   外层 IP 头                          内层 IP 头(原始包)

封装后的包在物理网络中就是一个普通的 IP 包(或者 UDP 包),路由器和交换机正常转发。到达目标节点后,CNI 插件的隧道接口(如 flannel.1 VXLAN 设备或 tunl0 IPIP 设备)负责解封装,还原出原始的 Pod IP 包,然后送入目标节点的 bridge。

方案二:纯路由(BGP / Host Route)

代表:Calico BGP 模式、AWS VPC CNI

原理:不做封装,直接在物理网络的路由表中注入 Pod 子网的路由。

Node1 路由表:
  10.244.1.0/24 dev cni0        # 本地 Pod 子网
  10.244.2.0/24 via 192.168.1.20 dev eth0  # Node2 的 Pod 子网

Node2 路由表:
  10.244.2.0/24 dev cni0        # 本地 Pod 子网
  10.244.1.0/24 via 192.168.1.10 dev eth0  # Node1 的 Pod 子网

这种方式需要物理网络也配合。如果 Node1 和 Node2 不在同一个 L2 网段(比如跨了路由器),就需要用 BGP 把 Pod 子网的路由通告给中间的路由器。Calico 用 BIRD 或者自研的 BGP 客户端来做这件事。

跨节点通信的完整路径(以 VXLAN 为例)

1. Pod A (netns) 调用 send(),包:src=10.244.1.2, dst=10.244.2.3
2. Pod A netns 路由表:default via 10.244.1.1 (cni0)
3. 包从 eth0 通过 veth pair 到达 cni0 bridge
4. cni0 查 FDB,10.244.2.3 的 MAC 不在本地 → 送到网关
5. Host netns 路由表:10.244.2.0/24 dev flannel.1
6. flannel.1 (VXLAN 设备) 做封装:
   外层 src=192.168.1.10, dst=192.168.1.20, UDP port 4789
7. 封装后的包从 eth0 发出,经过物理网络
8. 到达 Node2 的 eth0
9. 内核发现是 UDP:4789,交给 VXLAN 模块
10. flannel.1 解封装,还原出原始包:src=10.244.1.2, dst=10.244.2.3
11. 查路由表:10.244.2.0/24 dev cni0 → 送到 cni0
12. cni0 根据 MAC 表转发到 veth-c
13. 包通过 veth pair 到达 Pod C 的 eth0
14. Pod C 的应用 recv() 收到数据

注意:全程没有 NAT。Pod C 收到的包,源 IP 就是 10.244.1.2。这正是 K8s 网络模型第一条规则的要求。

两种方案的对比

维度 Overlay (VXLAN/IPIP) 纯路由 (BGP)
对物理网络的要求 低,只要节点间 IP 可达 高,需要路由器支持或 BGP peering
MTU 影响 有,封装头占用空间(VXLAN -50B, IPIP -20B)
性能 有封装/解封装开销 接近物理网络原生性能
部署复杂度 中到高
适用场景 公有云、跨 L3 网络 裸金属、自建机房

关于 MTU 问题的深入讨论,参见后续文章 MTU 与分片。关于隧道技术的底层原理,参见 路由与隧道


七、实验:在 kind 集群中追踪一个包的路径

理论讲完了,现在动手验证。我们用 kind(Kubernetes in Docker)搭一个双节点集群,然后用 nsenter 进入 Pod 的 network namespace,手工追踪一个包从发出到到达的完整路径。

搭建 kind 集群

# 创建 kind 集群配置文件
cat > kind-config.yaml << 'EOF'
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
networking:
  podSubnet: "10.244.0.0/16"
  serviceSubnet: "10.96.0.0/12"
  disableDefaultCNI: false
EOF

# 创建集群
kind create cluster --name netlab --config kind-config.yaml

# 确认双节点
kubectl get nodes -o wide
# NAME                   STATUS   ROLES           AGE   VERSION   INTERNAL-IP   ...
# netlab-control-plane   Ready    control-plane   1m    v1.30.0   172.18.0.3    ...
# netlab-worker          Ready    <none>          1m    v1.30.0   172.18.0.2    ...

部署测试 Pod

# 在 worker 节点上部署两个 Pod(同节点)
cat > test-pods.yaml << 'EOF'
apiVersion: v1
kind: Pod
metadata:
  name: pod-a
spec:
  nodeName: netlab-worker
  containers:
    - name: net-tools
      image: nicolaka/netshoot
      command: ["sleep", "infinity"]
---
apiVersion: v1
kind: Pod
metadata:
  name: pod-b
spec:
  nodeName: netlab-worker
  containers:
    - name: net-tools
      image: nicolaka/netshoot
      command: ["sleep", "infinity"]
---
apiVersion: v1
kind: Pod
metadata:
  name: pod-c
spec:
  nodeName: netlab-control-plane
  containers:
    - name: net-tools
      image: nicolaka/netshoot
      command: ["sleep", "infinity"]
EOF

kubectl apply -f test-pods.yaml
kubectl wait --for=condition=Ready pod/pod-a pod/pod-b pod/pod-c --timeout=60s

# 获取 Pod IP
kubectl get pods -o wide
# NAME    READY   STATUS    IP            NODE
# pod-a   1/1     Running   10.244.1.2    netlab-worker
# pod-b   1/1     Running   10.244.1.3    netlab-worker
# pod-c   1/1     Running   10.244.0.5    netlab-control-plane

实验一:查看 Pod 的 network namespace

# 进入 worker 节点
docker exec -it netlab-worker bash

# 找到 pod-a 的 pause 容器 PID
POD_A_PID=$(crictl inspect $(crictl ps --name pause --pod $(crictl pods --name pod-a -q) -q) | jq .info.pid)
echo "Pod A pause PID: $POD_A_PID"

# 进入 pod-a 的 netns 查看网络配置
nsenter -t $POD_A_PID -n ip addr show
# 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
#     inet 127.0.0.1/8 scope host lo
# 2: eth0@if7: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500
#     link/ether 4a:1b:2c:3d:4e:5f brd ff:ff:ff:ff:ff:ff
#     inet 10.244.1.2/24 brd 10.244.1.255 scope global eth0

# 查看路由表
nsenter -t $POD_A_PID -n ip route
# default via 10.244.1.1 dev eth0
# 10.244.1.0/24 dev eth0 proto kernel scope link src 10.244.1.2

# 注意 eth0@if7:@if7 表示 veth 的对端接口 index 是 7
# 在 host netns 查看 index 7 的接口
ip link show | grep "^7:"
# 7: vethXXXXXX@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> master cni0
#                                                       ^^^^^^^^^^
#                                                       连在 cni0 bridge 上

实验二:追踪同节点通信(Pod A -> Pod B)

# 在 worker 节点上

# 先在 Pod B 的 netns 里启动 tcpdump
POD_B_PID=$(crictl inspect $(crictl ps --name pause --pod $(crictl pods --name pod-b -q) -q) | jq .info.pid)
nsenter -t $POD_B_PID -n tcpdump -i eth0 -nn -c 5 &

# 从 Pod A 的 netns 发一个 ping
nsenter -t $POD_A_PID -n ping -c 2 10.244.1.3

# tcpdump 输出:
# 10.244.1.2 > 10.244.1.3: ICMP echo request
# 10.244.1.3 > 10.244.1.2: ICMP echo reply
# 源 IP 就是 Pod A 的 IP,没有 NAT

# 在 host netns 的 cni0 bridge 上也能看到这些包
tcpdump -i cni0 -nn icmp -c 4
# 10.244.1.2 > 10.244.1.3: ICMP echo request
# 10.244.1.3 > 10.244.1.2: ICMP echo reply

实验三:追踪跨节点通信(Pod A -> Pod C)

# Pod C 在 control-plane 节点上,IP 是 10.244.0.5

# 从 worker 节点上,Pod A 的 netns 发 ping
nsenter -t $POD_A_PID -n ping -c 2 10.244.0.5

# 在 worker 节点的 host netns 抓包
# 先看 cni0
tcpdump -i cni0 -nn icmp -c 2
# 10.244.1.2 > 10.244.0.5: ICMP echo request

# 再看 vxlan/隧道接口(kind 默认用 kindnet,基于 ptp + host-local)
# 或者看 eth0(物理接口)
tcpdump -i eth0 -nn -c 4
# 可能看到封装后的包,取决于 CNI 类型

# 在 worker 节点查看路由
ip route | grep 10.244.0
# 10.244.0.0/24 via 172.18.0.3 dev eth0
#                    ^^^^^^^^^
#                    control-plane 节点的 IP

# 这说明 kindnet 用的是纯路由方式(不是 overlay)
# 包直接从 eth0 发出,目标是 control-plane 节点的 IP
# control-plane 节点收到后,根据本地路由表送到 cni0 bridge

实验四:验证三条规则

# 规则一:Pod 之间直接通信不经 NAT
kubectl exec pod-a -- ping -c 1 10.244.0.5
# 直接通,不需要端口映射

# 规则二:Node 上的进程可以访问 Pod
docker exec netlab-worker ping -c 1 10.244.1.2
# PING 10.244.1.2: 64 bytes from 10.244.1.2: icmp_seq=1 ttl=64

docker exec netlab-worker ping -c 1 10.244.0.5
# 跨节点 Pod 也能从宿主机直接 ping

# 规则三:Pod 内外 IP 一致
kubectl exec pod-a -- hostname -I
# 10.244.1.2  — 这个 IP 就是其他 Pod 看到的 IP

实验五:观察 veth pair 的对应关系

# 在 worker 节点上

# 查看 host netns 中的所有 veth
ip link show type veth
# 5: vethXXXX@if2: <...> master cni0
# 7: vethYYYY@if2: <...> master cni0

# 查看 cni0 bridge 的所有 port
bridge link show dev cni0 2>/dev/null || brctl show cni0
# cni0      8000.da2bxxxxxx   no    vethXXXX
#                                   vethYYYY

# 在 Pod A 的 netns 里查看 eth0 的 ifindex
nsenter -t $POD_A_PID -n cat /sys/class/net/eth0/iflink
# 7  (对应 host netns 里 index 为 7 的 veth)

# 在 Pod B 的 netns 里查看
nsenter -t $POD_B_PID -n cat /sys/class/net/eth0/iflink
# 5  (对应 host netns 里 index 为 5 的 veth)

这就完整验证了:Pod netns 里的 eth0 通过 veth pair 连到了 host netns 的 vethXXXX,后者又连在 cni0 bridge 上。整个拓扑和我们前面讲的理论完全一致。


八、深入理解:为什么这三条规则如此重要

前面我们从技术层面讲了三条规则是什么、怎么实现的。现在从设计哲学的角度聊聊:为什么 Kubernetes 选择了这样一个看似”奢侈”的网络模型。

如果用 Docker 的端口映射模型会怎样

假设 K8s 采用 -p hostPort:containerPort 端口映射方式,问题立刻爆发:

  1. 端口冲突:同一节点上几十个 Pod 不能用相同的 hostPort,端口管理是噩梦
  2. 调度约束:调度器要考虑端口占用,复杂度大幅增加
  3. 服务发现失效:Pod 地址变成 NodeIP:hostPort,重调度后地址就变了
  4. NetworkPolicy 不可能实现:源 IP 被 NAT 改掉,根据什么做访问控制?
  5. 可观测性断裂:日志里的 IP 不是真实的 Pod IP,排障时链路追溯不可能

K8s 的选择:把复杂度从应用层移到基础设施层

应用不需要关心端口映射、NAT 穿越、IP 冲突。所有复杂度由 CNI 插件承担。基础设施的复杂度可以由专业团队一次性解决;而应用层的复杂度则会在每个开发团队中反复出现。

代价当然有:CNI 实现复杂、Overlay 有封装开销、大规模集群路由表膨胀、debug 要理解 veth/bridge/overlay/iptables 一整套技术栈。但一次投入基础设施建设,换来所有应用的网络透明性,这笔账是值得的。


九、常见误解与澄清

误解一:“每个 Pod 有独立 IP”意味着 IP 永远不变

不。Pod IP 是临时的,删除重建后通常会变。模型保证的是”Pod 生命周期内 IP 独立且稳定”。需要稳定访问入口就用 Service。

误解二:Pod 之间”不经 NAT”意味着 Service 也不经 NAT

不。K8s Service(ClusterIP)恰恰依赖 DNAT。三条规则说的是 Pod IP 之间的直接通信不经 NAT,不涉及 Service 层。详见 Service 与 kube-proxy

误解三:pause 容器可以去掉

理论上可以。CRI-O 在某些配置下用 pinns 直接 pin namespace 到文件系统,不启动 pause 进程。但在 containerd 的主流用法中,pause 容器仍然是标配。

误解四:同节点 Pod 通信一定走 bridge

不一定。不同的 CNI 插件有不同的实现:

# Calico 的同节点路由方式
ip route | grep cali
# 10.244.1.2 dev caliXXXX scope link
# 10.244.1.3 dev caliYYYY scope link
# 每个 Pod IP 直接指向对应的 veth,不走 bridge

关于 Calico 和 Cilium 的具体实现,参见 Calico 深入Cilium 深入


十、从网络模型到 CNI 规范

到这里你应该对 K8s 的网络模型有了透彻的理解。但模型只是”目标”,真正的”合同”是 CNI(Container Network Interface)规范。CNI 规范定义了 kubelet 和网络插件之间的接口:

{
  "cniVersion": "1.0.0",
  "name": "k8s-pod-network",
  "plugins": [
    {
      "type": "bridge",
      "bridge": "cni0",
      "isGateway": true,
      "ipMasq": false,
      "ipam": {
        "type": "host-local",
        "subnet": "10.244.1.0/24",
        "gateway": "10.244.1.1"
      }
    }
  ]
}

注意 "ipMasq": false——这就是在配置层面保证”不做 MASQUERADE”,从而满足网络模型的第一条规则。

下一篇文章 CNI 规范详解 会深入拆解 CNI 的接口定义、调用流程和主流插件的实现差异。


十一、总结

回顾一下本文的核心要点:

三条规则:(1) 每个 Pod 有独立 IP,Pod 之间直接通信不经 NAT;(2) Node 进程可以和所有 Pod 通信;(3) Pod 看到的自己的 IP 和别人看到的一致。

Docker 的问题:bridge + NAT 模式的 IP 不全局唯一、跨主机通信依赖 NAT、Pod IP 内外不一致。

pause 容器:创建并持有 Pod 的 network namespace,是 namespace 的锚点。应用容器崩溃重启不影响网络。

创建过程:kubelet -> containerd (RunPodSandbox) -> runc (创建 netns) -> CNI plugin (配网络) -> 创建应用容器。

同节点通信:veth pair + bridge,纯 L2 转发。跨节点通信:Overlay 或纯路由,全程不 NAT。

这三条规则是理解 K8s 网络体系的基石。后续的 Service、Ingress、NetworkPolicy、DNS 都建立在此之上。

附录 A:实验用命令速查

# === namespace 操作 ===
nsenter -t <pid> -n <command>        # 进入容器的 network namespace
ls -la /proc/<pid>/ns/               # 查看进程的 namespace
lsns -t net                          # 查看所有 network namespace

# === Pod 网络排查 ===
nsenter -t <pid> -n ip addr          # 接口
nsenter -t <pid> -n ip route         # 路由
nsenter -t <pid> -n ip neigh         # ARP 缓存
nsenter -t <pid> -n cat /sys/class/net/eth0/iflink  # veth 对端 ifindex

# === bridge 排查 ===
bridge fdb show dev cni0
ip link show master cni0

# === 抓包 ===
nsenter -t <pid> -n tcpdump -i eth0 -nn   # Pod netns
tcpdump -i cni0 -nn                        # bridge
tcpdump -i eth0 -nn                        # 物理口(跨节点封装)

# === CNI / CRI 排查 ===
cat /etc/cni/net.d/*.conflist              # CNI 配置
ls /var/lib/cni/networks/                  # IPAM 分配记录
ls /opt/cni/bin/                           # CNI 二进制
crictl pods && crictl ps                   # 容器列表
crictl inspect <id> | jq .info.pid         # 容器 PID

附录 B:推荐阅读



By .