加载中...
不想等待可以点我关掉

条件背景

网络条件:

  1. 校园网环境下,IPv4 单连接限速30兆,多连接限速100兆;IPv6 无限速,链路上限千兆
  2. 端口限制锥形 NAT(Port-Restricted Cone NAT)
  3. IPv4 为内网 IP,IPv6 是公网,但有防火墙拦截
  4. 学校 WebVPN 不设白名单

网络设施:

  1. 前期为实现校外访问,已使用 EasyTier 共享实验室子网网段
  2. 阿里云服务器,5兆带宽,IPv4 公网
  3. 境内内网穿透,20兆带宽
  4. 境外 Cloudflare Tunnel

现实状况:

  1. 数据采集设备连接公司内网仅 IPv4,无 IPv6;上下行百兆对等,无限速
  2. 标注员使用酒店热点,热点由光猫提供,需额外设置开启 IPv6,看作仅 IPv4,下行百兆
  3. 单图片压缩后主要有15MiB和3MiB两种规格
  4. 单日最高数据量达200+GiB,稳定下来后大概60+GiB,后期正式上线数据量更大

部分条件是后期加的,下文基本都是局部最优解,不断改进。

需求

flowchart LR
    A[采集设备] -->|原始采集数据| B[校内服务器]
    B --> C[人工筛查]
    C -->|筛查后上传| D[标注平台]
    D -->|分发标注任务| E[其他标注员]
    E -->|提交标注结果| D
    B -->|上传的数据| F[数据分析与测试]

可见有两处瓶颈:

  1. 数据回传
  2. 数据分发

数据回传

在实际勘察网络环境前,我还是对客户那边的网络环境保有一定幻想的,万一他们有 IPv6 呢?就不用搞那么麻烦了,点对点直连。

于是部署 EasyTier 尝试建立连接,结果设备搬进去后才发现,同样只有 IPv4,打洞也不成功。

只好下单内网穿透服务,分别映射采集设备侧和学校侧的 EasyTier TCP 端口,再让其他设备手动指定节点进行连接。同时也测试了校内用 Cloudflare Tunnel 映射 WS 协议来连接,估计是因为单连接的原因,只能说速度感人。

flowchart LR
    subgraph 采集端
        DEV[采集设备]
        ET_DEV[EasyTier 客户端]
        DEV --> ET_DEV
    end

    subgraph 其他设备
        ET_OTHER[其他 EasyTier 客户端]
    end

    subgraph 公网
        FRPS[FRP 服务端 frps
公网 IP:映射端口] end subgraph 校内服务器 FRPC[FRP 客户端 frpc] ET_SRV[EasyTier 客户端
监听 127.0.0.1:11010] SVC[数据接收 / 业务服务] FRPC -->|转发到本地 11010| ET_SRV ET_SRV --> SVC end ET_DEV -->|连接 公网IP:映射端口| FRPS ET_OTHER -->|连接 同一公网映射端口| FRPS FRPS -->|FRP 隧道| FRPC ET_DEV -.->|EasyTier 虚拟网络| ET_SRV ET_OTHER -.->|EasyTier 虚拟网络| ET_SRV ET_DEV -->|原始采集数据经虚拟网络| SVC

按 70 GiB 的数据量计算,即使跑满境内内网穿透带宽,也需要近 8 小时才能完成同步。时间长是一方面,主要是用量,国内的带宽和流量还是很贵的,虽然我买的是不限量套餐,但一直长时间跑满传数据我也怕被制裁。另一方面,长期跑满会挤兑其他地方的速度,比如校外访问内部服务也是通过这条隧道,只能错峰上传了。

同时,若 EasyTier 开启了延迟优先,流量会经过去阿里云服务器中转,导致带宽进一步被限制到5兆。

所以需要一个方法,大批量高速且低成本地传输数据。

多线路回源

上面的速度都有一个前提:只使用单条数据流传输数据,如果使用多条数据流,并将请求分散到不同网络路径上,能大幅提高整体吞吐量。但这样搞很麻烦,不同路径收到数据的时间不一致,还需要重排。

