WordPress
26 août 2026

Quand les sauvegardes remplissent le disque qu’elles protègent : remettre à plat le stockage d’une application Symfony

Le disque du serveur de production d’une plateforme web métier, une application Symfony en production depuis plusieurs années, est à 96 %. En regardant ce qui l’occupe, une évidence gênante : près de la moitié, ce sont les sauvegardes, stockées localement. La moitié du disque était occupée par les copies de l’autre moitié.

La purge des sauvegardes locales ramène l’occupation à 48 % et éloigne le crash. Mais elle impose de couper les sauvegardes automatiques en attendant une vraie solution, et c’est là que la situation devient réellement dangereuse : sans backup actif, toute panne, corruption ou incident de sécurité devient une perte de données irréversible. Au passage, la leçon est déjà là : des sauvegardes stockées sur le disque qu’elles protègent ne protègent de rien, elles le remplissent deux fois plus vite et disparaissent avec lui.

Ce n’est pas une situation qu’on laisse durer, et pourtant c’est une situation extrêmement courante sur des applications qui ont grandi progressivement, fonctionnalité après fonctionnalité.

Voici comment on l’a traitée : ce qui occupait réellement le disque, les commandes pour le savoir sur votre propre application, l’arbitrage entre la solution simple et la solution ambitieuse, et ce qu’on aurait dû faire avant d’en arriver là.

Au programme :

  • Les commandes qui disent en trente secondes ce qui remplit le disque d’une application Symfony
  • Le fichier de log que rien ne fait tourner et qui grossit depuis la mise en production
  • La méthode pour identifier les fichiers d’upload orphelins sans casser la production
  • Des commandes de migration qui ne font confiance à personne : dry-run, bornage, bascule après succès
  • Pourquoi on n’a pas compressé les images, et pourquoi c’était la bonne décision
  • Ce qu’on aurait fait différemment

Ce qui remplit réellement le disque d’une application Symfony

Le premier réflexe est de chercher les gros fichiers. C’est rarement là que ça se joue, parce que le problème vient presque toujours de l’accumulation de petits fichiers dans quelques dossiers précis. Sur une application Symfony, une partie de ces dossiers est imposée par le framework, l’autre dépend de votre propre code :

cd /var/www/plateforme
du -sh var/log var/cache public/uploads var/exports var/backups \
      2>/dev/null | sort -rh

Ce que chacun contient, et le piège associé :

  • var/log : les journaux Monolog. Le chemin par défaut d’un handler stream est %kernel.logs_dir%/%kernel.environment%.log, donc un unique prod.log qui grossit depuis la mise en production. La rotation n’est pas activée par défaut : le handler rotating_file existe dans le bundle Monolog, avec son option max_files, mais il faut le choisir explicitement. Personne ne le fait avant le premier incident.
  • var/cache : un dossier par environnement, reconstruit par cache:clear et cache:warmup. Volumineux mais sans enjeu : sa suppression coûte un premier hit plus lent, rien de plus.
  • public/uploads (ou l’endroit où votre code range les fichiers reçus) : photos et pièces justificatives déposées par les utilisateurs. Volume élevé, sensibilité élevée.
  • var/exports : les fichiers générés par les fonctionnalités métier, exports comptables, flux partenaires, rapports. Chaque fonctionnalité en produit, aucune ne les supprime.
  • var/backups : les dumps SQL du script de sauvegarde, stockés sur le même disque que la base qu’ils sauvegardent. Double peine : ils remplissent le disque, et ils disparaissent avec lui.

La différence avec un CMS, où l’arborescence est documentée et identique partout : ici la liste exacte est la vôtre. Avant de lancer du, il faut retrouver où le code écrit, et le plus court chemin est de chercher les paramètres de répertoire déclarés dans la configuration (%kernel.project_dir%/public/uploads et consorts dans services.yaml).

