记一次被 K8s 网络 SNAT 坑惨的经历
事情是这样的
上周遇到个特别诡异的问题,差点把我整崩溃了。我们的业务 Pod 通过 Ingress Gateway 访问外部服务,结果外部服务那边一直报错说收到了奇怪的源 IP。我一开始还以为是他们配置有问题,结果人家甩过来一个抓包文件,我一看,好家伙,源 IP 是 10.244.x.x,这不是我们集群内部的 Pod IP 吗?
外部服务那边懵了,我也懵了。按理说,Pod 的流量出集群应该被 SNAT 成节点 IP 啊,怎么会直接把内网 IP 暴露出去呢?
问题长啥样
我当时的场景大概是这样的:
10.244.1.5] -->|发请求| B[Ingress Gateway
同一台机器上] B -->|转发| C[外部服务] C -.->|看到的源IP| D[10.244.1.5 ???] style D fill:#ffcccc
正常情况下,外部服务应该看到的是我们节点的 IP(比如 192.168.1.10),但实际上它看到的是 Pod 的内部 IP。这就好比你给别人打电话,对方来电显示居然是你家路由器的内网地址,完全说不通啊。
外部服务那边自然是一脸懵逼,因为他们的防火墙规则里根本没有允许 10.244.0.0/16 这个网段,所以请求直接被拦了。
开始排查
我第一反应是检查 iptables 的 SNAT 规则,在节点上跑了一下:
bash
iptables -t nat -L POSTROUTING -n -v
规则都在,看起来没啥问题:
scss
Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 10.244.0.0/16 !10.244.0.0/16
这个规则的意思就是:只要源地址是 Pod 网段,目标不是 Pod 网段,就做 MASQUERADE(也就是 SNAT)。逻辑上完全没问题啊。
然后我又抓了个包,发现更诡异的事情:
bash
tcpdump -i eth0 -nn 'host 10.244.1.5'
流量确实出去了,但源 IP 就是没被转换。我当时真的有点抓狂了。
突然想到一个细节
后来我和同事讨论的时候,他随口问了一句:"你那个 Pod 和 Gateway 是不是在同一台机器上?"
我一查,卧槽,还真是!
bash
kubectl get pods -o wide
# NAME NODE
# business-pod node-1
# gateway-pod node-1 # 在同一个节点!
这时候我突然意识到,问题可能出在"同节点"这三个字上。
为啥同节点会出问题
我们来画个图看看正常的跨节点流量是咋走的:
做 MASQUERADE N2->>E: src=192.168.1.11 (节点IP)
这个流程很清楚,数据包经过了完整的 Netfilter 处理,在 POSTROUTING 链做了 SNAT。
但同节点的情况就不一样了:
走快速路径 N->>G: 直接转发 G->>N: 转发出去 N->>N: 跳过部分 iptables 链 N->>E: src=10.244.1.5 (Pod IP没变)
问题就出在这个"快速路径"上。Linux 内核发现源和目标都在本地,就会做一些优化,绕过部分 Netfilter 的钩子。本来是为了提高性能,结果这个优化反而把我们坑了。
网络拓扑的差异
我们再从物理层面看看这两种情况的区别。
跨节点的时候,数据包是这样走的:
数据包从节点 A 的物理网卡出去,经过物理网络到达节点 B,然后再出去。这个过程中会经过完整的 iptables 规则链。
但同节点就是这样:
看到没,数据包压根没走物理网络,就在 CNI Bridge 里转了一圈,然后直接从 eth0 出去了。这个过程太"短"了,以至于某些 iptables 规则根本来不及生效。
我们是怎么应急处理的
发现是同节点的问题后,我第一反应就是:那我把 Pod 挪到别的节点不就行了?
bash
# 先封锁当前节点,不让新 Pod 调度上来
kubectl cordon node-1
# 删掉业务 Pod,让它重建
kubectl delete pod business-pod
# Pod 重建后就会跑到别的节点上了
重建完一看,果然好了。外部服务那边看到的源 IP 变成了节点 IP,一切正常。
当时我就松了一口气,虽然知道这不是根本解决方案,但至少能先把线上问题止住。
不过这样做有个问题,就是以后 Pod 重启或者扩缩容,还是有可能调度到同一个节点上。所以我又加了个 Pod Anti-Affinity:
yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: business-app
spec:
template:
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: gateway
topologyKey: kubernetes.io/hostname
containers:
- name: app
image: my-app:latest
这个配置的意思就是:不要把我和带 app=gateway 标签的 Pod 调度到同一台机器上。简单粗暴,但确实有效。
但是这样不对啊
虽然问题解决了,但我心里很清楚,这只是绕过了问题,并没有真正修复。而且强制让 Pod 跨节点部署,会增加网络延迟和带宽消耗。
所以我开始研究怎么从根上修。
我重新审视了一遍 iptables 规则,发现虽然规则在,但可能匹配不到同节点的流量。于是我想,要不手动加一条更激进的规则试试?
bash
# 这条规则会匹配所有 Pod 网段出去的流量
iptables -t nat -A POSTROUTING \
-s 10.244.0.0/16 \
! -d 10.244.0.0/16 \
-j MASQUERADE
加完之后,我把 Pod 调度回同节点,再测试一下:
bash
kubectl exec -it business-pod -- curl http://httpbin.org/ip
返回:
json
{
"origin": "192.168.1.10"
}
成了!这次外部服务看到的是节点 IP 了。
但是这个规则重启之后会丢失,得想办法持久化。我当时用的是 Ubuntu,所以:
bash
# 安装 iptables-persistent
apt-get install iptables-persistent
# 保存规则
iptables-save > /etc/iptables/rules.v4
这样重启之后规则还在。
CNI 层面的配置
后来我又想,既然问题出在网络上,那会不会是 CNI 插件的配置有问题?我们用的是 Calico,我去查了一下文档,发现有个 natOutgoing 的选项。
bash
# 查看当前的 IP Pool 配置
calicoctl get ippool default-ipv4-ippool -o yaml
输出大概是这样的:
yaml
apiVersion: projectcalico.org/v3
kind: IPPool
metadata:
name: default-ipv4-ippool
spec:
cidr: 10.244.0.0/16
natOutgoing: true
ipipMode: Never
natOutgoing 已经是 true 了,按道理应该工作的。但我仔细看了下文档,发现 Calico 的 NAT 实现也是依赖 iptables 的,而且在某些情况下,同节点的流量确实可能绕过这些规则。
我试着把 Calico 的 DaemonSet 重启了一下,看看会不会重新生成更完善的 iptables 规则:
bash
kubectl rollout restart daemonset/calico-node -n kube-system
重启完之后,用 iptables -t nat -L -n -v 仔细看了一遍,发现确实多了几条规则。再测试一下,同节点的情况也能正常 SNAT 了。
不过说实话,我到现在也没完全搞清楚 Calico 在同节点场景下的 SNAT 逻辑到底是怎么实现的,这块还得再深入研究一下。
几个要注意的地方
经过这次折腾,我总结了几点经验:
第一,不要想当然。我之前一直以为只要 iptables 规则在那,就一定会生效。结果同节点场景下,数据包走的是快速路径,规则可能根本匹配不上。
第二,测试要覆盖各种场景。我们之前测试的时候,业务 Pod 和 Gateway 正好在不同节点上,所以一直没发现这个问题。直到生产环境出了,才知道还有同节点这个坑。
第三,iptables 的计数器很有用 。在排查的时候,我经常用 iptables -t nat -L -n -v 看规则的 pkts 和 bytes 字段,可以判断规则有没有被匹配到。如果数字一直是 0,那肯定是哪里不对。
第四,抓包是终极武器 。当你不确定流量到底走了哪条路径的时候,tcpdump 能给你最真实的答案。我当时就是抓包发现,数据包从 eth0 出去的时候,源 IP 还是 Pod IP,这才确认 SNAT 没生效。
关于性能的一点思考
有人可能会问:既然同节点有问题,那我全部强制跨节点部署不就行了?
理论上是可以的,但这样做会有性能损失。同节点通信走的是本地网络,延迟通常在 0.1ms 以内;跨节点通信要走物理网络,延迟可能是 1-2ms,甚至更高。如果你的业务对延迟特别敏感,这个差异可能就不能忽视了。
所以更好的方案还是修 SNAT 规则,既保证功能正确,又不损失性能。
如果用的是别的 CNI
我们用的是 Calico,如果你用的是 Flannel 或者 Cilium,可能情况又不太一样。
Flannel 是完全依赖主机的 iptables 规则的,所以同节点的 SNAT 问题可能更明显。你需要手动确保 iptables 规则覆盖了所有场景。
Cilium 就比较有意思了,它用的是 eBPF,理论上可以更精细地控制 SNAT 行为。我看文档里有个 enable-bpf-masquerade 的选项,可以用 eBPF 实现 SNAT,可能会比 iptables 更可靠。不过我们还没试过,这块就不乱说了。
怎么验证修复效果
修完之后,一定要测试一下,不然不放心。我用的是 httpbin.org 这个服务,它能直接返回你的源 IP:
bash
kubectl exec -it business-pod -- curl http://httpbin.org/ip
如果返回的是节点 IP,那就说明 SNAT 工作正常了。
另外,你也可以在节点上实时看 iptables 的计数器:
bash
watch -n 1 'iptables -t nat -L POSTROUTING -n -v'
然后在另一个终端生成流量,看 MASQUERADE 规则的 pkts 数字会不会增加。如果增加了,说明规则确实在工作。
一些还没想明白的事情
虽然问题解决了,但还有几个地方我没完全搞懂:
-
为什么 Linux 内核的快速路径会跳过 POSTROUTING 链?我查了一些资料,大概知道是性能优化,但具体是在哪个函数里做的判断,我还没细看内核代码。
-
Calico 的 iptables 规则生成逻辑到底是怎样的?为什么重启 DaemonSet 之后,规则就变了?是不是它会根据 Pod 的分布动态调整规则?
-
如果用 eBPF 实现 SNAT,是不是就不会有这个问题了?因为 eBPF 是在更底层拦截数据包的,理论上不会被内核的快速路径绕过。但我还没实际测试过。
这些问题我打算后面慢慢研究,有空的话可能会再写一篇更深入的文章。
写个自动检查脚本
为了避免以后再踩这个坑,我写了个简单的脚本,可以定期检查 SNAT 是否正常:
bash
#!/bin/bash
# check-snat.sh
# 找到一个业务 Pod
POD=$(kubectl get pods -l app=business -o jsonpath='{.items[0].metadata.name}')
if [ -z "$POD" ]; then
echo "没找到业务 Pod"
exit 1
fi
# 获取 Pod 访问外部服务看到的 IP
EXTERNAL_IP=$(kubectl exec $POD -- curl -s http://httpbin.org/ip | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+')
# 获取 Pod 所在节点的 IP
NODE=$(kubectl get pod $POD -o jsonpath='{.spec.nodeName}')
NODE_IP=$(kubectl get node $NODE -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}')
echo "外部服务看到的 IP: $EXTERNAL_IP"
echo "Pod 所在节点 IP: $NODE_IP"
# 简单判断(这里假设节点 IP 都是 192.168 开头的)
if [[ $EXTERNAL_IP == 192.168* ]]; then
echo "✓ SNAT 工作正常"
else
echo "✗ SNAT 可能失效了!"
exit 1
fi
把这个脚本加到监控系统里,每小时跑一次,出问题就告警。
最后说两句
这次问题虽然折腾了挺久,但也让我对 Kubernetes 的网络有了更深的理解。之前我一直觉得,只要 CNI 装好了,网络就该自动工作,不用管太多细节。现在发现,底层的 iptables、路由决策、Netfilter 钩子这些东西,还是得了解一下,不然出问题的时候真的两眼一抹黑。
另外,这次也让我意识到,测试真的很重要。我们之前的测试覆盖率还不够,没有考虑到各种边界情况。以后在测试环境里,得专门构造一些极端场景,比如同节点部署、高并发、网络抖动之类的,尽量在上线前把坑都踩一遍。
如果你也遇到类似的问题,希望这篇文章能帮到你。如果有更好的解决方案,也欢迎交流,我也还在学习中。
写于凌晨两点,终于把这个问题搞清楚了,睡觉去了...