简化一点,EasyTier 是在网络层上传输数据,如果在应用层上就把并发传输做好,不依赖底层实现“链路聚合”,难度会大大降低。

我们用的储存后端是SFTPGo,用了搭建Garage储存集群一文里提到的配置方法,简化了目录和权限管理,采集设备侧使用rclone定时sync近两天的数据,只需要加上参数--transfers 32即可多并发上传。

但这样仍只是对单一后端发起了多个请求,总链路上限不变,想要更快只能考虑将请求分到其他渠道。

我这里在设备侧使用 Nginx 中转了下请求:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
worker_processes 4;
worker_rlimit_nofile 65535;
pid logs/nginx.pid;
error_log logs/error.log warn;

events {
worker_connections 2048;
}

http {
include mime.types;
default_type application/octet-stream;

log_format webdav '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_user_agent" '
'up=$upstream_addr ust=$upstream_status srv=$upstream_http_server '
'rt=$request_time urt=$upstream_response_time';

access_log logs/access.log webdav;

sendfile on;
tcp_nopush on;
keepalive_timeout 65;
server_tokens off;

client_max_body_size 0;
client_body_temp_path tmp/client_body;
proxy_temp_path tmp/proxy;
fastcgi_temp_path tmp/fastcgi;
uwsgi_temp_path tmp/uwsgi;
scgi_temp_path tmp/scgi;

proxy_request_buffering off;
proxy_buffering off;

upstream webdav_backend {
# 没有配置 upstream 共享内存 zone时,max_conns 限制是针对每个 worker 生效的,使得总共的连接会超出限制
zone webdav_backend 64k;
# 三路轮询
server <escape>BACKEND_1_HOST</escape>:6181 max_conns=1 max_fails=3 fail_timeout=30s;
server <escape>BACKEND_2_HOST</escape>:6181 max_conns=1 max_fails=3 fail_timeout=30s;
server 127.0.0.1:16181 max_fails=3 fail_timeout=30s;

keepalive 32;
}

server {
include 8080;
server_name <escape>WEBDAV_DOMAIN</escape>;
location / {
proxy_pass http://webdav_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
}

}

upstream school_backend {
server <escape>SCHOOL_SFTP_DOMAIN</escape>:443; # http需同步修改为 80
keepalive 8;
}

server {
listen 127.0.0.1:16181;
server_name _;

access_log logs/school.log webdav;

location / {
proxy_pass https://school_backend;# http 需同步修改

proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host <escape>SCHOOL_SFTP_DOMAIN</escape>;
proxy_set_header X-Real-IP $http_x_real_ip;
proxy_set_header X-Forwarded-For $http_x_forwarded_for;

proxy_connect_timeout 10s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
send_timeout 3600s;
}
}
}

上述配置文件对3路做了负载均衡,对于部分带宽较小的链路可以限制max_conns=1,避免多个连接分配到这上面后卡住。示例中还有对上游其中一个后端反向代理到本地再作为上游,这是处理 HTTPS 用的,一个 upstream 里不能混用多种协议。最好还是 HTTP 吧,加密本身也有一定带宽开销。

flowchart LR
    subgraph 采集设备
        RC[Rclone
WebDAV 客户端] NG[Nginx 本地反代
负载均衡 / 故障切换] RC -->|WebDAV 请求
上传原始采集数据| NG end subgraph 传输链路1[链路一:EasyTier 内网穿透] ET1[EasyTier 客户端] FRP[FRP 公网端口映射
11010 → 公网] ET1 --> FRP end subgraph 传输链路2[链路二:EasyTier + 阿里云] ET2[EasyTier 客户端] ALI[阿里云服务器
EasyTier 中继] ET2 --> ALI end subgraph 传输链路3[链路三:Cloudflare Tunnel] CF[cloudflared 客户端] CFS[Cloudflare 边缘网络] CF --> CFS end NG -->|上行分流| ET1 NG -->|上行分流| ET2 NG -->|上行分流| CF subgraph 校内服务器 ET_SRV[EasyTier 节点] CFD[cloudflared 服务端
Tunnel 入口] SFTPGo[SFTPGo] ET_SRV --> SFTPGo CFD --> SFTPGo end FRP -->|转发到 11010| ET_SRV ALI -->|虚拟内网| ET_SRV CFS -->|Tunnel| CFD ET1 -.->|EasyTier 虚拟网络| ET_SRV ET2 -.->|EasyTier 虚拟网络| ET_SRV

