Ir al contenido principal

La CDN de Jellyfin: Mirrorbits para las masas

· 16 min de lectura
Joshua Boniface
Project Leader
Traducción Beta No Oficial

Esta página fue traducida por PageTurner AI (beta). No está respaldada oficialmente por el proyecto. ¿Encontraste un error? Reportar problema →

Para muchos proyectos, distribuir recursos binarios es sencillo: sube los archivos a GitHub y listo. Es algo en lo que pocos piensan. Pero en Jellyfin, necesitábamos algo más robusto, una solución que manejara nuestras necesidades con mayor elegancia que GitHub o un servidor web básico. Y tanto para los interesados como para quienes apoyan proyectos similares, me gustaría compartir cómo lo hacemos.

Prólogo - Antes de la versión 10.6.0

Antes de nuestro lanzamiento 10.6.0, teníamos una configuración de repositorio bastante simple: todo se construía en un VPS con Debian llamado build1, en respuesta a un web-hook de GitHub. Este servidor, ubicado en Digital Ocean en la zona de Toronto, albergaba tanto el proceso de compilación como nuestro repositorio principal, servido directamente mediante NGiNX.

Pero en este lanzamiento fue la primera vez que notamos un problema. Jellyfin es un proyecto global, y aunque personalmente estoy en Ontario, Canadá, la gran mayoría de nuestros usuarios no lo están. Los usuarios, especialmente en Europa y Asia, tenían problemas para descargar nuestras versiones. La principal queja eran velocidades de descarga pésimamente lentas y, ocasionalmente, incluso tiempos de espera agotados. Tuvimos que idear una mejor solución.

Como aficionado al bricolaje por naturaleza, y liderando un proyecto construido por y para DIYers, no me conformé con simplemente poner CloudFlare delante del repositorio - mis reservas con ese proveedor aparte. Quería algo que pudiéramos controlar, y busqué una solución: cómo crear efectivamente una CDN para descargas de archivos.

Entra Mirrorbits

Afortunadamente, no estaba solo. Muchos años antes, otro proyecto FLOSS había encontrado el mismo problema. VideoLAN, creadores del fantástico VLC Media Player, tenían el mismo desafío de distribución de archivos. Así que uno de sus talentosos desarrolladores creó una solución: Mirrorbits.

Mirrorbits es un programa en Go con un único objetivo: proporcionar una forma de distribuir solicitudes desde un repositorio central único a múltiples repositorios geodiversos, basándose en la información GeoIP del cliente, manejando disponibilidad y actualización sin problemas. Parecía encajar perfectamente.

Pero había un problema: la documentación sobre cómo ejecutar Mirrorbits era escasa, así que requirió bastante prueba y error determinar cómo usarlo. Espero que lo siguiente ayude a evitar estos problemas a otros.

Estructura de archivos

Lo primero a considerar es la estructura de los archivos. En el caso de Jellyfin, nuestro repositorio es grande y extenso, constituido por muchos componentes diferentes que incluyen el servidor, clientes, complementos y varios archivos secundarios. Mirrorbits requiere que todo esté alojado bajo un mismo directorio, y necesitábamos una forma de sincronizar fácilmente todo este directorio. También teníamos el problema de nuestros "archivos históricos", versiones estables antiguas que no necesitábamos ni queríamos sincronizar en todos nuestros servidores espejo para no desperdiciar espacio.

La solución que ideé fue la siguiente estructura de directorios:

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

En efecto, todo está bajo un directorio /mirror, con enlaces simbólicos externos para proporcionar los accesos raíz que queríamos.

VPS adicionales y sincronización

El siguiente paso después de diseñar una estructura de archivos utilizable fue crear algunos VPS adicionales y determinar cómo sincronizar los archivos.

Como nuestro servidor de origen está en Toronto, quería asegurar una amplia cobertura geográfica, así como una CDN dedicada para la misma región del servidor de origen, por si llegaba a sobrecargarse. Así, creé 4 VPS adicionales:

  • tor1.mirror.jellyfin.org: Toronto, Ontario, Canadá para Este de América Norte/Sur

  • sfo1.mirror.jellyfin.org: San Francisco, California, USA para Oeste de América Norte/Sur

  • fra1.mirror.jellyfin.org: Frankfurt, Alemania (¡no Francia como comúnmente se asume!) para Europa y África

  • sgp1.mirror.jellyfin.org: Singapur para Asia y el Pacífico

