Conteneurs Docker en lecture seule : durcir sans casser votre application

Cet article vous a aidé ?

Les conteneurs en lecture seule semblent être une victoire nette pour la sécurité jusqu’à ce que votre application tente d’écrire un fichier PID, de mettre en cache un résultat DNS, de faire une rotation de journal ou de décompresser un bundle de certificats dans /tmp comme si nous étions en 2012. Ensuite, vous vous retrouvez face à des erreurs EROFS en production et vous vous demandez pourquoi une « simple amélioration de durcissement » est devenue un incident de service.

Voici la façon pratique de procéder : verrouiller le système de fichiers du conteneur, garder l’application heureuse et préserver la santé mentale de vos opérateurs. Nous nous concentrerons sur Docker, mais ces modèles se mappent directement à Kubernetes et à tout runtime capable de monter tmpfs et des volumes.

Ce que « conteneur en lecture seule » signifie réellement (et ce qu’il ne signifie pas)

En termes Docker, « conteneur en lecture seule » signifie généralement exécuter un conteneur avec --read-only. Cela rend la couche modifiable du conteneur en lecture seule. Les couches d’image étaient déjà en lecture seule ; la différence est que la fine couche copy-on-write qui absorbe normalement les écritures devient immuable.

Nuance importante : cela ne veut pas dire que le conteneur ne peut écrire nulle part. Cela veut dire qu’il ne peut pas écrire sur le système de fichiers racine à moins que vous fournissiez des montages modifiables. Les volumes, les bind mounts et les montages tmpfs sont des montages séparés et peuvent rester en lecture-écriture. Si vous le faites correctement, le système de fichiers racine ressemble à un appareil scellé, et les seules surfaces d’écriture sont déclarées explicitement.

Le modèle de système de fichiers auquel vous avez réellement affaire

  • Couches d’image : immuables. Toujours en lecture seule.
  • Couche modifiable du conteneur : normalement copy-on-write (OverlayFS/overlay2 dans la plupart des cas). Avec --read-only, elle devient en lecture seule.
  • Montages : volumes, bind mounts, tmpfs. Chacun peut être en lecture-écriture ou en lecture seule indépendamment du rootfs.
  • Systèmes de fichiers virtuels fournis par le noyau : /proc, /sys, /dev. Ce sont des éléments spéciaux contrôlés par des flags du runtime et des capacités Linux, pas seulement par le « rootfs en lecture seule ».

Un root filesystem en lecture seule n’est pas une sandbox complète. C’est un contrôle : il réduit la persistance pour un attaquant et rétrécit le périmètre d’impact des « écritures accidentelles » par votre code. Il vous force aussi à modéliser explicitement l’état : journaux, caches, fichiers PID, téléchargements et configuration générée à l’exécution.

Une citation qui tient en opération (idée paraphrasée) : John Allspaw soutient que la fiabilité vient de la conception de systèmes qui rendent les modes de défaillance visibles et gérables, pas d’assumer que rien ne tombera en panne. Le rootfs en lecture seule est exactement ce type de contrainte de conception.

Pourquoi s’y intéresser : modèle de menace et valeur opérationnelle

Sécurité : rendre la persistance coûteuse

Si un attaquant obtient l’exécution de code dans un conteneur avec un rootfs modifiable, il peut déposer des outils dans /usr/local/bin, modifier le code de l’application ou implanter une persistance de type cron (oui, même dans les conteneurs, des gens essayent). Un rootfs en lecture seule n’empêche pas l’exécution à l’exécution, mais bloque une étape courante : écrire de nouveaux binaires et éditer les existants.

Est-ce que ça empêche l’exfiltration de données ? Non. Empêche-t-il les malwares résidant en mémoire ? Non plus. Mais cela augmente la difficulté et réduit le type de persistance du style « je modifie un fichier de config et j’attends ».

Opérations : arrêter la dérive de configuration dans le conteneur

Certaines équipes font encore des « hotfix » en éditant un fichier dans un conteneur en fonctionnement via docker exec. C’est tentant : ça fait disparaître le problème jusqu’au prochain déploiement, puis il réapparaît à 2h du matin. Le rootfs en lecture seule fait échouer bruyamment ce mauvais réflexe. Tant mieux. Corrigez l’image ou la gestion de configuration.

Performance et prévisibilité : moins d’écritures dans la couche COW

Les systèmes de fichiers en overlay peuvent mal se comporter lorsqu’une application écrit beaucoup de petits fichiers. Vous obtenez un overhead de copy-up, du churn d’inodes et des moments « pourquoi l’utilisation disque de mon conteneur explose ». Mettre les chemins d’écriture connus sur tmpfs ou sur un volume dédié rend la performance plus prévisible et rend votre équipe stockage moins grincheuse.

Blague n°1 : Un rootfs modifiable, c’est comme un tableau blanc partagé au bureau : pratique, mais tôt ou tard quelqu’un y dessine un schéma de base de données au marqueur indélébile.

