要把北京一台 OpenWrt 路由器(下称 bj-router)上的一个 30GB 压缩包传到纽约的 Mac(下称 mac)。第一反应是 cp,报错才想起来这是跨机器,改 scp。结果连不上——密码试了几次直接被目标机器判定为暴力破解,“Too many authentication failures”。
这本该是个五分钟的任务。最后变成了一次贯穿网络层、Tailscale ACL、对象存储 API 的连环排查。
起点:一个 scp 引出的慢速传输
问清楚账号密码,scp 终于连上了,但传输速度用肉眼就能感觉到不对——挂了几分钟进度条几乎不动。用 iperf3 单独测一下 bj-router 到 mac 的 Tailscale 直连带宽:
iperf3 -c 100.85.213.9 -p 5201 -t 10[ 5] 0.00-10.01 sec 256 KBytes 210 Kbits/sec 34 sender210 Kbits/sec。 30GB 的文件按这个速度算,需要 13 天以上。这已经不是”慢”,是”不可用”。
第一轮:先排除本机网络的问题
在怀疑任何远端设备之前,先确认本机(mac)自己的网络没问题。macOS 自带的 networkQuality 工具可以测真实互联网吞吐,不经过任何 VPN:
networkQuality -sUplink capacity: 300.340 MbpsDownlink capacity: 738.625 MbpsIdle Latency: 12.707 milliseconds上行 300Mbps,下行 738Mbps,延迟 12.7ms——本机网络完全健康。这排除了”本机 ISP 太差”这个最省事的解释,问题得往 Tailscale 或者对端身上找。
第二轮:会不会是 Tailscale 本身的问题
Tailscale 用 WireGuard 做隧道,一个常见猜测是 WireGuard 封装本身在这条链路上表现差。测试方法很直接:找一台跟 mac 同城的机器(下称 ny-vps,纽约的一台云主机),走 Tailscale 测一遍:
iperf3 -c 100.89.162.124 -p 5203 -t 10[ 5] 0.00-10.00 sec 147 MBytes 123 Mbits/sec 238 sender123 Mbps,延迟 12-16ms,重传只有 238 次。 干净得很。同一台 mac,同一个 Tailscale 客户端,只是换了个同城对端,速度直接从 210Kbps 跳到 123Mbps。
这就排除了”Tailscale/WireGuard 本身是通用瓶颈”的假设——瓶颈跟协议无关,跟这条具体链路的物理路径有关。
第三轮:绕开 Tailscale,测裸公网 IP
如果瓶颈是跨境物理链路本身的问题,那即使完全不走 Tailscale,直接裸公网 IP 之间跑流量,应该也快不到哪去。找一台北京的云主机(下称 bj-vps,有公网 IP)直接连 ny-vps 的公网 IP,完全绕开 Tailscale:
iperf3 -c <ny-vps公网IP> -p 5203 -t 10[ 5] 0.00-10.00 sec 16.8 MBytes 14.0 Mbits/sec 4856 sender[ 5] 0.00-10.25 sec 13.6 MBytes 11.2 Mbits/sec receiver11-14 Mbps,但重传高达 4856 次/10秒。 换算下来丢包率接近 40%。跟 Tailscale 隧道内的 210Kbps 比确实快了几十倍,但离”干净”还差得远——这条国际链路本身就在丢包,Tailscale/WireGuard 只是把这个已经不干净的链路进一步压到了 Kbps 级。
这个数量级差异不是噪声。按 Mathis 公式,TCP 单流吞吐大致跟 1/√(丢包率) 成反比,高延迟叠加哪怕零点几个百分点的丢包,都会把吞吐砸得很低——这解释了裸 TCP 为什么也上不去,但没解释 WireGuard 隧道为什么比裸 TCP 还差十倍。
搜了一下,这个现象在跑国际链路的 WireGuard/OpenVPN 场景下有据可查:GFW 对标准 WireGuard 握手包有专门的行为特征识别,UDP 类协议容易被单独限速,而同一条链路上的 TCP 流量不受影响——如果 TCP 能连上但慢、UDP 直接跑不动,是这类限速的典型症状。这跟我们观察到的现象吻合,但没有做包级抓取验证具体机制,只能算一个高置信度但未经证实的猜测。
顺手排除一个假设:多路并发流
高延迟+丢包的链路,理论上多开几条 TCP 并发流能提升聚合吞吐(每条流独立增长拥塞窗口)。测了一下:
iperf3 -c <ny-vps公网IP> -p 5207 -t 10 -P 8[SUM] 0.00-10.00 sec 28.4 MBytes 23.8 Mbits/sec 3690 sender[SUM] 0.00-10.26 sec 13.9 MBytes 11.3 Mbits/sec receiver8 路并发,聚合吞吐还是 11.3Mbps,跟单流几乎一样。这说明瓶颈更像是链路总带宽硬顶(可能是国际出口整体限速),不是单流拥塞控制效率问题——加并发流分不到更多带宽,这条优化路子直接堵死。
第四轮:搭建中转链路,撞上 Tailscale ACL 的墙
跨境链路本身就是瓶颈,那能不能借道中转?思路是:bj-router → bj-vps(同城北京,应该很快)→ ny-vps(同城纽约,应该很快),只让”跨境”这一段走裸公网 IP。
先测 bj-router 到 bj-vps 这一段(同城应该很快):
iperf3 -c 100.85.213.9 -p 5204 -t 8结果卡住,超过预期时间还没完成。换个方式验证纯 TCP 连通性:
nc -zv -w 5 100.85.213.9 22nc: connect to 100.85.213.9 port 22 (tcp) timed out连 SSH 端口都连不上,但 tailscale ping 却显示 15ms 的 direct 连接。 ICMP 通,TCP/UDP 全部超时。检查了 ufw(inactive,不是防火墙问题),检查了 tailscale netcheck(两边 NAT 类型都正常,MappingVariesByDestIP: false,理论上直连打洞应该没问题)。
反过来试一下方向:让 bj-router 主动连 bj-vps:
ssh bj-router "echo | nc bj-vps-tailscale-ip 22"SSH-2.0-OpenSSH_9.6p1 Ubuntu-3ubuntu13.18反方向通了。 同一对机器,bj-vps 连 bj-router 不通,bj-router 连 bj-vps 通。这不是网络故障,是单向的。
查了一下 Tailscale 的账户归属:bj-router 和 mac 都是我自己账号下的”owned devices”,bj-vps、ny-vps、还有一台备用云主机(下称 spare-vps)都是 tagged devices(用 tag 标记的服务器)。Tailscale 的 ACL 文档明确写了:tag 之间不会自动获得互通权限,需要显式的 grant 规则。这基本对应了一个常见的默认安全模型——用户自己的设备可以访问所有打了 tag 的服务器,但服务器之间、以及服务器反向连用户设备,都需要单独授权,默认是不通的。
补测了一下 tagged devices 互相之间的连通性,直接更彻底:
ssh bj-vps "tailscale ping spare-vps-tailscale-ip"no matching peerbj-vps 的 netmap 里压根看不到 spare-vps 这个 peer——不是超时,是根本不在列表里。这印证了”tagged devices 互相隔离”是这套 ACL 里刻意的设计,不是配置遗漏。
后来跟 ACL 的所有者确认过,这个隔离是有意为之——服务器之间互相隔离能防止一台被攻破后横向渗透到另一台,只有可信的用户设备能触达所有服务器。是标准的 hub-and-spoke 安全模型,只是这次刚好撞在了”想拿两台服务器接力”这个需求上。
找对方向,中转链路终于跑起来
既然反方向(owned device 主动发起)能通,把中转链路的发起方向倒过来:bj-router 主动推给 bj-vps,而不是 bj-vps 去拉。
# 在 bj-router 上执行,主动连 bj-vpsiperf3 -c bj-vps-tailscale-ip -p 5206 -t 8[ 5] 0.00-8.00 sec 103 MBytes 108 Mbits/sec 1144 sender106-108 Mbps。 bj-router 和 bj-vps 本来就同城,一旦方向对了,速度立刻正常。
至此,可用的中转链路方向确定:
bj-router --(Tailscale, 同城北京, ~106Mbps)--> bj-vps --(裸公网IP, 跨境瓶颈, ~11Mbps)--> ny-vps --(Tailscale, 同城纽约, ~123Mbps)--> mac瓶颈段还是那段跨境链路(~11Mbps),但比直连的 210Kbps 已经快了 50 多倍。按这个速率,30GB 大约需要 6 小时——可以接受,但也不算快。
磁盘空间逼出来的切片方案
bj-vps 只有 26GB 可用磁盘,装不下整个 30GB 文件。于是切片:
split -b 5G 240216.rar chunk_切成 6 片 5GB + 1 片 152MB,共 7 片。流程变成:每片 bj-router→bj-vps→ny-vps 走完、确认到达后删除 bj-vps 上的副本腾空间,再传下一片。给两跳分别配了临时 SSH 密钥对(生成一次性 keypair,公钥追加到下一跳的 authorized_keys,传完删除),避免动用长期凭据。
这套流水线跑起来了,第一片(chunk_aa)5GB 全链路走完确认无误,第二片在传的时候——想起了平时自己维护的一套内部服务里,正好有个对象存储网关。
第五轮:发现更快的路:直传 Cloudflare R2
ny-vps 不只是个中转跳板,它本身是我自己那套 lishuyu.app 网关服务的生产机,上面跑着一个对象存储服务 oss,后端是 Cloudflare R2,通过 api.lishuyu.app/oss/ 暴露 presigned URL 接口。
测了一下 bj-router 直传 R2(不经过任何中转):
bj-router → R2: ~42.6 MbpsR2 → mac: ~40 Mbps比中转链路的 11Mbps 快了将近 4 倍,而且不用占用 bj-vps 的流量(云主机流量是计费的,实测 bj-vps 直传 R2 反而只有 5.2Mbps,比 bj-router 家庭宽带还慢,猜测是云厂商对出向流量做了限速或者路由不同)。
R2 的这套 oss 服务之前专门写过一篇《同样是托管一个文本文件,为什么一个能打开一个只会下载》,当时聊的是 presigned URL 的 Content-Disposition 语义问题。这次用到的是同一套服务的 multipart 上传接口,流程是:
# 1. 初始化 multipart upload,拿 upload_idcurl -X POST -H "Authorization: Bearer $TOKEN" \ -d '{"key":"chunk_ac","content_type":"application/octet-stream"}' \ "https://api.lishuyu.app/oss/k50backup/multipart/init"
# 2. 对每个 part 单独拿 presigned PUT URL(15分钟过期)curl -H "Authorization: Bearer $TOKEN" \ "https://api.lishuyu.app/oss/k50backup/multipart/$UPLOAD_ID/part/1"
# 3. PUT 上传,响应头里的 ETag 要记下来curl -X PUT -T chunk_ac.part1 "$PRESIGNED_URL"
# 4. 全部 part 传完,提交 ETag 列表完成上传curl -X POST -d '{"parts":[{"PartNumber":1,"ETag":"..."}]}' \ "https://api.lishuyu.app/oss/k50backup/multipart/$UPLOAD_ID/complete"于是把还没走完中转链路的几片(chunk_ac 到 chunk_ag)全部改道走 R2 直传,chunk_aa 和 chunk_ab 已经走完中转链路的就不折腾了,留着不动。
踩坑一:curl —data-binary 把 2.68GB 的 part 读进内存炸了
R2 的 multipart 单 part 有大小限制。查文档才发现限制卡得很紧:单 part 最大是 5GiB 减 5MiB,实际是 4.995GiB,而我最初切的 5GB part 正好是 5*1024^3 = 5368709120 字节,精确等于 5GiB——超过实际限制,会被拒绝。 于是把每个 5GB 的 chunk 又切成两个约 2.68GB 的 part,留足余量。
第一次上传用的是常规写法:
curl -X PUT --data-binary @chunk_ac.part1 "$PRESIGNED_URL"curl: option --data-binary: out of memorybj-router 是一台 4 核 Cortex-A53、内存只有 1.8GB 的 ARM 小盒子。--data-binary @file 会把整个文件读进内存再发送——2.68GB 的文件对 1.8GB 内存的机器来说必炸无疑。查了一下,curl 7.73 版本之后引入了 MAX_FILE2MEMORY 限制,设为 1GB:用 --data-binary 传超过 1GB 的内容就会直接报 OOM,跟机器内存大小无关,是 curl 自己内置的硬限制。
换成流式上传就没问题:
curl -T chunk_ac.part1 "$PRESIGNED_URL"-T(--upload-file)是边读边发,不需要把整个文件放进内存,用多大文件都不会 OOM。这条踩坑挺基础,但因为一开始默认用惯了 --data-binary,加上小机器内存不够才第一次暴露出来。
踩坑二:网络偶发中断,curl 报 exit 56
换成流式上传后,大部分 part 都顺利传完,但 chunk_ad 的 part2 传到一半报了错:
EXIT=5656 是 CURLE_RECV_ERROR——接收数据时连接被断开。bj-router 是家庭宽带出口,长时间大文件传输偶尔断线不算意外。加上重试参数:
curl --retry 3 --retry-delay 2 -T chunk_ad.part2 "$PRESIGNED_URL"重传一次就成功了。这里的教训是:不能只看 HTTP 响应码判断成功,200 OK 只代表某一次请求的响应头正常,不代表整个流式传输过程没有中途断掉——curl 自己的退出码才是最终裁判,每次 PUT 完都要显式 echo EXIT=$? 确认是不是 0。
判断某个后台任务是否真的传完,也别自己写脚本 grep 响应文件里有没有 ETag——响应文件可能只写了一半(比如只有 100 Continue 那一行还没写到 200 OK),grep 会在传输还没完成时就误判为”完成”。靠谱的做法是等后台任务进程自身的退出信号,那才是真正可信的完成标记。
收尾:拼接与校验
7 片全部到齐(chunk_aa、chunk_ab 来自中转链路,chunk_ac 到 chunk_ag 来自 R2),拉回本地:
cat chunk_aa chunk_ab chunk_ac chunk_ad chunk_ae chunk_af chunk_ag > 240216.rarmd5 -q 240216.rar4f5b4d8f3c5358c4875f3b69ef14266c跟 bj-router 上原始文件的 md5(提前算好留存的)逐字符一致。30,364,680,381 字节,一个字节没错。
清理收尾:删掉 R2 bucket 里的临时对象、bj-vps 和 ny-vps 上互相追加的临时 SSH 公钥授权和私钥、bj-router 上残留的切片文件,以及排查过程里在各台机器上留下的 iperf3 测试进程。
经验总结
-
诊断要按”层”剥离,不要一上来就怀疑最复杂的那个环节。这次的排查顺序是:本机网络 → 隧道协议 → 裸公网链路 → 中转拓扑 → ACL 权限,每一层都用一个独立、干净的对照实验去证伪,而不是靠猜。最容易被跳过的往往是最基础的那一层(这次是”本机网络正常吗”),但跳过了就会在后面的层上瞎绕。
-
同城对照组比任何理论分析都管用。怀疑”是不是 Tailscale 本身慢”,最快的验证方式不是查文档,是找一台同城机器测一遍——如果同城也慢,问题在协议/客户端;如果同城很快,问题在具体这条链路的物理路径。这次两次用到同城对照(mac↔ny-vps,bj-router↔bj-vps),一次都指向”链路问题不是协议问题”。
-
Tailscale 的 tag 隔离是默认行为,不是 bug。用户自己的设备(owned devices)访问所有 tagged 设备没问题,但 tagged 设备互相之间、以及反向连用户设备,都需要显式 grant。排查”为什么 ping 通但 TCP 不通”这类现象时,如果两端都是同一个 tailnet 但账户归属不同,先查 ACL 再查网络层。
-
多路并发流不是万能药。它对”单流拥塞控制效率低”这类问题有效,但对”链路总带宽被硬性限速”这类问题完全无效——这次 8 路并发和单流的聚合吞吐几乎一样,说白了就是分不到更多带宽。先测清楚瓶颈的性质,再决定要不要花力气做并发优化。
-
一次性用途的凭据(临时 SSH 密钥、presigned URL)比长期凭据更适合搭临时中转链路。中途要跨好几台不完全互信的机器,每跳单独生成一次性密钥对,用完就从
authorized_keys里删掉,比图省事直接共享长期私钥要干净得多。 -
判断长时间任务是否真的完成,永远信进程自身的退出信号,不要信”看起来正常”的响应内容。这条在这次踩了两次坑(
--data-binary的 OOM 错误、curl exit 56 的静默失败),都是因为一开始只看了响应体/响应头觉得”看起来没问题”。
关联文章
- 同样是托管一个文本文件,为什么一个能打开一个只会下载 — 同一套
oss服务,上次聊的是 presigned URL 的 disposition 语义 - Verizon Fios 的隐形 MAC 封锁 — 另一次 Tailscale 相关的连环排查,同样是”ping 通但数据不通”的症状,根因完全不同
- Tailscale DNS 死锁 — Tailscale 接管 DNS 后自身掉线的另一种诡异表现