拆掉 WireGuard 跳板机:用集群内的 Cloudflare Tunnel 暴露家里 k3s 服务

家里 NAS 的服务对外暴露,从 WireGuard + 公网跳板机反代,换成了跑在 k3s 里的 cloudflared。链路短了一大截,跳板机也可以退租了。

引言

去年写过一篇 如何配置 WireGuard 穿透 Docker 网络,那会儿 NAS 上的服务还是一堆 Docker 容器。

后来 NAS 上的东西越来越多,干脆上了 k3s,入口统一交给 Traefik。WireGuard 那套还在用,只是 nginx 后面的目标从 Docker 网段换成了 Traefik 的 NodePort。

整条链路大概长这样:

1
2
3
Cloudflare → 跳板机 nginx:80 → wg 隧道 → NAS Traefik(NodePort) → Pod
↑
NAS wg0 → udp2raw 容器(UDP 伪装 TCP) → 跳板机

中间还多了个 udp2raw。原因是家里宽带对 UDP 不太友好,时不时丢包限速,只好把 WireGuard 的 UDP 包伪装成 TCP 再发出去。

能用,但是每一层都是补丁。

为啥要换

直接原因很简单:跳板机重装了一次系统。

重装完 wireguard-ui、nginx、udp2raw 全没了,NAS 上所有对外服务一起挂掉。

事后回头看,这套东西本来就有几个毛病:

  1. 链路太长。Cloudflare、跳板机、nginx 容器、wg、udp2raw、Traefik,哪一环出问题都是全站不可用。
  2. 有隐性依赖。跳板机宿主机其实是访问不到 NAS 那边 wg 地址的,只有 nginx 容器加了路由才能通(上一篇 entrypoint 里那两条 ip route add)。时间一长自己都忘了,排查的时候在宿主机上 curl 半天以为隧道断了。
  3. 跳板机是单点,而且是最容易被我自己搞坏的那个点。

正好集群里早就跑着一个 cloudflared(当时只是拿来开个 SSH 入口),那就别修了,直接切过去。

新架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
访问者
│
▼
Cloudflare(TLS 终结 + 按需 Access 认证)
│ DNS: *.example.com → CNAME <tunnel-id>.cfargotunnel.com
│
│ ← cloudflared 主动建立的出站长连接(QUIC)
▼
NAS(k3s,无公网 IP,无任何入站端口)
├─ cloudflared Deployment ×2
│ ├─ *.example.com → http://traefik.kube-system.svc:80
│ ├─ ssh.example.com → ssh://<宿主机局域网 IP>:22
│ └─ k8s-api.example.com → tcp://kubernetes.default.svc:443
└─ Traefik → IngressRoute → ClusterIP Service → Pod

一句话:cloudflared 跑在集群里,自己往外连 Cloudflare,外面的请求顺着这条连接进来直接交给 Traefik。

不需要公网 IP,不需要端口映射,不需要跳板机,也不需要 udp2raw 了(出站连接走的是正常的 QUIC/HTTP2,宽带对它没意见)。

cloudflared 部署

cloudflared 用的是官方镜像 cloudflare/cloudflared,token 模式运行,Deployment 开 2 个副本。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
namespace: cloudflared
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", "--metrics", "0.0.0.0:2000", "run"]
env:
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-token
key: token
# liveness 用 /healthcheck,别用 /ready
livenessProbe:
httpGet:
path: /healthcheck
port: 2000
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet:
path: /ready
port: 2000

探针这里我一开始写反了,liveness 用的 /ready。

/ready 返回的是当前连上边缘的连接数,网络一抖连接数掉到 0 就返回 503。拿它做 liveness,k8s 会在网络抖动的时候把 cloudflared 杀掉重启,重启又要重新注册连接、重新拉配置,本来几秒的抖动硬是被放大成了真中断。/healthcheck 只看进程活着没有,拿来做 liveness 才对。

两个副本是无状态的,Cloudflare 那边会自己做负载均衡,滚动重启也不会断。

这个镜像是 distroless 的,没有 shell,kubectl exec 进不去,排查只能看日志,这点要有心理准备。

路由用通配符,新服务不用碰 tunnel

tunnel 的 ingress 规则我用的是 remotely-managed 模式,规则存在 Cloudflare 那边,不在本地配置文件里。

关键是这两条:

hostname service
*.example.com http://traefik.kube-system.svc.cluster.local:80
example.com 同上
(兜底) http_status:404

DNS 也只加一条通配 CNAME 指向 tunnel。

这样以后加新服务,就只剩 k8s 里的两步:一个 ClusterIP Service,一个 IngressRoute。

1
2
3
4
5
6
7
8
9
10
11
12
13
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: demo
namespace: demo
spec:
entryPoints: [web]
routes:
- match: Host(`demo.example.com`)
kind: Rule
services:
- name: demo
port: 8080

