跳至主内容

Jellyfin CDN:面向大众的 Mirrorbits

· 1 分钟阅读
Joshua Boniface
Project Leader
非官方测试版翻译

本页面由 PageTurner AI 翻译(测试版)。未经项目官方认可。 发现错误? 报告问题 →

对多数项目而言,分发二进制文件很简单:上传到 GitHub 就大功告成了。很少有人会深入思考这个问题。但在 Jellyfin,我们需要更强大的解决方案——能比 GitHub 或基础 Web 服务器更优雅地满足需求。无论是对此感兴趣的开发者,还是支持类似项目的同行,我都想分享我们的实现方式。

序幕 - 10.6.0 版本之前

在 10.6.0 版本发布前,我们采用相当简单的仓库架构:所有构建都在名为 build1 的 Debian VPS 上响应 GitHub Webhook 完成。这台位于 Digital Ocean 多伦多区域的服务器,既承担构建任务,也通过 NGiNX 直接提供主仓库服务。

但这次发布首次暴露了问题。Jellyfin 是全球化项目,虽然我个人在加拿大安大略省,但绝大多数用户并非如此。尤其是欧洲和亚洲用户,下载版本时频频遭遇问题:主要投诉是下载速度极慢,甚至偶发完全超时。我们必须设计更好的方案。

身为骨子里的 DIY 爱好者,并领导着由 DIY 爱好者创建的项目,我不满足于简单套用 CloudFlare(且不论对该提供商的顾虑)。我需要完全可控的方案,于是开始寻找有效创建文件下载 CDN 的途径。

Mirrorbits 登场

幸运的是,我们并非孤军奋战。多年前,另一个 FLOSS 项目也曾遭遇相同困境。开发了优秀媒体播放器 VLC 的 VideoLAN,面临同样的文件分发难题。于是他们才华横溢的开发者创造了解决方案:Mirrorbits。

Mirrorbits 是个专注单一目标的 Go 程序:基于客户端 GeoIP 信息,将请求从中央仓库智能分发到多地镜像仓库,无缝处理可用性与文件时效性。它完美契合了我们的需求。

但存在一个问题:Mirrorbits 的实际操作文档稀缺,我们花费大量时间试错才掌握使用方法。希望以下分享能帮助他人规避这些困扰。

文件布局

首要考量是文件布局。Jellyfin 的仓库庞大而复杂,包含服务器、客户端、插件及各类辅助文件。Mirrorbits 要求所有内容置于单一目录下,我们需要便捷的全局同步方案。同时面临"归档文件"问题——那些无需同步到所有镜像服务器浪费空间的旧版稳定发行包。

我最终设计的目录结构如下:

/srv/repository
/mirror
/releases
/client
/server
/plugin
etc.
/debian
/dists
/pool
/ubuntu
/dists
/pool
/archive
etc.
/releases --symlink--> /mirror/releases
/debian --symlink--> /mirror/debian
etc.

实际上所有内容都位于 /mirror 目录下,通过外部符号链接提供所需的根级访问路径。

增设 VPS 与同步方案

确定可行的文件布局后,下一步是增设 VPS 并制定文件同步策略。

由于源服务器位于多伦多,我计划确保广泛地域覆盖,并为源服务器所在区域配备专用 CDN 以防过载。因此增设了 4 台 VPS:

  • tor1.mirror.jellyfin.org:加拿大安大略省多伦多(覆盖北美/南美东部)

  • sfo1.mirror.jellyfin.org:美国加利福尼亚州旧金山(覆盖北美/南美西部)

  • fra1.mirror.jellyfin.org:德国法兰克福(注意非常见误解:不是法国!覆盖欧洲与非洲)

  • sgp1.mirror.jellyfin.org: 新加坡节点,服务亚太地区

最初搭建这4个VPS节点时,我曾担心它们会不堪重负。但经历了一个主版本更新和多次小版本发布后,关于下载速度的抱怨已完全消失,证明这套方案行之有效。未来随着Digital Ocean区域扩展,我们还能增设更多节点,比如印度班加罗尔、非洲地区,以及更多的欧洲和南美节点。

VPS部署完成后,接下来要考虑文件同步方案。我脑海中只有一个选择:rsync。虽然多数用户通过SSH使用rsyncscp替代功能,但我需要更健壮的方案——无需处理SSH密钥,且具备未来扩展性(比如对接不受信任的第三方镜像站)。

因此我选择rsync守护进程模式。它在873端口监听,能够执行rsync-over-SSH所能做的一切操作,同时避免加密开销和SSH/shell认证的复杂性。

首先在源服务器(build1)配置本地/etc/rsyncd.conf。Debian的rsync包默认不启动守护进程,但添加此配置文件后即可启用服务。