Sur le cas qui nous occupe, le classement a suffi à orienter tout le reste du chantier. Le problème n’était pas volumétrique, il était structurel : certains fichiers n’avaient rien à faire sur le serveur applicatif, d’autres avaient une durée de vie définie qui n’avait jamais été appliquée, et d’autres encore contenaient des données personnelles conservées bien au-delà du nécessaire.

Le script des fichiers orphelins

La catégorie la plus rentable à traiter, et la plus délicate, ce sont les fichiers d’upload qui ne correspondent plus à rien en base : entités supprimées, imports ratés, doubles envois.

Sur une application Symfony, la référence entre la base et le disque tient dans une colonne d’entité. Chez nous, un chemin relatif au dossier d’uploads :

// L'entité qui fait foi : le fichier existe si une ligne le référence
#[ORM\Column(length: 255, nullable: true)]
private ?string $filename = null; // relatif à public/uploads

La première étape n’est donc pas une commande, c’est un inventaire : toutes les colonnes qui référencent des fichiers, pas seulement la plus évidente. En oublier une transforme des fichiers vivants en faux orphelins, et c’est le catalogue qui part en quarantaine. L’inventaire se matérialise en une requête :

-- Adapter les noms de tables et de colonnes à votre schéma
SELECT filename FROM document WHERE filename IS NOT NULL
UNION
SELECT image_path FROM product_media WHERE image_path IS NOT NULL
UNION
-- Certaines colonnes stockent une URL, pas un chemin : normaliser ici,
-- sinon tout paraîtra orphelin
SELECT REPLACE(avatar_url, '/uploads/', '') FROM app_user
WHERE avatar_url IS NOT NULL

La comparaison entre le disque et la base devient alors mécanique :

# 1. Ce que le disque contient (chemins relatifs au dossier d'uploads)
find public/uploads -type f -printf '%P\n' | sort -u > /tmp/fichiers-disque.txt

# 2. Ce que la base référence (la requête UNION ci-dessus)
mysql -N -B ma_base < refs-fichiers.sql | sort -u > /tmp/fichiers-base.txt

# 3. Présent sur le disque, référencé nulle part
comm -23 /tmp/fichiers-disque.txt /tmp/fichiers-base.txt > /tmp/orphelins.txt
wc -l < /tmp/orphelins.txt

# 4. L'espace que ça représente, AVANT de supprimer quoi que ce soit
cd public/uploads
tr '\n' '\0' < /tmp/orphelins.txt | du -ch --files0-from=- | tail -1

Deux précautions, et elles ne sont pas facultatives. On mesure avant de supprimer, parce que le résultat justifie ou non l’opération. Et on ne supprime jamais directement : on déplace vers un dossier de quarantaine, on laisse passer un cycle de sauvegarde complet, puis on purge. Une requête UNION incomplète sur ce script coûte le catalogue visuel entier.

La décision : sortir les fichiers du serveur applicatif

Nettoyer règle le présent, pas l’avenir. La direction retenue a donc été de migrer les médias vers un stockage objet compatible S3, pour quatre raisons dans cet ordre.

Le découplage. Les fichiers ne sont plus sur le même serveur que l’application. Redémarrer, migrer vers une instance plus grosse ou reconstruire l’infrastructure devient une opération sans enjeu sur les médias.

La croissance sans intervention. Plus d’alerte disque, plus de redimensionnement de volume, plus de décision à prendre en urgence un vendredi.

Restait à choisir l’offre, et le premier critère n’apparaît dans aucun comparatif de stockage objet : la plateforme s’appuyait déjà sur Cloudflare pour d’autres services. Un fournisseur de stockage distinct, c’était un compte de plus, une facturation de plus, une gestion d’accès et de secrets de plus. À capacités équivalentes, le meilleur fournisseur est souvent celui qui est déjà dans la place, et R2 l’était.

Le coût de sortie a confirmé le choix. Les photos des dossiers sont consultées bien plus souvent qu’elles ne sont déposées, donc le volume sortant dépasse largement le volume stocké. Les solutions qui facturent l’egress rendent la facture proportionnelle au trafic plutôt qu’au stockage, ce qui est exactement l’inverse de ce qu’on veut. R2 ne facture pas la sortie.