kubectl apply 完马上能访问。不用改 tunnel、不用加 DNS、不用 ssh 上跳板机改 nginx。

之前每加一个服务都要在跳板机上改一次 nginx 配置再 reload,现在想想真是遭罪。

entryPoints 用 web(HTTP 明文)就行,TLS 已经在 Cloudflare 边缘终结了。WebSocket 也不用额外配置,Traefik 默认透传 Upgrade 头。

用 API 改 ingress 的时候注意一下,这个接口是 PUT 全量覆盖:

1
2
3
curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
--data '{"config":{"ingress":[ ...完整规则数组,最后一条必须是兜底... ]}}' \
"https://api.cloudflare.com/client/v4/accounts/$ACCT/cfd_tunnel/$TUNNEL_ID/configurations"

只传想加的那一条,原来的规则就全没了。改之前先 GET 一份备份。改完 cloudflared 秒级热加载,不用重启。

公网 SSH

跳板机下线以后,人在外面想 SSH 回家,也走 tunnel。

服务端加一条 ingress:ssh.example.com → ssh://<宿主机局域网 IP>:22。

这里有个小坑:不能写 ssh://localhost:22。cloudflared 是跑在 Pod 里的,Pod 的 localhost 是它自己,不是宿主机。得写宿主机的局域网 IP。

(如果 cloudflared 直接装在机器上跑,那写 localhost 就没问题。我家里另一台 Mac 就是这么配的,两条 tunnel 写法不一样,别搞混。)

客户端:

1
2
3
4
5
# ~/.ssh/config
Host home-nas
HostName ssh.example.com
User root
ProxyCommand cloudflared access ssh --hostname %h

第一次连会弹浏览器做 Cloudflare Access 登录,之后就跟普通 ssh 一样了。

Access 保护和 Service Token 的坑

后台、数据库面板这类东西不能裸奔。我的做法是约定一个前缀,比如 cf-*.example.com,在 Cloudflare Access 里给这个前缀建一个应用,挂一条邮箱白名单策略。

以后哪个服务要认证,域名取 cf- 开头就自动被保护了,服务本身一行代码都不用改。

浏览器登录很顺利,问题出在机器访问上。

有些地方要无人值守地过 Access,比如脚本、另一台机器上的定时任务。这时候要用 Service Token:

1
2
3
TUNNEL_SERVICE_TOKEN_ID="<client_id>" \
TUNNEL_SERVICE_TOKEN_SECRET="<secret>" \
cloudflared access ssh --hostname cf-ssh.example.com

我一开始把 service token 加进了原来那条 Allow 策略的 Include 里。

结果死活不生效,请求一律被当成未登录,302 跳登录页。

折腾了很久,换过 cloudflared 版本、换过域名形式、删了应用重建……甚至一度以为是我账号的 service token 功能坏了,差点去找 Cloudflare 支持。

最后在官方文档里看到这么一句:

Make sure to set the policy action to Service Auth; otherwise, Access will prompt for an identity provider login and the token will not get you in.

原因是:Service Token 必须放在动作为 Service Auth 的策略里(API 里 decision 叫 non_identity)。放在 Allow 策略里,Cloudflare 会直接无视它。

改成这样就好了:

策略 动作 Include
人 Allow 邮箱白名单
机器 Service Auth Service Token

配置看起来全对、token 就是不生效的,先看这个。

排查过程中还有个坑把我坑得更惨:验证 token 之前一定要先把 ~/.cloudflared/ 挪走。

那里面缓存了浏览器登录拿到的用户 token,cloudflared 会优先用它,于是请求”看起来通了”。我前面有一轮就是被这个假阳性骗了,以为某个改动起作用了,其实根本没有。

还有两个小细节:

  • token 的有效期不传默认是 1 年;传个 87600h 想搞 10 年,会被静默改回 1 年。要永久得传 duration: "forever"。
  • 被策略引用着的 token 删不掉。轮换顺序只能是:先建新的 → 改策略 → 再删旧的。

顺手做了跨集群桥接

后来我在异地又放了一台机器做备份,也是一套独立的 k3s。

需求是让异地那边的 MySQL 从这边的从库做级联复制。按以前的思路又要组网了,这次没组,直接也走 tunnel:

1
2
异地集群里的 cloudflared access tcp(带 service token)
→ Cloudflare 边缘 → Access → 家里的 tunnel → mysql.mysql.svc:3306

家里 tunnel 加一条 cf-bridge-mysql.example.com → tcp://mysql-secondary.mysql.svc.cluster.local:3306,异地那边起一个 Pod 跑:

1
cloudflared access tcp --hostname cf-bridge-mysql.example.com --url 0.0.0.0:3306

