fr en

Django : Accélérer la collecte des fichiers statiques sur AWS S3

Posté le Fri 23 February 2024 dans développement

Le problème

Lorsque l'on stocke les fichiers statiques d'une application Django sur AWS S3, on utilise en général django-storages et boto3.

Dans cette configuration, la collecte des fichiers statiques peut s'avérer particulièrement lente. La raison est simple : tous les fichiers déjà existants sont téléchargés avant d'être comparés pour savoir si ils doivent être ou non (ré-)uploadés.

Collectfast propose une solution, mais elle ne fonctionne pas lorsque les noms des fichiers contiennent des hashs, ce qui est le cas lorsque Whitenoise (par exemple), fait partie de la stack.

Voici comment nous avons améliorer la collecte des fichiers statiques pour la rendre jusqu'à 50x plus rapide dans cette configuration.

L'approche

L'idée vient de l'approche proposée ici qui consiste à :

  1. collecter les fichiers statiques en local
  2. les synchroniser ensuite sur S3

Les deux inconvénients de cette méthode sont les suivants :

  • Il faut supprimer django-storages et revenir à un storage backend local classique
  • Il faut installer et utiliser la cli AWS pour la synchronisation

Nous avons donc adapté cette approche pour contrer ces inconvénients, en surchargeant la commande collectstatic pour qu'elle suive le processus suivant :

  1. remplacer temporairement le storage backend par un backend sur système de fichiers local classique comme le ManifestStaticFilesStorage de Django ou le CompressedManifestStaticFilesStorage de Whitenoise,
  2. exécuter le collectstatic natif (localement donc),
  3. détecter les différences entre les fichiers statiques locaux qui viennent d'être collectés et ceux existants sur S3,
  4. uploader les fichiers qui doivent l'être

La solution

La structure générale :

from django.contrib.staticfiles.management.commands.collectstatic import (
    Command as DjangoCollectStaticCommand,
)


class Command(DjangoCollectStaticCommand):

    @contextmanager
    def _force_file_system_storage(self):
        """
        Replaces S3 static_files storage configured in settings
        by whitenoise (a full-featured local storage with compression and manifest)
        """

    def _detect_differences(self) -> list(str):
        """
        Compares local and remote manifest and returns differences
        """

    def _sync_files_to_s3(self, diff_manifest_files: []):
        """
        Iterates on files collected locally and (re-upload) them if needed
        """

    def handle(self, **options):
        """
        Overrides django's native command to collect static files locally and
        sync files to S3 afterwards
        """
        with self._force_file_system_storage():
            ret = super().handle(**options)
        self._detect_differences()
        self._sync_files_to_s3()
        return ret

Le context manager :

    @contextmanager
    def _force_file_system_storage(self):
        """
        Replaces S3 static_files storage configured in settings
        by whitenoise (a full-featured local storage with compression and manifest)
        """
        self._original_storage = self.storage
        backend = "whitenoise.storage.CompressedManifestStaticFilesStorage"
        self.storage = import_string(backend)()
        yield
        self.storage = self._original_storage

La détection des fichiers à (re-)synchroniser :

    def _get_local_manifest(self) -> dict:
        """Open and return local manifest file (json) as dict"""
        with self.static_root.joinpath("staticfiles.json").open() as f:
            return json.load(f)

    def _get_remote_manifest(self) -> dict:
        """Open and return local manifest file (json) as dict"""
        with self.storage.open("staticfiles.json") as f:
            return json.load(f)

    def _detect_differences(self) -> list(str):
        """
        Compares local and remote manifest and returns differences
        """
        local_manifest = self._get_local_manifest()
        remote_manifest = self._get_remote_manifest()

        diff_manifest = {
            k: v
            for k, v in local_manifest["paths"].items()
            if remote_manifest.get("paths", {}).get(k, "") != v
        }
        return list(diff_manifest.values())

La synchronisation des fichiers :

    def _sync_files_to_s3(self, diff_manifest_files: []):
        """
        Iterates on files collected locally and (re-upload) them if needed
        """
        for file_path in self.static_root.rglob("*"):

            if file_path.is_dir():
                # It's a dir
                # => nothing to do
                continue

            relative_file_path = file_path.relative_to(self.static_root)
            if not any(
                str(relative_file_path).startswith(k) for k in diff_manifest_files
            ):
                # The file already exists remotely and doesn't have changed
                # => nothing to do
                continue

            # The file is new or has changed
            # => upload it
            with Path(file_path).open("rb") as f:
                self.storage.save(str(relative_file_path), f)

Pour aller plus loin

La version décrite ci-dessous est volontairement simplifiée pour la rendre plus lisible. Une version plus complète doit :

  • prendre en charge l'option "dry un"`" de Django
  • proposer une option permettant de forcer la resynchronisation globale de tous les fichiers
  • supprimer les fichiers statiques temporaires collectés localement
  • ajouter des logs

Nous avons publiée la version complète.