La compatibilité S3. Elle permet de réutiliser des bibliothèques standard et de changer d’hébergeur plus tard sans réécrire l’intégration.

Côté code, l’intégration Symfony passe par Flysystem : composer require league/flysystem-bundle league/flysystem-async-aws-s3 (la version 3.x du bundle demande PHP 8.2 et Symfony 6 minimum). R2 se branche comme n’importe quel S3, à un détail près qui n’est écrit nulle part en gros :

# config/packages/flysystem.yaml
services:
    cloudflare_r2_client:
        class: AsyncAws\S3\S3Client
        arguments:
            -
                endpoint: '%env(CLOUDFLARE_R2_ENDPOINT)%'
                accessKeyId: '%env(CLOUDFLARE_R2_ID)%'
                accessKeySecret: '%env(CLOUDFLARE_R2_SECRET)%'

flysystem:
    storages:
        media.storage:
            asyncaws:
                client: 'cloudflare_r2_client'
                bucket: '%env(CLOUDFLARE_R2_BUCKET)%'
                visibility: public
                # R2 n'implémente pas les ACL S3 : sans cette ligne,
                # move() et copy() appellent GetObjectAcl et échouent
                retain_visibility: false

Le service exposé est un FilesystemOperator qu’on injecte là où le code écrivait sur le disque. À partir de ce moment, l’application ne sait plus si ses fichiers sont locaux ou distants, et c’est exactement le but.

Sur cette plateforme, le choix réel a été plus direct encore : pas de couche d’abstraction, le SDK AWS officiel derrière un unique service applicatif. Quand un seul service concentre tous les accès au stockage, l’abstraction supplémentaire n’apporte rien, et R2 ne demande vraiment que trois réglages :

// Un client S3 standard, pointé sur R2 : c'est toute l'intégration
$this->s3Client = new S3Client([
    'region' => 'auto',
    'endpoint' => "https://{$accountId}.r2.cloudflarestorage.com",
    'use_path_style_endpoint' => true,
    'credentials' => [/* variables d'environnement */],
]);

Deux détails de mise en œuvre valent la peine d’être notés, parce qu’ils décident du comportement du CDN et de la sécurité. Les médias publics sont envoyés avec Cache-Control: public, max-age=31536000, immutable : un fichier dont le nom change à chaque version n’a aucune raison d’être revalidé. Les pièces justificatives, à l’inverse, portent private, no-cache, no-store, max-age=0 et ne sont jamais servies en URL publique : le service ne délivre que des URLs signées à durée de vie courte, 300 secondes par défaut. Un lien qui fuite dans un e-mail transféré ou un historique de navigateur est mort cinq minutes plus tard. C’est ça, la vraie réduction de surface sur des données sensibles : pas seulement déplacer les fichiers, changer leur mode d’accès.

Des commandes de migration qui ne font confiance à personne

Migrer plusieurs années de fichiers vers un bucket est le genre d’opération qu’on n’exécute qu’une fois, sans répétition générale possible en production. Les commandes de migration ont donc été écrites avec une paranoïa assumée :

$this
    ->addOption('dry-run', null, InputOption::VALUE_NONE,
        'Liste ce qui serait envoyé, ne modifie rien')
    ->addOption('ids', null, InputOption::VALUE_REQUIRED,
        "Borne la migration à une liste d'identifiants")
    ->addOption('concurrency', null, InputOption::VALUE_REQUIRED,
        'Uploads parallèles', 10)
    ->addOption('batch-size', null, InputOption::VALUE_REQUIRED,
        'Taille des lots', 500);