Faits intéressants et brève histoire

  • Les systèmes de fichiers en union préexistent à Docker. AUFS et OverlayFS étaient déjà utilisés pour des systèmes live et des appliances embarquées ; les conteneurs les ont popularisés à grande échelle.
  • Les premiers drivers de stockage Docker étaient chaotiques. AUFS, Device Mapper, btrfs et OverlayFS avaient tous des sémantiques et des cas limites différents ; le rootfs en lecture seule était une façon de réduire les « écritures dans des endroits bizarres ».
  • Le rootfs en lecture seule est antérieur aux conteneurs. C’est une astuce de durcissement classique pour les chroot jails et les systèmes type appliance Linux où seul /var est modifiable.
  • Kubernetes l’a rendu courant. securityContext.readOnlyRootFilesystem l’a transformé d’un « flag Docker sympa » en un contrôle de politique dans de nombreuses organisations.
  • Le copy-up d’OverlayFS est le coût caché. Écrire sur un fichier présent dans une couche inférieure force une copie complète du fichier dans la couche supérieure avant que l’écriture n’ait lieu.
  • De nombreuses images de base partent du principe que /tmp est modifiable. Les gestionnaires de paquets, les runtimes de langage et les outils TLS y déposent souvent des fichiers temporaires.
  • Certaines bibliothèques écrivent encore « utilement » à côté du code. Les caches de bytecode Python (__pycache__) et le partage de données de classe Java peuvent vous surprendre.
  • La journalisation était autrefois basée sur des fichiers. Les habitudes Syslog et logrotate apparaissent encore dans des images qui insistent pour écrire dans /var/log alors que stdout est la bonne réponse.
  • Les images distroless aident, mais ne règlent pas le problème des écritures. Elles réduisent les outils et la surface d’attaque, mais votre application a toujours besoin d’un endroit pour l’état d’exécution.

Schémas de conception qui ne cassent pas les applications

Schéma 1 : Traitez l’image comme du firmware

Construisez votre image pour qu’elle contienne tout le nécessaire : binaires, configs (ou templates), bundles CA, données de fuseau horaire et ressources statiques. Puis assumez qu’elle ne peut jamais changer à l’exécution. Cela force une séparation claire : code/config dans l’image (ou injectés), état en dehors.

Si vous « corrigez » actuellement des conteneurs en éditant des fichiers à l’intérieur, ce n’est pas un problème de lecture seule. C’est un problème d’ingénierie de release déguisé en Docker.

Schéma 2 : Chemins modifiables explicites (approche « /var est un contrat »)

La plupart des applis ont besoin d’un petit ensemble d’emplacements modifiables. Les plus courants :

  • /tmp pour fichiers temporaires et sockets
  • /var/run (ou /run) pour fichiers PID et sockets Unix
  • /var/cache pour caches
  • /var/log seulement si vous devez absolument écrire des logs dans des fichiers (évitez si possible)
  • répertoires d’état spécifiques à l’application : /data, /uploads, /var/lib/app

Rendez ces chemins modifiables en utilisant tmpfs (pour l’état éphémère) ou des volumes (pour l’état persistant). Tout le reste reste en lecture seule. C’est le mouvement central.

Schéma 3 : Utilisez tmpfs pour l’état qui ne doit pas persister

tmpfs est en RAM (et en swap si activé). C’est rapide, disparaît au redémarrage et empêche les déchets d’aller dans la couche du conteneur. Idéal pour :

  • fichiers PID
  • sockets d’exécution
  • décompression temporaire
  • caches de runtime de langage que vous ne devez pas persister

Soyez discipliné avec la taille du tmpfs. Une attitude « donnez-lui juste de la RAM » conduit à déboguer des OOM qui ressemblent à des plantages aléatoires.

Schéma 4 : Les logs vont sur stdout/stderr, pas dans des fichiers

Les conteneurs ne sont pas des animaux de compagnie. Les fichiers de logs dans un conteneur sont un piège : ils remplissent les disques, demandent une rotation et se perdent si le conteneur meurt. Préférez stdout/stderr et laissez la plateforme gérer l’agrégation. Si vous devez écrire des logs sur disque (conformité, agents hérités), montez un volume sur /var/log et acceptez le coût opérationnel.

Schéma 5 : N’oubliez pas les bibliothèques qui écrivent « utilement »

Les coupables fréquents :

  • Nginx : veut écrire dans /var/cache/nginx et parfois dans /var/run
  • Outils OpenSSL : peuvent écrire des fichiers temporaires sous /tmp
  • Java : écrit dans /tmp et demande parfois un $HOME modifiable
  • Python : peut écrire des caches de bytecode et attendre un $HOME modifiable pour certains paquets
  • Node : les outils peuvent écrire des caches sous /home/node/.npm si vous exécutez des étapes de build à l’exécution (ne le faites pas)

Schéma 6 : Préférez non-root + rootfs en lecture seule, mais gardez la débogabilité

