Migrer des fichiers sans interruption : ce que read-through fait, et ce qu'il ne fait pas

Arnaud Pointet · · 9 min de lecture

Read this article in English

Cet article part d'un exercice, à faire dans le navigateur : Migrer des fichiers au fil des lectures avec read-through →

Laravel 13.26 a ajouté un pilote de disque read-through. Dans l'exercice de Pratique qui le présente, un contrôleur sert des documents en pleine migration : il lit le nouveau stockage, se replie sur l'ancien, et recopie au passage. Le pilote remplace ces quinze lignes par trois lignes de configuration.

C'est une bonne nouvelle, mais une migration de stockage ne se résume pas à cette configuration. J'en ai mené plusieurs (disque local vers S3, d'un bucket à un autre, d'un fournisseur à un autre), et le repli à la lecture n'en est qu'une partie. Cet article part de l'exercice et va jusqu'au jour où l'on éteint l'ancien stockage.

L'exercice : trois lignes de configuration

Avant, le contrôleur faisait tout lui-même :

$nouveau = Storage::disk('nouveau');
if ($nouveau->exists($nom)) {
    return $this->texte($nouveau->get($nom));
}

$ancien = Storage::disk('ancien');
if ($ancien->exists($nom)) {
    $contenu = $ancien->get($nom);
    $nouveau->put($nom, $contenu);

    return $this->texte($contenu);
}

abort(404);

Avec Laravel 13.26, on déclare un disque qui combine les deux :

// config/filesystems.php
'documents' => [
    'driver' => 'read-through',
    'primary' => 'nouveau',
    'fallback' => 'ancien',
],

Et le contrôleur ne connaît plus que lui :

$documents = Storage::disk('documents');
abort_unless($documents->exists($nom), 404);

return $this->texte($documents->get($nom));

Le gain dépasse la ligne de code : la migration devient un détail de configuration, invisible pour le reste de l'application. Le jour où elle se termine, on remplace documents par un disque S3 ordinaire, et aucun appel à Storage::disk('documents') ne change.

Ce que fait vraiment le pilote

La documentation reste brève. Le code source en dit davantage, et ces détails comptent pour la suite (les opérations principales, en Laravel 13.26 ; move() et copy() d'un fichier resté sur l'ancien disque n'ont été prises en charge qu'en 13.27) :

Opération Comportement
get(), readStream() lit primary ; sinon lit fallback, puis écrit une copie sur primary
exists() vrai si le fichier est sur l'un ou l'autre disque
put(), writeStream() écrit sur primary seulement
delete() supprime sur les deux disques
files(), allFiles() listent primary seulement
url(), size(), mimeType(), lastModified() lisent le disque qui a le fichier, sans le recopier

Deux choix sont particulièrement bien pensés.

La suppression vise les deux disques. Sans cela, un fichier supprimé du nouveau stockage resterait sur l'ancien, et la lecture suivante le ferait réapparaître. C'est le bug classique des replis écrits à la main, et je l'ai déjà vu passer en production : des documents supprimés à la demande d'un client (RGPD compris) qui revenaient le lendemain.

La copie revérifie avant d'écrire. Entre le moment où le pilote constate l'absence du fichier sur primary et celui où il le recopie, une autre requête a pu écrire une version plus récente. Le pilote revérifie juste avant la copie pour ne pas l'écraser. La fenêtre de concurrence ne disparaît pas complètement (deux opérations ne sont jamais atomiques sur un stockage objet), mais elle devient très étroite.

Ce que read-through ne migre pas

Le pilote migre ce que l'application lit. Tout le reste demeure sur l'ancien disque, et c'est là que les migrations s'enlisent.

Les fichiers qu'on ne lit jamais

Une facture de 2019, un avatar d'utilisateur inactif, une pièce jointe oubliée : la plupart des fichiers d'une application ne sont presque jamais relus. Le read-through migre vite les fichiers chauds, puis plafonne. Il arrive qu'au bout de trois mois, 20 % seulement des fichiers aient bougé, et l'ancien stockage est toujours indispensable.

