k3s traefik-ingress获取客户端真实IP

同步自知乎:k3s traefik-ingress获取客户端真实IP

原文:知乎文章

K3s + Traefik 获取真实客户端公网 IP:一套可复用的配置

把服务迁到 K3s 后,我遇到过一个很常见的问题:

业务代码里拿到的请求 IP 总是 10.42.x.x10.43.x.x100.64.x.x,而不是客户端真正的公网 IP。

我最初把问题归因于 Flannel,并尝试改用 Calico

后来重新梳理完整流量链路后发现,这个判断并不准确。

真实客户端 IP 是否能传到应用,主要取决于公网入口如何进入 Traefik,

以及应用是否正确读取反向代理头;

它和使用 Flannel 还是 Calico 没有直接关系。

本文记录的方案基于下面这套网络结构:

  • K3s v1.34.3+k3s3
  • Flannel VXLAN,节点间流量通过 tailscale0
  • Traefik v3.6.7
  • 一个或多个 K3s 节点拥有公网入口
  • Traefik 以 DaemonSet + hostNetwork 方式直接监听所在节点的 80/443
  • 业务 Pod 通过普通 ClusterIP Service + Ingress 接入

这套方案不需要替换 Flannel,也不依赖 externalTrafficPolicy: Local

先理解:应用看到的 IP 为什么会变

当前流量链路如下:

公网客户端
    │
    │ 访问任一入口节点的公网 IP:80/443
    ▼
云厂商公网 NAT / 安全组
    │
    │ 原始 TCP 源地址仍是客户端公网 IP
    ▼
K3s 入口节点:80/443
Traefik(DaemonSet + hostNetwork)
    │
    │ HTTP 反向代理,并写入:
    │ X-Forwarded-For: <客户端公网 IP>
    │ X-Real-Ip: <客户端公网 IP>
    ▼
ClusterIP Service
    │
    │ Flannel VXLAN over tailscale0
    ▼
业务 Pod

这里要区分两种 IP:

  1. remoteAddrrequest.getRemoteAddr()req.socket.remoteAddress 是当前 TCP 连接对端的地址。业务 Pod 的直接对端是 Traefik,所以这里出现 Pod IP 或节点 IP 很正常。
  2. 原始客户端 IP 由 Traefik 放在 X-Forwarded-ForX-Real-Ip 中。业务框架需要启用“信任反向代理”后再读取。

Traefik 默认就会为代理请求添加这些头,不需要额外写一个 Header Middleware。

一、入口节点不需要硬编码

获取真实 IP 并不依赖某个固定节点。只需要满足一个条件:

公网流量到达某个节点时,这个节点本地必须有 Traefik 在监听 80/443

本文把 Traefik 配置成 DaemonSet。默认不写 nodeSelector 时,它会运行在所有可调度且满足污点容忍条件的节点上。公网 IP、DNS 或外部负载均衡器可以指向其中任意一个 Traefik 正常运行的节点。

如果只想让部分节点承担公网入口,再给这些节点统一加一个通用标签:

kubectl label node <node-name> ingress-ready=true --overwrite

随后在本文的 Helm values 中按需增加:

nodeSelector:
  ingress-ready: "true"

这个标签只负责控制 Traefik 调度范围,不是保留真实 IP 的必要配置。

增加多个入口节点时,给它们加同一个标签即可,不需要在配置中写任何具体节点名称。

二、用 HelmChartConfig 持久化 Traefik 配置

不要直接修改:

/var/lib/rancher/k3s/server/manifests/traefik.yaml

这是 K3s 自带的打包清单,K3s 重启或升级时可能重新生成。正确方式是创建同名的 HelmChartConfig

执行下面的配置:

kubectl apply -f - <<'EOF'
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
  name: traefik
  namespace: kube-system
spec:
  failurePolicy: reinstall
  valuesContent: |-
    deployment:
      kind: DaemonSet
      dnsPolicy: ClusterFirstWithHostNet
# Traefik 直接使用节点网络,在所在节点监听 80/443。
# 公网请求不再经过 NodePort、ServiceLB 或跨节点转发。
hostNetwork: true

