Doorgaan naar hoofdinhoud

De Jellyfin CDN: Mirrorbits voor iedereen

· 15 minuten leestijd
Joshua Boniface
Project Leader
Onofficiële Beta-vertaling

Deze pagina is vertaald door PageTurner AI (beta). Niet officieel goedgekeurd door het project. Een fout gevonden? Probleem melden →

Voor veel projecten is het distribueren van binaire bestanden eenvoudig: zet de bestanden op GitHub en klaar. Het is iets waar weinig mensen over nadenken. Maar bij Jellyfin hadden we iets robuusters nodig, iets dat onze behoeften eleganter kon afhandelen dan GitHub of een basiswebserver. En zowel voor geïnteresseerden als voor mensen die soortgelijke projecten ondersteunen, wil ik graag uitleggen hoe we dit hebben aangepakt.

Voorspel - Pre-10.6.0

Voor onze 10.6.0-release hadden we een vrij eenvoudige repository-opstelling: alles werd gebouwd op een VPS met Debian, genaamd build1, als reactie op een GitHub-webhook. Deze server, gevestigd bij Digital Ocean in de Toronto-zone, huisvestte zowel het bouwproces als onze hoofdrepository, rechtstreeks bediend via NGiNX.

Maar bij deze release merkten we voor het eerst een probleem. Jellyfin is een wereldwijd project, en hoewel ik persoonlijk in Ontario, Canada woon, geldt dat niet voor de overgrote meerderheid van onze gebruikers. Gebruikers, vooral in Europa en Azië, hadden moeite met het downloaden van onze releases. De belangrijkste klacht was belabberd lage downloadsnelheden en af en toe zelfs volledige time-outs. We moesten een betere oplossing bedenken.

Als natuurlijke DIY'er en leider van een project gebouwd door en voor DIY'ers, wilde ik niet simpelweg CloudFlare voor de repository zetten - afgezien van mijn bezwaren tegen die provider. Ik wilde iets dat we zelf konden beheren, dus zocht ik naar een oplossing: hoe creëer je effectief een CDN voor bestandsdownloads.

Introductie van Mirrorbits

Gelukkig stond ik niet alleen. Jaren eerder had een ander FLOSS-project hetzelfde probleem ondervonden. VideoLAN, makers van de fantastische VLC Media Player, hadden hetzelfde probleem met bestandsdistributie. Dus creëerde een van hun getalenteerde ontwikkelaars een oplossing: Mirrorbits.

Mirrorbits is een Go-programma met één doel: verzoeken verdelen vanaf één centrale repository naar meerdere geografisch verspreide repositories, gebaseerd op de GeoIP-informatie van de client, waarbij beschikbaarheid en versheid naadloos worden afgehandeld. Het leek precies te passen.

Maar er was een probleem: documentatie over het daadwerkelijk uitvoeren van Mirrorbits was schaars, dus kostte het flink wat trial-and-error om te ontdekken hoe het te gebruiken. Ik hoop dat het volgende anderen deze moeite bespaart.

Bestandsstructuur

Het eerste aandachtspunt is de bestandsstructuur. In het geval van Jellyfin is onze repository groot en uitgebreid, bestaande uit veel verschillende componenten zoals de server, clients, plugins en diverse secundaire bestanden. Mirrorbits vereist dat alles onder één map wordt geplaatst, en we moesten een manier vinden om deze hele map eenvoudig te synchroniseren. We hadden ook een probleem met onze "archieven", oude stabiele releases die we niet naar al onze mirrorservers wilden synchroniseren om ruimte te verspillen.

Mijn oplossing was de volgende mappenstructuur:

/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.

In feite staat alles onder een /mirror-map, met externe symlinks om de gewenste rootlinks te bieden.

Extra VPS'en en synchronisatie

De volgende stap na het creëren van een bruikbare bestandsstructuur was het aanmaken van extra VPS'en en bepalen hoe we de bestanden synchroniseren.

Omdat onze originserver in Toronto staat, wilde ik zorgen voor brede geografische dekking, plus een dedicated CDN voor dezelfde regio als de originserver, voor het geval die overbelast raakt. Zo creëerde ik 4 extra VPS'en:

  • tor1.mirror.jellyfin.org: Toronto, Ontario, Canada voor Noord-/Zuid-Amerika Oost

  • sfo1.mirror.jellyfin.org: San Francisco, Californië, VS voor Noord-/Zuid-Amerika West

  • fra1.mirror.jellyfin.org: Frankfurt, Duitsland (niet Frankrijk, zoals vaak gedacht!) voor Europa en Afrika

  • sgp1.mirror.jellyfin.org: Singapore voor Azië en de Stille Oceaan