Il faut donc toujours un rattrapage en masse (voir plus bas). Le read-through ne remplace pas la copie : il permet de la faire tranquillement, sans fenêtre de maintenance, pendant que l'application tourne déjà sur le nouveau stockage.

Les fichiers servis par URL

C'est le piège le plus courant. Sur S3, on sert rarement un fichier en le lisant avec PHP : on génère une URL, éventuellement signée, et le navigateur télécharge directement depuis le bucket.

$documents->temporaryUrl($nom, now()->addMinutes(5));

Le pilote renvoie bien une URL qui fonctionne : celle du disque qui contient le fichier. Mais il ne lit pas le fichier, donc il ne le recopie jamais. Une application qui sert tous ses fichiers par URL ne migre rien du tout avec le read-through, même si tout semble fonctionner.

Les listes

files() et allFiles() ne listent que le nouveau disque. Une page « Tous vos documents » qui liste un dossier n'affiche donc, pendant la migration, que les documents déjà migrés. Un export, un calcul d'espace occupé, une purge des fichiers orphelins : tout ce qui énumère au lieu de lire un chemin connu donne un résultat faux.

Si l'application liste des fichiers, la bonne réponse n'est souvent pas technique : c'est la base de données qui devrait savoir quels fichiers existent, pas le stockage. Pendant une migration, cette dette se paie.

Les métadonnées

La copie se fait avec un simple write(). Pour readStream(), le pilote met d'abord tout le fichier de côté (en mémoire jusqu'à 2 Mo, puis dans un fichier temporaire) avant de l'écrire sur le nouveau disque et de vous le rendre : le premier Storage::download() d'un gros fichier non migré attend donc la fin de la copie avant d'envoyer le premier octet. La date de modification devient celle de la copie. La visibilité (public ou private) et les en-têtes HTTP (Content-Type, Cache-Control, Content-Disposition) prennent les valeurs par défaut du nouveau disque. Si votre application s'appuie sur lastModified() pour un cache HTTP, ou sur des en-têtes S3 posés à l'envoi, vérifiez-le avant de migrer. Ce n'est pas un défaut du pilote : c'est vrai de presque toutes les copies de fichiers.

Les échecs de copie

Par défaut, si la copie vers le nouveau disque échoue (droits insuffisants, quota atteint), le pilote renvoie quand même le contenu lu sur l'ancien. C'est le bon choix pour l'utilisateur, qui obtient son fichier. Mais cela veut dire qu'une migration peut s'arrêter sans aucun bruit. Le pilote accepte une option throw_on_promotion_failure pour lever une exception dans ce cas. À défaut, surveillez le volume de lectures servies par l'ancien disque : s'il ne baisse pas, quelque chose bloque.

Mener la migration jusqu'au bout

Voici le plan que je suis, et le read-through n'en est qu'une étape.

1. Écrire sur le nouveau disque, lire sur les deux

On bascule l'application sur le disque read-through. À partir de ce moment, tout fichier nouveau ou modifié va directement sur le nouveau stockage, et l'ancien ne reçoit plus aucune écriture. C'est la vraie bascule, et elle se fait sans interruption.

Si plusieurs applications ou plusieurs serveurs écrivent dans l'ancien stockage, ils doivent tous basculer avant de passer à l'étape suivante. Sinon, des fichiers continuent d'arriver sur l'ancien disque derrière le rattrapage.

2. Rattraper le reste en tâche de fond

Une commande parcourt l'ancien disque et copie ce qui manque. Elle doit pouvoir s'interrompre et reprendre sans tout recommencer, et ne jamais écraser une version plus récente :

final class RattraperLesDocuments extends Command
{
    protected $signature = 'documents:rattraper';