Le rootfs en lecture seule se combine bien avec l’exécution en tant qu’utilisateur non-root. Vous réduisez à la fois la capacité d’écrire et la capacité de chmod/chown. Mais ne poussez pas trop loin en supprimant tous les outils puis en vous étonnant que l’astreinte ne puisse rien diagnostiquer.

Si vous optez pour distroless, prévoyez une image de debug séparée ou utilisez des conteneurs de debug éphémères. « Pas de shell » est acceptable. « Pas de plan » ne l’est pas.

Schéma 7 : Rendre l’état explicite dans l’application

Le meilleur durcissement est lorsque l’application elle-même est honnête sur les endroits où elle écrit. Fournissez des flags/variables d’environnement pour les répertoires de cache, de temporaires et d’état d’exécution. Si votre application écrit dans des valeurs par défaut aléatoires, vous jouerez au jeu du whack-a-mole avec les montages.

Blague n°2 : La seule chose plus permanente qu’un fichier temporaire est le fichier temporaire que votre application écrit à chaque requête.

Tâches pratiques : commandes, sorties, décisions

Ces tâches sont ordonnées comme vous feriez réellement un déploiement : inspecter, tester, contraindre, puis vérifier. Chacune inclut ce que la sortie signifie et la décision qui en découle.

Task 1: Confirm your storage driver (because semantics matter)

cr0x@server:~$ docker info --format 'Storage Driver: {{.Driver}}'
Storage Driver: overlay2

Ce que cela signifie : Vous êtes très probablement sur les sémantiques d’OverlayFS (copy-up, upperdir). Le comportement autour des écritures de fichiers et de l’épuisement d’inodes correspondra aux attentes d’overlay2.

Décision : Si vous n’êtes pas sur overlay2 (par exemple devicemapper), validez le comportement en lecture seule et les performances en staging. Les anciens drivers ont des cas limites surprenants.

Task 2: Baseline container writes before hardening

cr0x@server:~$ docker run --rm -d --name app-baseline myapp:latest
8b3c1d9d51a5a3a33bb3b4a2e7d0a9e5f3a7c1b0d8c9e2f1a6b7c8d9e0f1a2b3
cr0x@server:~$ docker exec app-baseline sh -c 'find / -xdev -type f -mmin -2 2>/dev/null | head'
/var/run/myapp.pid
/tmp/myapp.sock
/var/cache/myapp/index.bin

Ce que cela signifie : Dans les deux dernières minutes l’application a écrit dans /var/run, /tmp et /var/cache. C’est votre premier brouillon des « chemins modifiables ».

Décision : Prévoir des montages tmpfs pour /tmp et /var/run ; décider si /var/cache doit être tmpfs (éphémère) ou un volume (persistant).

Task 3: Check disk usage growth in the writable layer

cr0x@server:~$ docker ps --format '{{.Names}} {{.ID}}'
app-baseline 8b3c1d9d51a5
cr0x@server:~$ docker inspect --format '{{.GraphDriver.Data.UpperDir}}' app-baseline
/var/lib/docker/overlay2/7c6f3e.../diff
cr0x@server:~$ sudo du -sh /var/lib/docker/overlay2/7c6f3e.../diff
84M	/var/lib/docker/overlay2/7c6f3e.../diff

Ce que cela signifie : Le conteneur a déjà écrit 84 Mo dans sa couche supérieure. C’est un « état invisible » vivant sur l’hôte, pas dans un volume déclaré.

Décision : Si ce chiffre croît avec le trafic, vous devez déplacer ces écritures vers tmpfs/volumes ou vous finirez par recevoir une plainte pour « disque plein sur un nœud worker ».

Task 4: Run the container with a read-only rootfs and observe failure mode

cr0x@server:~$ docker rm -f app-baseline
app-baseline
cr0x@server:~$ docker run --rm --name app-ro --read-only myapp:latest
myapp: error: open /var/run/myapp.pid: read-only file system

Ce que cela signifie : La première écriture de l’application est un fichier PID. Classique. Vous avez maintenant un chemin précis à rendre modifiable.

Décision : Ajouter tmpfs pour /var/run (ou modifier l’application pour qu’elle n’écrive pas de fichiers PID si elle tourne en PID 1 et que vous n’en avez pas besoin).

Task 5: Add tmpfs mounts for common runtime paths

cr0x@server:~$ docker run --rm --name app-ro \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  myapp:latest
myapp: error: open /var/cache/myapp/index.bin: read-only file system

Ce que cela signifie : Vous avez corrigé le premier échec ; la prochaine écriture s’est manifestée. C’est normal. Le durcissement en lecture seule révèle itérativement des hypothèses.

Décision : Décidez si /var/cache/myapp peut être éphémère. Si oui, mettez-le en tmpfs. Si sa reconstruction est coûteuse, utilisez un volume.

Task 6: Mount a dedicated writable cache directory

cr0x@server:~$ docker volume create myapp-cache
myapp-cache
cr0x@server:~$ docker run --rm --name app-ro \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  -v myapp-cache:/var/cache/myapp:rw \
  myapp:latest
myapp: started on :8080