实测可跑满设备侧的百兆链路:

数据压缩后传输

rclone支持管道输入,也就是说你可以直接使用brotli -c input.dat | rclone rcat remote:input.dat.br,来将压缩以后的数据实时传输到远程服务器(使用rclone cat remote:input.dat.br | brotli -d -c > input.dat下载)。但是这样子就丧失了并发传输的优势。且压缩图片文件的收益不高,还不如不用。

而自带的压缩后端,在上传以后,储存布局是不一样的。需要额外处理。这个也排除了。

压缩后端配置
1
2
3
4
5
6
7
8
9
10
11
12
[webdav]
type = webdav
url = https://example.com/dav
vendor = other
user = username
pass = password

[webdav-compressed]
type = compress
remote = webdav:backup
mode = gzip
level = 1
客户端
1
2
3
4
5
backup/
├── a.txt
├── b.zip
└── folder/
└── c.log
服务端
1
2
3
4
5
6
7
8
9
10
backup/
├── a.txt.AAAAAAAA.gz
├── a.txt.json
│
├── b.zip.BBBBBBBB.gz
├── b.zip.json
│
└── folder/
├── c.log.CCCCCCCC.gz
└── c.log.json
  • .gz:实际压缩后的数据。
  • .json:rclone 管理的元数据。
  • 文件名中间的部分:原始文件大小的 Base64 编码。

实际编码长度会随文件大小变化。

所以说,最好还是可以魔改一下,将压缩的对象细化到单个文件上,客户端压缩再在服务端解压。

不过好像也用不上,没那么大需求,最后我们只是在保存图片的时候就以最高压缩率保存PNG格式图片。

数据分发

在项目早期的时候储存空间还很富裕,以及一些因素影响,测试时保存数据都是保存原始的 BMP 图像,一张图片 75MiB。

这个大小还是相当恐怖的,当时实验室的网络环境还是只有一个小米路由器 R4A,无线速率大概在 350 兆左右,单张图片加载也要一秒多。而通过 SMB 网络共享查看时,一旦并发加载图片,每一张图片都卡住,加载不出来。

开会的时候就更难受了,学校的 AP 是限上下行 30 兆的。加载一张图片要 20 秒,要看数据就是纯尴尬。

这还只是一个人访问的情况,如果再多几个人访问,那都不用玩了。

当时正好放假,需要“远程标注”,本就羸弱的速度更是雪上加霜。

压缩图片

裁切图片

储存流水线在保存原图后,会再裁切图片,此时原始数据约 15MiB。

实时压缩

通过修改 Label Studio,添加了使用 imgproxy 进行压缩,此时可压缩到 230KiB。

可参考部署 Label Studio 在线标注系统。

压缩图片

后期发现那些因素不影响,干脆重新压缩为 PNG 格式,原图可压缩到 5MiB。

内网优先

原先的流量路径:

sequenceDiagram
    autonumber
    participant C as 客户端
    participant LS as Label Studio 服务
    participant EO as EdgeOne
(含缓存) participant NAT as 内网穿透 participant NG as Nginx
(含缓存) participant IP as Imgproxy participant ST as 存储 C->>LS: 请求图片地址 LS->>LS: 生成签名地址
加密文件实际路径参数 LS-->>C: 返回签名后的地址 C->>EO: 请求图片(签名地址) alt EdgeOne 缓存命中 EO-->>C: 直接返回缓存图片 else EdgeOne 缓存未命中 EO->>NAT: 转发图片请求 NAT->>NG: 穿透到校内 Nginx alt Nginx 缓存命中 NG-->>NAT: 直接返回缓存图片 else Nginx 缓存未命中 NG->>IP: 转发图片请求 IP->>IP: 解密路径参数
还原实际文件路径 IP->>ST: 按实际路径读取原图 ST-->>IP: 返回图片数据 IP-->>NG: 返回图片 NG->>NG: 写入 Nginx 缓存 NG-->>NAT: 返回图片 end NAT-->>EO: 回传图片 EO->>EO: 写入 EdgeOne 缓存 EO-->>C: 返回图片 end

