本文分享了在内网 k3s 集群上,通过 Cloudflare Tunnel 暴露服务的完整实践,并重点解决了两个关键问题:一是发现并绕过上游网关中的透明代理(daed),通过静态路由将 Tunnel 流量指向正确的出口;二是将 Ingress 从阿里云 ALB 迁移到 Traefik,包括正确处理 ingressClass 和 CORS 中间件。文章提供了详细的配置和排障思路,以及最终上线新服务的简化流程。

在一台位于内网、没有公网 IP 的 k3s 上接入 Cloudflare Tunnel,最终实现“建一个 Ingress、加一条 CNAME 就能对外暴露服务”。部署本身只要几分钟,真正花时间的是一个隐藏在上游网关里的透明代理。

背景与目标

目标是让集群里的服务不开放任何入站端口,就能通过 Cloudflare 对外提供 HTTPS 访问。以后每上一个服务,只需要做两件事:

  1. 在集群里建一个 Ingress(host: app.example.com)。
  2. 在 Cloudflare DNS 里加一条开启代理的 CNAME。

环境如下:

项目 情况
宿主 Proxmox VE,k3s 跑在其中一台虚拟机里
k3s 单节点 v1.36,默认 Ingress 控制器为 Traefik
网卡 eth0 接内网桥(网关 10.0.0.1),eth1 接办公网(网关 192.168.1.1)
路由 两条默认路由 metric 相同,实际走 eth0
Tunnel 在 Zero Trust 控制台创建(远程管理),只拿到一个 token

最终链路:Cloudflare 边缘 → cloudflared(http2)→ Traefik → Ingress → 业务 Service。

sequenceDiagram
  participant U as 用户
  participant CF as Cloudflare 边缘
  participant C as cloudflared
  participant T as Traefik
  participant S as 业务 Service
  C->>CF: 主动建立 4 条 http2 长连接(出站 TCP 7844)
  U->>CF: HTTPS 请求 api.example.com
  CF->>C: 经已建立的连接下发请求
  C->>T: HTTP :80
  T->>S: 按 Ingress 的 host 转发
  S-->>U: 响应原路返回

连接由集群主动向外发起,所以不需要公网 IP,也不用开放任何入站端口。

部署 cloudflared

用独立命名空间 + Secret + 2 副本 Deployment,token 不写进启动参数,而是通过 TUNNEL_TOKEN 环境变量注入。

apiVersion: v1
kind: Namespace
metadata:
  name: cloudflare
---
apiVersion: v1
kind: Secret
metadata:
  name: cloudflared-token
  namespace: cloudflare
type: Opaque
stringData:
  token: <YOUR_TUNNEL_TOKEN>
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: cloudflare
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cloudflared
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:2026.9.1
          args: ["tunnel", "--no-autoupdate", "--protocol", "http2",
                 "--metrics", "0.0.0.0:2000", "run"]
          env:
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: cloudflared-token
                  key: token
          livenessProbe:
            httpGet:
              path: /ready
              port: 2000
            initialDelaySeconds: 20
            periodSeconds: 10
            failureThreshold: 6
          resources:
            requests: { cpu: 50m, memory: 64Mi }
            limits: { memory: 256Mi }

几个要点:

  • 镜像固定版本,不用 latest + imagePullPolicy: Always,避免重启时悄悄升级。
  • /ready 健康检查:只有至少一条连接注册成功才返回 200,连不上边缘时 Pod 会被重启。
  • 2 副本:每个副本各建 4 条连接,滚动更新时不断流。
  • 远程管理的 tunnel 不需要 config.yml:路由规则由控制台下发,启动后日志里会出现 Updated to new configuration。

验证成功的标志是日志里出现 4 条 Registered tunnel connection ... protocol=http2。

排障:TLS handshake EOF 追到上游透明代理

根因是 eth0 的网关其实是一台跑着 daed(dae 透明代理)的虚拟机,Cloudflare Tunnel 流量被分流到代理节点后握手失败。