为处理前文提到的"归档文件",我将仓库拆分为两个"组件":含归档文件和不含归档文件的版本。这样镜像站可选择同步完整归档,或仅同步当前稳定版仓库。当我们开始提供每日可能生成数十次的"不稳定构建"时,我也将其归入"归档"组件。

这是我们的配置方案:

# /etc/rsyncd.conf
[mirror]
path = /srv/repository/mirror
comment = Jellyfin mirror (stable only)
exclude = *unstable* *archive*

[mirror-full]
path = /srv/repository/mirror
comment = Jellyfin mirror (all)

多数情况下需要安全加固,但由于我们希望开放给公众使用,我完全公开了rsync端点。因此如果你想搭建本地Jellyfin镜像站——完全可以!只需同步此rsync目标,就能获得完整的Jellyfin镜像副本!

镜像服务器仍需获取内容。最终我决定所有"官方"镜像都应同步完整内容(含归档和不稳定构建),因此它们都同步mirror-full源。

每个节点设置了简单的cron任务,每15分钟从源服务器同步更新:

# /etc/cron.d/mirror-sync
12,27,42,57 * * * * root rsync -au --delete rsync://build1.jellyfin.org/mirror-full/ /srv/repository/mirror/

这个特殊时间点是精心设计的——若未来正式支持第三方镜像站,它们可在整点(如0030等)从这些"官方"镜像同步,而非直接连接build1。这样能确保第三方同步前内容已更新完毕,避免额外延迟。虽然尚未正式支持此功能,但如果流量持续增长,我们计划向第三方镜像站和更多Digital Ocean区域扩展。

rsync命令本应自动创建目标目录,但为保险起见,我选择手动预先创建。至此,我们拥有5台内容完全同步的服务器,镜像节点每15分钟从源服务器同步一次。

Web服务器配置

我们自始至终采用NGiNX作为Web服务器。原因很简单:极致配置灵活性与卓越性能。Apache虽有其优势,但我们不需要其额外功能,且早期预算紧张。NGiNX的优异表现让我们从未考虑更换方案。

镜像节点的配置极其简洁:仅配置单个"站点"提供仓库目录的完整访问。SSL证书由Let's Encrypt提供。

