5.6 容器网络与 Kubernetes 原语
容器里的 eth0 是从哪来的,包怎么走到宿主网卡?-p 8080:80 是开了一个代理进程还是改了 iptables?CNI 插件到底被谁调用、输入输出是什么?Kubernetes 的 Pod、Service、NetworkPolicy 分别落在哪些 Linux 原语上?
用 ip netns/veth/bridge 手工搭一套容器网络并做 NAT;看懂 Docker 与 kube-proxy 的 DNAT 规则;写一份 CNI conflist 与一条 NetworkPolicy;把 Pod/Service/Node 逐项映射到 namespace、veth、iptables、cgroup。
第 4 篇讲了包怎么从网卡进内核、怎么从 socket 发出去;这一章讲包的起点和终点被换掉了:容器有自己的网络栈,宿主要把 veth、bridge、路由、NAT 拼起来,才能让容器里的 eth0 看起来像一张真网卡。
容器网络没有专用内核模块。它复用的全是第 4 篇的零件:network namespace 提供独立的网卡/路由/端口空间,veth pair 像一根虚拟网线,bridge 像一台虚拟交换机,iptables/nftables 做 NAT 与 DNAT。Kubernetes 也没有发明新网络,它只是把这些零件按 Pod/Service 的语义组合起来,并约定 CNI 作为「插网线」的标准接口。
这一章先跟一个包从容器走到宿主,再手工搭一套最小网络,然后依次看端口映射、CNI、VXLAN、Service 与 NetworkPolicy。所有网络实验都在 root 下进行,清理命令会删掉 netns、veth、bridge 与 iptables 规则,请按顺序执行。
5.6.1 一个包从容器到宿主
先看 Docker 默认 bridge 网络的结构。容器里只有一张 eth0,它的对端是宿主上的一根 veth,插在 docker0 网桥上:
container netns(172.17.0.2/16)
┌──────────────────────────────┐
│ eth0 ──────┐ │
│ 默认路由 → 172.17.0.1 │
└────────────┼─────────────────┘
│ veth pair(一根虚拟网线)
┌────────────┼─────────────────┐
│ vethXXXX │ │
│ ┌─────▼──────┐ │
│ │ docker0 │ 172.17.0.1/16
│ └─────┬──────┘ │
│ │ │
│ 宿主路由表 │
│ │ │
│ POSTROUTING MASQUERADE │
│ │ │
│ 物理网卡 enp0s3 │
└────────────┼─────────────────┘
▼
外部网络
读这张图抓住三个「转换」:
- L2 转换:容器把帧发给
eth0,veth 把帧原样送到宿主侧的vethXXXX,后者挂在docker0上。bridge 根据 MAC 学习决定转发到哪个端口。 - L3 转换:容器不知道宿主的存在,它按默认路由把包发给
172.17.0.1(docker0的地址)。宿主打开ip_forward后按路由表转发。 - NAT 转换:包从物理网卡出去前,
POSTROUTING链把源地址172.17.0.2改成宿主地址,同时 conntrack 记下映射;回包按 conntrack 反向改回172.17.0.2。
容器与容器之间同网段通信不经过 NAT:bridge 直接按 MAC 转发。跨网段或出宿主才走路由 + MASQUERADE。
veth(virtual Ethernet)是一对双向连通的虚拟网卡:从一端发出的帧会立即从另一端收到。它没有「内部交换」,只是把两个网络栈接起来。把一端放进 network namespace、另一端留在宿主并挂到 bridge,就得到「容器一张网卡、宿主一根网线」的效果。
5.6.2 手工搭一套容器网络(root)
先不借助 Docker,用 ip 命令搭出「两个 netns + 一个 bridge + NAT」的最小网络。这样每一步都可验证,出问题也知道该看哪一层。
第一步:建 bridge 与两个 netns
# 需要 root
sudo ip netns add ns1
sudo ip netns add ns2
sudo ip link add br0 type bridge
sudo ip link set br0 up
sudo ip addr add 10.20.0.1/24 dev br0
第二步:给每个 netns 接一根 veth
# 需要 root;ns1
sudo ip link add veth1 type veth peer name veth1-ns
sudo ip link set veth1 master br0
sudo ip link set veth1 up
sudo ip link set veth1-ns netns ns1
sudo ip -n ns1 addr add 10.20.0.11/24 dev veth1-ns
sudo ip -n ns1 link set veth1-ns up
sudo ip -n ns1 link set lo up
sudo ip -n ns1 route add default via 10.20.0.1
# ns2
sudo ip link add veth2 type veth peer name veth2-ns
sudo ip link set veth2 master br0
sudo ip link set veth2 up
sudo ip link set veth2-ns netns ns2
sudo ip -n ns2 addr add 10.20.0.12/24 dev veth2-ns
sudo ip -n ns2 link set veth2-ns up
sudo ip -n ns2 link set lo up
sudo ip -n ns2 route add default via 10.20.0.1
第三步:验证二层与三层连通
# bridge 已学到两个 MAC
sudo bridge fdb show br br0 | grep -E 'veth1|veth2'
# netns 之间互 ping
sudo ip netns exec ns1 ping -c 2 10.20.0.12
# 看 ns1 的路由与邻居表
sudo ip -n ns1 route
sudo ip -n ns1 neigh
这一步不需要 ip_forward:同一网段内,bridge 直接按 MAC 转发,包不经过宿主路由。ping 通说明 veth、bridge、IP 配置都对。
第四步:加 NAT 让 netns 访问外网
# 需要 root;开启转发并给 10.20.0.0/24 做源地址伪装
sudo sysctl -w net.ipv4.ip_forward=1
sudo iptables -t nat -A POSTROUTING -s 10.20.0.0/24 ! -o br0 -j MASQUERADE
# 如果有外网,验证;没有就跳过
sudo ip netns exec ns1 ping -c 2 1.1.1.1
! -o br0 的作用是「从非 br0 接口出去的包才伪装」;如果不加,netns 互访的包也会被 MASQUERADE,虽然能通,但抓包时看不到原始源地址,排障会变难。
第五步:清理
# 先删规则,再删 netns(veth 会随 netns 一起消失)
sudo iptables -t nat -D POSTROUTING -s 10.20.0.0/24 ! -o br0 -j MASQUERADE
sudo ip netns del ns1
sudo ip netns del ns2
sudo ip link del br0
删除 netns 时,里面的 veth 端会被销毁,宿主侧的 peer 也会自动消失;br0 需要单独删。清理后用 ip netns list 与 ip link show type bridge 确认没有残留。
- 「veth 一端放进 netns 就自动 up」——不会。两端都要
ip link set up,netns 里的lo也要单独 up。 - 「同网段互 ping 需要 ip_forward」——不需要。bridge 是二层转发;
ip_forward只在跨网段路由时才需要。 - 「MASQUERADE 写在 PREROUTING」——源地址伪装必须放
POSTROUTING,因为要等路由决定出接口后才能选源地址。 - 「netns 里的默认路由可以写
dev veth1-ns」——可以写,但没有网关时无法到达其他网段;跨网段必须via 10.20.0.1。 - 「删了 veth 宿主端,netns 端会留着」——veth 是成对的,删任意一端,另一端一起消失;反过来,删 netns 也会带走里面的那端。
5.6.3 端口映射:DNAT、docker-proxy 与 hairpin
docker run -p 8080:80 让宿主 8080 的流量进容器 80。它不是「开一个端口转发进程」这么简单:主流路径是 iptables/nftables 的 DNAT,docker-proxy 只在少数场景兜底。
# 不需要 root(用户在 docker 组)
docker run -d --name web -p 8080:80 nginx:alpine
docker port web
# 80/tcp -> 0.0.0.0:8080
# 需要 root:看 nat 表的 DOCKER 链
sudo iptables -t nat -L DOCKER -n -v
# DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80
# 看是否有 docker-proxy 进程
ps -ef | grep docker-proxy | grep -v grep
docker rm -f web
DNAT 发生在 PREROUTING(外部流量)与 OUTPUT(本机流量)之后进入 DOCKER 链。包到达宿主 8080 时,目标地址被改写成 172.17.0.2:80,然后按容器网段路由到 docker0。回包时 conntrack 做反向转换,源地址看起来仍是宿主 8080。
三个容易混淆的点:
docker-proxy不是必须的。它处理「本机访问127.0.0.1:8080」以及某些 hairpin 场景;纯 DNAT 对来自外部的流量已经够用。可以用--userland-proxy=false关掉,观察行为差异。- hairpin NAT:容器自己访问「宿主 IP:8080」时,包从容器出发又回到同一 bridge,DNAT 后目标变成自己或同网段容器。某些配置需要额外 MASQUERADE 才能让回包走对路径;Docker 通过规则与 proxy 组合处理。
- Ubuntu 24.04 的 iptables 是 nft 后端。
iptables命令仍然可用,但底层是nf_tables;iptables-legacy与iptables-nft的规则不在同一处,排障时先iptables --version确认后端。
Kubernetes 的 Pod 网络假设「每个 Pod 有独立 IP,Pod 之间可直接互访」,不依赖 NAT。Service 的 ClusterIP 是另一层虚拟 IP,由 kube-proxy 做 DNAT;它与 Docker 的 -p 是两种不同的问题:前者解决「服务发现 + 负载均衡」,后者解决「把容器端口暴露到宿主」。
5.6.4 CNI:插件、配置与调用约定
CNI(Container Network Interface)不是守护进程,也不是网卡驱动,而是一份调用约定:容器运行时在创建/删除 Pod 网络时,执行 /opt/cni/bin 下的插件二进制,通过环境变量与 stdin 传参,插件返回 JSON 结果。
调用时最关键的环境变量:
| 变量 | 含义 |
|---|---|
CNI_COMMAND | ADD、DEL、CHECK、GC、STATUS |
CNI_CONTAINERID | 容器 ID |
CNI_NETNS | 容器 network namespace 的路径(/proc/<pid>/ns/net 或 /run/netns/...) |
CNI_IFNAME | 容器内要配置的网卡名,通常是 eth0 |
CNI_ARGS | 额外参数,如 K8S_POD_NAMESPACE=default;K8S_POD_NAME=web |
CNI_PATH | 插件搜索路径,如 /opt/cni/bin |
插件从 stdin 读网络配置,向 stdout 写结果。一个 conflist 可以串联多个插件:第一个负责建网卡与分配 IP,后面的负责端口映射、带宽、防火墙。
{
"cniVersion": "1.0.0",
"name": "mynet",
"plugins": [
{
"type": "bridge",
"bridge": "cni0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"ranges": [[{ "subnet": "10.244.0.0/24" }]]
}
},
{
"type": "portmap",
"capabilities": { "portMappings": true }
}
]
}
这段配置的含义是:创建 cni0 网桥,给容器接 veth 并从 10.244.0.0/24 分配 IP,开启 SNAT;再用 portmap 支持 hostPort。实际集群里,Calico、Cilium、Flannel 会用自己的插件替换 bridge,但调用约定相同。
在节点上观察 CNI 的落点:
# 需要 root 或对应读权限
ls /opt/cni/bin
cat /etc/cni/net.d/*.conflist
# kubelet 通过 CRI 调用运行时,运行时再调 CNI;看 Pod 的 sandbox
sudo crictl pods
sudo crictl inspectp "$(sudo crictl pods -q | head -1)" | jq '.status.network'
crictl inspectp 的输出里能看到 Pod 的 network namespace 路径与 IP。这个 netns 由 pause 容器持有;Pod 里的业务容器通过 setns 共享它(5.6.7 展开)。
- 「CNI 是一个程序」——是一组按约定调用的插件二进制;
bridge、host-local、portmap各是一个独立可执行文件。 - 「CNI 负责所有网络策略」——CNI 只负责「给容器接网线、分配 IP」;NetworkPolicy 是否实现、怎么实现,取决于具体 CNI 插件。
- 「CNI 配置改完立即生效」——已创建的 Pod 不会自动重配;要重建 Pod 或重启运行时。
- 「没有 CNI 集群也能跑 Pod」——Pod 会卡在
ContainerCreating,crictl inspectp里没有 IP;kubelet 事件会报network plugin not ready。
5.6.5 overlay 网络:VXLAN 与跨主机
单机 bridge 只能让同一宿主的容器互通。跨主机时,要么让底层网络知道 Pod 网段(路由/BGP),要么把二层帧封装进 UDP 里传(overlay)。VXLAN 是后者最常见的形式。
# 需要 root;在两台宿主上各建一个 VXLAN 设备(VNI 必须一致)
sudo ip link add vxlan0 type vxlan id 42 dev enp0s3 dstport 4789
sudo ip addr add 10.244.1.0/24 dev vxlan0
sudo ip link set vxlan0 up
sudo ip link set vxlan0 mtu 1450
# 添加远端 VTEP:目标宿主的外网 IP
sudo bridge fdb append 00:00:00:00:00:00 dev vxlan0 dst 192.168.1.12
# 查看封装参数
ip -d link show vxlan0
四个要点:
- VNI(VXLAN Network Identifier)是 24 位租户标识,两端必须一致;不一致时包能到但对端解不出正确的网络。
dstport 4789是 IANA 分配的 VXLAN 端口;有些实现用 8472(Flannel 旧默认),两端必须匹配。- MTU 要减 50 字节(外层 IP 20 + UDP 8 + VXLAN 8 + 内层以太网 14)。物理 MTU 1500 时,容器网卡通常设 1450,否则大包会被分片或丢弃,表现为「小包能通、大文件传输卡住」。
- underlay 必须能路由到对端 VTEP。VXLAN 不解决底层连通性;它只是把二层帧塞进 UDP。
跨主机方案的对照:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| bridge + 宿主路由 | 每个节点一条静态路由指向 Pod 网段 | 无封装、性能好 | 需要底层网络配合,规模大时路由表膨胀 |
| VXLAN overlay | 二层帧封装进 UDP | 不依赖底层、跨子网 | MTU 减 50、封装开销、排障多一层 |
| BGP(Calico) | 用 BGP 宣告 Pod 网段 | 无封装、可路由 | 需要网络设备支持,配置复杂 |
| IPIP/IPv6 | 三层隧道 | 简单 | 部分网络屏蔽协议号 |
5.6.6 Service:ClusterIP 是一次 DNAT
Kubernetes 的 Service 给一组 Pod 一个稳定虚拟 IP(ClusterIP)。这个 IP 不属于任何网卡,ping 它可能不通;它的作用是让 kube-proxy 在包路径上装一条 DNAT 规则,把「访问 VIP:port」改写成「访问某个 PodIP:port」。
# 只作观测;需要可用的集群与 kubeconfig
kubectl create deployment web --image=nginx:alpine --replicas=2
kubectl expose deployment web --port=80 --target-port=80
kubectl get svc web
kubectl get endpointslices -l kubernetes.io/service-name=web
# 需要节点 root:看 kube-proxy 装的 nat 规则
sudo iptables -t nat -L KUBE-SERVICES -n | grep web
sudo iptables -t nat -L KUBE-SVC-XXXXXXXX -n -v
sudo iptables -t nat -L KUBE-SEP-YYYYYYYY -n -v
规则的组织方式(iptables 模式):
KUBE-SERVICES
└─ 匹配 ClusterIP:port → 跳 KUBE-SVC-<hash>
├─ 1/2 概率 → KUBE-SEP-<pod1>
└─ 其余 → KUBE-SEP-<pod2>
└─ DNAT --to-destination <podIP>:<targetPort>
KUBE-SVC-* 用 statistic --mode random 在多个 endpoint 之间做选择,KUBE-SEP-* 执行 DNAT。选择发生在每条新连接上;同一条连接后续的包由 conntrack 固定到同一个 Pod,所以不会「一个请求里一半包去 A、一半去 B」。sessionAffinity: ClientIP 则是让 conntrack 按源 IP 保持更久。
不同模式的差别:
| 模式 | 数据面 | 特点 |
|---|---|---|
| iptables | nat 表规则 + conntrack | 成熟、通用;规则随 Service 数量线性增长 |
| IPVS | kube-ipvs0 虚拟网卡 + IPVS | 查找是哈希表,规模大时更快;需要 ip_vs 模块 |
| nftables | nft 规则集 | 新版本提供,语义相同,规则组织更紧凑 |
| eBPF | Cilium 等替换 kube-proxy | 绕过 netfilter,可做 L7 与策略 |
Service 的几种类型只是在同一套 DNAT 上多加一层:
- ClusterIP:集群内 VIP,kube-proxy 装 DNAT。
- NodePort:在每个节点开一个端口,
KUBE-NODEPORTS链把nodeIP:nodePortDNAT 到 Pod。 - LoadBalancer:云厂商分配外部 LB,转发到 NodePort 或 Pod。
- Headless(
clusterIP: None):不分配 VIP,DNS 直接返回 Pod IP,客户端自己做负载均衡;kube-proxy 不装规则。 - ExternalName:CNAME 到外部域名,没有 DNAT。
- 「ClusterIP 能 ping 通」——iptables 模式下 VIP 没有接口,ICMP 不会被 DNAT,
ping通常不通;TCP 能通不代表 ICMP 能通。 - 「Service 是轮询」——iptables 模式按连接随机选择,不是严格轮询;长连接会一直落在同一个 Pod。
- 「Service 的 DNAT 在容器里做」——在宿主的 netfilter 里做(Pod 所在节点的 PREROUTING/OUTPUT),容器不感知 VIP。
- 「NodePort 的包一定从 nodeIP 进」——也可能从 Pod 所在节点之外进入,取决于
externalTrafficPolicy与路由。 - 「改完 Service,已有连接立即切走」——conntrack 会保留已有连接;要等连接关闭或 conntrack 过期。
5.6.7 Pod、Service、Node 与 Linux 原语的映射
Kubernetes 的每个概念都能在 Linux 里找到落点。把这张表记住,排障时就不会在「YAML 层」打转:
| Kubernetes 概念 | Linux 原语 | 说明 |
|---|---|---|
| Node | 一台宿主内核 + kubelet + 容器运行时 | 所有 Pod 共享这个内核 |
| Pod(sandbox) | pause 容器 + 独立 net/ipc/uts namespace + cgroup | sandbox 持有网络栈,业务容器加入 |
| Pod 内容器 | 同一 cgroup 下的多个进程组 | 共享 network/ipc/uts;默认不共享 PID/mount |
| Pod IP | sandbox netns 里的 veth/eth0 | 由 CNI 分配,跨节点可路由 |
| Service | iptables/nftables DNAT 或 IPVS | kube-proxy 维护,VIP 不在网卡上 |
| K8s Namespace | API Server 的逻辑隔离 | 不是 Linux namespace(见 errata) |
| NetworkPolicy | CNI 实现的 iptables/nft/eBPF 规则 | 默认拒绝 + 追加允许 |
| requests/limits | cpu.weight/cpu.max、memory.max、pids.max | 由 kubelet → 运行时 → cgroup |
| emptyDir | bind mount 或 tmpfs | 随 Pod 生命周期存在 |
| Secret/ConfigMap | tmpfs/投影卷 + bind mount | 挂进容器时是文件 |
| hostPath | bind mount 宿主路径 | 高危,等同于把宿主文件系统交给 Pod |
hostNetwork: true | 加入宿主 net namespace | Pod 直接用宿主 IP 与端口 |
shareProcessNamespace | 加入同一 PID namespace | Pod 内容器互相看见进程 |
terminationGracePeriodSeconds | SIGTERM + 超时 + SIGKILL | 与 docker stop 同一套语义 |
| SecurityContext | capabilities/seccomp/no_new_privs/uid | 落到 OCI config.json 的 process/linux |
Pod 的网络共享方式值得单独说:kubelet 先让运行时创建 sandbox(pause 容器),由 CNI 给 sandbox 的 netns 配好 eth0 与 IP;然后业务容器以 linux.namespaces 里 path: /proc/<sandbox-pid>/ns/net 的方式加入同一个 netns。所以同一个 Pod 里的容器共享 localhost 与端口空间,但各自有独立的 mount namespace 与 rootfs。
# 需要节点 root;观察 Pod 的 sandbox 与业务容器
SANDBOX=sandbox-id CONTAINER=container-id SPID=sandbox-pid # 换成 crictl 输出里的实际值
sudo crictl pods
sudo crictl ps
sudo crictl inspectp "$SANDBOX" | jq '.status.network'
sudo crictl inspect "$CONTAINER" | jq '.info.runtimeSpec.linux.namespaces'
# 进入 Pod 网络命名空间抓包
sudo nsenter -t "$SPID" -n ip -brief addr
sudo nsenter -t "$SPID" -n tcpdump -i any -nn -c 5
crictl inspect 的 runtimeSpec 就是 OCI config.json 的运行时展开版;你能在里面看到 linux.namespaces、process.capabilities、linux.seccomp。第 5.5 节学的字段,在这里原样出现。
- 「Kubernetes namespace 就是 Linux namespace」——完全不同的东西。K8s Namespace 是 API 对象的租户分组;Linux namespace 是内核的视图隔离。一个 K8s Namespace 下的 Pod 可以分布在多个 Linux namespace 里。
- 「Pod 是一个容器」——Pod 是一个 sandbox + 一组容器;pause 容器不跑业务,只持有 netns 与 IPC。
- 「Pod 内容器共享所有 namespace」——默认只共享 network/ipc/uts;mount 与 PID 各自独立(除非
shareProcessNamespace)。 - 「Service 的 VIP 能在
ip addr里看到」——iptables 模式看不到;IPVS 模式会在kube-ipvs0上看到 VIP,这是两种模式的可见差异。
5.6.8 NetworkPolicy 与包过滤
NetworkPolicy 是 Kubernetes 的 L3/L4 防火墙抽象。它的语义是追加允许:只要有一个策略选中了 Pod,该 Pod 的对应方向就变成默认拒绝,然后逐条匹配允许规则。
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: demo
spec:
podSelector: {}
policyTypes: ["Ingress", "Egress"]
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns
namespace: demo
spec:
podSelector: {}
policyTypes: ["Egress"]
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
第一条把 demo 里所有 Pod 的入站与出站都设为默认拒绝;第二条只放行到 kube-system 的 UDP 53(DNS)。两条策略叠加后,Pod 只能发 DNS,其他出站与所有入站都被拒绝。
实现方式取决于 CNI:
| CNI | 数据面 | 特点 |
|---|---|---|
| Calico | iptables 或 eBPF | 支持 Policy,规则按 selector 生成 |
| Cilium | eBPF | 支持 L3/L4 与部分 L7,性能好 |
| Flannel | 不实现 NetworkPolicy | 需要额外组件(如 Calico policy-only) |
| 云厂商 CNI | 安全组/网络 ACL | 行为可能与 Kubernetes 语义有差异 |
# 只作观测
kubectl get networkpolicy -n demo
kubectl describe networkpolicy default-deny -n demo
kubectl get pods -n demo --show-labels
排障时先确认「策略是否被 CNI 实现」:kubectl describe 能看到对象,但不代表数据面有规则。到节点上看 iptables/eBPF map,或看 CNI 的日志,才能确认。
L7 策略需要解析 HTTP/gRPC,代价高、语义复杂(mTLS、路径、方法),而且不同协议差异大。Kubernetes 把 L7 留给 Service Mesh(Istio/Linkerd)或 Cilium 的 L7 Policy。L3/L4 策略足够表达「谁能连谁」,也是多数合规要求的落点。
5.6.9 veth 与 bridge 的内核路径
这一节回答:一个帧从容器 eth0 发出来后,在内核里经过哪些函数、在哪些结构体上被转发或丢弃。
veth 不是「一根线」,而是一对 net_device,各自属于一个网络命名空间。发送方的 ndo_start_xmit 是 veth_xmit(),它取出 peer,把 skb 交给 veth_forward_skb(),最终用 netif_rx() 或 dev_forward_skb() 注入对端网络栈。整个过程不复制数据,只转移 skb 的所有权。
bridge 是一个软件交换机:br_handle_frame() 在入口处理帧,br_fdb_update() 学习源 MAC,br_forward() 按目的 MAC 查 FDB 并从对应端口发出;查不到就 br_flood() 泛洪。FDB 表项有老化时间,默认 300 秒,由 br_fdb_cleanup() 周期清理。
container eth0(veth1-ns)
│ dev_queue_xmit
▼
veth_xmit() → veth_forward_skb() → netif_rx()
│ 对端 veth1 在宿主 netns
▼
__netif_receive_skb_core() → br_handle_frame()
│ br_fdb_update() 学源 MAC
│ br_forward() 查目的 MAC
▼
veth2(去 ns2)或宿主路由(去外部)
内核里与这条路径相关的结构体:
| 结构体 | 定义 | 关键字段 | 谁维护 |
|---|---|---|---|
net_device | include/linux/netdevice.h | name、ifindex、flags、mtu、dev_addr、netdev_ops | 驱动注册,内核分配 |
veth_priv | drivers/net/veth.c | peer(对端 net_device)、rq(每队列状态)、xdp_prog | veth 驱动 |
net_bridge | net/bridge/br_private.h | port_list、fdb_hash_tbl、dev、ageing_time、stp_enabled | bridge 模块 |
net_bridge_fdb_entry | 同上 | dst(端口)、key.addr、key.vlan_id、flags、used | FDB 学习/老化 |
net_bridge_port | 同上 | dev(挂载的网卡)、state、flags | 端口加入/离开 |
对应的内核文件:
| 文件 | 函数 | 作用 |
|---|---|---|
drivers/net/veth.c | veth_newlink()、veth_xmit()、veth_forward_skb() | 创建 veth 对、转发帧 |
net/core/dev.c | dev_queue_xmit()、__netif_receive_skb_core() | 通用发送/接收入口 |
net/bridge/br_input.c | br_handle_frame() | bridge 入口,处理 STP/组播/转发 |
net/bridge/br_fdb.c | br_fdb_update()、br_fdb_lookup() | MAC 学习与查找 |
net/bridge/br_forward.c | br_forward()、br_flood() | 单播转发与泛洪 |
net/bridge/br_device.c | br_dev_xmit() | 从 bridge 自身发出(宿主路由) |
观测这套路径不需要抓包器,先看计数器:
# 需要 root;veth 的收发/丢包计数
ip -s link show veth1
ethtool -S veth1 2>/dev/null | head -20
# bridge 的端口状态与 FDB
bridge link show br0
bridge fdb show br br0 | head
bridge -s fdb show br br0 | head # 带命中计数
# 内核丢包原因(需要 root)
nstat -az | grep -Ei 'drop|no_route|noroute' | head
bridge fdb show 里的 self 表示 bridge 自身的 FDB,master br0 表示学习到的端口项;permanent 是静态项,dynamic 会老化。看到 00:00:00:00:00:00 的项不要惊讶,那是 VXLAN 的默认远端项或未学习时的占位。
用 bpftrace 看一次 ping 走了哪些函数:
# 需要 root;另一个终端在 ns1 里 ping ns2
sudo bpftrace -e '
kprobe:veth_xmit { @veth[comm] = count(); }
kprobe:br_handle_frame { @br[comm] = count(); }
kprobe:br_fdb_update { @fdb[comm] = count(); }
interval:s:5 { exit(); }'
如果 veth_xmit 的计数为 0 而 br_handle_frame 有值,说明帧没有从容器发出来(可能是容器内路由/网卡 down);两者都为零,检查 ip link 与 netns 是否正确。
veth 的 offload 也会影响行为。ethtool -k veth1 里 tx-checksumming、tso、gso 默认开着,抓包时看到的可能是「未校验和的大包」;用 tcpdump -i veth1 -vv 看 bad cksum 不代表链路有问题,而是校验和卸载到对端才计算。排查 MTU 时先 ip link show 看 mtu,再用 ping -M do -s 验证。
5.6.10 CNI 协议深挖与实验一
这一节回答:CNI 插件被调用时到底收到什么、返回什么,以及如何不经过 Kubernetes 手工跑一次 ADD。
CNI 的协议只有「环境变量 + stdin + stdout + 退出码」四件事:
| 输入/输出 | 内容 | 说明 |
|---|---|---|
CNI_COMMAND | ADD/DEL/CHECK/GC/STATUS/VERSION | CHECK 从 0.4.0 起,GC/STATUS 从 1.2.0 起 |
CNI_CONTAINERID | 容器 ID | 必须唯一,用于缓存与幂等 |
CNI_NETNS | netns 路径 | 通常是 /proc/<pid>/ns/net 或 /var/run/netns/<name> |
CNI_IFNAME | 容器内网卡名 | 通常 eth0,必须符合内核接口名规则 |
CNI_ARGS | K=V;K=V | Kubernetes 传 K8S_POD_NAMESPACE/K8S_POD_NAME 等 |
CNI_PATH | 插件搜索路径 | 如 /opt/cni/bin,委托 IPAM 时也用它 |
| stdin | 网络配置 JSON | 单个 plugin 配置或 conflist 展开后的配置 |
| stdout | 结果 JSON | cniVersion、interfaces、ips、routes、dns |
| 退出码 | 0 成功,非 0 失败 | 失败时 stdout 应是错误 JSON:code、msg、details |
一个 conflist 的每个插件都会被依次执行,前一个插件的输出会作为后一个的输入(通过 prevResult)。ADD 从第一个插件跑到最后一个;DEL 逆序执行;CHECK 与 ADD 同序、逐层校验 prevResult。主插件(bridge、macvlan、ipvlan、ptp)负责建网卡,IPAM 插件(host-local、dhcp、static)负责分配地址,meta 插件(portmap、bandwidth、tuning、firewall)负责附加能力。
host-local 的租约存在 dataDir(默认 /var/lib/cni/networks/<network>)下,每个 IP 一个文件,内容是第一行容器 ID、第二行 IFNAME。这解释了两种常见故障:容器被强杀后租约没释放,新 Pod 可能拿到同一个 IP;DEL 时找不到租约,插件报 failed to delete IP。清理时要同时删租约文件与 netns。
下面的实验直接调用 bridge + host-local,不经过 Docker 或 Kubernetes。需要 root、ip 命令与 CNI 插件(Ubuntu 包 containernetworking-plugins 提供 /opt/cni/bin)。
#!/usr/bin/env bash
# cni-lab.sh — 手工执行 CNI ADD/DEL,验证网卡、IP 与租约
set -euo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root"; exit 1; }
CNI_BIN=${CNI_BIN:-/opt/cni/bin}
[[ -x "$CNI_BIN/bridge" && -x "$CNI_BIN/host-local" ]] || {
echo "缺少 CNI 插件:$CNI_BIN/{bridge,host-local}"; exit 1; }
NS=cni-lab
BR=cni-br0
CID=lab-$$
D=/tmp/cni-lab
cleanup() {
ip netns del "$NS" 2>/dev/null || true
ip link del "$BR" 2>/dev/null || true
rm -rf "$D"
}
trap cleanup EXIT
mkdir -p "$D/ipam"
ip netns add "$NS"
ip netns exec "$NS" ip link set lo up
cat > "$D/netconf.json" <<'JSON'
{
"cniVersion": "1.0.0",
"name": "cni-lab",
"type": "bridge",
"bridge": "cni-br0",
"isGateway": true,
"ipMasq": true,
"ipam": {
"type": "host-local",
"dataDir": "/tmp/cni-lab/ipam",
"ranges": [[{ "subnet": "10.88.0.0/24" }]]
}
}
JSON
export CNI_CONTAINERID="$CID" CNI_NETNS="/var/run/netns/$NS" \
CNI_IFNAME=eth0 CNI_PATH="$CNI_BIN"
CNI_COMMAND=ADD "$CNI_BIN/bridge" < "$D/netconf.json" > "$D/result.json"
jq '{cniVersion, interfaces, ips, routes}' "$D/result.json"
addr=$(jq -r '.ips[0].address' "$D/result.json")
echo "[ok] 分配地址:$addr"
ip netns exec "$NS" ip -brief addr show eth0
ip netns exec "$NS" ping -c 2 -W 1 10.88.0.1
cat "$D/ipam/cni-lab/"* 2>/dev/null || true
CNI_COMMAND=DEL "$CNI_BIN/bridge" < "$D/netconf.json" > /dev/null
ip netns exec "$NS" ip link show eth0 2>&1 || echo "[ok] eth0 已删除"
预期结果:result.json 的 ips[0].address 是 10.88.0.2/24 之类的地址,interfaces 里同时有宿主侧 vethXXX 与容器侧 eth0;ping 10.88.0.1 通;租约目录里出现以 IP 命名的文件;DEL 后容器里的 eth0 消失。如果 bridge 报 failed to set bridge addr,说明 cni-br0 已存在且地址冲突,先 ip link del cni-br0。
注意 CNI_NETNS 用的是 /var/run/netns/cni-lab,这是 ip netns add 创建的 bind mount 路径;用 /proc/<pid>/ns/net 也可以,但要求进程存活。DEL 必须传与 ADD 相同的 CNI_CONTAINERID 与配置,否则插件找不到缓存,可能留下网卡。
- 「
ADD失败可以重试」——可以,但插件必须幂等;bridge会用容器 ID 找已有 veth,重复ADD可能报already exists。重试前先DEL。 - 「
DEL不需要配置」——需要。DEL用配置里的bridge、ipam与CNI_IFNAME定位资源;只传环境变量不传 stdin 会失败。 - 「
CHECK会修复问题」——不会,它只检查并返回错误;修复要靠DEL+ADD。 - 「CNI 结果里的
ips就是 Pod IP」——单网卡场景通常是;多网卡或双栈时ips可能有多条,要按interface索引与地址族筛选。
5.6.11 Service 数据面:iptables、IPVS 与 eBPF
这一节回答:KUBE-SVC/KUBE-SEP 这些链具体怎么排、IPVS 模式差在哪、eBPF 又替换了哪一层。
iptables 模式下,kube-proxy 在 nat 表里维护一棵四层链:
| 链 | 作用 | 关键规则 |
|---|---|---|
KUBE-SERVICES | 所有 Service 的总入口 | 按 ClusterIP:port 匹配,跳 KUBE-SVC-<hash> |
KUBE-SVC-<hash> | 一个 Service 的负载均衡 | statistic --mode random 按概率选 endpoint |
KUBE-SEP-<hash> | 一个 endpoint | KUBE-MARK-MASQ + DNAT --to-destination <podIP>:<port> |
KUBE-NODEPORTS | NodePort 入口 | 匹配 nodePort 后跳对应 KUBE-SVC |
KUBE-MARK-MASQ | 打标记 | MARK --set-xmark 0x4000/0x4000 |
KUBE-POSTROUTING | 出站伪装 | 对带标记的包 MASQUERADE |
KUBE-FIREWALL/KUBE-FORWARD | filter 表兜底 | 丢弃非法状态、放行转发 |
一条真实规则的样子(注释与 hash 因集群而异):
-A KUBE-SERVICES -d 10.96.0.10/32 -p tcp -m comment --comment "default/kubernetes:https" -m tcp --dport 443 -j KUBE-SVC-XXXX
-A KUBE-SVC-XXXX -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-AAAA
-A KUBE-SVC-XXXX -j KUBE-SEP-BBBB
-A KUBE-SEP-AAAA -s 10.244.1.5/32 -p tcp -m tcp --dport 6443 -j KUBE-MARK-MASQ
-A KUBE-SEP-AAAA -p tcp -m tcp --dport 6443 -j DNAT --to-destination 10.244.1.5:6443
statistic 只作用于新连接:nat 表只对连接的第一个包做 DNAT,后续包由 conntrack 固定到同一 endpoint。最后一个 endpoint 不再写概率,直接跳转,保证所有流量都有归宿;没有 endpoint 时规则改成 REJECT --reject-with icmp-port-unreachable。
IPVS 模式把「按概率跳转」换成内核的 IPVS 哈希表:
# 需要节点 root;IPVS 模式才有 kube-ipvs0
ip addr show kube-ipvs0 | head
sudo ipvsadm -Ln
# TCP 10.96.0.10:443 rr
# -> 10.244.1.5:6443 Masq 1 0 0
# -> 10.244.2.7:6443 Masq 1 0 0
sudo ipvsadm -Ln --stats
kube-ipvs0 是一张 dummy 网卡,kube-proxy 把每个 ClusterIP 都挂上去,让本机路由认为 VIP 是本地地址;真正的转发由 IPVS 完成。IPVS 支持 rr、wrr、lc、wlc、sh 等调度器,kube-proxy 默认 rr,可用 --ipvs-scheduler 改。IPVS 模式仍然需要 iptables 做 masquerade、NodePort 的某些路径与防火墙兜底,所以看到 KUBE-* 链不要以为模式没生效。
三种数据面的对照:
| 维度 | iptables | IPVS | eBPF(Cilium) |
|---|---|---|---|
| 查找复杂度 | O(规则数) | O(1) 哈希 | O(1) map,socket 层可直连 |
| 规则规模 | 随 Service/Endpoint 线性增长 | 与数量弱相关 | map 条目,无链式遍历 |
| 连接跟踪 | conntrack | conntrack(可配) | 可绕过 conntrack |
| 负载均衡 | 随机概率 | rr/wrr/lc 等 | Maglev/随机,可一致性哈希 |
| L7 策略 | 不支持 | 不支持 | 支持(Envoy) |
| 可观测性 | iptables -vnL、计数器 | ipvsadm -Ln --stats | cilium monitor、Hubble |
| 主要限制 | 大集群规则多、更新慢 | 需要 ip_vs 模块 | 内核版本、eBPF 校验器、排障门槛 |
eBPF 不是「更快的 iptables」,而是把 hook 点前移:Cilium 在 tc/XDP 处理包,在 socket connect/sendmsg 时就能决定后端,绕过 netfilter 与 conntrack。它用 identity(标签)而不是 IP 做策略,Pod 重建换 IP 不影响策略;代价是内核依赖更强、抓包位置更靠前、排障要熟悉 BPF map。
- 建
svc-br0与cli/srv两个 netns,配10.99.0.0/24。 srv里起 8080 的 HTTP 服务;cli里把 VIP10.99.0.100挂到lo。cli的 natOUTPUT链加 DNAT:VIP:80 → 10.99.0.2:8080。- 从
cli里curl VIP:80,断言返回 200;看 iptables 计数器与 conntrack。 需要 root、iptables、python3、curl。
#!/usr/bin/env bash
# svc-dnat-lab.sh — 在 netns 里复现 ClusterIP 的 DNAT
set -euo pipefail
[[ ${EUID:-$(id -u)} -eq 0 ]] || { echo "需要 root"; exit 1; }
for c in ip iptables python3 curl; do
command -v "$c" >/dev/null || { echo "缺少 $c"; exit 1; }
done
BR=svc-br0
cleanup() {
[[ -n ${SRV_PID:-} ]] && kill "$SRV_PID" 2>/dev/null || true
ip netns del cli 2>/dev/null || true
ip netns del srv 2>/dev/null || true
ip link del "$BR" 2>/dev/null || true
}
trap cleanup EXIT
ip netns add cli
ip netns add srv
ip link add "$BR" type bridge
ip link set "$BR" up
ip addr add 10.99.0.1/24 dev "$BR"
for pair in "cli 10.99.0.11" "srv 10.99.0.2"; do
set -- $pair
ns=$1; ip4=$2
ip link add "veth-$ns" type veth peer name "veth-$ns-ns"
ip link set "veth-$ns" master "$BR"
ip link set "veth-$ns" up
ip link set "veth-$ns-ns" netns "$ns"
ip -n "$ns" addr add "$ip4/24" dev "veth-$ns-ns"
ip -n "$ns" link set "veth-$ns-ns" up
ip -n "$ns" link set lo up
ip -n "$ns" route add default via 10.99.0.1
done
# 后端:8080 上的 HTTP 服务
ip netns exec srv python3 -m http.server 8080 --bind 0.0.0.0 >/dev/null 2>&1 &
SRV_PID=$!
sleep 1
# 客户端:VIP 挂在 lo 上(等价于 kube-ipvs0 的作用),OUTPUT 链做 DNAT
ip -n cli addr add 10.99.0.100/32 dev lo
ip netns exec cli iptables -t nat -A OUTPUT \
-d 10.99.0.100/32 -p tcp --dport 80 \
-j DNAT --to-destination 10.99.0.2:8080
code=$(ip netns exec cli curl -s --noproxy '*' -o /dev/null -w '%{http_code}' --max-time 5 \
http://10.99.0.100:80/)
echo "HTTP code via VIP: $code"
[[ "$code" == 200 ]] || { echo "DNAT 未生效"; exit 1; }
ip netns exec cli iptables -t nat -L OUTPUT -n -v
ip netns exec cli conntrack -L 2>/dev/null | grep 8080 || true
预期输出 HTTP code via VIP: 200,iptables -t nat -L OUTPUT -v 的 DNAT 规则计数增加;如果装了 conntrack-tools,还能看到 10.99.0.100:80 被映射到 10.99.0.2:8080 的连接。这个实验与 kube-proxy 的差别只在「规则由谁维护」:kube-proxy 监听 Service/EndpointSlice 变化,自动增删同样的 DNAT 规则。
5.6.12 Pod、Namespace 与 NetworkPolicy 的语义细节
这一节回答:Kubernetes 的 Namespace 与 Linux namespace 到底怎么区分,Pod 共享哪些命名空间,NetworkPolicy 的「或/与」怎么写。
先彻底分开两个同名概念:
| 维度 | Kubernetes Namespace | Linux namespace |
|---|---|---|
| 本质 | API Server 里的对象,etcd 中的路径前缀 | 内核的视图隔离机制 |
| 作用 | 资源分组、RBAC、配额、名字作用域 | 进程看到的 PID/网络/挂载等 |
| 创建方式 | kubectl create ns demo | unshare/clone/setns |
| 删除后果 | 级联删除其中的对象 | 最后一个进程退出后内核释放 |
| 与 Pod 的关系 | Pod 属于某个 K8s Namespace | Pod 的容器各自加入一组 Linux namespace |
一个 K8s Namespace 下的 Pod 可以分布在任意节点、任意 Linux netns 里;反过来,同一个 Linux netns 里的容器可以属于不同的 K8s Namespace(只要调度到同一 Pod)。排障时不要把 kubectl get ns 的输出当成内核 namespace 列表。
Pod 的共享关系由 sandbox 决定:
| 配置 | 共享的 Linux namespace | 效果 |
|---|---|---|
| 默认 Pod | network、ipc、uts | 同 Pod 容器共享 IP、端口、hostname |
shareProcessNamespace: true | 再加 pid | 容器间能看见彼此进程,PID 1 是 pause |
hostNetwork: true | 加入宿主 netns | Pod 用宿主 IP,不调用 CNI |
hostPID: true | 加入宿主 pid ns | 看见宿主进程,高危 |
hostIPC: true | 加入宿主 ipc ns | 共享 SysV IPC |
hostNetwork 的 Pod 没有独立 IP,kubectl get pod -o wide 显示的是节点 IP;它的 dnsPolicy 通常要设成 ClusterFirstWithHostNet,否则 DNS 解析走宿主配置。shareProcessNamespace 下,业务容器的 /proc/1 是 pause,不是自己的主进程;依赖「自己是 PID 1」做信号处理的应用会受影响。
NetworkPolicy 的匹配规则只有三条,但组合起来容易写错:
| 写法 | 含义 |
|---|---|
podSelector: {} | 选中本 Namespace 的所有 Pod |
namespaceSelector: {} | 选中所有 Namespace |
from: [{podSelector: A, namespaceSelector: B}] | A 且 在 B 中(同一元素的字段是 AND) |
from: [{podSelector: A}, {namespaceSelector: B}] | A 或 B(不同元素是 OR) |
ipBlock: {cidr: 10.0.0.0/8, except: [10.1.0.0/16]} | CIDR 减去 except |
ports: [{protocol: TCP, port: 80, endPort: 90}] | 80–90 端口段(endPort 1.27 稳定) |
策略是追加允许:只要有一个策略选中 Pod 的某个方向,该方向先默认拒绝,再按所有策略的允许规则取并集。没有策略选中时默认全放行。策略没有顺序,也没有 deny 规则;想「只拒绝某个 IP」只能「默认拒绝 + 放行其余」,不能写一条单独的拒绝。
一个常见的漏配:加了 default-deny egress 后忘了放行 DNS,Pod 里所有域名解析都超时。DNS 的出站规则要同时放行 UDP 53 与 TCP 53(大响应会走 TCP),目标可以是 kube-system 的 namespaceSelector,也可以写 CoreDNS 的 Pod 标签。
实现层面对照:
| CNI | 数据面 | NetworkPolicy | 备注 |
|---|---|---|---|
| Calico | iptables 或 eBPF | 支持 | 规则按 selector 生成,可看 calicoctl |
| Cilium | eBPF | 支持,含部分 L7 | identity 级策略,Hubble 可观测 |
| Antrea | OVS | 支持 | 基于 Open vSwitch 流表 |
| Flannel | 无 | 不支持 | 需要额外装 Calico policy-only |
| 云厂商 CNI | 安全组/ACL | 视实现 | 语义可能与 K8s 有差异 |
eBPF 的 hook 点决定了它能看到什么:
| hook | 位置 | 典型用途 |
|---|---|---|
| XDP | 网卡驱动最早 | DDoS 清洗、快速丢弃 |
| tc ingress/egress | 协议栈收发 | Cilium 的主战场:转发、NAT、策略 |
| socket connect/sendmsg | 应用 syscall | kube-proxy 替换、服务发现 |
| cgroup skb | cgroup 层 | 按 Pod 限流/审计 |
| kprobe/tracepoint | 内核函数 | 观测,不参与转发 |
Cilium 的 kube-proxy replacement 就是在 socket connect 时直接选后端,包还没进 netfilter 就已经决定去向;这让它在大规模 Service 下避免 iptables 的线性遍历。代价是它依赖较新的内核与 BPF 特性,排障要会看 cilium bpf lb list、cilium monitor,而不是 iptables -L。
5.6.13 四个让包静默消失的开关:rp_filter、br_netfilter、conntrack、MTU
Pod 里 ping 得通自己、却连不上 Service;同节点两个 Pod 互相访问正常,跨节点就超时。这类问题经常不是 CNI 配错,而是四个内核开关按默认值工作。它们的共同点是不报错、只计数,所以必须先知道去哪看。
| 开关 | 默认/典型值 | 作用点 | 症状 | 排查 |
|---|---|---|---|---|
net.ipv4.ip_forward | 新 netns 里为 0 | ip_forward() 的入口判断 | 以 Pod 为路由的流量被丢 | sysctl net.ipv4.ip_forward |
net.ipv4.conf.*.rp_filter | 0/1/2,发行版不同(RHEL 常 1,Ubuntu 常见 2) | fib_validate_source() | 非对称路由的回包被当 martian 丢弃 | nstat -az | grep -i addr |
net.bridge.bridge-nf-call-iptables | 未加载时无此文件;加载后默认 1 | bridge 把帧送进 IPv4 钩子 | FORWARD/NetworkPolicy 对同节点 Pod 流量不生效 | lsmod | grep br_netfilter |
net.netfilter.nf_conntrack_max | 随内存缩放,常见 262144 | conntrack 哈希表容量 | 表满后新连接被丢,dmesg 有 table full | conntrack -C、sysctl |
nf_conntrack_tcp_timeout_established | 432000 秒(5 天) | conntrack 老化 | 长连接占满表、NAT 端口耗尽 | conntrack -L 的 ESTABLISHED |
| veth/bridge MTU | 1500 | 发送路径分片/DF | 大包黑洞,小包正常 | ip link、ping -M do -s |
| VXLAN/Geneve 开销 | 50 / 50+ 字节 | 封装后外层包变大 | underlay 1500 → overlay 1450 | ip -d link show vxlan0 |
net.ipv4.tcp_mtu_probing | 0 | PMTU 黑洞探测 | TCP 大包卡住、握手后无数据 | sysctl、ss -ti 的 mss |
rp_filter 的三个值要分清:0 不检查;1 严格模式,回包必须从「去程使用的同一网卡」进来;2 宽松模式,只要求源地址在任意网卡上可达。容器节点常有多张网卡、VXLAN 设备与策略路由,严格模式会把合法的非对称回包丢掉。内核在 fib_validate_source() 里用 IN_DEV_RPFILTER() 取当前值,失败返回 -EINVAL,上层把它记成输入错误,不发 ICMP。
br_netfilter 是另一类陷阱:Linux bridge 默认是纯二层转发,帧不经过 IPv4 的 FORWARD 链。只有加载 br_netfilter 并把 bridge-nf-call-iptables 设为 1,桥接帧才会被送进 netfilter。Kubernetes 的 NetworkPolicy 实现(Calico、Cilium 的 iptables 模式)依赖这一点;如果 kubelet 的 sysctl 没生效,策略看起来「装了但不拦」。
内核路径:一个转发包经过的钩子
ip_rcv() net/ipv4/ip_input.c
→ NF_INET_PRE_ROUTING
→ nf_conntrack_in() net/netfilter/nf_conntrack_core.c
→ ip_rcv_finish() → ip_route_input_noref() net/ipv4/route.c
→ fib_validate_source() net/ipv4/fib_frontend.c ← rp_filter
→ ip_forward() net/ipv4/ip_forward.c
→ NF_INET_FORWARD ← iptables FORWARD / NetworkPolicy
→ ip_output() → NF_INET_POST_ROUTING
→ nf_conntrack_confirm() ← 只有确认后才进入 conntrack 表
bridge 路径:
br_handle_frame() net/bridge/br_input.c
→ br_pass_frame_up() / br_forward()
→(br_netfilter 加载且 bridge-nf-call-iptables=1)
br_nf_pre_routing() → ip_rcv() → 上面的 IPv4 钩子
VXLAN 封装:
vxlan_xmit() drivers/net/vxlan/vxlan_core.c
→ vxlan_build_skb() → udp_tunnel_xmit_skb() (外层 +50 字节)
这张路径图解释了三个现象:rp_filter 在路由决策里执行,所以策略路由和多网卡会让它误判;FORWARD 只在路由转发时经过,纯二层桥接必须靠 br_netfilter 才进得来;conntrack 在 PRE_ROUTING 建表项、在 POST_ROUTING 确认,NAT 只在第一个包上做,后续包靠表项命中。
实验一:br_netfilter 决定 FORWARD 链是否管桥接流量
需要 root、iptables、conntrack 与 br_netfilter 模块。两个 netns 接在同一 bridge 上,用一条 FORWARD DROP 规则观察它是否生效。
ip netns add n1; ip netns add n2
ip link add br0 type bridge; ip link set br0 up
ip link add v1 type veth peer name v1b; ip link set v1 netns n1
ip link add v2 type veth peer name v2b; ip link set v2 netns n2
ip link set v1b master br0; ip link set v2b master br0
ip link set v1b up; ip link set v2b up
ip addr add 10.20.0.1/24 dev br0
ip -n n1 addr add 10.20.0.2/24 dev v1; ip -n n1 link set v1 up
ip -n n2 addr add 10.20.0.3/24 dev v2; ip -n n2 link set v2 up
ip netns exec n1 ping -c1 -W1 10.20.0.3
# 1 packets transmitted, 1 received
modprobe br_netfilter
sysctl -qw net.bridge.bridge-nf-call-iptables=0
iptables -A FORWARD -s 10.20.0.2 -d 10.20.0.3 -j DROP
ip netns exec n1 ping -c1 -W1 10.20.0.3
# 仍然 1 received:纯二层转发不经过 FORWARD
sysctl -qw net.bridge.bridge-nf-call-iptables=1
ip netns exec n1 ping -c1 -W1 10.20.0.3
# 100% packet loss:桥接帧进入 IPv4 钩子,被 FORWARD DROP
conntrack -L 2>/dev/null | grep -c '10.20.0' || true
iptables -D FORWARD -s 10.20.0.2 -d 10.20.0.3 -j DROP
ip netns del n1; ip netns del n2; ip link del br0
三行 ping 输出就是证据链:bridge-nf-call-iptables=0 时规则「存在但不生效」,改成 1 后立刻丢包。这就是为什么 kube-proxy/CNI 的 DaemonSet 要在启动时 modprobe br_netfilter 并写这两个 sysctl;漏掉时 NetworkPolicy 只在跨节点路由的流量上生效,同节点 Pod 之间照常互通。
实验二:MTU 与 -M do 的边界
# 仍在实验一的 netns/网卡上(或重建)
ip -n n1 link set v1 mtu 1400
ip netns exec n1 ping -M do -s 1372 -c1 10.20.0.3 # 1400-20-8=1372,成功
ip netns exec n1 ping -M do -s 1373 -c1 10.20.0.3
# ping: local error: message too long, mtu=1400
ip netns exec n1 ping -c1 -s 1400 10.20.0.3 # 允许分片,仍可能通
-M do 设置 DF 位,让内核在超过 MTU 时直接 EMSGSIZE,而不是分片。VXLAN 场景把 v1 换成 vxlan0 即可复现 overlay MTU 1450:underlay 1500 减去外层 IP(20)+UDP(8)+VXLAN(8)+内层以太网(14)=50 字节。Calico 的 IPIP 是 20 字节,overlay MTU 1480;Geneve 通常 50 字节。MTU 配错时,ping -s 56 永远正常,只有大包或 TLS 握手卡住——这是最难查的一类「网络慢」。
rp_filter=1在多网卡节点误杀合法回包——策略路由、VXLAN、多默认路由都会造成非对称;容器节点通常设2或在 CNI 网卡上单独设0,并记录原因。bridge-nf-call-iptables=0时以为 NetworkPolicy 生效——同节点 Pod 走二层,FORWARD 链根本不参与;先确认lsmod | grep br_netfilter与两个 sysctl。nf_conntrack_max按 CPU 核数拍脑袋——它约束的是并发五元组数,NAT 场景还要考虑端口范围;表满时内核丢新连接,已有连接不受影响,监控要看nf_conntrack_count/max的比例。- 把 VXLAN 的 50 字节开销忘掉——underlay 1500 的集群里 overlay MTU 必须是 1450;Calico IPIP 是 1480,混用两种封装时按最小的算。
- 用
ping -M do验证 TCP 的 PMTU——ICMP 只验证了 ICMP 路径;TCP 还受tcp_mtu_probing与中间设备 ICMP 过滤影响,黑洞时表现为「能连上、传不了数据」。 - 新 netns 的
ip_forward默认是 0——它不继承宿主的 1;把 Pod 当路由器(如 Pod 内跑 VPN、代理)时必须显式打开。 conntrack -L里有[UNREPLIED]就以为丢包——它只表示还没看到回包;NAT 与 Service 的 DNAT 只在第一个包上做,后续包靠表项。iptables -A FORWARD排到 K8s 链后面——kube-proxy/CNI 会把自己的链插在 FORWARD 顶部,-A追加的规则可能永远不命中;用-I FORWARD 1或iptables -L -n --line-numbers确认顺序。
5.6.14 排障顺序与 12 条陷阱
容器网络排障最容易犯的错是「一上来就抓包」。按层往下走,通常三步就能定位:
- Pod 有没有 IP:
kubectl get pod -o wide;没有 IP 看 kubelet 事件与 CNI 日志。 - sandbox 在不在:
crictl pods、crictl inspectp <id> | jq '.status.network';确认 netns 路径与 IP。 - 容器内地址/路由:
nsenter -t <pid> -n ip -brief addr、ip route;确认默认路由与 MTU。 - 二层通不通:
ip neigh、bridge fdb show;同网段先ping对端 Pod IP。 - 三层转发:宿主
ip route get <podIP>、sysctl net.ipv4.ip_forward;跨节点看 underlay 路由/VXLAN FDB。 - NAT/DNAT:
iptables -t nat -L -n -v、conntrack -L;确认 MASQUERADE 与 Service 规则命中计数。 - 策略:
kubectl get networkpolicy、CNI 日志;确认不是默认拒绝。 - DNS:
nslookup kubernetes.default、/etc/resolv.conf;CoreDNS 的 Service 与 endpoints 是否正常。 - MTU:
ping -M do -s 1472 <ip>;VXLAN 环境把 1472 改成 1452 再试。
# 一组常用观测命令(部分需要 root)
ip -brief addr
ip route get 10.244.1.5
sudo conntrack -L | grep 8080
sudo tcpdump -i any -nn -c 20 'port 8080'
sudo iptables -t nat -L -n -v | head -40
sudo bridge fdb show | head
- 「Pod 里能 ping 通外网,Service 就正常」——Service 是 DNAT,ping VIP 可能本来就不通;要用 TCP 连接目标端口验证。
- 「CNI 装好就一定有 NetworkPolicy」——Flannel 默认不实现;要确认 CNI 的能力。
- 「VXLAN 的 MTU 不用改」——封装多出 50 字节,不改会导致大包被丢,表现为 TLS 握手卡住、大响应失败。
- 「Docker 的
-p一定走 iptables」——本机访问与 hairpin 可能走docker-proxy;关掉 userland proxy 后行为会变。 - 「容器里
ip addr看到宿主网卡」——那说明用了hostNetwork或没进对 netns;先确认readlink /proc/self/ns/net。
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 同 bridge 两个容器 ping 不通 | 一端 veth 或 netns 里的 lo 没 up | 两端都 ip link set up;ip -n ns link 检查 |
| 2 | 跨网段不通,同网段正常 | 没开 ip_forward,或默认路由没写网关 | sysctl -w net.ipv4.ip_forward=1;route add default via |
| 3 | 出宿主不通,抓包看到私网源 IP | MASQUERADE 规则没命中 | 规则放 POSTROUTING,检查 -s 网段与 ! -o 条件 |
| 4 | Service 偶发连接重置 | rp_filter 丢了非对称路由的回包 | 调 net.ipv4.conf.*.rp_filter,或让回包走同一路径 |
| 5 | 容器访问 Service 很慢但直连 Pod 正常 | hairpin/conntrack 路径问题,或 bridge 上的帧没进 iptables(bridge-nf-call-iptables=0) | 设 bridge-nf-call-iptables=1;检查 KUBE-MARK-MASQ 与 conntrack |
| 6 | VXLAN 小包通、大包卡 | MTU 没减 50,分片被丢 | Pod/容器 MTU 设 1450;ping -M do -s 1452 验证 |
| 7 | Pod 重建后 CNI DEL 失败 | 旧 netns 已消失,缓存/租约还在 | 清 /var/lib/cni/results 与 /var/lib/cni/networks |
| 8 | 两个 Pod 拿到同一个 IP | host-local 租约泄漏(容器被强杀) | 删租约文件;让 CNI 走 GC;检查 IPAM dataDir |
| 9 | 加了 default-deny 后全部超时 | 没放行 DNS(UDP/TCP 53) | 加 egress 允许 kube-system 的 53 |
| 10 | NodePort 在本机不通、外部却通 | hairpin NAT 或 externalTrafficPolicy: Local 无本地端点 | 换 Cluster 策略;检查 endpoints |
| 11 | kube-ipvs0 上看不到 VIP | kube-proxy 是 iptables 模式 | kubectl -n kube-system logs kube-proxy 看 proxyMode |
| 12 | iptables 规则与 nft 规则各有一套 | 发行版默认 iptables-nft,脚本用了 legacy | 统一 iptables --version 与 update-alternatives |
第 8 条与第 7 条经常一起出现:Pod 被 SIGKILL 后 netns 立即消失,CNI 的 DEL 来不及执行,IPAM 租约留在磁盘上;下一次调度可能复用同一个 IP,而旧租约还指向已删除的容器。生产上要给 CNI 配 GC,或定期清理孤儿租约。
5.6.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 画出一个包从容器到宿主的路径 | 说清 veth、bridge、路由、NAT 四段 | 5.6.1 |
| 手工搭 netns + veth + bridge 并互通 | ping 通,清理干净 | 5.6.2 |
解释 -p 的 DNAT 与 docker-proxy | 知道规则在 nat 表哪条链 | 5.6.3 |
| 说出 CNI 的调用约定 | 环境变量、stdin、stdout | 5.6.4 |
| 解释 VXLAN 的 MTU 减 50 | 会算封装开销 | 5.6.5 |
| 读懂 Service 的 DNAT 链 | KUBE-SERVICES/SVC/SEP | 5.6.6 |
| 把 Pod/Service/Node 映射到 Linux 原语 | 至少六项 | 5.6.7 |
| 写一条默认拒绝 + 放行 DNS 的策略 | 语义正确 | 5.6.8 |
| 说出 veth/bridge 的函数路径 | veth_xmit→br_handle_frame→br_forward | 5.6.9 |
| 手工执行 CNI ADD/DEL | 读到 result.json 的 IP,DEL 后网卡消失 | 5.6.10 |
| 区分 iptables/IPVS/eBPF 数据面 | 至少三点 | 5.6.11 |
| 在 netns 里复现 ClusterIP DNAT | curl VIP 返回 200 | 5.6.11 |
| 区分 K8s Namespace 与 Linux namespace | 一个是 API 对象 | 5.6.12 |
| 写对 NetworkPolicy 的 AND/OR | 同元素 AND、跨元素 OR | 5.6.12 |
| 按层定位 Pod 网络故障 | 至少六步 | 5.6.13 |
Q1 两个容器在同一宿主、同一 bridge 上,IP 分别是 172.17.0.2 与 172.17.0.3。它们互 ping 时经过 NAT 吗?为什么?
Q2 docker run -p 8080:80 之后,宿主上一定会有 docker-proxy 进程吗?什么情况下需要它?
Q3 Pod 的 ClusterIP 是 10.96.0.10,ping 10.96.0.10 不通,但 curl 10.96.0.10:80 成功。这正常吗?为什么?
Q4 一个 Pod 里的两个容器,默认共享哪些 namespace?localhost:8080 能通吗?
Q5 VXLAN 环境下,容器里 ping -s 1472 大包不通,小包正常。最可能的原因与修法是什么?
Q6 CNI 的 DEL 为什么必须传与 ADD 相同的 CNI_CONTAINERID 与配置?只传 netns 路径行不行?
Q7 iptables 模式下 Service 规则数量随什么增长?IPVS 为什么在大规模下更稳?
Q8 shareProcessNamespace: true 之后,Pod 里的 PID 1 是谁?对应用有什么影响?
答案
A1 不经过 NAT。同一 bridge、同一网段,帧直接按 MAC 从 veth 转发;NAT 只在包要离开该网段(出宿主或跨网段路由)时发生。
A2 不一定。纯 DNAT 规则已能处理外部流量;docker-proxy 主要处理本机 127.0.0.1:8080 访问与部分 hairpin 场景。--userland-proxy=false 可以关闭它,此时本机访问依赖 DNAT 的 OUTPUT 链规则。
A3 正常。iptables 模式下 ClusterIP 是虚拟 IP,没有接口,ICMP 不会被 DNAT;TCP 80 会命中 DNAT 规则并被转发到 Pod。用 TCP 连接验证,不要用 ping。
A4 默认共享 network、IPC、UTS namespace(sandbox 持有),不共享 mount 与 PID。因此同一 Pod 内容器可以通过 localhost:8080 互访,因为它们在同一个网络栈里。
A5 VXLAN 封装增加约 50 字节,容器网卡 MTU 若仍是 1500,大包会在 underlay 被丢弃或分片失败。修法是把容器/Pod 网卡 MTU 设为 1450(或按 underlay 调整),并确认 underlay 的 MTU 足够。
A6 CNI 插件通常用 (network, containerID, ifname) 作为缓存键,DEL 靠缓存找到当初创建的 veth 与分配的 IP;只传 netns 路径时,netns 可能已被删除,插件无法反查资源,容易留下孤儿网卡与租约。所以 DEL 要复用 ADD 的环境变量与 stdin 配置。
A7 iptables 模式的规则数随 Service 数 × Endpoint 数线性增长,每次变更还要重写整条链;包路径上要逐条匹配,规模大时延迟与同步时间都会上升。IPVS 把 VIP 映射放进内核哈希表,查找是 O(1),规则数量对包路径影响小;但它仍依赖 conntrack 与部分 iptables 规则做 masquerade。
A8 PID 1 是 pause(sandbox)容器,不是业务应用。业务容器加入同一个 PID namespace 后能看见彼此进程,但不再拥有「PID 1」身份:它不会自动收到内核发给 PID 1 的默认信号处理,也不能依赖「我是 1 号」来收尸。需要信号转发与僵尸回收时,仍要在容器里加 init。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| N | 第 10 章 容器网络虚拟化(10.2 veth、10.3 netns、10.4 bridge、10.5 外部通信) | 5.6.1–5.6.3、5.6.9 | 完整(veth/bridge/netns 与外部网络路径) |
| 内核源码 | drivers/net/veth.c、net/bridge/{br_input,br_fdb,br_forward}.c、net/ipv4/{ip_input,ip_forward,fib_frontend}.c、net/netfilter/nf_conntrack_core.c(Linux 6.8) | 5.6.9、5.6.13 | 精选(veth_xmit、br_handle_frame、FDB) |
| CNI | spec 1.0/1.1:ADD/DEL/CHECK/GC、结果与错误 JSON | 5.6.4、5.6.10 | 完整(环境变量、stdin/stdout、IPAM 租约) |
| Cilium | eBPF datapath、kube-proxy replacement、Hubble | 5.6.11、5.6.12 | 精选(hook 点与策略模型) |
| N | 第 5 章 深度理解本机网络、第 6 章 TCP 连接建立 | 5.6.3、5.6.6 | 背景(DNAT/conntrack 与连接跟踪) |
| W | 第 4 章 网络层、第 5 章 运输层 | 5.6.1、5.6.3 | 背景(IP 转发、端口与 NAT 的协议基础) |
| K | 第 58 章 SOCKET:TCP/IP 网络基础、第 61 章 高级主题 | 5.6.2、5.6.4 | 背景(socket 与 netns 的关系) |
| OCI | runtime-spec(linux.namespaces 的 path 加入方式) | 5.6.7 | 精选(Pod sandbox 共享 netns 的机制) |
| Kubernetes | 官方文档:Pod 网络、Service、NetworkPolicy、CNI、EndpointSlice | 5.6.4–5.6.13 | 完整(语义以 Kubernetes 1.30+ 为准) |
下一站:第 5 篇到此结束。你已经把「隔离、限额、镜像、运行时、网络」串成一条链。接下来去项目 A · 云原生主线:用
mycontainer从零实现一个迷你运行时,把这六章的命令变成代码。