Ce que cela signifie : Le conteneur est maintenant opérationnel avec des zones modifiables explicites.

Décision : Documentez le contrat : /tmp et /var/run sont éphémères ; /var/cache/myapp persiste entre redémarrages du conteneur sur le même hôte (ou entre nœuds si vous utilisez un stockage réseau).

Task 7: Verify the root filesystem is actually mounted read-only

cr0x@server:~$ docker exec app-ro sh -c 'mount | head -n 6'
overlay on / type overlay (ro,relatime,lowerdir=/var/lib/docker/overlay2/l/...,
upperdir=/var/lib/docker/overlay2/u/...,
workdir=/var/lib/docker/overlay2/w/...)
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
tmpfs on /var/run type tmpfs (rw,nosuid,nodev,noexec,relatime,size=16384k)

Ce que cela signifie : Le montage overlay pour / est en ro. Vos montages tmpfs sont en rw. C’est la forme voulue.

Décision : Si vous ne voyez pas ro sur le montage racine, votre configuration runtime ne s’applique pas. Arrêtez et corrigez le déploiement, ne « supposez pas que tout va bien ».

Task 8: Prove writes fail where they should

cr0x@server:~$ docker exec app-ro sh -c 'echo test > /etc/should-fail && echo wrote'
sh: can't create /etc/should-fail: Read-only file system

Ce que cela signifie : Le conteneur ne peut pas modifier les chemins système. Bien.

Décision : Si cela réussit, vous avez accidentellement laissé la racine modifiable et votre modèle de menace vient de disparaître.

Task 9: Confirm writable mounts behave as expected

cr0x@server:~$ docker exec app-ro sh -c 'echo ok > /tmp/ok && cat /tmp/ok'
ok
cr0x@server:~$ docker exec app-ro sh -c 'echo ok > /var/cache/myapp/ok && cat /var/cache/myapp/ok'
ok

Ce que cela signifie : Le stockage temporaire et cache est disponible. Vous avez des surfaces d’écriture contrôlées.

Décision : Ajoutez noexec sur les montages tmpfs sauf raison contraire. Cela bloque une classe de comportements « télécharger et exécuter depuis /tmp ».

Task 10: Check for hidden writes via environment variables (HOME, XDG)

cr0x@server:~$ docker exec app-ro sh -c 'echo $HOME; ls -ld $HOME 2>/dev/null || true'
/home/app
drwxr-xr-x 2 app app 4096 Jan  1 00:00 /home/app
cr0x@server:~$ docker exec app-ro sh -c 'touch $HOME/.probe'
touch: cannot touch '/home/app/.probe': Read-only file system

Ce que cela signifie : Le home de l’utilisateur runtime existe mais n’est pas modifiable (parce qu’il est dans le rootfs en lecture seule). Certaines bibliothèques essaient d’écrire des configs ou des caches sous $HOME.

Décision : Montez soit un tmpfs sur /home/app (si acceptable) soit redirigez par variables d’environnement les caches vers un montage modifiable (préféré quand vous pouvez le contrôler).

Task 11: Validate that logging is not writing to disk

cr0x@server:~$ docker exec app-ro sh -c 'ls -l /var/log 2>/dev/null || true'
total 0
cr0x@server:~$ docker logs --tail=5 app-ro
2026-01-03T00:00:01Z INFO listening on :8080
2026-01-03T00:00:02Z INFO warmup complete

Ce que cela signifie : Les logs vont sur stdout/stderr (bien). /var/log n’accumule pas de fichiers.

Décision : Si vous voyez des fichiers de log apparaître, montez /var/log en volume et gérez la rotation en externe, ou changez la configuration du logger pour stdout.

Task 12: Catch permission issues early with a non-root user

cr0x@server:~$ docker run --rm --name app-ro-nonroot \
  --user 10001:10001 \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  -v myapp-cache:/var/cache/myapp:rw \
  myapp:latest
myapp: error: open /var/cache/myapp/index.bin: permission denied

Ce que cela signifie : Les permissions du répertoire du volume n’autorisent pas l’UID 10001 à écrire. Ce n’est pas un échec de lecture seule ; c’est un décalage UID/GID.

Décision : Corrigez la propriété du volume (init unique) ou utilisez un runtime qui supporte le réglage des permissions. Évitez d’exécuter en root juste pour éviter de réfléchir à la propriété des fichiers.

Task 13: Repair volume ownership safely (one approach)

cr0x@server:~$ docker run --rm -v myapp-cache:/var/cache/myapp alpine:3.20 \
  sh -c 'adduser -D -u 10001 app >/dev/null 2>&1; chown -R 10001:10001 /var/cache/myapp; ls -ld /var/cache/myapp'
drwxr-xr-x    2 app      app           4096 Jan  3 00:10 /var/cache/myapp

Ce que cela signifie : Le répertoire de cache appartient maintenant à l’UID/GID non-root.

Décision : Relancez le conteneur durci en non-root. Si votre plateforme supporte des init containers (Kubernetes), c’est un modèle de long terme plus propre que de le faire manuellement.