    public function handle(): int
    {
        $ancien = Storage::disk('ancien');
        $nouveau = Storage::disk('nouveau');
        $copies = $echecs = 0;

        foreach ($ancien->allFiles() as $chemin) {
            if ($nouveau->exists($chemin)) {
                continue; // déjà migré, ou réécrit depuis : on n'y touche pas
            }
            $flux = $ancien->readStream($chemin);
            $copie = null !== $flux && $nouveau->writeStream($chemin, $flux);
            if (\is_resource($flux)) {
                fclose($flux);
            }
            if (!$copie) {
                $this->error("Échec : $chemin");
                ++$echecs;
                continue;
            }
            ++$copies;
        }

        $this->info("$copies document(s) copié(s), $echecs échec(s).");

        return $echecs ? self::FAILURE : self::SUCCESS;
    }
}

Le exists() avant chaque copie rend la commande idempotente : la relancer après une coupure ne recopie que ce qui manque. La lecture en flux (readStream()) garde la mémoire constante, même pour des fichiers de plusieurs centaines de mégaoctets. Les échecs sont comptés à part : sans l'option 'throw' => true sur les disques, readStream() renvoie null et writeStream() renvoie false, sans exception, et une commande qui ne les regarde pas annoncerait un succès.

Sur des millions de fichiers, allFiles() devient lui-même un problème : il charge toute la liste en mémoire. Il vaut mieux alors parcourir la table qui référence les fichiers, par lots (chunkById()), ou répartir le travail dans une file d'attente, un job par préfixe ou par lot d'identifiants.

3. Vérifier

Avant d'éteindre quoi que ce soit, je vérifie que l'ancien stockage est inclus dans le nouveau : chaque fichier de l'ancien existe sur le nouveau, avec la même taille, et pour un échantillon, la même empreinte (md5, ou l'ETag sur S3 pour les fichiers envoyés en une seule partie). Comparer le nombre total de fichiers n'aurait pas de sens : le nouveau disque a reçu tous les fichiers écrits depuis le début de la migration, et une suppression a retiré le fichier des deux côtés. C'est aussi le moment de vérifier les métadonnées qui comptent pour l'application.

4. Débrancher le repli, garder l'ancien stockage

Le disque documents redevient un disque S3 ordinaire. Une étape intermédiaire, si vous voulez de la prudence : garder le disque read-through avec l'option 'copy' => false. Le repli sert encore un fichier oublié, sans plus rien écrire, et une hausse des lectures sur l'ancien stockage signale un trou dans le rattrapage. L'ancien stockage, lui, reste en place quelques semaines, en lecture seule. Si un fichier manque, on le retrouve encore. Les journaux d'erreurs 404 sur les documents sont à surveiller de près pendant cette période.

5. Éteindre

Seulement maintenant. C'est souvent l'étape qu'on oublie, et l'ancien bucket continue d'être facturé deux ans plus tard.

Le coût caché des 404

Un dernier détail, qui compte sur un stockage facturé à la requête : un fichier absent coûte désormais deux vérifications, une sur chaque disque. Si votre application reçoit beaucoup de requêtes pour des fichiers inexistants (robots, vieux liens, miniatures pas encore générées), le volume de requêtes vers le stockage double pendant toute la migration. Raison de plus pour ne pas la laisser traîner.

Ce que j'en retiens

Le pilote read-through règle proprement la partie la plus délicate d'une migration : basculer sans interruption, et sans qu'aucun code métier ne sache qu'une migration est en cours. Ses choix (suppression sur les deux disques, revérification avant copie) montrent que ses auteurs ont déjà fait ce genre de migration.

Mais une migration ne se termine pas toute seule. Le read-through, c'est l'étape 1 sur 5. Ce qui fait la différence, c'est le rattrapage idempotent, la vérification, et la discipline d'éteindre l'ancien stockage.

Pour voir le pilote à l'œuvre, l'exercice de Pratique se lance dans le navigateur, sans installation : un vrai Laravel 13, deux disques, et des tests qui vérifient chaque comportement.

← Tous les articles