拆掉 WireGuard 跳板机:用集群内的 Cloudflare Tunnel 暴露家里 k3s 服务
家里 NAS 的服务对外暴露,从 WireGuard + 公网跳板机反代,换成了跑在 k3s 里的 cloudflared。链路短了一大截,跳板机也可以退租了。
引言
去年写过一篇 如何配置 WireGuard 穿透 Docker 网络,那会儿 NAS 上的服务还是一堆 Docker 容器。
后来 NAS 上的东西越来越多,干脆上了 k3s,入口统一交给 Traefik。WireGuard 那套还在用,只是 nginx 后面的目标从 Docker 网段换成了 Traefik 的 NodePort。
整条链路大概长这样:
1 | Cloudflare → 跳板机 nginx:80 → wg 隧道 → NAS Traefik(NodePort) → Pod |
中间还多了个 udp2raw。原因是家里宽带对 UDP 不太友好,时不时丢包限速,只好把 WireGuard 的 UDP 包伪装成 TCP 再发出去。
能用,但是每一层都是补丁。
为啥要换
直接原因很简单:跳板机重装了一次系统。
重装完 wireguard-ui、nginx、udp2raw 全没了,NAS 上所有对外服务一起挂掉。
事后回头看,这套东西本来就有几个毛病:
- 链路太长。Cloudflare、跳板机、nginx 容器、wg、udp2raw、Traefik,哪一环出问题都是全站不可用。
- 有隐性依赖。跳板机宿主机其实是访问不到 NAS 那边 wg 地址的,只有 nginx 容器加了路由才能通(上一篇 entrypoint 里那两条
ip route add)。时间一长自己都忘了,排查的时候在宿主机上 curl 半天以为隧道断了。 - 跳板机是单点,而且是最容易被我自己搞坏的那个点。
正好集群里早就跑着一个 cloudflared(当时只是拿来开个 SSH 入口),那就别修了,直接切过去。
新架构
1 | 访问者 |
一句话:cloudflared 跑在集群里,自己往外连 Cloudflare,外面的请求顺着这条连接进来直接交给 Traefik。
不需要公网 IP,不需要端口映射,不需要跳板机,也不需要 udp2raw 了(出站连接走的是正常的 QUIC/HTTP2,宽带对它没意见)。
cloudflared 部署
cloudflared 用的是官方镜像 cloudflare/cloudflared,token 模式运行,Deployment 开 2 个副本。
1 | apiVersion: apps/v1 |
探针这里我一开始写反了,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 | apiVersion: traefik.io/v1alpha1 |
kubectl apply 完马上能访问。不用改 tunnel、不用加 DNS、不用 ssh 上跳板机改 nginx。
之前每加一个服务都要在跳板机上改一次 nginx 配置再 reload,现在想想真是遭罪。
entryPoints 用 web(HTTP 明文)就行,TLS 已经在 Cloudflare 边缘终结了。WebSocket 也不用额外配置,Traefik 默认透传 Upgrade 头。
用 API 改 ingress 的时候注意一下,这个接口是 PUT 全量覆盖:
1 | curl -X PUT -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ |
只传想加的那一条,原来的规则就全没了。改之前先 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 | # ~/.ssh/config |
第一次连会弹浏览器做 Cloudflare Access 登录,之后就跟普通 ssh 一样了。
Access 保护和 Service Token 的坑
后台、数据库面板这类东西不能裸奔。我的做法是约定一个前缀,比如 cf-*.example.com,在 Cloudflare Access 里给这个前缀建一个应用,挂一条邮箱白名单策略。
以后哪个服务要认证,域名取 cf- 开头就自动被保护了,服务本身一行代码都不用改。
浏览器登录很顺利,问题出在机器访问上。
有些地方要无人值守地过 Access,比如脚本、另一台机器上的定时任务。这时候要用 Service Token:
1 | TUNNEL_SERVICE_TOKEN_ID="<client_id>" \ |
我一开始把 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 | 异地集群里的 cloudflared access tcp(带 service token) |
家里 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 | -A DOCKER-USER -i wg0 -o docker0 -j ACCEPT |
眼熟吧,就是上一篇 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 | iptables -L DOCKER-USER --line-numbers |
好在这些规则都没有持久化,真不想删的话重启一次也就没了。
还有一个:卸 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 基本没管过,比之前省心太多了。