3835 字
19 分钟
一个 scp 引出的连环排查:跨境链路瓶颈、Tailscale ACL 隔离与 R2 接力传输

要把北京一台 OpenWrt 路由器(下称 bj-router)上的一个 30GB 压缩包传到纽约的 Mac(下称 mac)。第一反应是 cp,报错才想起来这是跨机器,改 scp。结果连不上——密码试了几次直接被目标机器判定为暴力破解,“Too many authentication failures”。

这本该是个五分钟的任务。最后变成了一次贯穿网络层、Tailscale ACL、对象存储 API 的连环排查。

起点:一个 scp 引出的慢速传输#

问清楚账号密码,scp 终于连上了,但传输速度用肉眼就能感觉到不对——挂了几分钟进度条几乎不动。用 iperf3 单独测一下 bj-router 到 mac 的 Tailscale 直连带宽:

Terminal window
iperf3 -c 100.85.213.9 -p 5201 -t 10
[ 5] 0.00-10.01 sec 256 KBytes 210 Kbits/sec 34 sender

210 Kbits/sec。 30GB 的文件按这个速度算,需要 13 天以上。这已经不是”慢”,是”不可用”。

第一轮:先排除本机网络的问题#

在怀疑任何远端设备之前,先确认本机(mac)自己的网络没问题。macOS 自带的 networkQuality 工具可以测真实互联网吞吐,不经过任何 VPN:

Terminal window
networkQuality -s
Uplink capacity: 300.340 Mbps
Downlink capacity: 738.625 Mbps
Idle Latency: 12.707 milliseconds

上行 300Mbps,下行 738Mbps,延迟 12.7ms——本机网络完全健康。这排除了”本机 ISP 太差”这个最省事的解释,问题得往 Tailscale 或者对端身上找。

第二轮:会不会是 Tailscale 本身的问题#

Tailscale 用 WireGuard 做隧道,一个常见猜测是 WireGuard 封装本身在这条链路上表现差。测试方法很直接:找一台跟 mac 同城的机器(下称 ny-vps,纽约的一台云主机),走 Tailscale 测一遍:

Terminal window
iperf3 -c 100.89.162.124 -p 5203 -t 10
[ 5] 0.00-10.00 sec 147 MBytes 123 Mbits/sec 238 sender

123 Mbps,延迟 12-16ms,重传只有 238 次。 干净得很。同一台 mac,同一个 Tailscale 客户端,只是换了个同城对端,速度直接从 210Kbps 跳到 123Mbps。

这就排除了”Tailscale/WireGuard 本身是通用瓶颈”的假设——瓶颈跟协议无关,跟这条具体链路的物理路径有关。

第三轮:绕开 Tailscale,测裸公网 IP#

如果瓶颈是跨境物理链路本身的问题,那即使完全不走 Tailscale,直接裸公网 IP 之间跑流量,应该也快不到哪去。找一台北京的云主机(下称 bj-vps,有公网 IP)直接连 ny-vps 的公网 IP,完全绕开 Tailscale:

Terminal window
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 receiver

11-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 并发流能提升聚合吞吐(每条流独立增长拥塞窗口)。测了一下:

Terminal window
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 receiver

8 路并发,聚合吞吐还是 11.3Mbps,跟单流几乎一样。这说明瓶颈更像是链路总带宽硬顶(可能是国际出口整体限速),不是单流拥塞控制效率问题——加并发流分不到更多带宽,这条优化路子直接堵死。

第四轮:搭建中转链路,撞上 Tailscale ACL 的墙#

跨境链路本身就是瓶颈,那能不能借道中转?思路是:bj-router → bj-vps(同城北京,应该很快)→ ny-vps(同城纽约,应该很快),只让”跨境”这一段走裸公网 IP。

先测 bj-router 到 bj-vps 这一段(同城应该很快):

Terminal window
iperf3 -c 100.85.213.9 -p 5204 -t 8

结果卡住,超过预期时间还没完成。换个方式验证纯 TCP 连通性:

Terminal window
nc -zv -w 5 100.85.213.9 22
nc: 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:

Terminal window
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 互相之间的连通性,直接更彻底:

Terminal window
ssh bj-vps "tailscale ping spare-vps-tailscale-ip"
no matching peer

bj-vps 的 netmap 里压根看不到 spare-vps 这个 peer——不是超时,是根本不在列表里。这印证了”tagged devices 互相隔离”是这套 ACL 里刻意的设计,不是配置遗漏。

后来跟 ACL 的所有者确认过,这个隔离是有意为之——服务器之间互相隔离能防止一台被攻破后横向渗透到另一台,只有可信的用户设备能触达所有服务器。是标准的 hub-and-spoke 安全模型,只是这次刚好撞在了”想拿两台服务器接力”这个需求上。

找对方向,中转链路终于跑起来#

既然反方向(owned device 主动发起)能通,把中转链路的发起方向倒过来:bj-router 主动推给 bj-vps,而不是 bj-vps 去拉。