Een zorg bij het opzetten van deze 4 VPS-servers was dat ze onvoldoende zouden zijn, maar tot nu toe zijn de klachten over downloadsnelheid volledig gestopt na een grote release en diverse kleinere releases, dus ze werken blijkbaar goed. In de toekomst, naarmate Digital Ocean uitbreidt, kunnen we extra locaties toevoegen, zoals Bangalore (India), Afrika en mogelijk extra servers in Europa en Zuid-Amerika.

Met de VPS-servers gecreëerd, keek ik naar bestandssynchronisatie. Eén programma stond direct voor ogen: rsync. Hoewel de meeste gebruikers rsync kennen als scp-vervanger via SSH, wilde ik iets robuusters zonder SSH-sleutelbeheer, dat bovendien toekomstige uitbreiding naar niet-vertrouwde (en wantrouwige) externe mirrors mogelijk zou maken.

Ik koos daarom voor de rsync-daemonmodus. Deze luistert op poort 873 en biedt alle functionaliteit van rsync-over-SSH, maar zonder encryptie-overhead of SSH/shell-authenticatie.

Eerst bereidde ik het lokale /etc/rsyncd.conf op de bronserver (build1) voor. Standaard installeert het Debian rsync-pakket de daemonservice niet zonder dit bestand, maar na toevoeging kan de daemon gestart worden.

Om de eerder genoemde "archieven" te verwerken, splitste ik de repository in twee "componenten": één mét archieven en één zonder. Dit stelt mirrors in staat om óf het volledige archief óf alleen de huidige stabiele repositories op te halen. Toen we later "instabiele" builds gingen aanbieden (die tientallen keren per dag gegenereerd kunnen worden), nam ik deze ook in de "archief"-component op.

Dit is onze configuratie:

# /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)

In veel gevallen zou beveiliging verstandig zijn, maar omdat we dit voor iedereen toegankelijk wilden maken, liet ik het rsync-eindpunt volledig openstaan. Dus als je een lokale Jellyfin-mirror wilt hosten: het kan! Clone simpelweg dit rsync-doel en je hebt een volledige kopie van de Jellyfin-mirror.

Op de mirrorservers moesten we de inhoud echter nog ophalen. Uiteindelijk besloot ik dat elke "officiële" mirror de volledige inhoud (inclusief archieven en instabiele builds) zou kopiëren, dus synchroniseren ze allemaal de mirror-full-bron.

Elke node heeft daarom een simpele cron-taak die elke 15 minuten draait om een bijgewerkte kopie van de repository van de bron op te halen:

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

De ietwat vreemde tijden zijn bewust gekozen – het doel voor derden (wanneer we dit officieel ondersteunen) is om elke X minuten op even intervallen (bv. 00, 30, etc.) te synchroniseren vanaf deze "officiële" mirrors in plaats van direct vanaf build1. Dit garandeert dat ze altijd actueel zijn voordat dat tijdstip aanbreekt, zonder extra vertraging voor externe mirrors. We ondersteunen dit nog niet officieel, maar bij groeiend verkeer zullen we waarschijnlijk uitbreiden naar derden en extra Digital Ocean-locaties.

De rsync-opdracht zou de doelmap automatisch moeten aanmaken, maar uit voorzorg heb ik deze handmatig aangemaakt. We hebben nu dus 5 servers met exact dezelfde inhoud, waarbij de mirrors elke 15 minuten synchroniseren vanaf de bron.

Webserverconfiguratie

Vanaf het begin hebben we NGiNX als webserver gebruikt. De redenen zijn simpel: maximale configuratieflexibiliteit en prestaties. Apache heeft zijn plek, maar we hadden de extra functies niet nodig en het budget was aanvankelijk krap. Ik ben er zo tevreden over dat veranderen nooit overwogen is.

Op de mirrors is de configuur doodsimpel. Slechts één "site" is geconfigureerd die volledige toegang biedt tot de repositorymap. SSL wordt verzorgd door 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;
}
}