Una preocupación inicial al configurar estos 4 VPS era que no serían suficientes, pero hasta ahora, tras otro lanzamiento importante y varias versiones menores, las quejas sobre la velocidad de descarga han cesado por completo, por lo que deben estar funcionando. En el futuro, a medida que Digital Ocean expanda su infraestructura, podríamos añadir otras ubicaciones como Bangalore (India), África, y quizás instancias adicionales en Europa y América del Sur.

Con los VPS creados, busqué entonces una solución para la sincronización de archivos, y solo un programa me vino a la mente: rsync. Pero aunque la mayoría de usuarios conoce rsync por su funcionalidad como reemplazo de scp sobre SSH, yo buscaba algo más robusto sin necesidad de gestionar claves SSH, y que pudiera extenderse en el futuro a espejos de terceros no confiables (y desconfiados).

Por eso opté por usar el modo demonio de rsync. Al escuchar en el puerto 873, puede hacer todo lo que rsync sobre SSH hace, pero sin la sobrecarga de cifrado ni requerir autenticación SSH/shell.

Primero preparé el archivo local /etc/rsyncd.conf en el servidor de origen (build1). Por defecto, el paquete rsync de Debian no instala el servicio demonio a menos que este archivo exista, pero tras crearlo el demonio puede iniciarse.

Para manejar los mencionados "archivos históricos", dividí el repositorio en dos "componentes": uno con los archivos históricos y otro sin ellos. Esto permite que cada espejo pueda descargar todo el archivo histórico o solo los repositorios estables actuales. Cuando comenzamos a ofrecer compilaciones "inestables" que pueden generarse docenas de veces al día, decidí incluirlas también en el componente "archivos históricos".

Aquí está nuestra configuración:

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

En muchos casos, sería prudente asegurar esto, pero como queríamos abrirlo a cualquiera, dejé el punto final de rsync completamente expuesto. Por lo tanto, si deseas alojar un espejo local de Jellyfin, puedes hacerlo. ¡Simplemente clona este objetivo rsync y tendrás una copia completa del espejo de Jellyfin!

En los servidores espejo, sin embargo, todavía necesitábamos obtener el contenido. Al final, decidí que era más prudente que cada espejo "oficial" que copiara el contenido completo (incluyendo archivos y compilaciones inestables), por lo que todos ellos sincronizan la fuente mirror-full.

Por lo tanto, cada nodo tiene un simple trabajo cron programado para ejecutarse cada 15 minutos, que descarga una copia actualizada del repositorio desde el origen.

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

Los tiempos ligeramente inusuales se eligieron específicamente: el objetivo para terceros, si y cuando los admitamos oficialmente, sería sincronizar cada X minutos en intervalos pares, por ejemplo, a las 00, 30, etc., desde estos espejos "oficiales", en lugar de hacerlo directamente desde build1. Esto asegura que siempre estarían actualizados antes de que llegue ese momento, evitando retrasos adicionales para espejos de terceros. Aún no admitimos esto oficialmente, pero si nuestro tráfico sigue creciendo, probablemente nos expandiremos a terceros y a más ubicaciones de Digital Ocean.

El comando rsync debería crear el directorio de destino automáticamente, pero para ser prudente, me aseguré de crearlo manualmente primero. Así, ahora tenemos 5 servidores con exactamente el mismo contenido, y los espejos se sincronizan desde el origen cada 15 minutos.

Configuración del servidor web

Desde el principio hemos usado NGiNX como servidor web. Las razones son simples: máxima flexibilidad de configuración y rendimiento. Apache tiene su lugar, pero no necesitábamos sus funciones adicionales y inicialmente el presupuesto era limitado. Estoy tan satisfecho con su rendimiento que ni siquiera he considerado cambiarlo.