# /etc/nginx/sites-enabled/jellyfin-mirror (from fra1.mirror.jellyfin.org)
server {
listen [::]:80 default_server ipv6only=on;
listen 80 default_server;
listen [::]:443 ssl ipv6only=on;
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/fra1.mirror.jellyfin.org/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/fra1.mirror.jellyfin.org/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

server_name fra1.mirror.jellyfin.org _;
root /srv/repository;
index index.php index.html index.htm;

access_log /var/log/nginx/access.log;

aio threads;
directio 1M;
output_buffers 3 1M;

sendfile on;
sendfile_max_chunk 0;

autoindex on;

location ~ [^/]\.php(/|$) {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/run/php/php7.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}

在测试过程中,我们注意到一个可能对其他管理员有用的现象。当大量大文件请求同时涌入时,性能会显著下降。最终我将问题追溯到NGiNX内部的I/O堆栈瓶颈。通过查阅相关文档,我设置了上述的aio threadsdirectiooutput_bufferssendfile选项。这些配置确保NGiNX对超过1MB的文件使用直接I/O,并提供3个输出缓冲区(最大块大小设为0),从而提升高负载下的性能表现。

在源服务器上,NGiNX配置要复杂得多。由于Mirrorbits仅负责分发实际的大文件,我需要在源服务器上预先处理文件重定向逻辑。这样客户端会被引导至正确的文件位置,再由Mirrorbits将请求分发到镜像节点。

# /etc/nginx/sites-enabled/jellyfin-origin (on build1.jellyfin.org)
server {
listen 80 default_server proxy_protocol;
listen [::]:80 default_server proxy_protocol;
server_name repo.jellyfin.org build.jellyfin.org repo1.jellyfin.org build1.jellyfin.org _;

access_log /var/log/nginx/access.log proxy;

aio threads;
directio 1M;
output_buffers 3 1M;

sendfile on;
sendfile_max_chunk 0;

autoindex on;

root /srv/repository/mirror;
index index.php index.html index.htm index.nginx-debian.html;

location / {
autoindex off;
}

#
# Kodi Repository redirection
#
location ^~ /releases/client/kodi/py2 {
index index.html index.php;
autoindex on;
}
location ^~ /releases/client/kodi/py3 {
index index.html index.php;
autoindex on;
}
location ^~ /releases/client/kodi {
# Kodi 20
if ($http_user_agent ~* "(Kodi.*/20.*)") {
rewrite ^/releases/client/kodi/(.*)$ /releases/client/kodi/py3/$1 last;
}

# Kodi 19
if ($http_user_agent ~* "(Kodi.*/19.*)") {
rewrite ^/releases/client/kodi/(.*)$ /releases/client/kodi/py3/$1 last;
}

# Kodi 18 and older
if ($http_user_agent ~* "(Kodi.*)") {
rewrite ^/releases/client/kodi/(.*)$ /releases/client/kodi/py2/$1 last;
}

index index.html index.php;
autoindex on;
}

#
# Main repository
#

# Main release directories
location /releases {
autoindex on;
}

# Main archive directories (not forwarded to Mirrorbits)
location ^~ /archive {
try_files $uri $uri/ =404;
autoindex on;
}

# Mirrorbits fallback
location ^~ /master {
try_files $uri $uri/ =404;
autoindex on;
}

# Mirrorbits forwards for large file types
location ~ ^/(?<fwd_path>.*)(?<fwd_file>\.apk|\.buildinfo|\.bz|\.changes|\.db|\.deb|\.dmg|\.dsc|\.exe|\.gz|\.md5|\.lzma|\.rpm|\.sha256sum|\.xml|\.xz|\.zip|\.css|\.ttf|\.woff2|\.json)$ {
proxy_pass http://127.0.0.1:8080;
proxy_buffering off;
}
location ~ ^/(?<fwd_path>.*)(/mirrorlist)$ {
proxy_pass http://127.0.0.1:8080/$fwd_path?mirrorlist;
proxy_buffering off;
}
location ~ ^/(?<fwd_path>.*)(/mirrorstats)$ {
proxy_pass http://127.0.0.1:8080/$fwd_path?mirrorstats;
proxy_buffering off;
}
location /mirrorstats {
proxy_pass http://127.0.0.1:8080/?mirrorstats;
proxy_buffering off;
}

#
# PHP handling
#
location ~ \.php$ {
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param HTTP_PROXY "";
fastcgi_pass unix:/run/php/php7.3-fpm.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
}
}

部分配置值得详细说明:

Kodi选择器的存在是因为其后端从Python 2迁移到Python 3。某些旧版本需要Python 2资源,而新版本需要Python 3资源。该选择器根据客户端发送的User-Agent判断其使用的Kodi版本,从而引导至正确路径。

核心路径包括/archive/releases/master。其中第一个存放历史归档文件(尽管文件已在上文同步),通过try_files指令绕过Mirrorbits转发。第二个是主仓库目录,而第三个作为用于回退的重复位置,同样不重定向到Mirrorbits。

接下来是Mirrorbits主处理模块。转发逻辑基于请求文件的扩展名实现。因此当加载PHP索引页等文件时,请求不会被转发;仅当请求指定类型的文件时,才会交由Mirrorbits进程分发给镜像节点。

后续三项配置用于Mirrorbits状态页面,展示当前可用镜像信息。在任何文件(例如https://repo.jellyfin.org/releases/server/debian/stable/meta/jellyfin_10.7.2-1_all.deb)的URL后添加/mirrorlist/mirrorinfo即可查看镜像信息。您也可以亲自尝试:https://repo.jellyfin.org/releases/server/debian/stable/meta/jellyfin_10.7.2-1_all.deb/mirrorlist。而/mirrorstats页面(文件级或根域名级如https://repo.jellyfin.org/mirrorstats)则展示全局镜像状态,包括离线节点。

这些NGiNX配置共同构成了Mirrorbits的运行基础,也是耗时最长的环节。特别感谢GitHub上的@PalinuroSec提供的优秀配置示例

Mirrorbits:核心引擎

安装Mirrorbits相当简单:我从其仓库下载最新二进制版本(0.5.1),将mirrorbits可执行文件放入/usr/local/bin,示例配置文件放入/etc/mirrorbits.conf,模板文件放入/usr/local/share/mirrorbits。遗憾的是,Mirrorbits自2018年起似乎停止了开发(包括文档更新)。我在排查过程中提交的issue至今未获回复,希望本文能帮助解决该问题。

Mirrorbits 的基础配置相当简单。大部分配置选项在示例配置文件中都有详细说明,我们只需根据需求进行调整即可。以下是我们移除了注释/说明后的配置:

# /etc/mirrorbits.conf (on build1.jellyfin.org)
Repository: /srv/repository/mirror
Templates: /usr/local/share/mirrorbits/
LogDir: /var/log/mirrorbits
GeoipDatabasePath: /usr/local/share/GeoIP/
OutputMode: redirect
Gzip: false
ListenAddress: localhost:8080
RPCListenAddress: localhost:3390
RedisAddress: 127.0.0.1:6379
RedisDB: 0
TraceFileLocation: /mirrorsync
RepositoryScanInterval: 5
Hashes:
SHA256: On
ConcurrentSync: 5
ScanInterval: 30
CheckInterval: 1
Fallbacks:
- URL: https://repo.jellyfin.org/master/
CountryCode: ca
ContinentCode: na

有几个配置项值得特别说明,因为它们与默认值有明显差异。

Repository 指向本地仓库路径,即通过 rsync 同步的精确文件位置。

GeoipDatabasePath 是本地 GeoIP 数据库的路径,后文将详细讨论。

OutputMode 设置为 redirect 模式,向客户端返回 HTTP 302 重定向至文件路径。虽然存在多种模式选项,但 302 重定向兼容性最佳,能被绝大多数 HTTP 程序支持,同时避免响应被缓存。

TraceFileLocation 指向用于判断镜像"新鲜度"的追踪文件。该文件必须位于 Repository 路径下,用于确保远程仓库副本与本地实例保持同步。

Hashes 提供多种哈希算法选项,为简化流程并匹配我们自行提供的哈希值,我们选用 SHA256 算法。

RedisAddress 指向 Mirrorbits 用于状态管理的本地 Redis 实例。

ConcurrentSyncRepositoryScanIntervalScanIntervalCheckInterval 是以分钟为单位的定时检测参数,但实际运行中发现其可靠性不足;我最终改用 cron 任务手动执行这些操作。

最后,Fallbacks 提供了回退镜像列表。前文提到 /master 路径与 /releases 内容完全一致,但不会触发 Mirrorbits 重定向造成循环。若无此回退机制,当所有镜像因文件更新或节点故障暂时不可用时,客户端将完全无法下载文件。回退机制确保始终存在备选源(即原始节点),虽不常用但能应急。

接下来为 Mirrorbits 创建 SystemD 服务。该进程需要特殊的重载和停止参数配置,具体如下。注意我以 www-data 用户(与 NGiNX 运行用户相同)启动 Mirrorbits:

# /etc/systemd/system/mirorrbits.service
[Unit]
Description=Mirrorbits redirector
Documentation=https://github.com/etix/mirrorbits
After=network.target

[Service]
Type=notify
DynamicUser=yes
LogsDirectory=mirrorbits
RuntimeDirectory=mirrorbits
User=www-data
PIDFile=/run/mirrorbits/mirrorbits.pid
ExecStart=/usr/local/bin/mirrorbits daemon -p /run/mirrorbits/mirrorbits.pid -debug
ExecReload=/bin/kill -HUP $MAINPID
ExecStop=-/bin/kill -QUIT $MAINPID
TimeoutStopSec=5
KillMode=mixed
Restart=on-failure

[Install]
WantedBy=multi-user.target

最后添加 cron 任务确保:1) 同步前 1 分钟更新 TraceFileLocation;2) 同步完成后 1 分钟扫描镜像(为同步预留缓冲时间);3) 每分钟刷新本地仓库以即时捕获新文件——这些正是 Mirrorbits 自身未能妥善处理的关键操作。