flowchart LR
  subgraph pve[Proxmox 宿主机]
    K[k3s 虚拟机<br/>cloudflared]
    G[proxy-gw 虚拟机<br/>daed · 10.0.0.1]
  end
  K -->|eth0 默认路由| G
  G -->|fallback: proxy| P[代理节点<br/>悉尼出口]
  P -.->|7844 握手超时| CF[Cloudflare 边缘]
  K -->|eth1| R[办公网网关<br/>192.168.1.1]
  R -->|新加坡出口| CF

实线是流量实际经过的路径,虚线是失败的那一段。k3s 默认走 eth0,恰好经过 daed。

线索是集群里一个旧的 cloudflared 一直报错:

ERR Unable to establish connection with Cloudflare edge error="TLS handshake with edge error: EOF"

排查步骤:

  1. 先查主机本身:没有 sing-box、clash、xray 等进程或服务,没有 TUN 网卡、策略路由、TPROXY/REDIRECT 规则,也没有代理环境变量。主机是干净的。
  2. 按网卡分别测出口:curl --interface ethX https://www.cloudflare.com/cdn-cgi/trace,eth0 出口在悉尼,eth1 出口在新加坡。一台内网机器的出口在海外,说明上游有代理。
  3. 用 cloudflared 的真实 SNI 测试:普通 TLS 握手两边都能成功,容易误判。带上 h2.cftunnel.com 这个 SNI 后才显出差异。
  4. 顺着网关找到源头:登录 Proxmox,按网关 MAC 对上一台名为 proxy-gw 的虚拟机,上面有 dae0 网卡和运行中的 daed.service。
flowchart TD
  A[cloudflared 报<br/>TLS handshake EOF] --> B{主机上有代理软件?}
  B -->|没有| C{按网卡测出口}
  C -->|eth0 出口在海外| D{带 h2.cftunnel.com<br/>SNI 测握手}
  D -->|eth0 超时<br/>eth1 成功| E[按网关 MAC<br/>找到虚拟机]
  E --> F[daed 分流规则<br/>fallback: proxy]

每一步都把问题范围缩小一层:主机 → 出口 → 协议 → 网关 → 规则。

第 3 步的测试命令和结果:

curl -sk --http2 -m 8 --interface eth0 \
  --resolve h2.cftunnel.com:7844:198.41.192.7 \
  -o /dev/null -w "appconnect=%{time_appconnect}\n" https://h2.cftunnel.com:7844/
网卡 网关 出口 带 h2.cftunnel.com SNI 的握手
eth0(默认) 10.0.0.1(daed) 悉尼 4 个边缘 IP 全部超时
eth1 192.168.1.1 新加坡 全部成功,约 0.32s

第 4 步从 daed 的 /etc/daed/wing.db 里只读出了分流规则(节点和订阅带凭据,没有读):

routing {
  pname(NetworkManager, systemd-resolved, dnsmasq) -> must_direct
  dip(geoip:private) -> direct
  dip(geoip:cn) -> direct
  domain(geosite:cn) -> direct
  fallback: proxy
}

Cloudflare Tunnel 的边缘地址既不是国内 IP 也不是内网 IP,落入 fallback: proxy,被转到悉尼的代理节点,7844 端口的握手在这条链路上过不去。

修复:静态路由绕开代理 + 强制 http2

最终选择只在 k3s 主机上让 Cloudflare Tunnel 边缘网段走 eth1,不改动 daed。

当时有两个方案可选:

方案 做法 影响范围
A 在 daed 里加 dip(198.41.192.0/24, 198.41.200.0/24) -> direct 网关后面所有机器
B(采用) k3s 主机加静态路由,这两个网段走 eth1 只有 k3s 这台

先加路由,立即生效:

ip route replace 198.41.192.0/24 via 192.168.1.1 dev eth1
ip route replace 198.41.200.0/24 via 192.168.1.1 dev eth1

