用 Headscale + Tailscale 自建虚拟组网:让散落在互联网上的上百台机器人「像在同一个局域网」一样调试
用 Headscale + Tailscale 自建虚拟组网:让散落在互联网上的上百台机器人「像在同一个局域网」一样调试
关键词:Headscale、Tailscale、WireGuard、DERP、NAT 穿透、P2P、CGNAT、frp、NetBird、机器人远程调试
一、背景:ToDesk 调试机器人,到底难在哪
我们公司在全国各地部署了大量移动机器人 / 工控机(Ubuntu + ROS 2 为主),它们的网络环境是典型的最坏情况:
- 有的插 4G/5G 物联网卡(运营商 CGNAT,没有公网 IP);
- 有的接客户办公室 Wi-Fi(防火墙严格、只放 80/443);
- 有的临时用工程师手机热点(双层 NAT);
- 客户不可能给我们做端口映射,更不可能开 VPN 专线。
早期我们的调试方式是在机器人上装 ToDesk / 向日葵,工程师远程桌面进去操作。痛点非常具体:
| 痛点 | 说明 |
|---|---|
| 只能「看屏幕」 | 本质是像素流,不是网络。想 scp 一个 20MB 的 rosbag 出来、想用本地 Foxglove/RViz 连远端 DDS,做不到 |
| 无法多协议 | SSH、RTSP 摄像头、Web 后台、ROS 2 DDS、gdb 远程调试、Jupyter,全都得手动折腾 |
| 无法自动化 | CI 想批量给 50 台机器人推固件、跑巡检脚本,ToDesk 没法脚本化 |
| 机器人没人时进不去 | 无人值守设备、无头(headless)设备体验极差 |
| 安全与审计 | 第三方云中转,账号密码满天飞,无法做「谁在什么时候访问了哪台机器」的 ACL 与审计 |
| 带宽与画质 | 现场 4G 上行小,桌面流卡顿,调参靠猜 |
我们真正需要的不是「远程桌面」,而是三层网络的可达性:让办公室里的开发机、CI Runner、工程师笔记本和机器人处在同一个逻辑二/三层网络里,ping 100.64.0.12 就能通,然后所有现成工具(ssh、scp、rsync、curl、ros2 topic list、gdbserver、VS Code Remote)全部原样可用。
这就是虚拟组网 / Overlay Mesh VPN要解决的问题。
二、方案总览:Tailscale 客户端 + 自建 Headscale + 自建 DERP(且不依赖任何域名/DNS)
最终落地的架构非常简单,只需要一台带公网 IP 的小云主机(2C2G 足够):
┌──────────────────────────────────────────┐
│ 公网云主机 (只有 IP,无域名) │
│ │
│ Headscale (控制面 / Coordination) │
│ - 节点注册、公钥分发、NetMap 下发 │
│ - ACL 策略、路由审批 │
│ │
│ Embedded DERP + STUN (中继面) │
│ - udp/3478 STUN 探测反射地址 │
│ - tcp/443|8080 DERP 加密中继 │
└───────▲──────────────────▲───────────────┘
│ 控制信令(长连接) │ 控制信令
│ + DISCO 探测 │ + DISCO 探测
┌─────────────┘ └─────────────┐
│ │
┌─────────┴─────────┐ ┌─────────┴─────────┐
│ 开发机 / 笔记本 │ │ 机器人 A (4G) │
│ tailscaled │ │ tailscaled │
│ 100.64.0.2 │◀════ WireGuard P2P ═════▶│ 100.64.0.11 │
└───────────────────┘ 直连成功后不经过服务器 └───────────────────┘
│ --advertise-routes
▼
机器人车内网 192.168.8.0/24
(相机/雷达/PLC/交换机)
三个平面(这是理解一切差异的钥匙):
| 平面 | 组件 | 是否承载业务流量 | 说明 |
|---|---|---|---|
| 控制面 Control Plane | Headscale | ❌ 不承载 | 只交换公钥、端点候选、ACL、路由表(NetMap) |
| 数据面 Data Plane | WireGuard(各节点之间) | ✅ | 尽可能 P2P 直连 |
| 中继面 Relay Plane | DERP + STUN | ⚠️ 兜底才用 | 打洞失败时中转已加密的密文 |
核心思想一句话:控制面负责「介绍双方认识」,数据面是「两人自己私聊」,中继面是「实在联系不上时的传话人,而且传话人听不懂内容」。
2.1 为什么不用官方 Tailscale 云?
- 控制面
controlplane.tailscale.com与官方 DERP 节点在国内访问不稳定,握手慢、偶发失败; - 上百台机器人的设备数量与计费;
- 客户合规要求:网络拓扑、设备标识、密钥编排不能出境;
- 需要自定义 DERP 落在国内机房,兜底链路才可用。
Headscale 是 Tailscale 控制服务器的开源自托管实现(BSD-3),复用官方未修改的 Tailscale 客户端,这点非常关键——机器人端我们用的是官方二进制,稳定性、跨平台(x86_64 / aarch64 / OpenWrt)、长期维护都有保障。
三、技术方案细节:一次连接是怎么建立起来的
3.1 数据面:WireGuard 做了什么、没做什么
WireGuard 本身极其精简:Noise_IK 握手 + ChaCha20-Poly1305 + 一张静态的 AllowedIPs → Peer Endpoint 表。它没有:
- 服务发现
- NAT 穿透
- 密钥分发与轮换编排
- 访问控制策略
也就是说,裸 WireGuard 要求你事先知道对端的 IP:Port。而机器人在 4G 网络下,公网映射地址随时变化,人工维护配置是不可能的。
Headscale/Tailscale 干的活,就是把这张表变成动态的、自动收敛的。
3.2 控制面:NetMap 与端点候选集
节点启动后通过 Noise(ts2021)与 Headscale 建立长连接,周期性/事件驱动地拿到一份 NetMap,其中对每个 peer 包含:
- WireGuard 公钥、DISCO 公钥
- overlay 地址(
100.64.0.0/10,即 CGNAT 保留段) - 端点候选集(Endpoint Candidates)
- 该 peer 的 home DERP region
候选集由客户端自己收集,大致包括:
C A = { ( i p l a n , p ) } ⏟ 本地网卡 ∪ { ( i p s t u n , p s t u n ) } ⏟ STUN 反射地址 ∪ { ( i p v 6 , p ) } ⏟ IPv6 全局地址 ∪ { ( i p p m p , p p m p ) } ⏟ UPnP/NAT-PMP/PCP 映射 C_A=\underbrace{\{(ip_{lan},p)\}}_{\text{本地网卡}}\ \cup\ \underbrace{\{(ip_{stun},p_{stun})\}}_{\text{STUN 反射地址}}\ \cup\ \underbrace{\{(ip_{v6},p)\}}_{\text{IPv6 全局地址}}\ \cup\ \underbrace{\{(ip_{pmp},p_{pmp})\}}_{\text{UPnP/NAT-PMP/PCP 映射}} CA=本地网卡 {(iplan,p)} ∪ STUN 反射地址 {(ipstun,pstun)} ∪ IPv6 全局地址 {(ipv6,p)} ∪ UPnP/NAT-PMP/PCP 映射 {(ippmp,ppmp)}
3.3 连接建立时序:所有连接都从 DERP 开始
这是很多人误解的点。Tailscale 的路径升级过程是:
t0 A 要访问 B
├─ A 立刻通过 DERP 把首包发给 B(连接零等待可用,只是慢)
└─ 同时通过 DERP 交换 DISCO 消息(各自的候选端点、nonce)
t0+ 双方对 |C_A| × |C_B| 条候选路径并行发 DISCO ping("赛马")
t1 某条 UDP 路径 pong 回来 → 记录 RTT
t2 按 RTT 最优选路,WireGuard 流量切到直连 UDP(无缝,不断连)
t∞ 后台持续复探;网络变化(切 Wi-Fi、换基站)时重新赛马、重新升级/降级
选路目标函数就是最小 RTT:
path ∗ = arg min ( a , b ) ∈ C A × C B R T T ( a , b ) \text{path}^{*}=\arg\min_{(a,b)\in C_A\times C_B}\ \mathrm{RTT}(a,b) path∗=arg(a,b)∈CA×CBmin RTT(a,b)
由于三角不等式,直连几乎总优于中继:
R T T relay = R T T A → D + R T T D → B ≥ R T T A → B = R T T direct \mathrm{RTT}_{\text{relay}}=\mathrm{RTT}_{A\to D}+\mathrm{RTT}_{D\to B}\ \ge\ \mathrm{RTT}_{A\to B}=\mathrm{RTT}_{\text{direct}} RTTrelay=RTTA→D+RTTD→B ≥ RTTA→B=RTTdirect
我们线上实测的典型数字(工程师在杭州办公室 → 机器人在苏州厂区,DERP 在香港):
| 路径 | RTT | iperf3 吞吐 |
|---|---|---|
| 直连 P2P | 28 ms | 46 Mbps(受 4G 上行限制) |
| DERP 中继 | 71 ms | 18 Mbps(且服务器出网带宽被瓜分) |
用 tailscale status 一眼看出来当前是哪种:
$ tailscale status
100.64.0.11 robot-a robot linux active; direct 113.x.x.x:41641, tx 1.2M rx 8.9M
100.64.0.17 robot-b robot linux active; relay "sh", tx 40k rx 88k
↑ 这个就是走中继了
3.4 「没有域名 / 没有 DNS」怎么落地
这是我们方案里最有实操价值的一段。Headscale 官方文档默认假设你有 FQDN + Let’s Encrypt。我们只有一个公网 IP,做法如下。
先澄清一个常见误解:
MagicDNS(robot-a.your-net.ts.net 这种名字)根本不需要公网 DNS。它是 tailscaled 在本机起的 100.100.100.100 解析器,域名与 A 记录由 Headscale 通过 NetMap 直接下发。所以「没有 DNS」完全不影响 MagicDNS 主机名可用。
真正受影响的只有两处:控制面 URL 的证书 和 DERP 的 TLS。
方案 A(我们采用):自建 CA,签发 SAN 含 IP 的证书
# 1. 生成 CA
openssl req -x509 -newkey rsa:4096 -sha256 -days 3650 -nodes \
-keyout ca.key -out ca.crt -subj "/CN=RobotNet Internal CA"
# 2. 关键:SAN 必须写 IP,而不是 DNS 名
cat > san.cnf <<'EOF'
[req]
distinguished_name=dn
req_extensions=ext
[dn]
CN=203.0.113.10
[ext]
subjectAltName=IP:203.0.113.10
extendedKeyUsage=serverAuth
EOF
openssl req -newkey rsa:2048 -nodes -keyout hs.key -out hs.csr -config san.cnf
openssl x509 -req -in hs.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out hs.crt -days 3650 -sha256 -extfile san.cnf -extensions ext
Headscale 配置:
# /etc/headscale/config.yaml
server_url: https://203.0.113.10:443
listen_addr: 0.0.0.0:443
metrics_listen_addr: 127.0.0.1:9090
tls_cert_path: /etc/headscale/hs.crt
tls_key_path: /etc/headscale/hs.key
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
derp:
server:
enabled: true
region_id: 900
region_code: "cn-sh"
region_name: "Self-hosted Shanghai"
stun_listen_addr: "0.0.0.0:3478"
# 显式声明公网地址,显著提升穿透与稳定性
ipv4: 203.0.113.10
# ipv6: 2001:db8::10
automatically_add_embedded_derp_region: true
# 关掉官方 DERP map,国内访问它只会拖慢握手
urls: []
paths: []
auto_update_enabled: true
update_frequency: 24h
dns:
magic_dns: true
base_domain: robotnet.internal # 不需要真实注册,纯内部
nameservers:
global:
- 223.5.5.5
然后把 ca.crt 打进机器人系统镜像的信任库(这一步对我们反而是好事:机器人是我们自己出厂的设备,装 CA 天经地义,而且顺手实现了「只有我们出厂的镜像才能加入网络」的弱绑定):
sudo cp ca.crt /usr/local/share/ca-certificates/robotnet-ca.crt
sudo update-ca-certificates
Windows/macOS 工程师电脑同理导入到系统钥匙串/受信任的根证书颁发机构。
Tailscale 客户端使用系统证书池,装好 CA 后tailscale up --login-server=https://203.0.113.10直接通。
方案 B(更省事,但要理解安全边界):控制面走 HTTP
server_url: http://203.0.113.10:8080
listen_addr: 0.0.0.0:8080
tls_cert_path: ""
tls_key_path: ""
为什么这不是灾难?因为 Tailscale 客户端与控制面之间跑的是 ts2021 Noise 协议,在 HTTP 之上还有一层端到端的 Noise 加密与双向密钥认证;HTTPS 在这里主要提供的是「服务端身份的 PKI 锚点」和抗中间人劫持的第一跳信任。但:首次注册时你缺少了服务端身份校验,存在理论上的 MITM 劫持注册风险,同时嵌入式 DERP 的 WebSocket 也会退化成明文 WS 承载(内容仍是 WireGuard 密文,但握手元数据暴露)。
结论:内网/PoC 用 B,生产用 A。 我们生产用 A。
顺带避坑:反向代理必须支持 WebSocket
如果你在 Headscale 前面套了 Nginx/Caddy,务必开 WebSocket 升级,否则 DERP 与新版客户端会连不上;同时 udp/3478(STUN)无法被 HTTP 反代,必须直接放行到主机。
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade; # 关键
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 3600s;
}
3.5 机器人侧的落地细节
批量注册用 PreAuthKey,不要用交互式登录:
# 服务端:给 robots 用户签发一个可复用、90 天有效、自动打 tag 的 key
headscale users create robots
headscale preauthkeys create --user robots --reusable --expiration 90d
机器人首次开机脚本:
tailscale up \
--login-server=https://203.0.113.10 \
--authkey="${TS_AUTHKEY}" \
--hostname="robot-$(cat /etc/robot-sn)" \
--advertise-tags=tag:robot \
--advertise-routes=192.168.8.0/24 \
--accept-routes=false \
--accept-dns=false \
--ssh
几个参数的实战理由:
--advertise-routes=192.168.8.0/24:把机器人车内网(相机、激光雷达、PLC、下位机)整段暴露进 overlay。这是杀手级特性——那些不能装客户端的嵌入式设备,通过机器人做 Subnet Router 一并可达。服务端还需headscale nodes approve-routes审批。--accept-routes=false/--accept-dns=false:机器人是被访问方,绝不接受下发路由和 DNS,避免污染车上原有网络与 ROS 通信。--advertise-tags=tag:robot:机器人不绑「人」的身份,绑设备 tag,密钥永不过期、便于 ACL。--ssh:用 Tailscale SSH 接管认证,工程师不用发 SSH key,且可审计。- 无 TUN 设备的极端环境(老内核、受限容器):加
--tun=userspace-networking,配合tailscale nc/ SOCKS5 也能用。
ACL 策略(Headscale 的 policy.json / HuJSON):
{
"tagOwners": { "tag:robot": ["group:devops"] },
"groups": {
"group:dev": ["alice@", "bob@"],
"group:devops": ["carol@"]
},
"acls": [
{
"action": "accept",
"src": ["group:dev"],
"dst": ["tag:robot:22", "tag:robot:8080", "tag:robot:554",
"192.168.8.0/24:80,554"]
},
{ "action": "accept", "src": ["group:devops"], "dst": ["tag:robot:*"] },
// 机器人之间互相不可见 —— 横向移动收敛,非常重要
{ "action": "accept", "src": ["tag:robot"], "dst": ["tag:robot:0"] }
],
"ssh": [
{ "action": "check", "src": ["group:dev"], "dst": ["tag:robot"],
"users": ["root", "robot"], "checkPeriod": "12h" }
]
}
默认拒绝 + 白名单 + 机器人之间零可见性,这是 ToDesk 时代根本给不了的东西。
3.6 MTU 与吞吐:一个必须算清的账
WireGuard 在 IPv4/UDP 上的封装开销:
H wg = 20 ⏟ outer IPv4 + 8 ⏟ UDP + 16 ⏟ WG header + 16 ⏟ Poly1305 tag = 60 bytes H_{\text{wg}}=\underbrace{20}_{\text{outer IPv4}}+\underbrace{8}_{\text{UDP}}+\underbrace{16}_{\text{WG header}}+\underbrace{16}_{\text{Poly1305 tag}}=60\ \text{bytes} Hwg=outer IPv4 20+UDP 8+WG header 16+Poly1305 tag 16=60 bytes
Tailscale 出于跨 4G/隧道/PPPoE 的保守考虑,默认 overlay MTU 取 1280。有效载荷效率:
η = L L + H wg \eta=\frac{L}{L+H_{\text{wg}}} η=L+HwgL
- 大包 L = 1280 L=1280 L=1280: η ≈ 95.5 % \eta \approx 95.5\% η≈95.5%
- 小包(ROS 2 DDS 心跳、SSH 交互) L = 64 L=64 L=64: η ≈ 51.6 % \eta \approx 51.6\% η≈51.6%
工程含义:跑大文件(rosbag 回传)几乎无损;但如果你在 overlay 上跑高频小包(比如 1kHz 的控制回路、DDS 大量 discovery 报文),开销占比会飙升,CPU 中断和 pps 才是瓶颈,不是带宽。所以我们的原则是:
overlay 只用于调试、观测、升级、文件传输;实时控制闭环永远留在车内网。
另外记得处理 MSS clamping,避免 PMTU 黑洞导致「ssh 能连上但 ls -R 卡死」的经典现象:
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN \
-j TCPMSS --clamp-mss-to-pmtu
四、NAT 穿透原理:什么时候能 P2P,什么时候不能
4.1 NAT 的两个正交维度(RFC 4787)
很多人只知道「对称/非对称 NAT」,其实要拆成两个独立维度:
① 映射行为 Mapping Behavior(决定「外部看到我的地址是否稳定」)
设内部端点 ( i p i , p i ) (ip_i,p_i) (ipi,pi) 发往外部 ( i p d , p d ) (ip_d,p_d) (ipd,pd),NAT 分配外部映射 M M M:
- EIM(Endpoint-Independent Mapping,端点无关)
M = f ( i p i , p i ) M=f(ip_i,p_i) M=f(ipi,pi)
无论发给谁,外部映射相同。这是能打洞的前提。 常见于家用路由器(俗称 Full-cone / Restricted cone)。 - ADM(Address-Dependent Mapping)
M = f ( i p i , p i , i p d ) M=f(ip_i,p_i,ip_d) M=f(ipi,pi,ipd) - APDM(Address-and-Port-Dependent Mapping,即对称 NAT)
M = f ( i p i , p i , i p d , p d ) M=f(ip_i,p_i,ip_d,p_d) M=f(ipi,pi,ipd,pd)
发给不同目标就是不同外部端口 → STUN 探测到的端口对 peer 毫无意义。
② 过滤行为 Filtering Behavior(决定「谁能给我回包」)
- EIF(Endpoint-Independent Filtering):映射建立后任何人都能进
- ADF:只有你发过包的那个 IP 能进
- APDF(Address-and-Port-Dependent Filtering):只有你发过包的那个
IP:Port能进(最严)
4.2 打洞可行性判据
同时打洞(simultaneous open)的本质:双方同时向对方的预测端点发 UDP 包,各自的 NAT 因为看到「出方向流量」而建立映射并放行反向包。
用逻辑式表达直连可行条件(近似):
D i r e c t ( A , B ) ⟺ ( E I M ( A ) ∨ E I M ( B ) ) ∧ ¬ ( A P D M ( A ) ∧ A P D M ( B ) ) \mathrm{Direct}(A,B)\ \Longleftrightarrow\ \big(\mathrm{EIM}(A)\ \lor\ \mathrm{EIM}(B)\big)\ \land\ \neg\big(\mathrm{APDM}(A)\land \mathrm{APDM}(B)\big) Direct(A,B) ⟺ (EIM(A) ∨ EIM(B)) ∧ ¬(APDM(A)∧APDM(B))
直观理解:只要有一方的外部端点是可预测的,另一方就能把包打到那里,从而完成同时开放。两边都是对称 NAT(APDM)时,双方都不知道该往哪个端口打 → 常规打洞失败。
再叠加时间维度:NAT 映射有生存期 T n a t T_{nat} Tnat,双方探测必须落在同一个时间窗内:
∣ t A − t B ∣ < min ( T n a t A , T n a t B ) |t_A-t_B|<\min\big(T^{A}_{nat},\,T^{B}_{nat}\big) ∣tA−tB∣<min(TnatA,TnatB)
这正是 DERP 侧信道的价值:它让控制面能协调双方「数三下一起发」,把 ∣ t A − t B ∣ |t_A-t_B| ∣tA−tB∣ 压到毫秒级。
4.3 对称 NAT 下的端口预测:生日悖论
如果非要在双边对称 NAT 下硬打,唯一办法是暴力猜端口。假设 NAT 从 N N N 个端口中随机分配,双方各发 k k k 个探测包,则至少命中一次的概率(生日悖论形式):
P hit = 1 − ( 1 − 1 N ) k 2 ≈ 1 − e − k 2 / N P_{\text{hit}}=1-\left(1-\frac{1}{N}\right)^{k^{2}}\approx 1-e^{-k^{2}/N} Phit=1−(1−N1)k2≈1−e−k2/N
取 N = 64512 N=64512 N=64512(49152–65535 之外的可用高位端口范围量级):
| 每侧探测包数 k k k | 命中概率 P hit P_{\text{hit}} Phit |
|---|---|
| 64 | ≈ 6.1 % \approx 6.1\% ≈6.1% |
| 128 | ≈ 22.3 % \approx 22.3\% ≈22.3% |
| 256 | ≈ 63.9 % \approx 63.9\% ≈63.9% |
| 400 | ≈ 91.7 % \approx 91.7\% ≈91.7% |
这就是为什么「暴力打洞」在实验室能演示、在生产不可靠:它需要发出 k 2 k^2 k2 量级的探测流量( k = 256 k=256 k=256 时约 6.5 万次尝试),在 4G 网络上会触发运营商限速甚至封端,且映射还在不断老化。所以 Tailscale 的策略是理性的:有限次赛马,失败就走 DERP,并在后台持续复探等网络变好。 官方内部指标显示典型环境下直连成功率 > 90 % >90\% >90%。
五、为什么「手机热点」几乎一定要走中继?
这是团队最常问的问题。答案是多个不利因素在手机热点场景下同时叠加。
5.1 双层甚至三层 NAT
机器人 192.168.43.x
│ ① 手机 Tethering NAT(Android/iOS 自己实现的 NAT,通常 APDM 且端口随机)
▼
手机 10.x.x.x / 100.64.x.x (运营商分配的私有地址)
│ ② 运营商 CGNAT(LSN,共享公网 IP,NAT44)
▼
公网 111.x.x.x:随机端口 (这个端口甚至不是固定的)
│ ③ 部分省份还有 NAT 池 / 多出口
▼
Internet
有效外部映射是多层复合函数:
M eff = ( g CGNAT ∘ g tether ) ( i p i , p i , i p d , p d ) M_{\text{eff}}=\big(g_{\text{CGNAT}}\circ g_{\text{tether}}\big)(ip_i,p_i,ip_d,p_d) Meff=(gCGNAT∘gtether)(ipi,pi,ipd,pd)
只要链路上任意一层是 APDM,整个复合映射就是 APDM。 手机热点这两层通常都是 APDM。于是 4.2 节的判据直接判死:
A P D M ( A ) ∧ A P D M ( B ) ⇒ ¬ D i r e c t ( A , B ) \mathrm{APDM}(A)\land \mathrm{APDM}(B)\ \Rightarrow\ \neg\mathrm{Direct}(A,B) APDM(A)∧APDM(B) ⇒ ¬Direct(A,B)
5.2 STUN 探测到的地址「拿到手就过期/失效」
STUN 的工作原理是:客户端把包发给 STUN 服务器,服务器把「我看到你来自 X:Y」告诉你。但在 APDM 下:
M ( … , i p STUN , 3478 ) ⏟ STUN 告诉你的 ≠ M ( … , i p B , p B ) ⏟ 对端实际需要的 \underbrace{M(\dots,ip_{\text{STUN}},3478)}_{\text{STUN 告诉你的}}\ \neq\ \underbrace{M(\dots,ip_{B},p_{B})}_{\text{对端实际需要的}} STUN 告诉你的 M(…,ipSTUN,3478) = 对端实际需要的 M(…,ipB,pB)
这不是「探测不到」,而是「探测到了但那是给 STUN 服务器专用的门,peer 走不了」。 所以你会看到 tailscale netcheck 输出:
$ tailscale netcheck
Report:
* UDP: true
* IPv4: yes, 111.x.x.x:38271
* IPv6: no, but OS has support
* MappingVariesByDestIP: true ← 罪魁祸首:APDM / 对称 NAT
* PortMapping: ← UPnP/PMP/PCP 全都没有
* Nearest DERP: Self-hosted Shanghai
* DERP latency: cn-sh: 31.2ms
MappingVariesByDestIP: true 基本就是「今天你注定走中继」的判决书。
5.3 端口映射协议全线失效
在家用路由器上,Tailscale 还能通过 UPnP IGD / NAT-PMP / PCP 主动向路由器申请一个稳定的外部端口,把 APDM 变成事实上的 EIM。但:
- 手机热点不实现 UPnP/NAT-PMP 服务;
- 运营商 CGNAT 绝对不可能让你申请端口映射(你连它的管理面都碰不到)。
所以「手动做端口转发」这条 Plan B 也没了。这正是 CGNAT 时代的核心困境。
5.4 IPv6 在 Tethering 下也常被削弱
前面说 IPv6 是救星,但手机热点场景下经常失效:
- 部分 Android/iOS 的热点对下游设备做 NAT66 或只下发 ULA,不做前缀委派(Prefix Delegation),机器人拿不到 GUA;
- 有的机型热点干脆只提供 IPv4;
- 运营商侧 v6 入向策略不一致。
5.5 移动网络的 NAT 映射老化窗口极短 + 手机省电
移动核心网的 UDP NAT 映射空闲超时常低至 30 s,甚至有 15 s 的实测案例。维持一条洞需要:
T keepalive < T n a t min T_{\text{keepalive}}<T^{\min}_{nat} Tkeepalive<Tnatmin
WireGuard 的 persistent-keepalive 与 Tailscale DISCO 心跳大约 25 s 25\ \text{s} 25 s 量级,本就贴着红线;再叠加手机的 Doze/省电模式对后台 UDP 的抑制、基站切换导致 NAT 表项迁移(外部 IP 直接变了),即使侥幸打通也会频繁掉洞、反复降级到中继。
5.6 实践结论与应对
| 场景 | 建议 |
|---|---|
| 手机热点调试 | 接受走中继,把 DERP 部署在离机器人近的国内机房;DERP 只中转密文,安全性不受影响 |
| 长期驻场机器人 | 优先争取有线/Wi-Fi + 公网 IPv6,或用支持 IPv6 的 4G 路由器代替手机热点 |
| 需要大带宽(回传 rosbag) | 把现场一台有较好网络的工控机设为 Subnet Router / Peer Relay(Tailscale ≥1.86 支持把自己网内节点当高吞吐中继),避免打满云主机带宽 |
| 排障口诀 | 先 tailscale netcheck 看 MappingVariesByDestIP 与 PortMapping,再 tailscale ping <peer> 看它是否从 via DERP 升级成 direct |
$ tailscale ping robot-a
pong from robot-a (100.64.0.11) via DERP(cn-sh) in 68ms
pong from robot-a (100.64.0.11) via DERP(cn-sh) in 64ms
pong from robot-a (100.64.0.11) via 113.x.x.x:41641 in 27ms ← 升级成功
六、和 frp / frpc 的区别:不是同一层的东西
我们最早就是用 frp 做的,所以这块体会很深。根本差异:frp 是「服务暴露」(Service Exposure),Headscale/Tailscale 是「网络组建」(Network Overlay)。
6.1 架构对比
frp(隧道/反向代理模型):所有流量都必须经过 frps
工程师 ──TCP──▶ frps(公网VPS):6001 ──隧道──▶ frpc(机器人) ──▶ 127.0.0.1:22
▲ 永久瓶颈 + 单点 + 带宽账单
Headscale/Tailscale(Overlay Mesh 模型):服务器只在控制面
工程师 ══════ WireGuard 直连(90%+)══════▶ 机器人
└── 控制面信令 ──▶ Headscale ◀── 控制面信令 ──┘
(打洞失败时才把 DERP 拉进数据面)
6.2 复杂度的数量级差异
frp 需要为每台设备的每个端口手工分配一个服务端端口并写配置:
C frp = ∑ i = 1 N m i ( 隧道条目数,且服务端端口必须全局唯一 ) C_{\text{frp}}=\sum_{i=1}^{N} m_i\quad(\text{隧道条目数,且服务端端口必须全局唯一}) Cfrp=i=1∑Nmi(隧道条目数,且服务端端口必须全局唯一)
我们 137 台机器人,每台至少要 SSH(22)、Web(8080)、RTSP(554)、DDS 若干、gdbserver、Jupyter…… 取 m i = 6 m_i=6 mi=6,就是 822 条隧道配置 + 822 个服务端端口。而且端口和设备的映射关系要靠一个 Excel 台账维护,换机器就得改配置、重启 frps。
Overlay 方案:
C mesh = N ( 每台设备一次 enroll,之后寻址靠固定 overlay IP ) C_{\text{mesh}}=N\quad(\text{每台设备一次 enroll,之后寻址靠固定 overlay IP}) Cmesh=N(每台设备一次 enroll,之后寻址靠固定 overlay IP)
且逻辑可达关系是全网状的 ( N 2 ) = N ( N − 1 ) 2 \binom{N}{2}=\frac{N(N-1)}{2} (2N)=2N(N−1) 条,但按需惰性建立(lazy connection),不会一开机就建 137 条隧道。
6.3 带宽与成本
frps 必须承载所有业务流量的双向:
B frps = ∑ i = 1 N ( b i ↑ + b i ↓ ) B_{\text{frps}}=\sum_{i=1}^{N}\big(b^{\uparrow}_i+b^{\downarrow}_i\big) Bfrps=i=1∑N(bi↑+bi↓)
3 个工程师各回传一个 500MB rosbag,云主机 5 Mbps 带宽就直接跪了。而 Headscale 控制面的流量是常数级心跳 + NetMap 增量,我们 137 台设备的控制面日均出网流量 < 200 MB。DERP 只在少数场景介入。
B headscale ≈ N ⋅ b heartbeat ≪ B frps B_{\text{headscale}}\approx N\cdot b_{\text{heartbeat}} \ll B_{\text{frps}} Bheadscale≈N⋅bheartbeat≪Bfrps
6.4 能力对比表
| 维度 | frp | Headscale + Tailscale |
|---|---|---|
| 抽象层次 | L4/L7 端口与请求转发 | L3 虚拟三层网络 |
| 数据路径 | 恒经服务器 | P2P 直连优先,中继兜底 |
| P2P 支持 | 有 xtcp(STUN 打洞),但需手动为每对配置、无自动升级/降级、跨平台与稳定性一般 | 内建、自动、透明、持续复探 |
| 配置规模 | O ( ∑ m i ) O(\sum m_i) O(∑mi),端口台账地狱 | O ( N ) O(N) O(N),固定 overlay IP |
| 双向可达 | 单向(只能「外→内」访问已暴露端口) | 双向,机器人也能主动上报/推数据 |
| UDP / 组播 | 支持 UDP 但每端口一条;DDS/组播基本不可用 | 原生 IP,UDP 正常;组播需 Subnet Router 侧处理 |
| ROS 2 / DDS | ❌ 几乎不可行(动态多端口、双向、需对端可路由 IP) | ✅ ROS_DOMAIN_ID + 单播 discovery 可用 |
| 整段内网可达 | ❌ 需为每个设备再套一层 | ✅ --advertise-routes 一行 |
| 加密 | 需自行开启 TLS/transport.tls | 默认端到端 WireGuard,中继看不到明文 |
| 身份与 ACL | token/端口级,粒度粗 | 身份/Tag/端口级 ACL + SSH 审计 |
| IP 漫游 | 机器人换网要重连隧道 | 无缝,路径自动重新赛马 |
| 适用场景 | 给不能装客户端的第三方暴露某个 HTTP 服务 | 自有设备舰队的运维与调试 |
我们现在的结论:frp 没有被淘汰,它被降级为一个专用工具——只用来把某个内部 Web 后台临时开放给不方便装客户端的客户,其余全部走 overlay。
七、和 NetBird 的区别:同一层,不同哲学
NetBird 和 Headscale 解决的是同一个问题(自托管 WireGuard Mesh + 中心化控制面),我们也做过一轮 PoC,最后选了 Headscale。差异如下。
7.1 架构差异
| Headscale + Tailscale | NetBird | |
|---|---|---|
| 客户端 | 官方 Tailscale 客户端(BSD-3,未修改) | 自研客户端 netbird(BSD-3) |
| 控制面 | Headscale(复刻 Tailscale 协议,BSD-3) | management(AGPLv3 since 0.53) |
| 信令 | 复用 DERP 长连接传 DISCO | 独立 signal 服务(gRPC) |
| 穿透栈 | 自研 DISCO + STUN + 端点赛马 | 标准 ICE(基于 pion) + STUN |
| 中继 | DERP(HTTPS/WSS over 443) | 自研 relay(WebSocket / QUIC,自动选优)+ 兼容 coturn/TURN |
| WireGuard 实现 | userspace(wireguard-go) | Linux 默认 内核态,也支持 userspace |
| 管理界面 | 无官方 UI(CLI + YAML),社区有 headscale-ui / Headplane | 官方 Web Dashboard,开箱即用 |
| 身份 | Tag / User / PreAuthKey | 深度 OIDC/SAML 集成(自带 Zitadel),SSO+MFA |
| 部署组件数 | 1 个二进制 + 1 个 SQLite/PG | management + signal + relay + dashboard + IdP(多容器) |
| 组织模型 | 单 tailnet | 多账户/多网络 |
7.2 为什么我们选了 Headscale(结合「机器人 + 无域名」这个具体约束)
-
客户端就是官方客户端,这在嵌入式场景是决定性优势。
我们要把它塞进 Jetson(aarch64)、x86 工控机、甚至几台 OpenWrt 网关的出厂镜像里。Tailscale 官方提供全平台静态二进制、OpenWrt/Alpine 包、Docker 镜像、以及--tun=userspace-networking(无 TUN 环境救命选项)。机器人固件五年不一定升级,我押注生态更大、更新更勤、被验证最多的那个客户端。 -
部署面积小 = 故障面积小。
Headscale 是一个 Go 二进制 + 一个数据库文件,DERP 和 STUN 都内嵌。整个控制面的备份就是cp headscale.sqlite。NetBird 完整自托管要跑 management / signal / relay / dashboard 4 个服务,标准姿势还要一个 Zitadel 做 IdP——在「只有一个 IP、没有域名」的约束下,多服务多端口的 TLS 与回调地址配置痛苦度指数上升(OIDC 回调对纯 IP 尤其不友好)。 -
无域名友好度。 见第 3.4 节,Headscale 只有 1 处证书要处理(
server_url+ 内嵌 DERP 复用同一监听)。NetBird 的 dashboard/OIDC/relay 各自都要可信 URL。 -
CLI/GitOps 优先。 我们的 ACL 是 HuJSON 文件进 Git、CI 里跑测试、
headscale policy set下发,天然符合基础设施即代码。NetBird 是 UI 优先(虽然有 API),对我们反而是负担。
7.3 什么情况下我会选 NetBird
诚实地讲,NetBird 在这些点上更强,如果你的场景匹配,应该选它:
- 要吞吐:Linux 内核态 WireGuard 相比 userspace 少了上下文切换和内存拷贝,单流吞吐优势明显(本文第三方评测中两者在数据中心间都能跑到 1.2–1.4 Gbps,但 NetBird 在同等 CPU 下更省)。如果你要在 overlay 上传大量视频流,这很重要。
- 要 Web UI 和 SSO:需要把访问权限交给非工程师同事管理、要 SSO/MFA/审计报表、要「策略可视化图谱」。
- 要全栈开源且被审计:Headscale 是对 Tailscale 闭源控制面协议的第三方复刻,理论上存在被上游协议变更打破的风险(历史上确实出现过客户端新版本与旧 Headscale 不兼容,必须锁定客户端版本区间)。NetBird 客户端与服务端同源同版本,无此风险。
- 多租户:Headscale 是单 tailnet 设计;要给多个客户各自隔离的网络,NetBird 更合适。
7.4 一个必须提前建立的运维纪律
无论选哪个,客户端版本必须锁定。我们的做法:
# 机器人镜像里固定版本,并禁用自动升级
apt-mark hold tailscale
tailscale version # 1.8x.y,与 Headscale vX.Y 的兼容矩阵登记在内部 Wiki
Headscale 官方文档明确列出了每个版本支持的 Tailscale 客户端范围,升级顺序永远是:先升 Headscale,验证,再分批升客户端(灰度 5 台 → 20 台 → 全量)。踩过一次「客户端自动更新后全体掉线」的坑就懂了。
八、踩坑清单
udp/3478必须在云主机安全组放行,否则 STUN 全废,直连率暴跌到接近 0,而现象只是「有点慢」,极难察觉。- 反向代理不开 WebSocket → DERP 与新客户端连不上,日志报
derphttp: connect: unexpected status 400。 - 忘记
headscale nodes approve-routes→--advertise-routes声明了但整段内网就是不通。 --accept-routes=true打在机器人上 → 机器人把办公室路由吸进来,车内网 IP 冲突,现场直接失联(我们真炸过一台,要人跑现场)。100.64.0.0/10与运营商 CGNAT 撞段:Tailscale 选这个段本身是为了少撞家用192.168/10.x,但它正好和 4G CGNAT 同段。若机器人的 WAN 地址是100.64.x.x,务必检查路由优先级,必要时用prefixes.v4换到100.100.0.0/16之外的其它子段并配合策略路由。- 自签 CA 的有效期与时间同步:机器人断电久了 RTC 漂移,证书「尚未生效」导致 enroll 失败。开机脚本先
chronyd -q对时再tailscale up。 - MTU 黑洞:能 ssh 上但
git clone/scp卡死 → 上 MSS clamping。 - DERP 单点:DERP 挂了,已建立的直连不受影响,但新连接和掉洞重连全废。至少部署两个 region(不同机房),写进
derp.yaml。 - NAT 类型自检脚本化:把
tailscale netcheck --format=json采集进监控,把MappingVariesByDestIP和direct/relay比例做成看板,比工程师口头反馈「今天有点慢」有用。
久、总结
- Overlay Mesh VPN 与反向代理不是竞品,是不同抽象层:frp 暴露「服务」,Headscale/Tailscale 组建「网络」。机器人舰队运维要的是后者。
- 一句话记住控制面/数据面分离:Headscale 只做介绍人, ∼ 90 % \sim 90\% ∼90% 的流量根本不经过你的服务器,所以一台 2C2G 小云主机能撑起上百台设备。
- 能否 P2P 的判据是 NAT 的映射行为,而不是「有没有公网 IP」:
D i r e c t ( A , B ) ⟺ ( E I M ( A ) ∨ E I M ( B ) ) ∧ ¬ ( A P D M ( A ) ∧ A P D M ( B ) ) \mathrm{Direct}(A,B)\Longleftrightarrow\big(\mathrm{EIM}(A)\lor\mathrm{EIM}(B)\big)\land\neg\big(\mathrm{APDM}(A)\land\mathrm{APDM}(B)\big) Direct(A,B)⟺(EIM(A)∨EIM(B))∧¬(APDM(A)∧APDM(B)) - 手机热点打不通是必然,不是 bug:Tethering NAT ⊕ 运营商 CGNAT 双层 APDM + 无 UPnP + 30s 短映射老化 + 省电抑制,五个因素叠加,直接命中判据的否定分支。接受中继,并把 DERP 放到离设备近的机房。
- 提升直连率的最高性价比动作是打通 IPv6,而不是折腾打洞参数。
- 没有域名完全可以做生产:自建 CA + SAN 含 IP 的证书 + CA 预置进出厂镜像,同时 MagicDNS 本来就不依赖公网 DNS。
- Headscale vs NetBird:要「官方客户端 + 极简部署 + GitOps」选 Headscale;要「内核态吞吐 + Web UI + SSO + 多租户 + 全栈同源开源」选 NetBird。
参考资料
- Tailscale, How NAT traversal works — https://tailscale.com/blog/how-nat-traversal-works
- Tailscale, How Tailscale is improving NAT traversal (part 1) — https://tailscale.com/blog/nat-traversal-improvements-pt-1
- Tailscale Docs, DERP servers / Peer Relays — https://tailscale.com/kb/1232/derp-servers ・ https://tailscale.com/docs/features/peer-relay
- Headscale Docs, DERP / Reverse proxy — https://headscale.net/stable/ref/derp/
- RFC 4787(NAT UDP 行为要求)、RFC 5389(STUN)、RFC 6598(
100.64.0.0/10) - NetBird 官方文档与架构说明 — https://netbird.io
Learn more:
- Solving the NAT Traversal Nightmare
- Tailscale Peer Relays · Tailscale Docs
- How Tailscale is improving NAT traversal (part 1)
- DERP servers · Tailscale Docs
- wikipedia.org
- How NAT traversal works
- NetBird vs Tailscale vs WireGuard: Compared
- Cloudflare Mesh vs NetBird vs Tailscale
- NetBird vs Headscale vs Tailscale
- NetBird vs Tailscale vs Headscale (2026): A Mesh VPN Comparison for Private Cloud Access
- What Is Headscale? Self-hosted Tailscale
- Tailscale vs. NetBird
- headscale/docs/reverse-proxy.md at v0.22.3 · juanfont/headscale · GitHub
- Reverse proxy
- Reverse proxy
- Reverse proxy
- Headscale
- headscale/docs/ref/derp.md at main · juanfont/headscale · GitHub
- The Ultimate Guide to Remote Access 2026: Tailscale vs. Cloudflare vs. Netmaker vs. FRP & More
- Reverse Proxy vs WireGuard vs Tailscale – Zima Store Online
- Tailscale + Reverse Proxy vs VPN-Only Access – Zima Store Online
- Cloudflare Mesh vs NetBird vs Tailscale
- Cloudflare WARP vs Tailscale vs. Pangolin vs. Headscale — practical comparison (who/where to use, where not to) · GitHub
- Tailscale vs Reverse Proxy vs VPN: How Should You Access Self-Hosted Services Remotely?
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)