Servir des images efficacement avec Django et Nginx

Tripodocus est un site entièrement réalisé en Django sur lequel je publie quelques photos. La plupart des images sont publiques, d'autres sont privées et uniquement accessibles aux personnes autorisées. Django génère aussi des thumbnails pour chaque photo.

J'ai passé pas mal de temps à travailler sur la manière dont les images sont servies à mes (encore bien rares) visiteurs. J'utilise les outils fournis par Django et Nginx pour proposer des urls cohérentes et des performances optimales.

Voici un tutoriel (qui présuppose que vous connaissez un minimum Django) sur ma recette pour servir des images avec Django et Nginx.

Définition des ressources et URLs

En accord avec les principes énoncés dans le livre « RESTful Web Services », notre première étape sera de définir nos ressources et leur associer des urls.

Sur Tripodocus, j'uploade des photos. Il faut évidemment que cette photo puisse être accessible pour le visiteur. Je vais donc l'intégrer dans une belle page Web avec un titre, une légende plus ou moins pertinente, des méta-données, etc.

Ressource 1 : la page Web qui affiche la photo.

Évidemment, il faut bien que le fichier de ladite photo soit visible.

Ressource 2 : le fichier de la photo.

Enfin, pour de meilleures performances, je vais également proposer des versions retaillées (thumbnails) dudit fichier.

Ressource 3 et plus : les thumbnails.

Parce que je suis un peu puriste sur les bords, je vais utiliser les urls les plus simples et les plus logiques possibles.

Ressource URL
Page Web https://www.miximum.fr/pictures/2015/le-grand-saut/
Fichier https://www.miximum.fr/pictures/2015/le-grand-saut.jpg
Thumbnail https://www.miximum.fr/pictures/2015/le-grand-saut_medium.jpg

Définir les responsabilités de chacun

Des pigons à l'air louche (encore plus que les pigeons normaux)

Je pars du principe que votre projet Django est hébergé sur un système Debian avec Nginx en reverse proxy et Gunicorn en serveur Wsgi. À vrai dire, seul Django et Nginx seront considérés ici.

La liste de mes photos est gérée par Django et stockée en base de donnée. Quand vous demandez la page Web d'une photo, c'est à Django que vous vous adressez.

Par contre, quand vous demandez le fichier correspondant, j'aimerais éviter que la requête atteigne le framework python, et que Nginx se charge lui-même de servir le fichier. Tout simplement pour des questions de performances.

Reste l'épineuse question des images privées. En effet, c'est bien Django qui va devoir se charger de vérifier que vous avez le droit d'accéder à la photo. Pour autant, une fois que le framework a déterminé que votre requête est légitime, on aimerait qu'il délègue la suite de l'opération (a.k.a. servir le fichier) à Nginx, toujours pour des questions de performances. Nous allons voir par la suite comment on peut faire ça.

Pour obtenir une configuration Nginx qui fonctionne, il va nous falloir distinguer les urls des images publiques et privées, et les stocker dans deux répertoires différents. Notre nouveau schéma d'urls sera le suivant :

Ressource URL
Page Web (publique ou privée) https://www.miximum.fr/pictures/2015/le-grand-saut/
Fichier public https://www.miximum.fr/pictures/2015/le-grand-saut.jpg
Thumbnail public https://www.miximum.fr/pictures/2015/le-grand-saut_medium.jpg
Fichier privé https://www.miximum.fr/private/2015/photo-compromettante.jpg
Thumbnail privé https://www.miximum.fr/private/2015/photo-compromettante_medium.jpg

Définition du modèle dans Django

Voici un exemple de modèle Django simplifié pour nous permettre de travailler.

# -*- coding: utf-8 -*-
# /src/pictures/models.py

from __future__ import unicode_literals

from os.path import splitext, basename

from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.core.files.storage import FileSystemStorage
from django.utils import timezone
from django.conf import settings


# In settings:
# PICTURES_ROOT = '/home/tripodocus/media/'
# PICTURES_URL = '/'