Terminal window
# 在 bj-router 上执行,主动连 bj-vps
iperf3 -c bj-vps-tailscale-ip -p 5206 -t 8
[ 5] 0.00-8.00 sec 103 MBytes 108 Mbits/sec 1144 sender

106-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 文件。于是切片:

Terminal window
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 Mbps
R2 → mac: ~40 Mbps

比中转链路的 11Mbps 快了将近 4 倍,而且不用占用 bj-vps 的流量(云主机流量是计费的,实测 bj-vps 直传 R2 反而只有 5.2Mbps,比 bj-router 家庭宽带还慢,猜测是云厂商对出向流量做了限速或者路由不同)。

R2 的这套 oss 服务之前专门写过一篇《同样是托管一个文本文件,为什么一个能打开一个只会下载》,当时聊的是 presigned URL 的 Content-Disposition 语义问题。这次用到的是同一套服务的 multipart 上传接口,流程是:

Terminal window
# 1. 初始化 multipart upload,拿 upload_id
curl -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,留足余量。

第一次上传用的是常规写法:

Terminal window
curl -X PUT --data-binary @chunk_ac.part1 "$PRESIGNED_URL"
curl: option --data-binary: out of memory

bj-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 自己内置的硬限制。

换成流式上传就没问题:

Terminal window
curl -T chunk_ac.part1 "$PRESIGNED_URL"

-T--upload-file)是边读边发,不需要把整个文件放进内存,用多大文件都不会 OOM。这条踩坑挺基础,但因为一开始默认用惯了 --data-binary,加上小机器内存不够才第一次暴露出来。

踩坑二:网络偶发中断,curl 报 exit 56#

换成流式上传后,大部分 part 都顺利传完,但 chunk_ad 的 part2 传到一半报了错:

EXIT=56

56CURLE_RECV_ERROR——接收数据时连接被断开。bj-router 是家庭宽带出口,长时间大文件传输偶尔断线不算意外。加上重试参数:

Terminal window
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),拉回本地:

Terminal window
cat chunk_aa chunk_ab chunk_ac chunk_ad chunk_ae chunk_af chunk_ag > 240216.rar
Terminal window
md5 -q 240216.rar
4f5b4d8f3c5358c4875f3b69ef14266c

跟 bj-router 上原始文件的 md5(提前算好留存的)逐字符一致。30,364,680,381 字节,一个字节没错。

清理收尾:删掉 R2 bucket 里的临时对象、bj-vps 和 ny-vps 上互相追加的临时 SSH 公钥授权和私钥、bj-router 上残留的切片文件,以及排查过程里在各台机器上留下的 iperf3 测试进程。

经验总结#

  1. 诊断要按”层”剥离,不要一上来就怀疑最复杂的那个环节。这次的排查顺序是:本机网络 → 隧道协议 → 裸公网链路 → 中转拓扑 → ACL 权限,每一层都用一个独立、干净的对照实验去证伪,而不是靠猜。最容易被跳过的往往是最基础的那一层(这次是”本机网络正常吗”),但跳过了就会在后面的层上瞎绕。

  2. 同城对照组比任何理论分析都管用。怀疑”是不是 Tailscale 本身慢”,最快的验证方式不是查文档,是找一台同城机器测一遍——如果同城也慢,问题在协议/客户端;如果同城很快,问题在具体这条链路的物理路径。这次两次用到同城对照(mac↔ny-vps,bj-router↔bj-vps),一次都指向”链路问题不是协议问题”。

  3. Tailscale 的 tag 隔离是默认行为,不是 bug。用户自己的设备(owned devices)访问所有 tagged 设备没问题,但 tagged 设备互相之间、以及反向连用户设备,都需要显式 grant。排查”为什么 ping 通但 TCP 不通”这类现象时,如果两端都是同一个 tailnet 但账户归属不同,先查 ACL 再查网络层。

  4. 多路并发流不是万能药。它对”单流拥塞控制效率低”这类问题有效,但对”链路总带宽被硬性限速”这类问题完全无效——这次 8 路并发和单流的聚合吞吐几乎一样,说白了就是分不到更多带宽。先测清楚瓶颈的性质,再决定要不要花力气做并发优化。

  5. 一次性用途的凭据(临时 SSH 密钥、presigned URL)比长期凭据更适合搭临时中转链路。中途要跨好几台不完全互信的机器,每跳单独生成一次性密钥对,用完就从 authorized_keys 里删掉,比图省事直接共享长期私钥要干净得多。

  6. 判断长时间任务是否真的完成,永远信进程自身的退出信号,不要信”看起来正常”的响应内容。这条在这次踩了两次坑(--data-binary 的 OOM 错误、curl exit 56 的静默失败),都是因为一开始只看了响应体/响应头觉得”看起来没问题”。

关联文章#

一个 scp 引出的连环排查:跨境链路瓶颈、Tailscale ACL 隔离与 R2 接力传输
https://blog.lishuyu.app/posts/跨境30gb传输排查-tailscale隔离与r2接力/
作者
猫猫魔女
发布于
2026-08-03
许可协议
CC BY-NC-SA 4.0