本文分享了在内网 k3s 集群上,通过 Cloudflare Tunnel 暴露服务的完整实践,并重点解决了两个关键问题:一是发现并绕过上游网关中的透明代理(daed),通过静态路由将 Tunnel 流量指向正确的出口;二是将 Ingress 从阿里云 ALB 迁移到 Traefik,包括正确处理 ingressClass 和 CORS 中间件。文章提供了详细的配置和排障思路,以及最终上线新服务的简化流程。
在一台位于内网、没有公网 IP 的 k3s 上接入 Cloudflare Tunnel,最终实现“建一个 Ingress、加一条 CNAME 就能对外暴露服务”。部署本身只要几分钟,真正花时间的是一个隐藏在上游网关里的透明代理。
背景与目标
目标是让集群里的服务不开放任何入站端口,就能通过 Cloudflare 对外提供 HTTPS 访问。以后每上一个服务,只需要做两件事:
- 在集群里建一个 Ingress(
host: app.example.com)。 - 在 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"
排查步骤:
- 先查主机本身:没有 sing-box、clash、xray 等进程或服务,没有 TUN 网卡、策略路由、TPROXY/REDIRECT 规则,也没有代理环境变量。主机是干净的。
- 按网卡分别测出口:
curl --interface ethX https://www.cloudflare.com/cdn-cgi/trace,eth0 出口在悉尼,eth1 出口在新加坡。一台内网机器的出口在海外,说明上游有代理。 - 用 cloudflared 的真实 SNI 测试:普通 TLS 握手两边都能成功,容易误判。带上
h2.cftunnel.com这个 SNI 后才显出差异。 - 顺着网关找到源头:登录 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 和密码要轮换。
最终上线一个新服务的流程:
- 写 Ingress,
ingressClassName: traefik,需要 CORS 就引用demo-cors@kubernetescrd。 - DNS 已有
*通配记录时什么都不用做;否则加一条开启代理的 CNAME 到<TUNNEL_ID>.cfargotunnel.com。
DISCUSSION
评论
还没有公开评论,欢迎留下想法。