Skip to main content

torrent-web-seeder Server 端代码阅读

·941 words·5 mins
IHEXON
Author
IHEXON
Do You Hear the People Sing ?

torrent-web-seeder 是 Webtor 体系里的一个 BitTorrent 到 HTTP 的网关服务。它的目标不是做一个普通 BT 下载器,而是把 torrent 中的文件变成 HTTP 可访问资源:客户端请求某个 info-hash/path,server 端按需加入 BitTorrent 网络下载对应 piece,并把数据通过 HTTP 流式返回出去。

这篇记录一次代码阅读结果,重点放在 server 端:它怎么启动、请求怎么走、torrent 怎么被加载和回收、mmap 存储层为什么复杂,以及实际运行时应该带哪些参数。

项目定位
#

从 server 端看,这个项目提供的是一个按需拉取的内容服务:

HTTP client
    |
    | GET /<info-hash>/<file-path>
    v
torrent-web-seeder
    |
    | load .torrent metadata
    | join BitTorrent swarm
    | download requested pieces
    v
BitTorrent peers

它对外主要暴露 HTTP:

GET /<info-hash>/                  列出 torrent 内的文件
GET /<info-hash>/<path>            流式读取文件,支持 Range
GET /<info-hash>/source.torrent    返回原始 .torrent
GET /<info-hash>/<path>?stats      查看下载状态
GET /<info-hash>/<path>?warmup     预热文件或 Range
GET /<info-hash>/<path>?done       判断文件是否已经可用

仓库里还有 client/,但它基本可以看作旧的 gRPC 进度查看工具,不是当前核心链路。它只调用 StatStatStreamFiles,而 server 端这些 gRPC 调用需要 metadata 里携带 info-hash,当前 client 并没有传,所以不适合作为主要入口理解。

启动入口
#

server 的入口很薄:

server/main.go
server/configure.go

main.go 创建 urfave/cli app,然后调用 configure(app)。真正的服务组装都在 configure.gorun 函数里完成。

run 大致按这个顺序创建组件:

TorrentStore
TorrentClient
Vault
TorrentStoreMap
FileStoreMap
TouchMap
TorrentMap
Stat
StatGRPC
StatWeb
Warmup
FileCacheMap
TorrentFileCountMap
WebSeeder
Web
Probe / Prom / Pprof
Serve

核心对象是 WebSeeder。HTTP server 只是把所有请求挂到 /,真正分发逻辑都在 server/services/web_seeder.go

推荐运行方式
#

本地开发先用仓库里的 torrents/ 目录跑最直接:

go run ./server \
  --host 0.0.0.0 \
  --port 8080 \
  --input ./torrents \
  --data-dir /tmp/torrent-web-seeder \
  --use-probe \
  --use-prom \
  --per-torrent-cache-budget 10GB \
  --max-readahead 20MB

然后打开:

http://localhost:8080/

如果只加载单个 torrent:

go run ./server \
  --port 8080 \
  --input ./torrents/Sintel.torrent \
  --data-dir /tmp/torrent-web-seeder \
  --use-probe \
  --use-prom

几个关键参数:

--input

本地 .torrent 文件或目录。目录模式会加载目录下一层的 .torrent 文件。

--data-dir

下载内容、mmap 文件、.torrent.db 都在这里。建议显式指定,不要依赖系统临时目录。

--per-torrent-cache-budget

每个 torrent 的 piece cache 上限。大文件流式场景建议设置,比如 10GB50GB。设为 0 表示不启用 LRU eviction。

--max-readahead

HTTP reader 的预读窗口,默认 20MB。视频播放和顺序读取场景可以保留默认;seek 多、网络差时可以增大。

--use-probe
--use-prom

开启健康检查和 Prometheus metrics。默认 probe 是 8081,Prometheus 是 8083/metrics

不建议一开始打开 --torrent-client-debug,日志会非常多;只有排查 peer、tracker、DHT 问题时再开。

Docker 构建
#

Dockerfile 做的是多阶段构建:

alpine:latest         -> 准备 ca-certificates
golang:latest         -> go build ./server
alpine:latest         -> 运行编译好的 ./server

Dockerfile 没有调用 make protoc,也没有安装 protoc。它依赖仓库里已经提交的生成文件:

proto/torrent-web-seeder.pb.go
proto/torrent-web-seeder_grpc.pb.go

所以 Makefile 不是 Docker 构建必须的东西,只是开发时重新生成 protobuf 代码用。

容器运行示例:

docker build -t torrent-web-seeder .