Task 14: Re-test the non-root hardened run

cr0x@server:~$ docker run --rm -d --name app-ro-nonroot \
  --user 10001:10001 \
  --read-only \
  --tmpfs /tmp:rw,nosuid,nodev,noexec,size=64m \
  --tmpfs /var/run:rw,nosuid,nodev,noexec,size=16m \
  -v myapp-cache:/var/cache/myapp:rw \
  myapp:latest
f2aa0e8d1bf2f7ad7f0c2b8b2b2a9a3d9a9e1e3c4b5d6e7f8a9b0c1d2e3f4a5b
cr0x@server:~$ docker exec app-ro-nonroot sh -c 'id'
uid=10001 gid=10001

Ce que cela signifie : Vous exécutez durci et en non-root. C’est un pas significatif vers une meilleure contention.

Décision : Faites-en une règle dans CI/CD : les images doivent s’exécuter en non-root et le rootfs doit être en lecture seule sauf exception documentée.

Task 15: Validate that your app isn’t silently failing to persist data

cr0x@server:~$ docker exec app-ro-nonroot sh -c 'test -f /var/cache/myapp/index.bin && echo "cache exists" || echo "cache missing"'
cache exists

Ce que cela signifie : Votre cache est créé et stocké là où vous l’attendiez.

Décision : S’il manque, l’application peut ignorer les erreurs d’écriture et fonctionner en mode dégradé. Ajoutez des health checks et des métriques pour le warmup du cache, les uploads ou tout état critique.

Task 16: Check kernel/LSM denials that look like filesystem errors

cr0x@server:~$ dmesg --ctime | tail -n 5
[Fri Jan  3 00:12:10 2026] audit: type=1400 audit(1735863130.123:120): apparmor="DENIED" operation="open" profile="docker-default" name="/proc/kcore" pid=31245 comm="myapp"

Ce que cela signifie : Toutes les « permission denied » ne sont pas des bits de mode de fichier. AppArmor (ou SELinux) peut aussi bloquer l’accès.

Décision : Si vous voyez des refus LSM, ne désactivez pas au hasard les profils de sécurité. Ajustez le profil ou corrigez le comportement de l’application qui le déclenche.

Feuille de route pour un diagnostic rapide

Quand un déploiement en lecture seule casse quelque chose, vous n’avez pas besoin d’un débat philosophique. Vous avez besoin d’une boucle de triage rapide qui vous dit quoi essaie d’écrire , et si la correction est un montage, un changement de config ou un changement de code.

Premier : identifier le chemin exact en échec

  • Vérifiez les logs du conteneur pour EROFS, Read-only file system, permission denied.
  • Trouvez le premier chemin en échec ; c’est généralement la première écriture et souvent la plus simple à régler.
cr0x@server:~$ docker logs --tail=50 app-ro
myapp: error: open /var/run/myapp.pid: read-only file system

Deuxième : déterminer s’il s’agit du rootfs en lecture seule ou de permissions/possession du montage

  • Si l’erreur porte sur un chemin que vous vouliez modifiable, c’est probablement un problème de propriété/permissions.
  • Si c’est sur un chemin que vous n’avez pas monté, c’est attendu ; il faut ajouter un montage modifiable ou modifier l’application pour qu’elle n’écrive pas là.
cr0x@server:~$ docker exec app-ro sh -c 'mount | grep -E " /var/run | /var/cache | /tmp "'
tmpfs on /tmp type tmpfs (rw,nosuid,nodev,noexec,relatime,size=65536k)
tmpfs on /var/run type tmpfs (rw,nosuid,nodev,noexec,relatime,size=16384k)

Troisième : inventoriez rapidement les écritures

  • Utilisez find sur les fichiers récemment modifiés si le conteneur peut démarrer.
  • S’il ne peut pas démarrer, lancez l’entrypoint en mode debug (ou overridez la commande) pour obtenir un shell et reproduire.
cr0x@server:~$ docker exec app-ro sh -c 'find / -xdev -type f -mmin -5 2>/dev/null | head -n 20'
/var/cache/myapp/index.bin
/tmp/myapp.sock
/var/run/myapp.pid

Quatrième : vérifiez les goulots de ressources introduits par tmpfs

  • Tmpfs consomme de la mémoire. Sous charge, la pression mémoire ressemble à des « redémarrages aléatoires ».
  • Surveillez l’utilisation mémoire et la consommation de tmpfs.
cr0x@server:~$ docker exec app-ro sh -c 'df -h /tmp /var/run'
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            64M  2.1M   62M   4% /tmp
tmpfs            16M   44K   16M   1% /var/run

Cinquième : confirmez que vous n’avez pas cassé les flux de mise à jour ou de rafraîchissement de certificats

  • Si votre image exécutait auparavant apt-get ou téléchargeait des assets au démarrage, la lecture seule le bloquera. Bien — déplacez-le au build time.
  • Si vous comptez sur des mises à jour du bundle CA à l’exécution dans le conteneur, arrêtez. Mettez à jour en rebuildant et redéployant les images.