为了减少内网穿透带宽开销,使用了 EdgeOne 缓存,效果还是很可观的:

在 1Panel 里面配置缓存会有 bug,比如缺少参数、单位不对,建议自己在配置文件里面手动调整。

1
proxy_cache_path /www/sites/imgproxy.example.com/cache levels=1:2 keys_zone=proxy_cache_zone_of_imgproxy-marker.hzchu.top:128m max_size=20g inactive=60d; 

下文有提到分片回源,要求后端支持范围请求,而 imgproxy 显然不支持,需要设置 proxy_force_ranges 强行支持。

1
2
3
4
location ^~ / {
...
proxy_force_ranges on;
}

但存在的问题是:在内网条件下如果不额外配置,流量就会去外网绕一圈,白白浪费。

最简单的方法就是改 hosts 了,但又因为实验室网段的 DHCP 服务器没有预留 IP 范围,手动设置固定 IP 容易产生冲突(手动设置时没有向 DHCP 服务器请求分配地址,DHCP 服务器认为该 IP 可用,可能会分配给别的设备导致 IP 冲突),所以除了某些必须要固定 IP 的设备(我设置了几个比较高的 IP),都是遵循动态分配的。改 hosts 一旦 IP 变了就麻烦了。

使用 NRPT 管理网络流量

NRPT(Name Resolution Policy Table,名称解析策略表)是 Windows 操作系统中的一项 DNS 客户端策略机制。它本质上是一张由管理员配置的规则表,允许系统在发出 DNS 查询之前,根据查询的名称或命名空间来决定“该用什么特殊方式来处理这次解析”。

—— Deepseek

由于我们恰好有自建的 DNS 服务器,学校的 DNS 烂到自带污染,微信表情包都加载不出来,所以可以将其重写到内网服务器的 DDNS 记录上,算是 CNAME 了。

通过以下脚本切换:

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
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
#Requires -RunAsAdministrator

$Namespace = ".example.com"
$ResolveName = "<DNS-SERVER>"

# Step 1: Detect current NRPT rule state
$existingRule = Get-DnsClientNrptRule | Where-Object { $_.Namespace -eq $Namespace }

if ($existingRule) {
# Rule exists — show current state
$currentServers = $existingRule.NameServers -join ", "
Write-Host "[状态] 检测到现有 NRPT 规则:" -ForegroundColor Yellow
Write-Host " Namespace : $($existingRule.Namespace)" -ForegroundColor Yellow
Write-Host " DNS 服务器 : $currentServers" -ForegroundColor Yellow
Write-Host ""

$choice = Read-Host "当前已启用本地 DNS,是否要切换为关闭? (y/N)"
if ($choice -eq 'y' -or $choice -eq 'Y') {
# 修正点:通过管道将现有规则传给 Remove-DnsClientNrptRule
$existingRule | Remove-DnsClientNrptRule -Force
Clear-DnsClientCache
Write-Host "[完成] NRPT 规则已移除,DNS 解析恢复默认。" -ForegroundColor Green
} else {
Write-Host "[取消] 未做任何更改。" -ForegroundColor Gray
}
} else {
# No rule exists — offer to enable
Write-Host "[状态] 未检测到 $Namespace 的 NRPT 规则。" -ForegroundColor Yellow
Write-Host " 当前 .hzchu.top 域名使用系统默认 DNS 解析。" -ForegroundColor Yellow
Write-Host ""

$choice = Read-Host "是否要启用本地 DNS 解析? (y/N)"
if ($choice -eq 'y' -or $choice -eq 'Y') {
try {
Write-Host "正在解析 $ResolveName ..." -ForegroundColor Gray

# 1. 获取所有的 A 记录 IP 并强制转为数组
$allIPs = @((Resolve-DnsName -Name $ResolveName -Type A -ErrorAction Stop).IPAddress)

# 2. 过滤局域网内网 IP,并用 Select-Object -First 1 安全提取第一个完整 IP
$dnsIP = $allIPs | Where-Object {
$_ -match '^10\.' -or
$_ -match '^172\.(1[6-9]|2[0-9]|3[01])\.' -or
$_ -match '^192\.168\.'
} | Select-Object -First 1

# 3. 校验提取到的 IP 是否有效
if (-not $dnsIP) {
throw "未能从 $ResolveName 解析到任何局域网内网 IP!解析到的所有 IP 为: ($($allIPs -join ', '))"
}

Write-Host "成功提取到内网 DNS 服务器 IP: $dnsIP" -ForegroundColor Gray
Add-DnsClientNrptRule -Namespace $Namespace -NameServers $dnsIP
Clear-DnsClientCache
Write-Host "[完成] NRPT 规则已添加,$Namespace 将固定使用 $dnsIP 解析。" -ForegroundColor Green
} catch {
Write-Host "[错误] 操作失败: $_" -ForegroundColor Red
}
} else {
Write-Host "[取消] 未做任何更改。" -ForegroundColor Gray
}
}