class PictureStorage(FileSystemStorage):
    def __init__(self, *args, **kwargs):
        kwargs.update({
            'location': settings.PICTURES_ROOT,
            'base_url': settings.PICTURES_URL
        })
        super(PictureStorage, self).__init__(*args, **kwargs)


def upload_path(instance, filename):
    filename = basename(filename)
    _, extension = splitext(filename)
    now = timezone.now()
    prefix = 'private' if instance.is_private else 'pictures'
    return '{}/{}/{}{}'.format(
        prefix, now.year, instance.slug, extension)


# The actual picture model
class Picture(models.Model):
    title = models.CharField(
        _('Title'),
        max_length=250)
    slug = models.SlugField(
        _('Slug'),
        max_length=250,
        unique=True)
    is_private = models.BooleanField(
        _('Is private?'),
        default=False)
    image = models.ImageField(
        _('Image file'),
        upload_to=upload_path,
        storage=PictureStorage())
    created_on = models.DateTimeField(
        _('Created on'),
        default=timezone.now)

Je vous laisse consulter la doc de Django pour comprendre l'utilité et la différence entre les paramètres storage et upload_path.

Ce qu'il faut retenir, c'est que les fichiers uploadés seront stockés dans un répertoire media à la racine de notre projet, dans un sous répertoire pictures ou private en fonction de la valeur du champ is_private, et l'intégralité de chemin de l'image (e.g pictures/2015/le-grand-saut.jpg) sera stocké en base de données. Le fichier est également renommé en fonction du slug de l'objet Django.

Tout le code concernant l'admin et l'upload de fichiers étant déjà parfaitement documenté, je laisserai cette partie de côté.

Une configuration Nginx basique

Voici un point de départ pour notre configuration Nginx. Notez que cet exemple fonctionne parfaitement en local, avec le serveur de dev de Django.

Notez que j'ai également laissé de côté tout ce qui ne concerne pas directement ce tutoriel (fichiers statiques, favicon.ico, caches, gzip, expires, etc.)

# /etc/nginx/sites-available/tripodocus

# Définition de notre serveur wsgi (Gunicorn ou serveur de dev Django)
upstream tripodocus {
    server localhost:8000;
}