Trois micro-histoires d’entreprise issues du terrain

Micro-histoire 1 : L’incident causé par une mauvaise hypothèse

Dans une entreprise SaaS de taille moyenne, une équipe plateforme a déployé des rootfs en lecture seule pour « tous les services sans état ». Ils ont fait la chose responsable : canary, monitoring, plan de rollback. Le canary est quand même tombé en quelques minutes.

Le service était une API Go. Elle semblait sans état. Elle parlait à une base et à une queue. L’équipe a supposé qu’elle n’écrivait rien localement. En réalité, elle avait un client TLS qui mettait en cache des réponses OCSP et des intermédiaires de certificats dans un répertoire sous $HOME, hérité d’un vieux défaut de bibliothèque. En conditions normales c’était « ok », parce que le répertoire existait et était modifiable dans la couche supérieure du conteneur.

Avec le rootfs en lecture seule, les écritures du cache échouaient. La bibliothèque n’a pas échoué en fermé ; elle a relancé des récupérations réseau agressivement. La latence a grimpé, puis la dépendance en aval a commencé à rate-limiter. L’API est restée disponible, mais suffisamment lente pour provoquer des timeouts en cascade sur quelques services qui l’appelaient.

La correction était ennuyeuse : rediriger le répertoire de cache vers un montage tmpfs et plafonner le comportement de retry. La leçon était plus nette : « sans état » n’est pas une croyance. C’est un contrat que vous appliquez par conception et vérifiez par mesure.

Micro-histoire 2 : L’optimisation qui a mal tourné

Une autre organisation voulait le rootfs en lecture seule et a voulu être maligne sur la performance. Ils ont déplacé /tmp sur tmpfs et mis une taille grande « pour qu’il ne se remplisse jamais ». En staging, tout semblait parfait. Builds plus rapides, traitement plus rapide pour un service de traitement d’images. Tout le monde était content.

Puis le trafic de production est arrivé avec un vrai comportement utilisateur : un petit pourcentage de requêtes téléchargeait d’énormes images, et le service écrivait plusieurs fichiers intermédiaires dans /tmp par requête. L’usage mémoire de tmpfs a grimpé rapidement. Linux a fait ce que Linux fait sous pression : il a commencé à libérer de la mémoire, puis a invoqué le killer OOM.

L’astreinte voyait des conteneurs redémarrer et a supposé une régression de code. Ils ont rollbacké le changement de lecture seule et le problème « a disparu », parce que le service est retourné à écrire les intermédiaires sur disque. Le lendemain, ils ont réessayé et ont eu les mêmes redémarrages « mystérieux ».

La bonne correction était de garder tmpfs pour les petits fichiers temporaires mais de déplacer les intermédiaires volumineux sur un volume dédié (ou redessiner pour streamer). Aussi : fixer des limites de taille tmpfs réalistes. « tmpfs illimité » est juste une façon créative de transformer la mémoire en disque sans le dire à personne.

Micro-histoire 3 : La pratique ennuyeuse qui a sauvé la mise

Une équipe de services financiers avait une politique : chaque conteneur déclare ses chemins modifiables en un seul endroit, revu comme n’importe quelle autre interface. Ils tenaient un petit document interne « contrat conteneur » à côté du Dockerfile : quels chemins doivent être modifiables, lesquels sont tmpfs, lesquels sont volumes, lesquels sont bind en lecture seule, et pourquoi.

Pendant une poussée de sécurité, ils ont activé le rootfs en lecture seule sur des dizaines de services. La plupart des changements étaient de routine parce que les chemins modifiables étaient déjà explicites. Une poignée d’applications legacy a échoué, mais les échecs étaient localisés : le contrat indiquait ce qui devait être modifiable, et leurs tests vérifiaient l’existence des montages.

Un service a quand même échoué en production à cause d’une nouvelle version de bibliothèque qui a commencé à écrire un cache sous /var/lib. Leur canary l’a détecté. Le rollback a été propre, et l’action post-incident a été simple : mettre à jour le contrat, ajouter le montage et ajouter un test qui greppe les logs pour Read-only file system au démarrage.

Rien d’héroïque n’a eu lieu. C’est le but. La pratique ennuyeuse a empêché une panne inter-services et transformé un projet de durcissement risqué en une migration contrôlée.

Erreurs courantes : symptômes → cause → correction

1) L’application échoue instantanément avec « Read-only file system » sur /var/run

Symptôme : Création de fichier PID ou socket échoue au démarrage.

Cause racine : L’état d’exécution attendu sous /var/run ou /run mais le rootfs est en lecture seule.

Correction : Monter tmpfs sur /var/run (et possiblement /run) ou configurer l’application pour écrire les PID/sockets dans /tmp.

2) L’application tourne mais devient lente ; les downstream voient des pics

Symptôme : Pas de crash, mais la latence augmente après activation de la lecture seule.