En los espejos, la configuración es extremadamente simple. Solo se configura un "sitio" que proporciona acceso completo al directorio del repositorio. SSL lo proporciona 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;
}
}

Durante las pruebas, notamos algo que podría ser útil para otros administradores. Descubrimos que el rendimiento, cuando llegaban muchas solicitudes de archivos grandes al mismo tiempo, disminuía significativamente. Finalmente rastreé el problema hasta la pila de E/S dentro de NGiNX, que parecía ser un cuello de botella. Pude encontrar documentación sobre este problema y configuré las opciones aio threads, directio, output_buffers y sendfile mencionadas arriba. Estas garantizan que NGiNX usará E/S directa para cualquier archivo mayor a 1MB, y proporcionan 3 buffers de salida con un tamaño máximo de fragmento de 0, mejorando el rendimiento bajo carga.

En el servidor de origen, la configuración de NGiNX es mucho más compleja. Como Mirrorbits solo distribuye los archivos grandes en sí, necesito manejar cualquier redirección previa primero en el origen. Así los clientes son dirigidos a la ubicación correcta del archivo, y Mirrorbits dirige esa solicitud a los 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;
}
}

Algunas partes de esta configuración merecen una descripción detallada.

El selector de Kodi se usa debido a la transición del backend de Kodi de Python 2 a Python 3. Algunas versiones requieren recursos para Python 2, y las más nuevas para Python 3. Este selector opera basándose en el agente de usuario enviado por el cliente, determinando qué versión de Kodi están usando y así dirigirlos a la ubicación correcta.

Las ubicaciones principales son /archive, /releases y /master. La primera contiene archivos históricos y no se reenvía al proceso Mirrorbits (a pesar de que los archivos están sincronizados) debido a la directiva try_files. La segunda es el directorio principal del repositorio, y la tercera es una ubicación duplicada que se usa como respaldo y tampoco redirige a Mirrorbits.

Luego está el manejador principal de Mirrorbits. El reenvío se basa en la extensión del archivo solicitado. Así, al cargar por ejemplo las páginas índice PHP, las solicitudes no se reenvían; solo las solicitudes de los tipos de archivo listados se reenvían al proceso Mirrorbits para ser distribuidas a los mirrors.

