你把应用部署到 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 网络方案。
本文要做的事:
- 把三条规则讲透——理解每条规则背后的设计动机
- 解释为什么 Docker 默认的 bridge + NAT 模式不满足这个模型
- 拆解 pause 容器的真实作用——网络命名空间的锚点
- 走一遍 Pod 网络命名空间从 kubelet 到 CNI plugin 的创建全过程
- 同节点通信 vs 跨节点通信的路径差异
- 在 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 改掉
这条规则要求:
- 每个 Pod 有一个集群范围内唯一的 IP 地址
- 任意两个 Pod 之间可以直接用对方的 Pod IP 通信
- 通信路径上不能有 NAT——Pod A 发出去的源
IP 是
10.244.1.2,Pod B 收到的源 IP 也必须是10.244.1.2
为什么要强调”不经 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):
- 所有 Pod 在同一个”虚拟大二层”里,彼此直接可达
- 没有 NAT 打破端到端的 IP 一致性
- 宿主机也能无障碍地参与这个网络
这个模型的好处是巨大的:应用不需要知道自己跑在容器里。传统微服务搬到 K8s 里,网络行为和物理机上几乎一样——直接用 IP 互联,不处理端口映射,不关心 NAT 穿越。
二、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,所以:
- 所有容器看到的网络接口是一样的(同一个
eth0) - 所有容器的 IP 地址是一样的
- 容器之间可以通过
localhost直接通信 - 容器之间的端口不能冲突(因为是同一个 IP)
# 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。这一步会:
- 拉取 pause 镜像(如果本地没有)
- 调用 runc 创建 pause 容器
- runc 在创建过程中通过
clone(CLONE_NEWNET | CLONE_NEWUTS | CLONE_NEWIPC | ...)创建新的 namespace 集合 - 其中
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
端口映射方式,问题立刻爆发:
- 端口冲突:同一节点上几十个 Pod 不能用相同的 hostPort,端口管理是噩梦
- 调度约束:调度器要考虑端口占用,复杂度大幅增加
- 服务发现失效:Pod 地址变成
NodeIP:hostPort,重调度后地址就变了 - NetworkPolicy 不可能实现:源 IP 被 NAT 改掉,根据什么做访问控制?
- 可观测性断裂:日志里的 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 插件有不同的实现:
- bridge 插件(Flannel 默认):走 cni0 bridge
- Calico(默认):不用 bridge,直接用
veth pair + 宿主机路由表。每个 Pod 的 veth 直接连在 host
netns 里,靠
ip route路由 - Cilium:用 eBPF 在 veth 上直接做转发,可以 bypass bridge 和 iptables
# 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 和网络插件之间的接口:
- ADD:给容器配置网络(创建 veth、分配 IP、配路由)
- DEL:清理容器的网络(删除 veth、回收 IP)
- CHECK:检查容器的网络配置是否仍然有效
- VERSION:返回 CNI 插件支持的版本
{
"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:推荐阅读
- Kubernetes 官方文档:Cluster Networking
- CNI 规范:containernetworking/cni
- pause 容器源码:kubernetes/build/pause
- 本系列前置:虚拟网络设备、netfilter 与 iptables、路由与隧道
- 上一篇:eBPF 与网络
- 下一篇:CNI 规范详解
- 相关:虚拟网络设备
- 相关:Service 与 kube-proxy