Cause racine : Une écriture de cache échoue et la bibliothèque bascule silencieusement en recalcul/refetch à chaque requête.

Correction : Identifier le répertoire de cache, monter un chemin de cache modifiable et ajouter de l’observabilité pour le taux de hit du cache ou le succès du warmup.

3) « Permission denied » sur un volume monté

Symptôme : Les écritures échouent malgré un montage en lecture-écriture.

Cause racine : Décalage UID/GID. Le processus non-root ne peut pas écrire dans le répertoire du volume dont la propriété existe.

Correction : Définir la bonne propriété via une étape d’initialisation (init container, job de chown unique) ou utiliser un stockage qui supporte fsGroup en Kubernetes.

4) Redémarrages aléatoires après avoir basculé /tmp sur tmpfs

Symptôme : Conteneur OOM-killé ou évincé sous charge.

Cause racine : tmpfs consomme de la mémoire ; des fichiers temporaires volumineux amplifient la pression mémoire.

Correction : Dimensionner tmpfs de façon conservative, déplacer les charges temporaires volumineuses sur un volume, ou repenser pour streamer plutôt que déverser sur disque.

5) Nginx échoue avec des erreurs d’écriture de cache ou client_body temp

Symptôme : Logs Nginx : « open() … failed (30: Read-only file system) » ou « client_body_temp ».

Cause racine : Nginx par défaut écrit des fichiers temporaires et du cache sous /var/cache/nginx.

Correction : Monter /var/cache/nginx en écriture (tmpfs pour les temporaires, volume pour le cache) et configurer client_body_temp_path vers un répertoire modifiable.

6) Outils Java ou Python cassent car ils veulent un home modifiable

Symptôme : Erreurs d’écriture sous /root ou /home/app ou chemins XDG.

Cause racine : Les bibliothèques utilisent $HOME par défaut pour caches/config même pour des processus serveurs.

Correction : Définir HOME vers un tmpfs modifiable (avec précaution) ou configurer les répertoires de cache spécifiques au langage vers des chemins montés en écriture.

7) « Marche sur Docker, échoue sur Kubernetes » (ou inversement)

Symptôme : Comportement différent entre environnements.

Cause racine : Montages, contextes de sécurité ou flags read-only différents par défaut. Kubernetes injecte aussi des montages de service account et peut définir des filesystem groups.

Correction : Rendre les montages explicites dans les deux environnements. Ne comptez pas sur les « valeurs par défaut de la plateforme » pour les chemins modifiables.

Listes de contrôle / plan étape par étape

Plan de déploiement étape par étape (ce que je ferais dans une équipe production)

  1. Inventorier les écritures dans le conteneur de base. Faites tourner sous charge normale et listez les fichiers récemment modifiés sous / avec -xdev. Capturez les chemins.
  2. Classifiez chaque chemin : éphémère (tmpfs), persistant (volume) ou « ne devrait pas exister » (corriger l’app/image).
  3. Arrêtez les installateurs à l’exécution. Si l’entrypoint exécute des installations, téléchargements ou compilations, déplacez cela au build time. La lecture seule forcera la question de toute façon.
  4. Ajoutez --read-only et les montages tmpfs minimaux. Commencez par /tmp et /var/run.
  5. Itérez sur les échecs. Ajoutez le prochain chemin modifiable seulement après avoir confirmé qu’il est nécessaire et correctement limité (un répertoire, pas « montez / en rw »).
  6. Exécutez en non-root. Corrigez proprement les ownership de volumes. C’est là que les équipes cherchent des raccourcis. N’en prenez pas.
  7. Verrouillez davantage : utilisez noexec sur tmpfs, retirez des capacités Linux et appliquez un profile seccomp restrictif quand c’est possible.
  8. Ajoutez des tests : test d’intégration qui vérifie que le conteneur démarre avec un rootfs en lecture seule et qu’il n’écrit que dans les chemins déclarés.
  9. Canary en production. Surveillez latence, taux d’erreur, redémarrages et mémoire (tmpfs). Avancez ou reculez rapidement.
  10. Documentez le contrat. Les chemins modifiables font désormais partie de l’interface du service.

Checklist de durcissement (rapide mais stricte)

  • Root filesystem monté en lecture seule (--read-only / readOnlyRootFilesystem: true).
  • tmpfs pour /tmp et /var/run avec nosuid,nodev,noexec quand possible.
  • Montages de volumes dédiés pour l’état réellement persistant.
  • Journaux sur stdout/stderr ; éviter d’écrire dans /var/log.
  • Utilisateur non-root, avec ownership des volumes géré explicitement.
  • Pas d’installation de paquets à l’exécution, pas de binaires auto-mis à jour.
  • Health checks détectant le « fonctionnement dégradé » (échecs de cache, échecs d’upload).
  • Monitoring de l’utilisation tmpfs et des redémarrages de conteneurs.

