Dragonfly, Nginx et contenu partiel

Publié le 22 mars 2017 par Martin Catty | ops

Cet article est publié sous licence CC BY-NC-SA

En utilisant Dragonfly nous nous sommes rendus compte qu’au moment de servir des vidéos pour Safari et Safari mobile depuis notre application Rails, cela ne fonctionnait pas.

En creusant un peu il s’avère que Safari est assez exigeant puisqu’il impose un support du byte-range de la part du serveur qui sert la vidéo.

C’est à dire que le player veut pouvoir demander une tranche de la vidéo uniquement, de sorte à gérer sa mise en cache et ne pas nécessairement tout télécharger d’un coup. Il demande donc un contenu partiel (code HTTP 206).

Pour cela il envoie un entête byte-range dans la requête et attend en retour un entête Accept-Ranges si le serveur le supporte.

Exemple :

curl --verbose --range 0-99 https://example.com/video.mp4 -o /dev/null

Comme notre serveur le supporte nous recevons ce type d’entête :

Accept-Ranges: bytes
Content-Range: bytes 0-99/55766047
Content-Type: video/mp4
Content-Length: 100

À qui la charge de renvoyer des réponses partielles ?

La première idée serait de faire en sorte que notre application Rails puisse produire des réponses partielles.

En fouillant un peu il existe un ticket ouvert sur le projet Dragonfly accompagné d’un gist qui vise à introduire un nouveau middleware en charge de produire un morceau de réponse depuis le fichier final.

Je pense que c’est une mauvaise idée de s’appuyer sur ce code pour plusieurs raisons :

  • parce que c’est mal de copier/coller des gist sortis de nul part et vieux de 5 ans
  • ce code effectue une lecture bit à bit particulièrement lente et qui impose de travailler sur le fichier complet
  • autant que faire se peut, on souhaite éviter que ça soit Ruby qui fasse ce traitement car il sera nécessairement (plus) lent
  • Nginx sait le faire de base et sans doute infiniment mieux et plus vite

Une autre solution serait d’utiliser Rack::File qui gère les entêtes de type range mais autant utiliser notre reverse proxy pour faire ce travail plus efficacement.

Faire en sorte que Nginx traite nos requêtes partielles

La première chose à faire est de demander à Nginx de prendre la main lorsque nous renvoyons des fichiers (send_file) dans Rails de sorte à ce que le flux de requêtes soit géré ainsi :

Flux de la requête

Pour cela nous activons l’entête qui va bien :

config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect'

Ce paramètre peut être configuré soit par environnement dans le fichier correspondant, soit dans le fichier config/application.rb pour l’ensemble des environnements. Attention toutefois à la précédence, car si vous mettez la configuration dans les deux fichiers c’est celle du fichier d’environnement qui prendra le pas.

Une fois fait, lors des demandes de fichier, Rack enverra un entête à Nginx en lui demandant de charger le fichier et en joignant une réponse (body) vide.

Côté Rails, le travail est fini. Il a passé la main à Nginx et peut passer à la requête suivante.

C’est bien gentil mais Nginx a besoin de savoir où trouver le fichier en question. Dans le cas d’une requête type https://example.com/files/foo.zip le mapping est simple, la configuration de Nginx utilise le répertoire public de Rails comme root et il va donc le trouver sans configuration supplémentaire.

Dans le cas de Dragonfly, on a des URL un peu plus complexes type /media/W1siZiIsIjIwMTcvMDMvMDEvMTI/video.mp4?sha=ueiu

Dragonfly utilise Rack::Cache pour mettre en cache les réponses et les servir plus rapidement. Il faut donc que nous expliquions à Nginx comment servir ces requêtes.

En premier lieu nous allons raffiner un peu la configuration de Rack::Cache pour définir où nous voulons stocker notre cache.

Dans le fichier application.rb, pour le généraliser à tous les environnements, ou dans production.rb pour la production uniquement, nous remplaçons config.action_dispatch.rack_cache = true par :

config.action_dispatch.rack_cache = {
  verbose: false,
  metastore: URI.encode("file:#{File.expand_path(Rails.root.join('../../shared/cache/dragonfly/meta'))}"),
  entitystore: URI.encode("file:#{File.expand_path(Rails.root.join('../../shared/cache/dragonfly/body'))}")
}

Ici nous sommes dans le cadre d’un déploiement Capistrano, on veut donc stocker le cache dans le dossier shared pour qu’il persiste les différents déploiements (attention à purger le cache de temps à autre).

Maintenant il faut que Nginx prenne connaissance de ces URL afin de pouvoir les servir. Dans sa configuration nous allons définir ces chemins comme internes :

location /chemin/complet/vers/dragonfly/body {
  internal;
  alias /chemin/complet/vers/dragonfly/body;
}

Ici location et alias sont identiques mais, sans même parler de Rails, on pourrait faire en sorte que Nginx serve les fichiers depuis /var/uploads lorsqu’il reçoit une requête sur /files :

location /files {
  internal;
  alias /var/uploads;
}

Dans ce cas, si on demande /files/foo.zip il servira /var/uploads/foo.zip. Dernière chose à faire côté Nginx, renvoyer un entête de mapping des fichiers.

location {
  proxy_set_header X-Accel-Mapping /chemin/complet/vers/dragonfly/body=/chemin/complet/vers/dragonfly/body;
}

Lorsque Rack va recevoir cet entête de mapping, il va pouvoir en retour indiquer où se trouve le fichier à aller chercher (car l’URL d’entrée est toujours /media/…) et demander à Nginx de le servir directement via l’entête ‘X-Accel-Redirect’.

En clair, il va lui dire /media/zzz se trouve dans /chemin/complet/vers/dragonfly/body/A1 et Nginx pourra le servir lui même.

J’espère que cet article vous aidera à servir des contenus partiels dans le cas d’une stack Rails + Nginx ou tout du moins à délester votre serveur applicatif en confiant à votre reverse proxy le soin de servir vos fichiers.


L’équipe Synbioz.

Libres d’être ensemble.