Tijdens het testen merkten we iets op dat nuttig kan zijn voor andere beheerders. We zagen dat de prestaties aanzienlijk daalden wanneer veel verzoeken voor grote bestanden tegelijkertijd binnenkwamen. Uiteindelijk traceerde ik het probleem naar de I/O-stack binnen NGiNX, wat een knelpunt bleek te zijn. Ik vond documentatie over dit probleem en heb daarom de aio threads, directio, output_buffers en sendfile opties hierboven ingesteld. Deze zorgen ervoor dat NGiNX directe I/O gebruikt voor bestanden groter dan 1M en biedt 3 uitvoerbuffers met een maximale chunkgrootte van 0, wat de prestaties onder belasting verbetert.

Op de originele server is de NGiNX-configuratie veel complexer. Omdat Mirrorbits alleen de daadwerkelijke grote bestanden zelf distribueert, moet ik eerst eventuele voorafgaande bestandsomleidingen op de originele server afhandelen. Zo worden clients naar de juiste bestandslocatie geleid, en stuurt Mirrorbits dat verzoek vervolgens door naar de mirrors.

# /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;
}
}

Een deel van deze configuratie is het waard om gedetailleerd te beschrijven.

De Kodi-selector wordt gebruikt vanwege de overgang van de Kodi-backend van Python 2 naar Python 3. Sommige versies vereisen assets voor Python 2, en nieuwere versies voor Python 3. Deze selector werkt op basis van de user agent van de client, bepaalt welke Kodi-versie ze gebruiken, en leidt ze zo naar de juiste locatie.

De belangrijkste locaties zijn /archive, /releases en /master. De eerste bevat archieven en wordt niet doorgestuurd naar het Mirrorbits-proces (ondanks dat de bestanden hierboven worden gesynchroniseerd) vanwege de try_files-directive. De tweede is de hoofdrepositorymap, en de derde is een duplicaatlocatie die als fallback wordt gebruikt en ook niet doorverwijst naar Mirrorbits.

Vervolgens komt de hoofd-Mirrorbits-handler. De doorsturing is gebaseerd op de bestandsextensie van het aangevraagde bestand. Dus bij het laden van bijvoorbeeld PHP-indexpagina's worden de verzoeken niet doorgestuurd; alleen verzoeken voor de genoemde bestandstypen worden doorgestuurd naar het Mirrorbits-proces om naar mirrors te worden gedistribueerd.

De volgende 3 opties zijn voor Mirrorbits-statuspagina's, die informatie geven over de momenteel beschikbare mirrors. Voor elk bestand (bijv. https://repo.jellyfin.org/releases/server/debian/stable/meta/jellyfin_10.7.2-1_all.deb) kan men de /mirrorlist of /mirrorinfo locaties toevoegen om informatie over de beschikbare mirrors te tonen. Probeer het zelf: https://repo.jellyfin.org/releases/server/debian/stable/meta/jellyfin_10.7.2-1_all.deb/mirrorlist. Tot slot toont de /mirrorstats-pagina, of deze nu bij een bestand of in de root van het domein staat (https://repo.jellyfin.org/mirrorstats), de huidige status van de mirrors in het algemeen, inclusief eventuele offline exemplaren.

Samen vormen deze NGiNX-configuraties de basis voor Mirrorbits om te werken, en dit was het deel dat eigenlijk het langst duurde. Met dank aan @PalinuroSec op GitHub voor zijn fantastische voorbeeld-gist.

Mirrorbits: Het werkpaard

Mirrorbits installeren is vrij eenvoudig: ik heb simpelweg de laatste binaire release (0.5.1) uit de Mirrorbits-repository gedownload, het mirrorbits-binair bestand geïnstalleerd in /usr/local/bin, de voorbeeldconfiguratie in /etc/mirrorbits.conf, en de sjablonen in /usr/local/share/mirrorbits. Helaas lijkt de ontwikkeling van Mirrorbits sinds 2018 stil te liggen, inclusief documentatie. Ik heb een issue geopend tijdens het oplossen van problemen dat nog steeds onbeantwoord is, wat jammer is, en ik hoop dat deze blogpost dat probleem kan oplossen.

De basisconfiguratie van Mirrorbits is vrij eenvoudig. De meeste configuratieopties worden goed uitgelegd in het voorbeeldconfiguratiebestand, en het was vooral een kwestie van deze afstemmen op onze behoeften. Hier is onze configuratie zonder commentaar/toelichting:

# /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