Trois règles gouvernent l’exécution. Tout passe d’abord par un dry-run, qui liste sans toucher à rien. Tout est bornable : la première exécution réelle porte sur une poignée d’identifiants, vérifiée à la main, avant d’élargir. Et surtout, la référence en base ne bascule vers le stockage objet que dans le callback de succès de l’upload : un envoi qui échoue laisse la ligne pointer vers le fichier local, et la migration ne supprime rien en local. La purge des fichiers migrés est une opération séparée, lancée après vérification, jamais un effet de bord de la migration elle-même.

Les envois par lots passent par le pool de commandes du SDK, avec un callback de succès et un callback d’échec par objet : sur des dizaines de milliers de fichiers, un taux d’échec de quelques unités est normal, et c’est la liste des échecs qui fait la valeur du rapport final, pas le compteur de succès.

À quoi s’ajoutent trois mesures qui, elles, évitent le retour du problème : une rétention automatique sur les fichiers générés, la purge des fichiers orphelins décrite plus haut, et une suppression programmée des fichiers déposés par les clients une fois le dossier associé traité. Ce dernier point n’a rien à voir avec la place disque : moins de données personnelles conservées, c’est moins de surface exposée en cas d’incident.

Sur la rétention, la version aboutie ne raisonne pas en âge de fichier mais en état métier. Un cron qui supprime « les fichiers de plus de 30 jours » supprime aussi les pièces d’un dossier encore ouvert depuis 31 jours. La bonne clé n’est pas la date du fichier, c’est la date de clôture du dossier auquel il appartient, et chaque nature de fichier a son propre délai :

#[AsCommand(
    name: 'app:files:clean',
    description: 'Purge par nature : photos 1 mois après clôture, pièces justificatives 10 ans',
)]
final class CleanFilesCommand extends Command
{
    // Les photos de travail n'ont plus d'usage un mois après la clôture.
    private const PHOTO_RETENTION = '-1 month';

    // Factures et pièces d'identité : conservation légale décennale.
    private const LEGAL_RETENTION = '-10 years';

    // La purge est pilotée par l'état métier, jamais par l'âge du fichier
    $dossiers = $this->dossierRepository->createQueryBuilder('d')
        ->where('d.cloturedAt < :date')
        ->setParameter('date', new \DateTimeImmutable(self::PHOTO_RETENTION))
        ->getQuery()->getResult();
}

Cette commande porte les mêmes garde-fous que la migration, --dry-run et options de bornage compris, et copie une archive avant toute suppression locale. Deux délais seulement, mais deux natures : un fichier de travail meurt vite, un document à valeur probante survit dix ans, et aucun automatisme ne doit pouvoir confondre les deux.

Pour les dossiers techniques qui ne sont référencés par aucune entité, la version minimale reste un script planifié par âge de fichier, à condition de séparer les seuils :

#!/usr/bin/env bash
set -euo pipefail
cd /var/www/plateforme

# Exports générés par les fonctionnalités métier : durée de vie courte
find var/exports -type f -mtime +30 -delete

# Journaux déjà tournés par logrotate : on borne le pire cas
find var/log -type f -name '*.log.*' -mtime +90 -delete

# Dumps SQL du script de sauvegarde : on garde les plus récents
find var/backups -type f -name '*.sql*' -mtime +14 -delete

# Pièces comptables : seuil beaucoup plus long, et jamais de suppression
# automatique sans validation métier. On se contente de les signaler.
find var/factures -type f -mtime +365 -print > /var/log/factures-a-archiver.txt

Le dernier bloc illustre la règle que nous appliquons systématiquement : une purge automatique ne touche jamais à un document à valeur probante. Elle le signale, un humain décide.

Pourquoi on n’a pas compressé les images

Le poste le plus lourd était les images, et tous les guides de stockage donnent le même réflexe : compresser. À l’envoi pour les nouveaux fichiers, par un worker asynchrone pour reprendre le stock existant. Les deux options ont été posées sur la table, chiffrées, débattues.

Puis le débat s’est refermé pour une raison qu’aucun de ces guides ne mentionne : la nature des fichiers.