再给这个 Pod 套个 Service,异地的 MySQL 连这个 Service 就像连本地一样。两边都不开任何入站端口。

这里要注意两点。

一是域名不能做成二级子域。我一开始想用 mysql.bridge.example.com,结果 TLS 握手直接失败。Cloudflare 免费的 Universal SSL 只覆盖一级子域 *.example.com,二级要买 Advanced Certificate Manager。所以改成了 cf-bridge-xxx 这种一级子域,顺便还能被 cf- 前缀的 Access 应用覆盖。

二是这条隧道的吞吐很低。实测:

通道 吞吐
普通 HTTP(经 Traefik) 0.5 ~ 8 MB/s,波动大
cloudflared access tcp 稳定 211 KB/s 左右
同上,跑 mysqldump 0.9 KB/s

跑 mysqldump 慢成这样,是因为它逐表来回交互,每个来回都被跨洲 200ms 的 RTT 放大了。

所以 binlog 复制走隧道完全够用(binlog 也就每秒几 KB),但全量初始化、备份归档千万别走隧道。我的做法是本地先 dump 好,再走 HTTP 传过去,而且一定要用 curl -C - 断点续传。这条跨洲链路撑不了几分钟不断,用 --retry 的话每次都从头下,看着速度只有几十 KB/s,其实链路本身有几 MB/s。

下线 WireGuard 时的残留

切完以后把 WireGuard、udp2raw 都卸了。

过了几天翻 iptables,发现还剩一堆规则:

1
2
3
-A DOCKER-USER -i wg0 -o docker0 -j ACCEPT
-A DOCKER-USER -i wg0 -o br-4476e974a10f -j ACCEPT
-t nat -A POSTROUTING -s 10.20.0.0/24 -o br-4476e974a10f -j MASQUERADE

眼熟吧,就是上一篇 PostUp 里加的那几条。而且同一条规则重复了 15 次,一共 45 条。

原因是 PostUp 里的规则用的是 -I / -A,每次 wg 重启都会加一遍,而 PostDown 不一定每次都跑到(比如 Docker 重启连带 wg 重启的时候)。上一篇的配置不是幂等的,这里自己打脸了。如果你照着上一篇配了,建议 PostUp 里先 -C 检查一下规则在不在再加。

清理的时候又踩一个坑:按规则内容删会失败。

1
iptables -D DOCKER-USER -i wg0 -o docker0 -j ACCEPT

因为 wg0 接口已经不存在了,这样删不掉。只能按行号删:

1
2
iptables -L DOCKER-USER --line-numbers
iptables -D DOCKER-USER <行号>

好在这些规则都没有持久化,真不想删的话重启一次也就没了。

还有一个:卸 wireguard-tools 的时候,dnf 顺手把 systemd-resolved 当依赖一起卸了,然后整台机器 DNS 全挂。卸东西之前先 dnf remove --assumeno 看一眼会连带删掉什么。

中间试过 Cloudflare Mesh,没成

切 tunnel 之前其实还试过 Cloudflare 的 Mesh(WARP Connector),想做两台机器的三层互通,替代 WireGuard。

两边 warp-cli 都显示 Connected,connector 都是 healthy,官方要求的配置一条条对过都满足。但是双向 ping 和 TCP 全不通,Cloudflare 边缘直接回 ICMP host unreachable。社区里也有一堆一样症状没解决的帖子,客户端这边没办法,放弃了。

记两个顺带踩的坑:

  • 管理网段千万别放进 Split Tunnel 的 include。我把家里局域网网段放进去了,结果局域网 SSH 延迟从 4ms 变成 92ms,基本卡死。
  • 默认模式会接管 DNS 走 DoH,而 DoH 服务器地址又被路由进了隧道,隧道没起来 DNS 就不通、DNS 不通隧道就起不来,自己把自己锁死了,2 分钟断开重连五六十次。模式要改成 tunnel only。

另外,排查 DNS 类问题别用 ping 判断,ping 通不代表 DNS 正常。直接看 dig / getent hosts 花了多久。

跳板机还要留着吗

不留了。

想了一下,WireGuard 下线以后,跳板机已经没有任何回到家里的路径了。家里没有公网 IP,所有连接都是家里主动往外连的。不管是 cloudflared 挂了、k3s 挂了、Cloudflare 挂了,还是家里断网,跳板机都帮不上忙。

留着它就是每个月白交钱。

真要搞一个异地兜底通道,正确思路也是让家里主动出站连过去(第二个 cloudflared、Tailscale 都行),而不是在外面守着一台机器等人进来。

最后的兜底就是人在家,局域网直接 ssh。

现在这套跑了一个多月,tunnel 那两个 Pod 基本没管过,比之前省心太多了。