Las siguientes 3 opciones son para las páginas de estado de Mirrorbits, que muestran información sobre los mirrors disponibles. Para cualquier archivo (ej. https://repo.jellyfin.org/releases/server/debian/stable/meta/jellyfin_10.7.2-1_all.deb), se puede añadir /mirrorlist o /mirrorinfo para mostrar información sobre los mirrors disponibles. Pruébalo tú mismo: https://repo.jellyfin.org/releases/server/debian/stable/meta/jellyfin_10.7.2-1_all.deb/mirrorlist. Finalmente, la página /mirrorstats, ya sea en un archivo o en la raíz del dominio (https://repo.jellyfin.org/mirrorstats), muestra el estado actual de los mirrors en general, incluyendo si alguno está fuera de línea.

En conjunto, estas configuraciones de NGiNX proporcionan la base para que Mirrorbits funcione, y esta fue la parte que realmente tomó más tiempo. Gracias a @PalinuroSec en GitHub por su fantástico ejemplo en gist.

Mirrorbits: El caballo de batalla

Instalar Mirrorbits es bastante sencillo: simplemente descargué el último binario (0.5.1) del repositorio de Mirrorbits, instalé el binario mirrorbits en /usr/local/bin, la configuración de muestra en /etc/mirrorbits.conf y las plantillas en /usr/local/share/mirrorbits. Lamentablemente, el desarrollo de Mirrorbits parece haberse estancado desde 2018, incluyendo la documentación. Abrí un issue durante mi solución de problemas que sigue sin respuesta, lo cual es desafortunado, y espero que esta publicación de blog pueda resolver ese problema.

La configuración básica de Mirrorbits es bastante sencilla. La mayoría de las opciones de configuración están bien explicadas en el archivo de configuración de ejemplo, y solo fue necesario ajustarlas a nuestras necesidades. Esta es nuestra configuración sin comentarios/explicaciones:

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

Vale la pena mencionar algunas opciones que difieren de los valores predeterminados obvios.

Repository apunta hacia la ruta local del repositorio, los archivos exactos que se sincronizan con rsync mencionado anteriormente.

GeoipDatabasePath es la ruta a una copia local de la base de datos GeoIP; esto se discutirá más adelante.

OutputMode está configurado como redirect para dar a los clientes una redirección HTTP 302 hacia la ruta del archivo. Hay varias opciones aquí, pero el 302 pareció el más robusto, siendo ampliamente compatible con la mayoría de programas HTTP y evitando el almacenamiento en caché de la respuesta.

TraceFileLocation apunta a un archivo que se utiliza para juzgar la "frescura" de los mirrors. Debe ser un archivo que garantice que las copias remotas del repositorio están sincronizadas con la instancia local, y debe estar bajo la ubicación de Repository.

Hashes proporciona varias opciones, pero usamos hash SHA256 por simplicidad y para que coincida con nuestros propios hashes proporcionados.

RedisAddress apunta a una instancia local de Redis que Mirrorbits utiliza para gestionar el estado.

ConcurrentSync, RepositoryScanInterval, ScanInterval y CheckInterval son tiempos en minutos que Mirrorbits debería estar verificando y sincronizándose, pero descubrí que son poco confiables; en su lugar usé una tarea cron para realizar estas tareas manualmente.

Finalmente, Fallbacks proporciona una lista de mirrors de respaldo. Como se mencionó anteriormente, la ruta /master proporciona exactamente el mismo contenido que /releases, solo que sin ser reenviado a Mirrorbits y crear un bucle. Sin este respaldo, si todos los mirrors están no disponibles para un archivo determinado, ya sea por su novedad o fallos en los mirrors, los clientes no podrían descargar archivos. El respaldo asegura que haya al menos una fuente (el origen) que normalmente no se usa, pero que está disponible en caso de necesidad.

El siguiente paso fue crear un servicio SystemD para Mirrorbits. El proceso requiere algunas opciones especiales para recargas y detenciones, así que se incluyen aquí. Nota: ejecuto Mirrorbits como www-data, el mismo usuario que NGiNX:

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

Finalmente, agregué algunas tareas cron para asegurar que TraceFileLocation se actualice regularmente, y que el mirror sea escaneado y actualizado periódicamente (cosas que Mirrorbits en sí no parecía manejar correctamente). El archivo de seguimiento siempre se actualiza 1 minuto antes de la sincronización, mientras que el escaneo del mirror ocurre 1 minuto después, para dar tiempo a que la sincronización se complete. El repositorio local se actualiza cada minuto para detectar nuevos archivos lo más rápido posible.

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

Agregar mirrors a Mirrorbits

Una vez completada toda la configuración y con Mirrorbits en ejecución, agregué los mirrors al sistema Mirrorbits. Este es un proceso sencillo usando el binario de Mirrorbits:

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

Una vez escaneado, habilitado y actualizado, el mirror se volvió activo y visible en la salida de /mirrorstats o en la CLI, listo para atender solicitudes.

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

Una complicación al usar Mirrorbits es requerir una base de datos GeoIP. Específicamente necesita el formato mmdb de GeoLite2 para operar correctamente. Desafortunadamente, esto ya no se proporciona gratuitamente. Debes obtenerlo tú mismo o encontrar una alternativa; preferiría usar una base de datos disponible libremente, pero aún no he encontrado una que funcione con Mirrorbits. ¡Si conoces alguna, por favor házmelo saber!

Esta base de datos se extrae a la ubicación GeoipDatabasePath y es cargada por Mirrorbits en tiempo de ejecución, proporcionando la información GeoIP que luego utiliza para dirigir a los clientes al servidor mirror más cercano.

Conclusión

En conjunto, esperamos que esta configuración nos permita seguir escalando nuestras descargas para usuarios en todo el mundo. Y espero que esta publicación ayude a otro administrador de un proyecto pequeño que necesite distribuir sus descargas a través de múltiples servidores geodiversos. ¡Buena suerte!