docker run --rm \
  -p 8080:8080 \
  -p 8081:8081 \
  -p 8083:8083 \
  -v "$PWD/torrents:/torrents:ro" \
  -v /tmp/torrent-web-seeder:/data \
  torrent-web-seeder \
  --host 0.0.0.0 \
  --port 8080 \
  --input /torrents \
  --data-dir /data \
  --use-probe \
  --use-prom

HTTP 请求路径
#

HTTP server 在 server/services/web.go

mux.Handle("/", l.Handler(RecoverMiddleware(s.ws), ""))
return http.Serve(s.ln, mux)

也就是说所有 HTTP 请求都会进入 WebSeeder.ServeHTTP

ServeHTTP 的分发逻辑可以简化为:

没有 info hash:
    renderIndex()

有 info hash:
    ?stats  -> serveStats()
    ?warmup -> serveWarmup()
    ?done   -> serveDone()
    path 为空 -> renderTorrentIndex()
    path == source.torrent -> renderTorrent()
    否则 -> serveFile()

info-hash 可以来自两个地方:

X-Info-Hash header
URL path 的第一段

比如:

GET /2f9c.../Sintel/Sintel.mp4

第一段被当作 hash,后面的部分被当作 torrent 内文件路径。

文件服务优先级
#

真正返回文件的是 serveFile。它的优先级很明确:

Vault
    -> local file cache
        -> live torrent reader

Vault 优先
#

如果配置了 --vault-host,server 会先检查 Vault:

HEAD /webseed/<hash>/<path>

如果 Vault 里已有这个文件,GET 请求通常会得到一个 302/307 跳转,server 会把这个跳转透传给客户端。这样已经被外部存储缓存好的文件就不会再从当前 seeder 下载。

这是合理的:Vault/S3 是更权威、更稳定的已完成内容来源。

本地 cache
#

如果 Vault 没有,再检查本地 cache。FileCacheMap 会打开:

DATA_DIR/<hash>/.torrent.db

查询 file_completion 表。如果该文件已经完成,再按路径 SHA-1 找真实内容文件:

DATA_DIR/<hash>/content/<sha1前两位>/<sha1完整值>

命中后用 http.ServeContent 返回,天然支持 Range、If-None-Match、If-Modified-Since 等 HTTP 行为。

live torrent reader
#

如果 Vault 和本地完整文件都没有命中,就进入 live torrent 路径:

t, err := s.tm.Get(ctx, h)
for _, f := range t.Files() {
    if f.Path() == p {
        torReader := f.NewReader()
        torReader.SetResponsive()
        torReader.SetReadaheadFunc(NewReadaheadFunc(s.maxReadahead))
        return NewTouchWriter(w, s.tm, h), torReader, nil
    }
}

这里会从 TorrentMap 获取 torrent 对象,然后创建 anacrolix 的 file reader。后续 http.ServeContent 读取 reader 时,torrent client 会按需下载对应 piece。

torrent 元数据来源
#

TorrentMap.Get(ctx, h) 是 torrent 生命周期的核心。

它先拿到 torrent.Client,然后按 hash 查找 .torrent metadata:

FileStoreMap
    -> TorrentStoreMap

FileStoreMap 对应 --input

--input /path/to/file.torrent
--input /path/to/torrent-dir

如果本地没有,TorrentStoreMap 会连接远程 torrent-store gRPC 服务,调用 Pull 按 info hash 拉 .torrent 文件。

拿到 metainfo.MetaInfo 后:

t, err = cl.AddTorrent(mi)

同一个 torrent 会被 TorrentMap 管理一段时间。每个 hash 有一个 timer,默认 TTL 是 600 秒。每次访问会 reset timer;超时后:

t.Drop()
active_torrents_count--

所以这个服务不是永久持有所有 torrent,而是按访问热度保活。

BitTorrent client 配置
#

TorrentClientserver/services/torrent_client.go。它包装了 anacrolix/torrent,同时加了很多可调参数:

download-rate
http-proxy
no-upload
seed
disable-utp
disable-webtorrent
disable-webseeds
established-conns-per-torrent
half-open-conns-per-torrent
max-unverified-bytes
dial-rate-limit
per-torrent-cache-budget

依赖里有一处重要 replace:

replace github.com/anacrolix/torrent => github.com/webtor-io/torrent ...
replace github.com/anacrolix/utp => github.com/webtor-io/utp ...

也就是说它并不是直接使用 upstream anacrolix/torrent,而是 Webtor 自己的 fork。排查行为差异时要注意这一点。

mmap 存储层
#

这个项目最有工程含量的部分在 server/services/mmap.go

它给 anacrolix/torrent 提供自定义 storage backend。每个 torrent 文件不会直接按原始文件名落盘,而是使用 safe path 后再做 SHA-1:

safe torrent file path
    -> sha1
    -> DATA_DIR/<hash>/content/<sha1前两位>/<sha1>

每个文件被 mmap.MapRegion 映射到内存,然后多个文件组成一个 mmap-span,让 torrent storage 可以按全局 piece offset 读写。

piece 完成状态不是只存在内存里,而是写入 SQLite:

piece_completion(index, complete)
file_completion(path)

这样进程重启后可以恢复哪些 piece 已经完成,哪些文件已经完整。

LRU piece 淘汰
#

大文件流式服务最怕两件事:

磁盘被完整 torrent 撑满
page cache / RSS 被顺序读取撑爆

所以这个 storage backend 有 per-torrent LRU eviction。启用条件:

per-torrent-cache-budget > 0
并且 torrent 总大小 > budget

piece 下载并校验完成后:

MarkComplete()
    -> 写 piece_completion
    -> madvise 已完成范围
    -> 加入 LRU
    -> 如果超预算,淘汰旧 piece

淘汰 piece 时做几件事:

1. piece_completion 标记为 false
2. 对受影响 file 删除 file_completion
3. 对底层文件 punch hole
4. madvise 释放 mmap 页面
5. 从 LRU 删除
6. 异步 VerifyData,让 torrent client 知道这个 piece 需要重新获取

这里有不少并发细节。代码用 1024 个 shard RWMutex 保护 piece read 和 eviction:

ReadAt: RLock
evictPiece: Lock

目的是避免 HTTP 正在从 mmap 读数据时,后台 eviction 同时对同一 piece punch hole,导致客户端读到零填充数据。

file_completion 的意义
#

piece_completion 只能说明 piece 是否完整,但 HTTP 服务更关心“某个文件是否完整”。多文件 torrent 里,一个 piece 可能跨文件边界,所以文件完成状态需要单独维护。

piece_completion.go 里有一个后台 goroutine,每 5 秒扫描一次:

哪些文件的所有 piece 都完成了
    -> 写入 file_completion(path)

当 piece 被淘汰时,对应文件会被 uncomplete:

delete from file_completion where path = ?

这样 FileCacheMap 才不会把一个已经被挖洞的文件误判为完整文件。

warmup
#

?warmup 是一个很实用的接口。它不是返回文件内容,而是通过 SSE 返回预热进度:

Content-Type: text/event-stream
data: <downloaded-bytes>

它会解析 Range header,找到这个范围覆盖的 piece,把这些 piece 的优先级调高:

t.Piece(firstPieceInFile + i).SetPriority(torrent.PiecePriorityHigh)

连接会一直开着,直到请求范围内的字节都已经完成,或者客户端断开,或者 30 分钟超时。

这个接口适合播放器或上层代理提前要求 seeder 把某个片段拉下来。

stats
#

server 有两种状态接口:

gRPC Stat/StatStream/Files
HTTP ?stats

Stat 会返回:

total bytes
completed bytes
peers
seeders
leechers
status
piece states

StatStream 每 3 秒发送一次变化。HTTP 的 ?stats 是在 StatWeb 里把 gRPC streaming 适配成 HTTP streaming JSON。

当前 gRPC stat 服务的开关代码里有一个明显问题:注册了 --use-stat,但 NewStatGRPC 判断的是 StatHostFlag,不是 StatUseFlag。所以如果真的要使用独立 gRPC client,需要先修这个逻辑。HTTP 主链路不受影响。

诊断命令
#

server 还有一个 diagnose 子命令:

go run ./server diagnose --timeout 60s "magnet:?xt=urn:btih:..."
go run ./server diagnose ./path/to/file.torrent

它会检查:

client 是否启动
DHT 状态
metadata 是否拿到
tracker 状态
peer/seeders 数量
是否能收到 useful data
下载速度
原始 torrent client status

排查 torrent 为什么不动时,比直接看 server 日志更清楚。

总结
#

torrent-web-seeder server 端的主线可以概括成:

HTTP request
    -> parse info hash and path
    -> prefer Vault
    -> prefer local completed cache
    -> load .torrent metadata
    -> add torrent to anacrolix client
    -> create file reader
    -> ServeContent streams bytes
    -> mmap storage downloads/verifies pieces
    -> SQLite records piece/file completion
    -> LRU eviction bounds disk/cache usage

它真正解决的问题是“把 torrent 文件变成稳定的 HTTP 内容服务”,而不是单纯下载。mmap + SQLite completion + piece LRU + Vault fallback 这些设计,都是围绕长时间运行、大文件流式读取、容器内存/磁盘可控这几个目标展开的。