ports:
  web:
    port: 80
  websecure:
    port: 443

# Service 只用于集群内服务发现和 publishedService 状态。
# 公网流量实际走上面的 hostNetwork 端口。
service:
  type: ClusterIP

podSecurityContext:
  runAsUser: 0
  runAsGroup: 0
  runAsNonRoot: false
  seccompProfile:
    type: RuntimeDefault

securityContext:
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop:
      - ALL
    add:
      - NET_BIND_SERVICE

updateStrategy:
  type: RollingUpdate
  rollingUpdate:
    maxUnavailable: 1
    maxSurge: 0

EOF

等待配置生效:

kubectl rollout status daemonset/traefik -n kube-system –timeout=180s
kubectl get pods -n kube-system 
-l app.kubernetes.io/name=traefik
-o wide kubectl get svc traefik -n kube-system

正确结果应该满足:

  • Traefik 是 DaemonSet
  • Traefik Pod 运行在预期节点上;如果配置了可选的 nodeSelector,就只会运行在带相应标签的节点上。
  • Pod IP 和所在节点的 Tailscale IP 相同,说明 hostNetwork 已生效。
  • traefik Service 类型是 ClusterIP
  • 所有被公网流量选中的节点,其 80/443 都已由 Traefik 监听。

可以在任意入口节点上继续检查:

sudo ss -lntp | grep -E ‘:(80|443)\b’

最后确认云安全组和主机防火墙允许公网访问 TCP 80/443

域名的 A 记录指向任一正常运行 Traefik 的节点公网 IP。

三、用 whoami 验证整条链路

不要一上来就从业务日志猜问题。部署一个临时回显服务,直接观察 Traefik 传给后端的请求头:

kubectl apply -f - <<‘EOF’
apiVersion: v1
kind: Namespace
metadata:
name: ip-check

apiVersion: apps/v1 kind: Deployment metadata: name: whoami namespace: ip-check spec: replicas: 1 selector: matchLabels: app: whoami template: metadata: labels: app: whoami spec: containers: - name: whoami image: traefik/whoami:v1.11.0 ports: - name: http containerPort: 80

apiVersion: v1 kind: Service metadata: name: whoami namespace: ip-check spec: selector: app: whoami ports: - name: http port: 80 targetPort: http

apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: whoami namespace: ip-check spec: ingressClassName: traefik rules: - host: ip-check.example.com http: paths: - path: / pathType: Prefix backend: service: name: whoami port: number: 80 EOF

ip-check.example.com 换成自己的测试域名。

如果暂时不想配置 DNS,可以从集群外的电脑直接指定 Host:

curl -sS 
-H ‘Host: ip-check.example.com’
http://<ingress-node-public-ip>/

一定要从集群外测试,例如家里宽带、手机 5G 或另一台公网服务器。从集群节点内部请求,只能验证内网路径,不能证明公网客户端 IP 是否保留。

响应中应看到类似内容:

RemoteAddr: <Traefik 连接后端时使用的源 IP>:xxxxx
X-Forwarded-For: <发起 curl 的公网 IP>
X-Real-Ip: <发起 curl 的公网 IP>
X-Forwarded-Proto: http

RemoteAddr 可能是 10.42.x.x100.64.x.x 或其他入口节点地址。它是 Traefik 建立后端连接时使用的源地址,并不代表配置失败。判断是否成功,要看 X-Forwarded-ForX-Real-Ip 是否包含测试客户端的公网 IP。

验证完成后删除临时资源:

kubectl delete namespace ip-check

四、业务代码必须信任 Traefik 代理头

入口配置正确后,如果业务代码仍然读取 TCP 对端地址,

拿到的依然会是 Traefik 或节点 IP。需要按所用框架启用代理支持。

Spring Boot

application.yml 中启用容器对转发头的原生支持:

server:
forward-headers-strategy: native

然后继续使用:

request.getRemoteAddr();

如果项目已有自定义 IP 工具,也可以读取 X-Forwarded-For

但不要简单地无条件取客户端传来的第一个值;应该只信任来自 Traefik 的代理链。

Express

