记一次被 K8s 网络 SNAT 坑惨的经历

记一次被 K8s 网络 SNAT 坑惨的经历

事情是这样的

上周遇到个特别诡异的问题,差点把我整崩溃了。我们的业务 Pod 通过 Ingress Gateway 访问外部服务,结果外部服务那边一直报错说收到了奇怪的源 IP。我一开始还以为是他们配置有问题,结果人家甩过来一个抓包文件,我一看,好家伙,源 IP 是 10.244.x.x,这不是我们集群内部的 Pod IP 吗?

外部服务那边懵了,我也懵了。按理说,Pod 的流量出集群应该被 SNAT 成节点 IP 啊,怎么会直接把内网 IP 暴露出去呢?

问题长啥样

我当时的场景大概是这样的:

graph LR A[业务 Pod
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  # 在同一个节点!

这时候我突然意识到,问题可能出在"同节点"这三个字上。

为啥同节点会出问题

我们来画个图看看正常的跨节点流量是咋走的:

sequenceDiagram participant P as Pod (节点A) participant N1 as 节点A 网络栈 participant N2 as 节点B 网络栈 participant G as Gateway (节点B) participant E as 外部服务 P->>N1: 发包 src=10.244.1.5 N1->>N2: 跨节点转发 N2->>G: 到达 Gateway G->>N2: 转发出去 N2->>N2: 执行 POSTROUTING
做 MASQUERADE N2->>E: src=192.168.1.11 (节点IP)

这个流程很清楚,数据包经过了完整的 Netfilter 处理,在 POSTROUTING 链做了 SNAT。

但同节点的情况就不一样了:

sequenceDiagram participant P as Pod (节点A) participant N as 节点A 网络栈 participant G as Gateway (节点A) participant E as 外部服务 P->>N: 发包 src=10.244.1.5 N->>N: 本地路由决策 Note over N: 发现目标在本机
走快速路径 N->>G: 直接转发 G->>N: 转发出去 N->>N: 跳过部分 iptables 链 N->>E: src=10.244.1.5 (Pod IP没变)

问题就出在这个"快速路径"上。Linux 内核发现源和目标都在本地,就会做一些优化,绕过部分 Netfilter 的钩子。本来是为了提高性能,结果这个优化反而把我们坑了。

网络拓扑的差异

我们再从物理层面看看这两种情况的区别。

跨节点的时候,数据包是这样走的:

graph TB subgraph "节点 A" P1[Pod: 10.244.1.5] CNI1[CNI Bridge] ETH1[eth0: 192.168.1.10] P1 --> CNI1 --> ETH1 end subgraph "节点 B" P2[Gateway Pod] CNI2[CNI Bridge] ETH2[eth0: 192.168.1.11] P2 --> CNI2 --> ETH2 end ETH1 -.->|物理网络| ETH2 ETH2 --> EXT[外部服务]

数据包从节点 A 的物理网卡出去,经过物理网络到达节点 B,然后再出去。这个过程中会经过完整的 iptables 规则链。

但同节点就是这样:

graph TB subgraph "节点 A" P1[Pod: 10.244.1.5] P2[Gateway Pod] CNI1[CNI Bridge] ETH1[eth0: 192.168.1.10] P1 -.->|本地转发| P2 P1 --> CNI1 P2 --> CNI1 CNI1 --> ETH1 end ETH1 --> EXT[外部服务]

看到没,数据包压根没走物理网络,就在 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 看规则的 pktsbytes 字段,可以判断规则有没有被匹配到。如果数字一直是 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 数字会不会增加。如果增加了,说明规则确实在工作。

一些还没想明白的事情

虽然问题解决了,但还有几个地方我没完全搞懂:

  1. 为什么 Linux 内核的快速路径会跳过 POSTROUTING 链?我查了一些资料,大概知道是性能优化,但具体是在哪个函数里做的判断,我还没细看内核代码。

  2. Calico 的 iptables 规则生成逻辑到底是怎样的?为什么重启 DaemonSet 之后,规则就变了?是不是它会根据 Pod 的分布动态调整规则?

  3. 如果用 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 钩子这些东西,还是得了解一下,不然出问题的时候真的两眼一抹黑。

另外,这次也让我意识到,测试真的很重要。我们之前的测试覆盖率还不够,没有考虑到各种边界情况。以后在测试环境里,得专门构造一些极端场景,比如同节点部署、高并发、网络抖动之类的,尽量在上线前把坑都踩一遍。

如果你也遇到类似的问题,希望这篇文章能帮到你。如果有更好的解决方案,也欢迎交流,我也还在学习中。


写于凌晨两点,终于把这个问题搞清楚了,睡觉去了...

相关推荐
奋斗的蛋黄4 小时前
CI/CD 全流程指南:从概念到落地的持续交付实践
运维·ci/cd·kubernetes
肖祥5 小时前
OpenObserve日志分析平台
kubernetes·运维开发
呜呜。5 小时前
WebSocket-学习调研
websocket·网络协议·学习
小黑_深呼吸6 小时前
三、ingress全面详解: 实例配置及访问
docker·容器·kubernetes
挠到秃头的涛某6 小时前
华为防火墙web配置SSL-在外人员访问内网资源
运维·网络·网络协议·tcp/ip·华为·ssl·防火墙
树在风中摇曳7 小时前
TCP连接还在吗?主机拔掉网线后再插上,连接会断开吗?
arm开发·网络协议·tcp/ip
企鹅侠客7 小时前
Kubeconfig文件自动合并-K8S多集群切换
云原生·容器·kubernetes
せいしゅん青春之我7 小时前
【JavaEE初阶】IP协议-IP地址不够用了咋办?
java·服务器·网络·网络协议·tcp/ip·java-ee
我最厉害。,。7 小时前
内网对抗-隧道技术篇&防火墙组策略&HTTP反向&SSH转发&出网穿透&CrossC2&解决方案
网络协议·http·ssh