再写 netplan 让它重启后保留。新建一个文件,不改 cloud-init 生成的 50-cloud-init.yaml:

# /etc/netplan/60-cloudflare-tunnel.yaml
network:
  version: 2
  ethernets:
    eth1:
      routes:
        - to: 198.41.192.0/24
          via: 192.168.1.1
        - to: 198.41.200.0/24
          via: 192.168.1.1

执行 netplan generate 后检查 /run/systemd/network/10-netplan-eth1.network,确认两个 [Route] 段已经生成。没有执行 netplan apply,避免网卡短暂断开影响其他业务。

Pod 出网时会先 SNAT 成节点地址,再按主机路由表走,所以这条路由对集群里的 cloudflared 同样生效。改完后走默认路由的握手恢复到约 0.33s。

flowchart LR
  subgraph before[修复前]
    A1[cloudflared] -->|eth0| B1[daed] --> C1[代理节点]
    C1 -.->|超时| D1[Cloudflare 边缘]
  end
  subgraph after[修复后]
    A2[cloudflared] -->|198.41.192.0/24<br/>198.41.200.0/24| B2[eth1 · 办公网网关]
    B2 --> D2[Cloudflare 边缘]
  end

只有发往 Cloudflare Tunnel 边缘的流量改道,其他出网流量仍按原来的路径走。

关于 http2:cloudflared 默认的 auto 模式是先 QUIC(UDP 7844)再退回 http2,没有“先 http2 再 QUIC”的选项。在 UDP 不可靠的网络里直接加 --protocol http2 更稳,已经部署的 Deployment 可以这样改:

kubectl -n cloudflare patch deploy cloudflared --type=json -p '[{"op":"replace",
  "path":"/spec/template/spec/containers/0/args",
  "value":["tunnel","--no-autoupdate","--protocol","http2","--metrics","0.0.0.0:2000","run"]}]'

原理:cfargotunnel.com 与通配规则

一个请求要过两道关:DNS 决定它进哪个 tunnel,tunnel 的路由规则决定它进来后转给谁。

flowchart LR
  U[浏览器] --> D{DNS CNAME<br/>进哪个 tunnel}
  D --> T{tunnel 路由规则<br/>按 hostname}
  T -->|匹配| K[Traefik]
  T -->|不匹配| X[http_status:404]
  K --> I{Ingress<br/>按 host}
  I --> S[业务 Service]

任何一道没对上,用户看到的都是 404,所以要分清是哪一层返回的。

为什么 CNAME 指向 <TUNNEL_ID>.cfargotunnel.com

  • tunnel token 是一段 base64 编码的 JSON,字段 a 是账户 ID、t 是 tunnel ID、s 是密钥。这个 UUID 就是 t。
  • <tunnel-ID>.cfargotunnel.com 是 Cloudflare 内部的特殊地址,在公网解析不出任何东西。开启代理(橙色云)的 CNAME 指向它,就是告诉 Cloudflare:这个域名的请求送进这个 tunnel。
  • 请求沿着 cloudflared 主动建立的长连接进入集群,所以主机不需要公网 IP,也不用开放入站端口。
  • 必须开启代理,而且域名和 tunnel 要在同一个 Cloudflare 账户下。

为什么要配 *.example.com 通配规则

在控制台创建的 tunnel,路由规则由 Cloudflare 下发。最初下发的规则只有一个域名:

{"ingress": [
  {"hostname": "dev.example.com", "service": "http://traefik.kube-system.svc.cluster.local:80"},
  {"service": "http_status:404"}
]}

这时新建一个 Ingress 加一条 CNAME,请求能进 tunnel,却匹配不到任何 hostname,被最后一条 404 挡回去。把 Public Hostname 改成 *.example.com → http://traefik.kube-system.svc.cluster.local:80 后,所有子域名都交给 Traefik,由 Ingress 分发。Cloudflare 这边就成了一个固定的总入口。