# /etc/cron.d/mirror-sync
11,26,41,56 * * * * root /usr/bin/date +\%s | /usr/bin/tee /srv/repository/mirror/mirrorsync &>/dev/null
13,28,42,58 * * * * root /usr/local/bin/mirrorbits scan -all
* * * * * root /usr/local/bin/mirrorbits refresh

向 Mirrorbits 添加镜像节点

完成基础设置并启动 Mirrorbits 后,通过其命令行工具添加镜像节点:

mirrorbits add -http https://fra1.mirror.jellyfin.org -rsync rsync://fra1.mirror.jellyfin.org/mirror fra1
mirrorbits scan --enable fra1
mirrorbits refresh

节点经扫描、启用并刷新后,将激活并显示在 /mirrorstats 输出或 CLI 中,准备处理请求。

joshua@build1.jellyfin.org ~ $ mirrorbits list
Identifier STATE SINCE
fra1 up (Mon, 12 Apr 2021 07:15:29 UTC)
sgp1 up (Mon, 12 Apr 2021 06:51:03 UTC)
tor1 up (Sun, 11 Apr 2021 21:48:48 UTC)
sfo1 up (Sun, 11 Apr 2021 17:43:27 UTC)

GeoIP 地理定位数据库

使用 Mirrorbits 的难点在于需配置 GeoIP 数据库,且必须采用 GeoLite2mmdb 格式。遗憾的是该数据库已不再免费提供。您需自行获取或寻找替代方案;我倾向于使用免费数据库,但尚未找到兼容版本。若有推荐方案,敬请告知!

该数据库解压至 GeoipDatabasePath 路径,由 Mirrorbits 运行时加载,为其提供地理定位数据以将客户端导向最近的镜像节点。

结语

总而言之,我们希望这一配置能让我们继续为全球用户扩展下载服务。我希望这篇文章能帮助其他需要在多个地理分散的服务器上分发下载内容的小型项目管理员。祝你好运!