K8s 集群迁移踩坑:Istio 升级后 nginx 403

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.gobuildInboundPassthroughCluster() 函数里,这个地址被设为 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;新路径明确绑定 .66 与 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 配置为准。

参考:

修复方案

把 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-init init 容器在 pod 启动时设置(如果用了 Istio CNI 插件,则由节点上的 CNI 代劳,但作用对象仍然是 pod 的 network namespace)。规则只影响该 pod 的流量,pod 之间互相隔离。可以用 kubectl exec <pod> -c istio-proxy -- iptables -t nat -S 查看某个 pod 的规则。

关键点:Envoy 连接 nginx 时有两层信息在传递------

  1. TCP 连接层 (nginx 看到的 $remote_addr):本次为 127.0.0.1127.0.0.6,取决于 Envoy 为实际 inbound cluster 选择的源地址
  2. 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 看不到)。
相关推荐
执笔画流年呀1 小时前
网络原理(http)(https)
网络·http·https
caimouse1 小时前
tcpip.sys 传输层 (TCP) 详细分析
网络·网络协议·tcp/ip
SendTomo2 小时前
send.wang私传网:P2P直连传大文件首选工具
javascript·网络·网络协议·webrtc·p2p
caimouse2 小时前
tcpip.sys 传输层 (UDP) 详细分析
网络·网络协议·udp
云川之下3 小时前
【k8s】sriov‑device‑plugin、sriov‑network‑config‑daemon详解
云原生·容器·kubernetes
珠***格13 小时前
双碳目标下:四可装置如何助力光伏消纳与碳数据上报
网络·人工智能·分布式·安全·边缘计算
mounter62514 小时前
绕过主机:通过 Devmem TCP 运行 RDMA 应用
网络·网络协议·tcp/ip·rdma·devmem
我星期八休息15 小时前
网络编程—网络层
开发语言·前端·网络·人工智能·智能路由器
科力锐品牌君15 小时前
行业龙头|科力锐全链路防勒索 + 多中心容灾方案,构筑河南翔宇医疗业务安全闭环!
网络·数据库·分布式·安全·数据安全·备份