显式信任集群内的 Traefik 来源,再读取 req.ip

app.set(’trust proxy’, [
’loopback’,
‘10.42.0.0/16’,
‘100.64.0.0/10’,
]);

app.get(’/ip’, (req, res) => { res.json({ ip: req.ip, chain: req.ips }); });

不要为了省事直接使用 app.set(’trust proxy’, true)。如果入口没有清洗伪造的 X-Forwarded-For,客户端可能伪造任意 IP。

后端还有一层 Nginx

如果链路是 Traefik -> Nginx -> 应用,Nginx 也需要恢复真实地址:

set_real_ip_from 10.42.0.0/16;
set_real_ip_from 100.64.0.0/10;
real_ip_header X-Forwarded-For;
real_ip_recursive on;

这里的 10.42.0.0/16 是本文集群的 Pod CIDR,100.64.0.0/10 是 Tailscale 地址段。生产环境可以根据 whoami 显示的 RemoteAddr,进一步收窄为实际 Traefik 节点或代理地址,不必无条件信任整个网段。

应用再把 Nginx 视为可信代理。原则始终一样:从最靠近应用的一侧开始,只跳过明确可信的代理地址,遇到的第一个非可信地址才是客户端地址。

五、为什么当前配置不需要 externalTrafficPolicy: Local

externalTrafficPolicy 只影响 NodePortLoadBalancerExternalIP 这类 Service 的外部流量。

当前 Traefik Service 是 ClusterIP,公网请求通过 hostNetwork 直接到达入口节点上的 Traefik,根本不经过 Traefik Service。因此下面这种修改对当前入口链路没有作用:

kubectl patch svc traefik -n kube-system 
-p ‘{“spec”:{“externalTrafficPolicy”:“Local”}}’

这条命令甚至可能因为 Service 类型是 ClusterIP 而被拒绝。

只有改回 K3s 默认的 LoadBalancer Service + ServiceLB,或使用 NodePort、云负载均衡器时,才需要重点考虑:

service:
type: LoadBalancer
spec:
externalTrafficPolicy: Local

Local 会避免 kube-proxy 把请求再转发到其他节点并做 SNAT,从而保留源 IP;代价是流量打到没有本地 Traefik 端点的节点时会被丢弃。所以外部负载均衡器必须只选择真正运行 Traefik 的节点。

六、前面还有 CDN 或负载均衡器怎么办

本文主方案假设公网客户端直接访问 K3s 入口节点。如果前面还有 Cloudflare、CDN、Nginx 或云负载均衡器,Traefik 在 TCP 层看到的就会是上游代理 IP,而不是客户端 IP。

这时要在 Traefik 的 webwebsecure 入口上配置 forwardedHeaders.trustedIPs,并且只填写上游代理官方公布的出口网段。例如:

ports:
web:
forwardedHeaders:
trustedIPs:
- “<upstream-proxy-cidr>”
websecure:
forwardedHeaders:
trustedIPs:
- “<upstream-proxy-cidr>"

不要在生产环境使用:

forwardedHeaders:
insecure: true

这等于信任任何客户端自己提交的 X-Forwarded-For,会让 IP 封禁、审计日志和限流规则都可以被绕过。

如果上游使用的是 PROXY Protocol,则必须让上游和 Traefik 两端同时启用,并在 Traefik 中通过 proxyProtocol.trustedIPs 限定可信来源。不能把普通 HTTP 转发头和 PROXY Protocol 混为一谈。

最终结论

这套 K3s 集群要拿到正确的客户端公网 IP,关键只有三层:

  1. 公网 80/443 只能进入实际运行 Traefik 的节点;节点名称和标签都可以自由选择。
  2. Traefik 使用 DaemonSet + hostNetwork 直接接收公网连接,并自动写入 X-Forwarded-ForX-Real-Ip
  3. 业务应用启用可信代理支持,从代理链中读取第一个非可信地址。

Flannel 继续负责 Pod 网络,tailscale0 继续负责跨云节点互联,不需要为了真实客户端 IP 把 CNI 换成 Calico。

参考资料:

Built with Hugo
Theme Stack designed by Jimmy