Enkele opties verdienen extra aandacht omdat ze afwijken van de standaardinstellingen.

Repository verwijst naar het lokale pad van de repository, exact de bestand(en) die gesynchroniseerd worden via rsync zoals hierboven beschreven.

GeoipDatabasePath is het pad naar een lokale kopie van de GeoIP-database; dit wordt later besproken.

OutputMode staat ingesteld op redirect om clients een HTTP 302-omleiding naar het bestandspad te geven. Er zijn meerdere opties mogelijk, maar 302 bleek het meest robuust, met brede ondersteuning in HTTP-programma's en voorkoming van caching van de response.

TraceFileLocation verwijst naar een bestand dat gebruikt wordt om de "versheid" van de mirrors te beoordelen. Dit moet een bestand zijn dat garandeert dat de externe kopieën van de repository gesynchroniseerd zijn met de lokale instantie, en moet binnen de Repository-locatie vallen.

Hashes biedt verschillende opties, maar wij gebruiken SHA256-hashing voor eenvoud en consistentie met onze eigen aangeleverde hashes.

RedisAddress verwijst naar een lokale Redis-instantie die Mirrorbits gebruikt voor statusbeheer.

ConcurrentSync, RepositoryScanInterval, ScanInterval en CheckInterval zijn tijdsintervallen in minuten waarop Mirrorbits zou moeten controleren en synchroniseren, maar in de praktijk bleken deze instellingen onbetrouwbaar; ik gebruik daarom een cron-taak om deze taken handmatig uit te voeren.

Ten slotte biedt Fallbacks een lijst met fallback-mirrors. Zoals eerder genoemd, biedt het /master-pad exact dezelfde inhoud als /releases, maar zonder terugverwijzing naar Mirrorbits (om loops te voorkomen). Zonder deze fallback zouden clients bestanden mogelijk helemaal niet kunnen downloaden als alle mirrors onbereikbaar zijn, bijvoorbeeld door nieuwheid van bestanden of mirrorstoringen. De fallback garandeert dat er altijd minstens één bron beschikbaar blijft - de originele server - die normaal niet gebruikt wordt maar wel beschikbaar is in noodgevallen.

De volgende stap was het maken van een SystemD-service voor Mirrorbits. Het proces vereist speciale opties voor herladen en stoppen, die hier zijn opgenomen. Let op: Mirrorbits draait als www-data, dezelfde gebruiker als NGiNX zelf:

# /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

Ten slotte voegde ik enkele cron-jobs toe om te garanderen dat de TraceFileLocation regelmatig bijgewerkt wordt en dat de mirror regelmatig gescand en ververst wordt - taken die Mirrorbits zelf niet correct leek af te handelen. Het tracebestand wordt altijd 1 minuut vóór de synchronisatie bijgewerkt, terwijl de mirrorscan 1 minuut erna plaatsvindt om de sync tijd te geven. De lokale repository wordt elke minuut ververst om nieuwe bestanden zo snel mogelijk op te pikken.

# /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

Mirrors toevoegen aan Mirrorbits

Nadat alle setup voltooid was en Mirrorbits draaide, voegde ik de mirrors toe aan het Mirrorbits-systeem. Dit is een rechttoe rechtaan proces met het Mirrorbits-binair:

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

Na het scannen, activeren en verversen werd de mirror actief en zichtbaar in de /mirrorstats-output of via CLI, klaar om requests af te handelen.

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

Een complicatie bij het gebruik van Mirrorbits is de vereiste voor een GeoIP-database. Specifiek vereist het het mmdb-formaat van GeoLite2 om correct te werken. Helaas wordt dit niet langer gratis aangeboden. Je moet dit zelf verkrijgen of een alternatief vinden; ik zou liever een vrij beschikbare database gebruiken, maar ik heb nog geen werkend alternatief gevonden. Als je er een kent, laat het me weten!

Deze database wordt uitgepakt op de GeoipDatabasePath-locatie en wordt door Mirrorbits tijdens runtime geladen, waarna het de GeoIP-informatie gebruikt om clients naar de dichtstbijzijnde mirror server te sturen.

Conclusie

Alles bij elkaar hopen we dat deze opzet het mogelijk maakt om het downloaden voor onze gebruikers wereldwijd verder op te schalen. En ik hoop dat dit bericht een andere beheerder van een klein project helpt die downloads over meerdere geografisch diverse servers wil verspreiden. Succes!