几个注意点:

  • 再加一条 DNS 通配记录 * CNAME 到 tunnel,连单独加 CNAME 这步也能省掉,已存在的单独记录优先级更高。
  • Cloudflare 免费的通用证书只覆盖一级子域名,a.b.example.com 这样的两级子域名会报证书错误。
  • 区分 404 来源:响应体是 404 page not found 的是 Traefik,说明 tunnel 已经通了,问题在 Ingress。

Ingress 迁移:从阿里云 ALB 到 Traefik

通配规则生效后,api.example.com 依然返回 Traefik 的 404,原因是 Ingress 从阿里云 ACK 原样搬来,用的是不存在的 class。

spec:
  ingressClassName: alb-demo      # 集群里只有 traefik
metadata:
  annotations:
    alb.ingress.kubernetes.io/enable-cors: "true"   # 阿里云专用,Traefik 不认
    alb.ingress.kubernetes.io/traffic-limit-qps: "50000"

Traefik 只处理属于自己 class 的 Ingress,这条被直接忽略,而且没有任何报错。kubectl get ingressclass 和 kubectl get ingress -A 两条命令就能看出问题。

改法是 class 换成 traefik,CORS 改用 Traefik 的 Middleware:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: cors
  namespace: demo
spec:
  headers:
    accessControlAllowMethods: ["PUT", "GET", "POST", "PATCH", "OPTIONS"]
    accessControlAllowOriginListRegex:
      - "^https://.*\\.example\\.ai$"
      - "^https://.*\\.example\\.com$"
      - "^https://.*\\.pages\\.dev$"
    accessControlAllowHeaders: ["*"]
    addVaryHeader: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo-ingress
  namespace: demo
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: demo-cors@kubernetescrd
spec:
  ingressClassName: traefik
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api-gateway
                port:
                  number: 3000

ALB 注解逐条处理如下:

ALB 注解 Traefik 下的处理
enable-cors / cors-allow-* 改用 headers Middleware;https://*.xxx 这种通配写法不支持,改成正则
traffic-limit-qps 不搬。经过 tunnel 后来源 IP 全是 cloudflared Pod,Traefik 按 IP 限流会让所有用户共用一个额度,应放到 Cloudflare Rate Limiting
unhealthy-threshold-count 去掉,Service 和 readiness 探针已覆盖

验证结果:公网 GET 返回 200;来源为允许域名的 CORS 预检返回 access-control-allow-origin,来源为其他域名时不返回。

这条 Ingress 带有 objectset.rio.cattle.io/* 注解,是通过 Rancher 下发的。要记得把改动同步回来源,否则下次同步可能被还原。

经验总结与检查清单

最大的教训是:主机干净不代表链路干净,透明代理可能藏在上游网关里。

  • 用真实的 SNI 测试:普通 TLS 握手成功不代表 cloudflared 能连上,要带 h2.cftunnel.com 测试。
  • 按网卡测出口:curl --interface 加 cdn-cgi/trace 看 ip 和 colo,出口在海外就要怀疑上游有代理。
  • 顺着网关 MAC 找源头:在虚拟化环境里,ip neigh 里的网关 MAC 对应到宿主机上的具体虚拟机。
  • 多网卡主机要明确出口:两条 metric 相同的默认路由很脆弱,DHCP 顺序一变出网路径就变。
  • UDP 不可靠就固定 http2:--protocol http2 只需要 TCP 7844。
  • 分清 404 是谁返回的:Cloudflare catch-all、Traefik、业务服务各有特征。
  • 迁移云厂商配置先查 ingressClass:错误的 class 不会报错,只会被静默忽略。
  • 敏感信息不进命令行:token 放 Secret,用完在聊天或工单里出现过的 token 和密码要轮换。

最终上线一个新服务的流程:

  1. 写 Ingress,ingressClassName: traefik,需要 CORS 就引用 demo-cors@kubernetescrd。
  2. DNS 已有 * 通配记录时什么都不用做;否则加一条开启代理的 CNAME 到 <TUNNEL_ID>.cfargotunnel.com。