Write-Host ""
Write-Host "按任意键退出..." -ForegroundColor Gray
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")

因为在测试时发现偶尔能获取到一大堆不知道哪里来的外网 IP,加了提取内网 IP 的逻辑。

在 Linux 上可以通过 dns-delegate 机制:

在 /etc/systemd/dns-delegate.d/ 目录下创建一个文件:

example.com.dns-delegate
1
2
3
[Delegate]
DNS=192.168.1.100
Domains=.example.com

内网条件探测

Label Studio 本身已经使用 Service Worker 处理请求了,不妨加个检测,访问某个仅内网可访问的域名,能访问就直接把请求递过去。

用不上,鸽了。

网络链路环境

(等零件)

外网分片并发回源

在外网访问时,如果访问到没有缓存的图片,回源只能挤 20 兆 FRP,在影响其他服务同时还会占满 FRP 连接池:

1
2026-11-08 12:17:56.631 [E] [client/control.go:144] [64fde7a103336369] StartWorkConn contains error: work connection pool is full, discarding

既然多连接回传数据能跑满带宽,那相同的思路应该也能用到这里。

浏览器在请求图片时是顺序传输的,也就是说一个图片只会慢慢从头到尾地传输。同时,在请求多张图片时浏览器也不一定会发起多个 TCP 连接(多路复用),需要外挂一个软件处理。

flowchart TD
    B[浏览器] -->|HTTP 代理请求| P[本地缓存服务]

    P --> C{缓存是否命中}
    C -->|命中| H[读取本地缓存]
    H --> R1[返回缓存内容]
    R1 --> B

    C -->|未命中| S[按 Content-Range 切分单次请求
生成多个子请求] S --> S1[子请求 1
Range: bytes=0-...] S --> S2[子请求 2
Range: bytes=...-...] S --> S3[子请求 3
Range: bytes=...-...] S1 --> T1[TCP 连接 1] S2 --> T2[TCP 连接 2] S3 --> T3[TCP 连接 3] T1 --> O[上游服务器] T2 --> O T3 --> O O -->|206 Content-Range 分段响应| M[按 Content-Range 合并分段数据] M --> W[写入本地缓存] W --> R2[返回完整响应] R2 --> B

软件有需要可以把提示词扔给 Agent,我这个也是喊 AI 写的,小工具 AI 写写无所谓~

实测能达到250兆左右,看来cloudflared自动走ipv6了啊

WebVPN

我校用的是深信服的WebVPN,可以使用lcandy2/webvpn-converter将普通链接转换为VPN的链接格式,测试下来大概10兆的网速

非web服务可以用像fsfgrafb/tcp-over-websocket这类的软件

总结

能花钱解决的就花钱,能摇人解决的就摇人。

现在又说招了很多人来标注,我也不想搞啥邪修了,再搞就成网络工程的了🥲,直接下单万兆全家桶。内网穿透慢也发邮件问网络中心讨公网 IP 了,应该会好起来吧