Guide de sélection de montage (décidez comme un adulte)

  • tmpfs : secrets déchiffrés à l’exécution, fichiers PID, petits fichiers temporaires, sockets. Parfait pour la vitesse ; dangereux si non borné.
  • volume nommé : caches que vous souhaitez persister sur un hôte, petit état, tampons de queue (idéalement pas dans le conteneur applicatif, mais la réalité impose parfois).
  • bind mount : config ou certificats fournis par l’hôte (souvent en lecture seule). Attention : vous coupler l’application aux chemins de l’hôte.
  • ne pas monter : tout ce que vous pouvez éliminer en changeant l’application pour streamer, pour logger sur stdout, ou pour traiter l’image comme immuable.

FAQ

1) Est-ce que --read-only rend mon conteneur « sécurisé » ?

Non. C’est un contrôle parmi d’autres qui réduit la persistance sur le système de fichiers et les mutations accidentelles. Vous avez toujours besoin de non-root, de retraits de capacités, de contrôles réseau et de rebuilds patchés.

2) Si je monte un volume modifiable, est-ce que cela ne nuit pas au but recherché ?

Non si vous êtes intentionnel. Le but est de réduire la surface modifiable et de la rendre explicite. Un petit volume modifiable pour /var/cache/myapp vaut mieux que « tout peut écrire partout ».

3) Pourquoi ne pas rendre tout stateless et éviter les volumes ?

Parce que beaucoup d’applications « sans état » ont quand même besoin d’espace temporaire, de sockets et de caches. L’objectif est de minimiser et contrôler l’état, pas de prétendre qu’il n’existe pas.

4) Puis-je simplement monter / en lecture seule puis remonter des parties en écriture à l’intérieur du conteneur ?

À l’intérieur d’un conteneur, remonter requiert typiquement des privilèges élevés et des capacités que vous ne devriez pas accorder. Faites les montages depuis le runtime (Docker/Kubernetes) pour que le processus du conteneur ne puisse pas étendre ses permissions d’écriture.

5) Quels sont les montages modifiables minimum dont la plupart des applis ont besoin ?

Généralement /tmp et /var/run. Ensuite c’est spécifique à l’application : caches, uploads, fichiers SQLite, etc. La bonne réponse est « ce que votre application prouve avoir besoin sous charge ».

6) Comment ça marche dans Kubernetes ?

Vous définissez securityContext.readOnlyRootFilesystem: true puis vous déclarez des volumes emptyDir (optionnellement medium: Memory) pour un comportement similaire à tmpfs, plus des volumes persistants quand nécessaire. Le même contrat de chemins d’écriture s’applique.

7) Pourquoi ai-je « permission denied » alors que le montage est en rw ?

Parce que la propriété et les permissions de fichiers s’appliquent toujours. Un montage en lecture-écriture ne donne pas magiquement la permission d’écriture à l’UID 10001 dans un répertoire possédé par root. Corrigez la propriété ou utilisez fsGroup/init.

8) Et les applications qui génèrent des fichiers de config au démarrage ?

Préférez générer la config dans un montage modifiable (comme /tmp ou /var/run) et pointez l’application dessus. Mieux : générez les configs au build time ou via de la config injectée (env vars, fichiers montés).

9) Est-ce que noexec sur /tmp casse des choses ?

Parfois. Les outils qui extraient et exécutent des binaires depuis /tmp échoueront. C’est souvent désirable en production. Si votre application en a vraiment besoin (rare pour des services bien conçus), documentez l’exception et contraignez-la.

10) Comment tester ça en CI ?

Exécutez le conteneur avec --read-only et les tmpfs/volumes requis, lancez un smoke test et échouez la build sur toute ligne de log contenant Read-only file system ou sur un exit non nul.

Conclusion : prochaines étapes efficaces

Les conteneurs en lecture seule sont une de ces rares mesures de durcissement qui améliorent aussi la clarté opérationnelle. Ils vous forcent à déclarer où l’état vit, ce qui facilite le débogage et rend la compromission plus difficile à transformer en persistance. Le coût est que les hypothèses paresseuses de votre application sur le système de fichiers deviennent votre problème. Ce n’est pas un inconvénient ; c’est la réalité révélée.

Faites ceci ensuite

  1. Choisissez un service dont vous détenez l’intégralité et exécutez-le avec --read-only en staging.
  2. Itérez sur les montages modifiables jusqu’à ce qu’il tourne, puis réduisez-les aux répertoires minimum.
  3. Déplacez les logs vers stdout, stoppez les installateurs à l’exécution et rendez les caches explicites.
  4. Exécutez en non-root et corrigez proprement la propriété des volumes.
  5. Transformez la liste finale de montages en contrat : versionné, revu et testé.

Si vous ne faites qu’une chose : arrêtez de laisser les conteneurs écrire où bon leur semble. Vos incidents futurs seront plus ennuyeux, et c’est le compliment le plus élevé que la production puisse offrir.

← Précédent
Le retour de Ryzen : pourquoi ça a paru soudain (alors que ce n’était pas le cas)
Suivant →
ZFS redundant_metadata : quand davantage de copies de métadonnées comptent vraiment

Laisser un commentaire