Une partie du stock était constituée de pièces justificatives, documents d’identité et factures, soumis à une conservation légale longue. Les faire transiter par un service de compression tiers était exclu d’office : on n’envoie pas des pièces d’identité à une API externe pour gagner des kilooctets. Et même une recompression locale pose une question qu’on préfère ne pas avoir à trancher : un document à valeur probante recompressé est-il encore l’original ? Le gain de place ne vaut pas l’ombre d’un doute là-dessus.

Restait le stock des photos ordinaires, compressibles sans état d’âme. Mais le critère de décision du chantier n’était pas technique : les backups étaient toujours désactivés. Tant que cette phrase reste vraie, aucune considération d’élégance ne pèse. Le seul indicateur qui compte est le délai avant réactivation des sauvegardes, et sur cet indicateur, la seule action à la fois rapide et universellement sûre était de déplacer les fichiers sans les transformer. C’est ce qui a été fait : la migration bornée vers R2, telle que décrite plus haut, et rien d’autre.

La compression du stock d’images ordinaires reste une itération possible, et l’infrastructure pour la faire proprement existe déjà, un transport Messenger dédié aux traitements de fichiers étant en place. Mais elle se fera, si elle se fait, avec des sauvegardes actives et sur des fichiers explicitement triés par nature.

Deux réflexes à retenir de cet arbitrage. Avant de compresser, demandez ce que sont les fichiers, pas seulement ce qu’ils pèsent : le tri par nature précède toute transformation. Et quand un risque de perte de données est ouvert, la bonne solution est celle qui le referme le plus vite, pas celle qui produit la meilleure architecture.

Ce que ça a changé

Les backups ont été réactivés, ce qui était l’objectif unique. Le disque a repris une trajectoire prévisible au lieu d’une croissance non contrôlée. Les fichiers clients ne séjournent plus indéfiniment sur le serveur. Et le coût du stockage est devenu une ligne visible et prévisible, découplée du coût serveur, donc pilotable.

Le bénéfice le moins attendu est ailleurs : la maintenance est devenue moins risquée. Une migration d’instance, une montée de version, une reconstruction d’infrastructure sont des opérations bien plus sereines quand les médias ne sont pas sur le volume qu’on manipule.

Ce qu’on aurait fait différemment

Cette situation n’aurait pas dû être traitée en urgence, et l’honnêteté oblige à le dire clairement.

Le stockage objet aurait dû être prévu à la conception. Le brancher après coup coûte plusieurs fois ce qu’il aurait coûté au départ, parce qu’il faut reprendre tous les points d’accès aux fichiers dans le code existant, chaque file_put_contents et chaque chemin construit à la main, au lieu d’injecter un FilesystemOperator dès le premier jour.

La rétention aurait dû être définie en même temps que les fonctionnalités qui génèrent des fichiers. Chaque export, chaque document généré aurait dû arriver avec sa durée de vie. Ajouter une politique de rétention a posteriori suppose de qualifier des milliers de fichiers dont plus personne ne connaît l’usage.

Et surtout, une alerte à 70 % aurait suffi. Le problème n’est pas que les sauvegardes locales occupaient la moitié du disque, c’est de l’avoir compris à 96 %, au moment où la seule issue était de les couper. Une supervision qui prévient à 70 % laisse le temps de choisir ses solutions ; découvert trop tard, le même chiffre impose l’urgence.

Si vous n’avez pas de supervision, le strict minimum tient en une tâche planifiée. C’est moins bien qu’un vrai outil, c’est infiniment mieux que rien :

#!/usr/bin/env bash
# Alerte à 70 %, largement avant que la décision ne soit plus la vôtre.
SEUIL=70
USAGE=$(df --output=pcent /var/www | tail -1 | tr -dc '0-9')

if [ "$USAGE" -ge "$SEUIL" ]; then
  {
    echo "Disque à ${USAGE}% sur $(hostname -s)."
    echo
    echo "Cinq premiers postes :"
    cd /var/www/plateforme
    du -sh var/log var/cache public/uploads var/exports var/backups \
          2>/dev/null | sort -rh | head -5
  } | mail -s "[Plateforme] disque à ${USAGE}%" ops@exemple.fr
