K8s 集群迁移踩坑:Istio 升级后 nginx 403
同一份 nginx 配置,老集群正常,新集群 403。排查到最后发现新集群的 Istio sidecar 连接应用时使用
127.0.0.6,而 nginx 的可信来源和白名单只覆盖了127.0.0.1。
背景
最近在做 K8s 集群迁移,把一个静态资源服务从老集群迁到新集群。这个服务用 nginx 做前置,后面接对象存储,部分接口有 IP 白名单限制。
迁移完一测,发现 /internal/data/ 这个路径在新集群返回 403,老集群一切正常。两个集群跑的是同一份镜像、同一份配置,差别只在集群本身。
排查过程
第一步:确认 403 是谁返回的
分别请求两个集群的 LB IP:
- 新集群 LB → 403
- 老集群 LB → 200
响应头里有 nginx 的特征(content-security-policy: frame-ancestors 'none',body 是自定义 403 页面),说明请求已经到了 nginx,是 nginx 主动拒绝的。不是 LB 层的防火墙问题。
第二步:确认后端没问题
进 pod 直连 nginx(127.0.0.1:8080/internal/data/<file>),返回 200,文件大小正常。说明对象存储里有这个文件,nginx → 后端存储的链路是通的。
问题锁定在 nginx 的 IP 白名单。
第三步:抓 nginx 实际看到的 remote_addr
nginx 的 access log 走了 fluent-bit,stdout 看不到。换个思路------看 istio-proxy 的 access log,里面有 sidecar 连接 nginx 时用的源地址:
bash
kubectl logs -n app-namespace <pod> -c istio-proxy --tail=20 | grep "internal/data"
对比两个集群:
| 集群 | Istio 版本 | sidecar→nginx 连接源 |
|---|---|---|
| 老集群 | 1.9.9 | 127.0.0.1 |
| 新集群 | 1.24.3 | 127.0.0.6 |
新集群里,Envoy 连 nginx 时用的源 IP 是 127.0.0.6,不是 127.0.0.1。
第四步:对照 nginx 配置,定位不匹配点
nginx 配置里有两道关卡:
nginx
# 关卡1:判断是否信任连接来源(决定要不要读 x-envoy-external-address 头)
set_real_ip_from 127.0.0.1/32;
real_ip_header x-envoy-external-address;
# 关卡2:IP 白名单
location ^~ /internal/data/ {
allow 10.0.0.0/16;
allow 100.64.0.0/10;
allow 10.0.0.0/8;
allow 127.0.0.1;
deny all;
}
两个集群的执行结果:
老集群(TCP 源 = 127.0.0.1)→ 200:
关卡1: 127.0.0.1 在 127.0.0.1/32 内 → 信任
→ remote_addr = x-envoy-external-address 的值 = 10.0.12.85
关卡2: 10.0.12.85 在 allow 10.0.0.0/16 内 → 通过 → 200
新集群(TCP 源 = 127.0.0.6)→ 403:
关卡1: 127.0.0.6 不在 127.0.0.1/32 内 → 不信任
→ remote_addr 保持 127.0.0.6(不读头)
关卡2: 127.0.0.6 不在任何 allow 网段内 → deny all → 403
根因找到了。
第五步:127.0.0.6 哪来的?
翻 Istio 源码,在 pilot/pkg/networking/core/listener_address.go 里找到定义:
go
// 6 is the magical number for inbound: 15006, 127.0.0.6, ::6
InboundPassthroughBindIpv4 = "127.0.0.6"
InboundPassthroughBindIpv6 = "::6"
在 pilot/pkg/networking/core/cluster_builder.go 的 buildInboundPassthroughCluster() 函数里,这个地址被设为 Envoy 上游连接的 bind 地址:
go
c.UpstreamBindConfig = &core.BindConfig{
SourceAddress: &core.SocketAddress{
Address: src, // "127.0.0.6"
...
},
}
对应的 iptables 规则(tools/istio-iptables/pkg/capture/run.go)也专门处理了这个地址:
go
// 127.0.0.6/::6 is bind connect from inbound passthrough cluster
cfg.ruleBuilder.AppendVersionedRule("127.0.0.6/32", "::6/128",
constants.ISTIOOUTPUT, "nat",
"-o", "lo", "-s", constants.IPVersionSpecific, "-j", "RETURN")
先把两个名称分清:
inbound|8080||:Envoy 给"转发到本 Pod 的 8080 端口"创建的普通上游 cluster 名称。它不是 IP,也不等同于InboundPassthroughCluster。InboundPassthroughCluster:入站流量没有匹配到已知应用端口时使用的兜底 cluster;它从 Istio 1.3 起就用127.0.0.6防止回环。
本次两个集群的日志都显示 inbound|8080||,但这个同名普通 cluster 的转发方式不同,因此 nginx 看到不同 IP:
text
老集群(Istio 1.9.9)
Envoy 的 inbound|8080|| -> 127.0.0.1:8080
^ 未指定 source address
^ 内核对 loopback 连接自动选择 127.0.0.1
nginx 看到 127.0.0.1
新集群(Istio 1.24.3)
Envoy 的 inbound|8080|| -> 按原始目标转发到应用的 8080
^ 显式指定 source address = 127.0.0.6
^ iptables 对该源地址 RETURN,避免再送回 Envoy
nginx 看到 127.0.0.6
较新的 Istio 将普通入站 cluster 默认配置为 ORIGINAL_DST 模式(由 PILOT_ENABLE_INBOUND_PASSTHROUGH 控制,默认 true)。该模式保留原始目标地址;Envoy 再连回同 Pod 应用时,使用专用源地址 127.0.0.6,iptables 据此放行,避免连接再次被拦截到 15006 形成循环。
所以,127.0.0.1 没有被"转换"为 127.0.0.6。它们是 Envoy 两次建连时分别选用的 TCP 源地址:老路径没有绑定源地址,内核选 .1;新路径明确绑定 .6。6 与 inbound 监听端口 15006 对应,是 Istio 的内部约定。
从 Istio 上游代码看,127.0.0.6 的专用 source-bind 机制在 Istio 1.3 引入;普通入站 cluster 默认使用 passthrough 的行为,在本文对照范围内可确认发生在 1.9.9 与 1.17.0 之间。控制面开关或 Sidecar.ingress.defaultEndpoint 也能改变该行为,因此最终以该 Pod 的 Envoy 配置为准。
参考:
- Istio 源码 listener_address.go ---
127.0.0.6的定义- Istio 源码 cluster_builder.go ---
buildInboundPassthroughCluster()设置 bind 地址- Istio 源码 run.go (istio-iptables) --- iptables 规则中对
127.0.0.6的处理- Istio 官方博客 Demystifying Istio's Sidecar Injection Model --- sidecar 流量拦截机制
- Istio 官方文档 Install the Istio CNI node agent --- sidecar 流量重定向工作原理
修复方案
把 nginx 配置里两处 127.0.0.1 都改成 127.0.0.0/8:
nginx
# 旧(只信任 127.0.0.1,本次老集群可用,新集群失效)
set_real_ip_from 127.0.0.1/32;
allow 127.0.0.1;
# 新(信任整个 loopback,兼容新旧版本)
set_real_ip_from 127.0.0.0/8;
allow 127.0.0.0/8;
为什么两处都要改?
allow/deny 检查的是 $remote_addr,它的值取决于关卡1是否通过:
| 改法 | 关卡1 | 关卡2 | 结果 | 评价 |
|---|---|---|---|---|
| 只改关卡1 | 通过,remote_addr 变成真实客户端 IP | 真实 IP 在 allow 列表 → 通过 | 200 | 能拿到真实 IP 做白名单 ✅ |
| 只改关卡2 | 不通过,remote_addr 保持 127.0.0.6 | 127.0.0.6 在 127.0.0.0/8 → 通过 | 200 | 白名单形同虚设 ❌ |
| 两处都改 | 通过,remote_addr 变成真实客户端 IP | 真实 IP 在 allow 列表 → 通过 | 200 | 最稳妥 ✅ |
两处都改:关卡1确保能拿到真实客户端 IP 做白名单判断,关卡2确保即使关卡1没生效也不会误杀。对本次老集群(源 IP 是 127.0.0.1)完全兼容,因为 127.0.0.1 仍在 127.0.0.0/8 内。
原理补充:sidecar 和应用怎么通信的
同一个 pod 里,istio-proxy 和 nginx 共享 network namespace,通过 loopback 通信。外部请求进来的链路(注意 iptables 规则在 pod 自己的 network namespace 里,不是节点级的):
客户端
→ LB → Gateway Pod istio-proxy (:80)
设置 x-envoy-external-address: <真实客户端IP>
→ VirtualService 路由 → Service → Pod:8080
┌═══ Pod 的 network namespace ════════════════════════════┐
║ ║
║ ① iptables 拦截到 :8080 的入站流量 ║
║ REDIRECT 到 istio-proxy 的 :15006 ║
║ 原始目标端口 8080 保留在 SO_ORIGINAL_DST ║
║ (规则由 istio-init 容器设置,只对本 pod 生效) ║
║ ║
║ ② Envoy virtualInbound listener (:15006) ║
║ 从 SO_ORIGINAL_DST 读出原始目标端口 8080 ║
║ → 选择该端口的普通 cluster:inbound|8080|| ║
║ ║
║ ③ Envoy 连接 nginx :8080 ║
║ 新路径:显式绑定源 IP 127.0.0.6,iptables 放行 ║
║ 老路径:未绑定源 IP,内核选择 127.0.0.1 ║
║ 这就是 nginx 看到不同 $remote_addr 的原因 ║
╚══════════════════════════════════════════════════════════╝
关于 iptables 的作用范围: 这里的 iptables 不是节点级的,而是 pod 级 的------运行在 pod 自己的 network namespace 里。每个注入了 sidecar 的 pod 都有独立的一套规则,由
istio-initinit 容器在 pod 启动时设置(如果用了 Istio CNI 插件,则由节点上的 CNI 代劳,但作用对象仍然是 pod 的 network namespace)。规则只影响该 pod 的流量,pod 之间互相隔离。可以用kubectl exec <pod> -c istio-proxy -- iptables -t nat -S查看某个 pod 的规则。
关键点:Envoy 连接 nginx 时有两层信息在传递------
- TCP 连接层 (nginx 看到的
$remote_addr):本次为127.0.0.1或127.0.0.6,取决于 Envoy 为实际 inbound cluster 选择的源地址 - HTTP 头层 (真实客户端信息):
x-envoy-external-address: <真实IP>,这是应用层的
nginx 的 set_real_ip_from + real_ip_header 机制就是用来把这两层信息关联起来的:先判断 TCP 源地址是否可信,可信才去读 HTTP 头里的真实 IP。问题是这个"可信"判断只配了 127.0.0.1/32,没覆盖到 127.0.0.6。
排查命令速查
bash
# 1. 看 sidecar→nginx 的连接源 IP(关键)
POD=$(kubectl get pod -n <ns> -l app=<app> -o jsonpath='{.items[0].metadata.name}')
kubectl logs -n <ns> $POD -c istio-proxy --tail=20 | grep "<path>"
# 2. 看 nginx real_ip / allow 配置
kubectl get cm <nginx-configmap> -n <ns> -o yaml | grep -iE "real_ip|allow|deny"
# 3. pod 内直连 nginx 验证后端链路(绕过白名单)
kubectl exec -n <ns> $POD -c ngx -- wget -qS -O /dev/null "http://127.0.0.1:8080/<path>"
# 4. 看 Istio 版本
kubectl get pod -n <ns> $POD -o jsonpath='{.spec.containers[?(@.name=="istio-proxy")].image}'; echo
小结
- Istio 从 1.3.0 起包含以
127.0.0.6作为 inbound-passthrough 上游连接源地址的机制;普通inbound|<port>||是否也使用.6,取决于版本、控制面开关和实际 XDS 配置。 - 本次老集群的
inbound|8080||未绑定源地址,nginx 看到.1;新集群显式绑定.6,所以只信任.1的 nginx 返回 403。 - 排查关键是看 istio-proxy 的 access log 里 sidecar 连应用的源地址,而不是去翻 nginx 的 access log(如果走了 syslog 采集的话 stdout 看不到)。