server {
    # Config de base
    server_name www.miximum.fr;
    access_log /var/log/nginx/tripodocus.access.log;
    error_log /var/log/nginx/tripodocus.error.log;

    # Définition d'une location nommée
    # qui va nous servir à indiquer une redirection vers
    # le serveur wsgi défini plus haut.
    location @django {
        proxy_pass http://tripodocus;
        proxy_redirect off;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # Pour toutes les urls commençant par /pictures/ nginx
    # commencera par vérifier si le fichier demandé existe sur le
    # système de fichier. Si c'est le cas, elle va le servir directement sans
    # rien demander à personne, ce qui nous arrange bien. Sinon, elle forwarde
    # la requête à Django.
    location /pictures/ {
        alias  /home/tripodocus/media/pictures/;
        expires 1w;
        try_files $uri @django;
    }

    # Les urls commençant par /private/ sont forwardées à Django
    # À vrai dire, cette règle est inutile puisque elle est redondante
    # avec la suivante, mais je la garde parce que ça me parait plus
    # clair comme ça.
    location /private/ {
        error_page 418 = @django;
        return 418;
    }

    # Toutes les autres urls sont transmises à Django sans autre forme de
    # procès. Notez qu'il n'y a pas moyen (je n'ai pas trouvé) de forwarder
    # directement une url à une location nommée, et qu'il faut pour ça utiliser
    # un hack tout moche. Si quelqu'un a une meilleure idée…
    location / {
        error_page 418 = @django;
        return 418;
    }
}

Grâce à cette configuration Nginx, il se passe les choses suivantes.

Toutes les requêtes de fichiers publics (présents dans le sous-répertoire pictures) sont servies directement par Nginx. Sauf si le fichier n'existe pas, auquel cas, la requête est envoyée à Django. Nous allons voir que c'est utile pour la génération des thumbnails.

Toutes les autres requêtes sont envoyées à Django.

Le fonctionnement des thumbnails

Deux touristes consultent un plant

Certains plugins permettant de gérer des thumbnails génèrent et enregistrent les différentes vignettes au moment ou la photo est uploadée et l'objet est créé dans Django.

C'est un système qui ne me convient pas, parce qu'il manque de souplesse (et j'aime la souplesse).

Au lieu de ça, je veux que mes thumbnails soient générés en juste-à-temps, au moment ou l'utilisateur le demande. Évidemment, une fois la vignette générée, elle est enregistrée sur le système de fichiers et n'a pas besoin d'être générée une seconde fois. On n'est quand même pas des bêtes.

Le processus est le suivant :

  1. un utilisateur enthousiaste requiert la vignette https://www.miximum.fr/pictures/2015/le-grand-saut_medium.jpg correspondante à une photo que je viens d'uploader ;
  2. le fichier n'existant pas, Nginx transmet la requête à Django ;
  3. le framework génère diligemment le thumbnail en question, et l'enregistre sur le système de fichier ;
  4. une fois fait, plutôt que de servir lui-même le fichier, il* rend la main à Nginx en lui disant « voilà le fichier demandé, tu peux l'envoyer au client, moi je retourne me coucher » ;
  5. Nginx reprend la main, elle* constate que le fichier existe, et l'envoie au client.

*Je ne sais pas pourquoi, dans mon esprit, Django est un homme et Nginx une femme.

Si l'utilisateur recharge la page, l'enchaînement est maintenant différent :

  1. un utilisateur enthousiaste requiert la vignette https://www.miximum.fr/pictures/2015/le-grand-saut_medium.jpg ;
  2. le fichier existant déjà, Nginx l'envoie au client ;
  3. et c'est tout.

Ainsi, si je modifie dans mes settings la configuration des thumbnails medium, tout ce que j'ai à faire pour regénérer toutes les vignettes correspondantes, c'est de les effacer.

find /home/tripodocus/media/pictures -name "*_medium.*" -exec rm {} \;

Les avantages de ce système sont multiples :

  • il est simple à mettre en œuvre ;
  • il est élégant (et j'aime l'élégance) ;
  • il est souple ;
  • il est tolérant à l'erreur ;
  • uploader une image ne prend pas trois plombes parce qu'il faut générer 15 vignettes dans la foulée.

Le seul inconvénient, c'est que la première fois qu'un utilisateur consulte la page d'une nouvelle photo, le chargement du thumbnail prend quelques dixièmes de secondes. D'ailleurs, c'est souvent pour ma pomme. C'est tellement peu gênant que je me demande même pourquoi je prends la peine de le mentionner.

Tu l'as vue ma vue ?

Nous allons maintenant définir les vues qui correspondent à toutes ces urls. Voici le contenu du fichier urls.py.

# L'url de la page photo
url(r'^pictures/(?P<year>\d+)/(?P<slug>[\w-]+)/$',
    PictureView.as_view(),
    name='picture'),

# La vue qui génère les thumbnails
url(r'^pictures/(?P<year>\d+)/(?P<slug>[a-zA-Z0-9-]+)_(?P<size>\w+).(?P<extension>\w+)$',
    ThumbnailView.as_view(),
    name='thumbnail'),

# La vue qui sert les fichiers photo privés
url(r'^private/(?P<year>\d+)/(?P<slug>[a-zA-Z0-9-]+).(?P<extension>\w+)$',
    PrivatePictureFileView.as_view(),
    name='private_image'),

# La vue qui génère et sert les thumbnails pour les photo privées
url(r'^private/(?P<year>\d+)/(?P<slug>[a-zA-Z0-9-]+)_(?P<size>\w+).(?P<extension>\w+)$',
    PrivateThumbnailView.as_view(),
    name='private_thumbnail'),

Notez qu'il n'y a pas de vue pour servir les fichiers des photos publiques. C'est parce qu'il n'y en a pas besoin, elles sont directement servies par Nginx (suivez, un peu).

Voici le contenu des vues correspondantes.

PictureView

La vue qui affiche la page Web d'une photo (e.g https://www.miximum.fr/pictures/2015/le-grand-saut/) n'a rien de particulier, c'est une vue Django tout ce qu'il y a de plus classique, il n'y a rien à dire dessus. Retenez juste qu'il y a une vérification de droit d'accès à réaliser, c'est tout.

ThumbnailView

Cette vue génère un thumbnail d'une taille donnée, et repasse la main à Nginx.

class BasePictureFileView(DetailView):
    """Vue de base pour toutes les vues qui travaillent sur une photo.

    Notez que toutes les vues définies plus haut travaillent sur une photo
    identifiée par un "slug" et une année de publication.

    """
    model = Picture
    context_object_name = 'picture'

    def get_queryset(self):
        qs = super(BasePictureFileView, self).get_queryset()
        return qs.filter(created_on__year=self.kwargs.get('year'))


class ThumbnailView(BasePictureFileView):
    """Génère un thumbnail sur le disque."""

    def write_thumbnail_on_disk(self, picture):
        """Génère et enregistre le thumbnail au bon endroit.

        Il y a plusieurs façons de faire ça, la documentation est
        abondante, je vous laisse le soin de vous renseigner.

        Retourne l'url relative du fichier e.g /pictures/2015/super-photo_medium.jpg

        Notez que nous avons fait en sorte que cette url soit exactement la
        même que celle demandée par le client.

        """
        pass

    def render_to_response(self, context):
        thumbnail_url = self.write_thumbnail_on_disk(context['picture'])
        extension = self.kwargs.get('extension')

        response = HttpResponse(content_type='image/{}'.format(extension))
        response['X-Accel-Redirect'] = thumbnail_url
        return response

Les dernières lignes sont importantes. En effet, nous aurions pu retourner bêtement le contenu du fichier dans notre réponse Django (par exemple en utilisant la classe FileResponse).

Nous aurions pu, mais ça n'aurait pas été élégant, parce que le but d'un framework Python n'est pas de servir des fichiers binaires. Au lieu de ça, nous utilisons une fonctionnalité nommée X-sendfile accessible grâce au module X-accel de Nginx.

Le fonctionnement est simple. Si vous retournez une réponse vide avec un header X-Accel-Redirect contenant une url, alors Nginx va intercepter cette réponse, et se comporter exactement comme si le client venait de faire une nouvelle requête sur cette url.

Notez que dans ce cas, l'url du thumbnail est exactement la même que celle de la requête initiale, mais nous aurions pu retourner une url complètement différente.

Le détail du flux est le suivant :

  1. l'utilisateur envoie une requête pour l'url /pictures/2015/le-grand-saut_medium.jpg ;
  2. le fichier n'existe pas, Nginx passe la requête à Django qui exécute ThumbnailView ;
  3. le thumbnail est enregistré sur le disque ;
  4. Django retourne une réponse vide avec un header X-Accel-Redirect qui contient la même url que de la requête initiale ;
  5. Nginx intercepte la réponse, et se comporte comme s'il s'agissait d'une nouvelle requête ;
  6. cette fois, le fichier existe, Nginx l'envoie au client.

Je vous en prie, prenez quelque secondes pour admirer à quel point cette solution est élégante. Prenez votre temps, je ne suis pas pressé.

Notez quand même que si votre vue ThumbnailView ne parvient pas à écrire le fichier au bon endroit, vous allez rentrer dans une boucle infinie. Ça craint.

PrivatePictureFileView

class BasePrivatePictureFileView(BasePictureFileView):
    """Vue de base pour les fichiers privés.

    Les permissions sont gérées ici.

    """
    def get_object(self, queryset=None):
        obj = super(BasePictureFileView, self).get_object(queryset)
        if obj.is_private:
            # Check permissions here
            if not self.request.user.is_authenticated():
                raise Http404()

        return obj


class PrivatePictureFileView(BasePrivatePictureFileView):
    """Vérifie les permissions et laisse Nginx servir le fichier."""

    def render_to_response(self, context):
        # À ce stade, les droits ont déjà été vérifiés

        # /private/2015/photo-privee.jpg -> /xaccel/2015/photo-privee.jpg
        image_url = context['picture'].image.url
        xaccel_url = image_url.replace('/private/', '/xaccel/')
        extension = self.kwargs.get('extension')

        response = HttpResponse(content_type='image/{}'.format(extension))
        response['X-Accel-Redirect'] = xaccel_url
        return response

Ici, nous utilisons une stratégie similaire. Une fois que nous avons vérifié que l'utilisateur a effectivement le droit d'accéder à la photo, nous retournons une réponse vide avec un header X-Accel-Redirect pour laisser Nginx servir la photo.

Il y a toutefois une petite subtilité. Nous ne pouvons pas utiliser la même url que la requête initiale, parce que toutes les urls commençant pas /private/ doivent systématiquement être envoyées à Django. Donc, si nous retournons la même url, nous allons rentrer dans une boucle infinie. Pas cool.

La solution est très simple : définir une nouvelle url dans Nginx pour servir directement les fichiers privés. Voici la configuration correspondante.

# Les urls privées sont systématiquement envoyées à Django
location /private/ {
    error_page 418 = @django;
    return 418;
}

# Les urls qui commencent par /xaccel/ sont servies statiquement.
# Notez que grâce à la directive "alias", le préfixe /xaccel/ est
# supprimée de l'adresse du fichier à servir.
#
# E.g en retournant l'url /xaccel/2015/photo-privee.jpg, c'est le fichier
# /home/tripodocus/media/private/2015/photo-privee.jpg
# qui sera servi.
location /xaccel/ {
    internal;
    alias /home/tripodocus/media/private/;
    expires 1w;
}

Eh ! Mais qu'est-ce qui empêche un petit malin de taper l'url https://www.miximum.fr/xaccel/2015/photo-privee.jpg ?! Et bien c'est tout simplement la directive internal, qui indique à Nginx que cette url n'est atteignable que via des directives internes, et pas via des requêtes normales.

PrivateThumbnailView

class PrivateThumbnailView(BasePrivatePictureFileView):
    """Vérifie les droits *et* génère le thumbnail.

    Cette vue n'est jamais qu'une combinaison des précédentes.

    """
    def render_to_response(self, context):
        thumbnail_url = self.write_thumbnail_if_doesnt_exist(
            context['picture'])
        xaccel_url = thumbnail_url.replace('/private/', '/xaccel/')
        extension = self.kwargs.get('extension')

        response = HttpResponse(content_type='image/{}'.format(extension))
        response['X-Accel-Redirect'] = xaccel_url
        return response

Cette vue n'est jamais qu'une composition des précédentes, il n'y a rien de plus à dire dessus.

Conclusion

Architecture moderne andalouse

En conclusion, ce système tire parti des fonctionnalités de Django et Nginx pour servir des images efficacement, tout en gardant la possibilité de contrôler finement les droits d'accès de chaque photo.

Notez que mon parti pris est de mettre l'accent sur l'interface (les urls) et d'en faire découler les choix techniques. Il aurait sans doute été possible de simplifier deux ou trois trucs et de gagner en performances au prix d'urls moins claires, moins cohérentes, moins élégantes. J'imagine qu'un site qui sert des To d'images par jour a d'autres impératifs que les miens.

Notez également que la page qui correspond à un album privé – qui affiche des dizaines de thumbnails privés – génère autant de requêtes au serveur Django car chaque vignette est vérifiée individuellement. Ça reste gérable dans le contexte de mon site, ça peut être inenvisageable dans d'autres contextes.

Bref ! J'espère que ce petit tutoriel vous aura été utile. À peluche !