fi

Le message contient déjà le classement des postes, donc la personne qui le reçoit sait quoi faire avant même de se connecter. C’est le genre de détail qui transforme une alerte ignorée en action.

Ces lacunes sont banales sur une plateforme qui a grandi au fil des fonctionnalités. Elles rappellent simplement que les décisions d’infrastructure ont une durée de vie, et qu’elles méritent d’être revisitées avant qu’une alerte ne décide à votre place. C’est typiquement ce qu’on audite en premier quand on reprend une application Symfony existante, bien avant de parler de nouvelles fonctionnalités.

Questions fréquentes

Pourquoi ne pas simplement agrandir le disque ?
C’est valable comme mesure d’urgence pour rouvrir les backups, et il ne faut pas s’en priver si c’est possible en quelques minutes. Mais un disque plus grand se remplit aussi, et souvent plus vite qu’on ne l’anticipe. Sans rétention ni externalisation, on rachète du délai au prix fort.

Quel dossier regarder en premier sur une application Symfony ?
var/log. Le chemin par défaut des handlers Monolog est un fichier unique par environnement, et la rotation (rotating_file, option max_files) est un choix explicite que peu de projets font avant le premier incident. var/cache impressionne souvent dans le classement du, mais il se reconstruit tout seul, ce n’est pas lui le problème.

Comment repérer les fichiers d’upload orphelins sans risque ?
En comparant la liste des fichiers du dossier d’uploads avec l’union de toutes les colonnes qui référencent des fichiers en base, chemins normalisés des deux côtés. Puis en déplaçant vers une quarantaine plutôt qu’en supprimant, avec un cycle de sauvegarde complet avant purge définitive.

Pourquoi Cloudflare R2 plutôt qu’une autre offre S3 ?
D’abord parce que Cloudflare était déjà dans la place : la plateforme s’appuyait sur ses services, et un fournisseur de stockage distinct aurait ajouté un compte, une facturation et une gestion d’accès de plus. Ensuite pour l’absence de frais de sortie : les fichiers sont consultés bien plus souvent qu’ils ne sont déposés, donc le volume servi dépasse largement le volume stocké, donc une facturation à l’egress rend le coût proportionnel au trafic. Ce second raisonnement vaut pour n’importe quelle offre sans egress.

Faut-il modifier le code pour passer au stockage objet ?
Oui, mais sur Symfony la facture dépend surtout de la discipline du code existant. Flysystem (league/flysystem-bundle, avec l’adaptateur league/flysystem-async-aws-s3 ou league/flysystem-aws-s3-v3) fournit l’abstraction : le vrai travail est de retrouver tous les endroits où le code lit et écrit le disque directement. L’abstraction n’est d’ailleurs pas obligatoire : quand un seul service applicatif concentre les accès, le SDK AWS pointé sur l’endpoint du compte fonctionne tout aussi bien, et c’est le choix qui a été fait ici.

Peut-on compresser les images pour retarder l’échéance ?
Seulement après avoir trié les fichiers par nature. Des photos de travail se compressent sans état d’âme ; des pièces justificatives à conservation légale ne transitent pas par un service tiers, et leur recompression pose la question de l’altération de l’original. Sur cette plateforme, le tri a conclu que le gain ne valait pas le risque tant que les sauvegardes étaient coupées : les fichiers ont été déplacés, pas transformés.

La performance en souffre-t-elle ?
Bien configurée, non, souvent l’inverse : les fichiers statiques sont servis par un réseau de diffusion plutôt que par le serveur applicatif, qui se retrouve déchargé.

Votre disque approche ses limites, ou vous voulez savoir où vous en êtes avant que le monitoring ne vous prévienne ? Lancez la commande du en tête d’article, le classement suffit généralement à voir venir. Si le résultat vous inquiète, écrivez-nous avant de désactiver les sauvegardes, pas après.

Nous vous recommandons aussi