Explorons les Whiteout files : tout savoir sur la suppression de fichiers dans nos conteneurs Docker

Les unions de systèmes de fichiers sont un mécanisme permettant de fusionner deux ou plusieurs systèmes de fichiers, pour les présenter unifiés, sous un seul point d’accès à l’utilisateur.

L’idée principale derrière ce mécanisme est de pouvoir permettre d’altérer le contenu du premier système de fichiers (par exemple le contenu d’un CD-ROM) en inscrivant toutes les modifications (ajouts, suppressions, modifications) dans le second (qui pourra être une partition d’un disque).

Si l’ajout et la modification peuvent sembler triviales, ce n’est pas le cas de la suppression. Alors explorons dans cet article ce que sont les whiteout files et comment ils permettent de simuler la suppression d’un fichier.

Un autre usage courant des unions de systèmes de fichiers se retrouve dans les conteneurs : les images des conteneurs sont composées de couches. Si vous lancez un conteneur php puis un conteneur nginx, deux images basées sur debian, vous ne téléchargerez qu’une seule fois l’image debian sous-jacente. Des fichiers issus de l’image debian peuvent se trouver modifié ou supprimé par une image telle que php ou nginx. Grâce à une union de système de fichiers!

Comprendre les unions de systèmes de fichiers

Les unions de systèmes de fichiers partagent un certain nombre de concepts que nous allons illustrer au travers du schéma suivant :

Accès aux fichiers en fonction des couches

On voit ici un système de fichiers à deux couches, on parle de deux branches dans le jargon. Elles sont notées Lower pour la couche la plus basse et upper la couche qui s’insère par dessus la couche lower ; et enfin Merged le résultat. Certaines implémentations supportent plus que 2 branches, avec des politiques d’accès et de modifications parfois complexes.

Lorsque l’on supprime un fichier de l’union, un fichier dit whiteout file est placé dans la couche en écriture (upper) pour indiquer que ce fichier ne doit plus être affiché dans la couche merged. Le même concept existe pour les dossiers, on parle alors d'opaque directory.

Lorsqu’il s’agit d’accéder à un fichier présent dans la branche lower et qui n’a pas été modifié dans upper, on accède directement au fichier de lower.

Lorsqu’un fichier est modifié, on recopie son contenu intégralement dans la branche upper, depuis la branche lower. Un fichier qui est ajouté, écrasé ou modifié aura donc son contenu intégralement dans la couche upper.

Historique

Le concept de whiteout file trouve son origine dans les premiers développements des unions de systèmes de fichiers.

Translucent File System est sans doute la première mise en œuvre du concept de whiteout file. Développé par David Hendricks dans les années 1980 pour SunOS 3, il s’agissait de permettre aux utilisateurs d’une machine de profiter du système de base, en y apportant des modifications sans impacter les autres utilisateurs, et sans avoir accès aux fichiers des autres utilisateurs.

Vint ensuite les premières implémentation d'union mounts avec BSD 4.4, dans les années 90.

L’implémentation la plus connue aujourd’hui est l'UnionFS, de Erez Zadok. Elle devait être l’implémentation utilisée pour le noyau Linux, mais tout comme aufs, leur code et leur solution ne convainct pas pour être pleinement intégré.

Il faudra attendre 2014, pour qu’un union mount soit intégré dans le noyau Linux. Il s’agit d’OverlayFS. Il est arrivé dans le noyau 3.18, après de plus de 4 années de réécritures et d’améliorations structurelles, pour atteindre le niveau d’exigence et sans compromis nécessaire à son intégration dans le noyau officiel.

Quelles problématiques rendent l'implémentation d'une union de systèmes de fichier compliquée ?

L’un des problèmes les plus délicats est de trouver une manière de représenter les suppressions de fichiers et de dossiers : cela doit être un fichier valide (avec ou sans métadonnée) car il faut pouvoir stocker l’information concrètement. Dans de nombreuses implémentations, un fichier .wh.<filename> sert de whiteout file, ce qui peut créer des conflits avec les noms des fichiers de l’utilisateur (ou réduire ses choix de noms de fichiers).

Un problème similaire s’applique aux dossiers : est-ce qu’il faut supprimer chaque fichier contenu dans le dossier ou la simple présence d’un opaque directory empêche toute découverte ?

L’usage de la mémoire peut vite devenir incontrôlable, surtout si l’implémentation autorise beaucoup de branches, car si on veut que le système soit performant il faudra avoir en mémoire les topologies de chaque système de fichiers.

L’implémentation de mmap(2) est nécessairement un cauchemar : lorsqu’un fichier est modifié par deux processus qui le mmap(2), on s’attend normalement à voir les modifications dans les deux processus, or le premier à faire une modification crée un nouveau fichier dans la branche accessible en écriture. Il est alors ardu de réconcilier les pointeurs des deux processus.

D’une manière similaire, il faut penser à la gestion des hard links : tous les pointeurs d’un contenu mis à jour devraient être modifiés dans la couche en écriture, cependant il n’y a pas d’index des pointeurs, il n’est donc pas facile de retrouver les fichiers à mettre à jour.

Ajoutons aussi que les systèmes de fichiers sous-jacents de chacune des branches n’ont pas forcément les mêmes contraintes (tailles des noms de fichiers, attributs étendus, métadonnées, encodage des accents, …) et qu’il faut réussir à jongler entre chaque, tout en retournant des erreurs cohérentes le cas échéant.

Et bien d’autres encore. Notamment readdir(2) qui doit être stable malgré les turbulences qui pourraient arriver entre deux appels, …

Voir cette série d’articles résumant les différentes implémentations, leurs choix et différences : https://lwn.net/Articles/325369/, https://lwn.net/Articles/327738/.

Par la suite, nous allons donc surtout nous concentrer sur le fonctionnement de ce système de fichier, en tentant autant que possible de faire le parallèle avec les autres.

Les whiteout files en pratique

Avant tout, il faut savoir monter un tel système. Voici un exemple général de création d’une union simple entre un système de fichiers en lecture seule et un en lecture/écriture :

mount -t overlay -olowerdir=/lower,upperdir=/upper,workdir=/work ignored /merged

Le type à utiliser est overlay, avec les options lowerdir qui indique l’emplacement du/des dossiers à combiner en lecture seule (on les sépare par des : lorsqu’il y en a plusieurs), on indique également le répertoire contenant le système en lecture/écriture dans l’option upperdir, et il ne faut pas oublier l’option workdir un chemin sur la même partition que l'upperdir, qui doit être vide.

On termine l’appel par donner le périphérique source, qui est inutile dans notre cas (ignored ou tout autre chaîne fera l’affaire), et enfin le dossier vers lequel sera monté notre union : /merged dans l’exemple.

Usage dans les conteneurs

Analysons un conteneur Docker en cours d’exécution pour en apprendre davantage.

D’abord, on vérifie que l’on utilise bien le storage driver overlay2 :

42sh$ docker info | grep "Storage Driver"
 Storage Driver: overlay2

C’est le cas (en fonction de la configuration de votre noyau, Docker aura peut-être choisi un driver différent), commençons donc l’analyse :

42sh$ docker container run --rm -it debian
  incntr$ mount | grep "on / "
  overlay on / type overlay (rw,relatime,lowerdir=/var/lib/docker/overlay2/l/B62UNV3UB3X4TBWQMM6XCMM6W5:/var/lib/docker/overlay2/l/V6HGFN3C3PEW6CZ6XWRSHHDKJH,upperdir=/var/lib/docker/overlay2/2a353708e5b16ea7775cf1a33dd23ce31430faaa504bcde5508691b230f9d700/diff,workdir=/var/lib/docker/overlay2/2a353708e5b16ea7775cf1a33dd23ce31430faaa504bcde5508691b230f9d700/work)

On remarque que 2 lowerdir sont utilisés. Il s’agit de liens symboliques pointant vers les dossiers identifiant les couches (les noms des liens sont aléatoires, il s’agit en fait d’avoir un chemin raccourci par rapport au chemin complet vers le système de fichiers de la couche, car le nombre de caractères que l’on peut passer à l’appel système mount(2) est limité).

La branche la plus basse (le plus à droite du paramètre lowerdir) contient l’unique couche de notre image debian, celle un peu plus à gauche superpose un certain nombre de fichiers de configuration nécessaire à l’exécution du conteneur (/etc/hosts, resolv.conf, …).

La branche en lecture/écriture est également enregistrée dans le dossier /var/lib/docker/overlay2 et l’on peut voir son identifiant. L'upperdir se trouve dans le dossier diff, tandis que le workdir est dans le dossier work, sous le même identifiant de couche.

On peut également voir les dossiers utilisés en inspectant notre conteneur :

42sh$ docker container inspect youthful_wilbur | jq .[0].GraphDriver.Data
{
  "LowerDir": "/var/lib/docker/overlay2/22753d0d81...8706f1a31-init/diff:/var/lib/docker/overlay2/2cc3656c06...c0fb91d6/diff",
  "MergedDir": "/var/lib/docker/overlay2/22753d0d81...8706f1a31/merged",
  "UpperDir": "/var/lib/docker/overlay2/22753d0d81...8706f1a31/diff",
  "WorkDir": "/var/lib/docker/overlay2/22753d0d81...8706f1a31/work"
}

Si on teste avec une image ayant plus de couches, on obtient davantage de lowerdir, un par couche. N’hésitez pas à faire la même série de commandes avec l’image python par exemple.

Ajout de fichiers

À ce stade, si nous regardons le contenu de notre dossier upperdir, nous pouvons remarquer que celui-ci est vide. C’est normal puisque nous n’avons apporté aucune modification.

Dans notre conteneur précédemment lancé, apportons une modification, en ajoutant un fichier :

incntr$ echo "newfile" > /root/foobar
42sh$ tree /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
/var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
└── root
    └── foobar

Notre nouveau fichier, qui n’est pourtant pas le seul dans l’arborescence que l’on voit dans le conteneur, a été ajouté comme on pouvait s’y attendre, dans la branche en lecture/écriture.

Modification de fichiers

Si nous apportons une modification à un fichier, par exemple en ajoutant une ligne, ce n’est pas seulement la différence qui est stockée dans la branche en écriture, mais bien tout le fichier, tel qu’il a été modifié :

incntr$ echo "Bienvenue dans le conteneur" >> /etc/issue
42sh$ tree /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
/var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
└── etc
    └── issue
42sh$ cat /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/etc/issue
Debian GNU/Linux 11 \n \l
Bienvenue dans le conteneur

Suppression de fichiers

Lorsque l’on souhaite supprimer un fichier que l’on vient d’ajouter, il n’y a pas grand chose à faire puisque supprimer ce fichier de la branche en écriture fera bien disparaître le fichier de l’arborescence montée.

Lorsqu’il s’agit de supprimer un fichier présent dans une branche en lecture seule, il faut réussir à faire en sorte de masquer ce fichier au moyen d’un marqueur. En fonction du storage driver, ce marqueur est différent : dans OverlayFS, une suppression est matérialisée par un fichier spécial de type caractère du même nom.

incntr$ rm /etc/adduser.conf
42sh$ tree /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
/var/lib/docker/overlay2/1531651afa872006a4b2b9b913d5d8ee317cf12be7883517ba77f3d094f871b4/diff
└── etc
    └── adduser.conf
42sh$ cat /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/etc/adduser.conf
cat: No such device or address

42sh$ stat /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/etc/adduser.conf
  File: /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/etc/adduser.conf
  Size: 0         	Blocks: 0          IO Block: 4096   character special file
Device: fe0bh/65035d	Inode: 515773      Links: 2     Device type: 0,0

Notons ici Device type: 0,0.

Pour créer nous-mêmes un fichier similaire, il faudrait utiliser :

42sh$ mkdir /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/bin
42sh$ mknod /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/bin/sh c 0 0

Attention, comportement indéterminé !

Faire cette commande mknod alors que l’union de système de fichiers est montée par ailleurs ne va pas faire disparaître le fichier /bin/sh car les modifications qui pourraient être apportées aux branches en dehors du système monté conduisent à des résultats explicitement indéfinis.

Suppressions pour unionfs et AuFS

Le concept de whiteout file, comme on a pu le voir, diffère en fonction du système de fichiers. Il s’avère que même si l’OverlayFS a été intégré dans le noyau Linux après maintes péripéties, Docker, lorsqu’a été spécifié le format des archives utilisées pour distribuer les couches, utilise aujourd’hui le format d’AuFS pour représenter les suppressions. Il est donc important de le voir également.

Au lieu d’utiliser un fichier spécial, AuFS crée un fichier standard .wh.<filename>, où <filename> est le nom du fichier à masquer.

Afin de s’adapter au storage driver, lors de la décompression de l’archive, Docker s’emploie à convertir1 les whiteout files qu’il rencontre dans le format attendu.

Conclusion

Alors que vous pensiez peut-être ne pas vouloir savoir de quoi s’agissait les whiteout files, je suis sûr qu’à la lecture de cet article vous avez entrevu la complexité que revêtent tant les union mounts que les logiciels tirant parti de différentes implémentations.

Vous savez maintenant pourquoi il est notamment inutile de supprimer un gros fichier dans une autre couche que celle qui l’a apportée, par exemple :

RUN wget https://dumps.wikimedia.org/enwiki/enwiki-pages-articles-multistream.xml.bz2

RUN ... # some other stuff

RUN rm enwiki-pages-articles-multistream.xml.bz2

Chaque RUN créant une couche distincte, notre fichier enwiki-pages-articles-multistream.xml.bz2 sera distribué avec la première couche de notre image, puis un whiteout file sera inséré dans la couche correspondante au troisième RUN.


  1. Voir le code https://github.com/moby/moby/blob/master/pkg/archive/archive_linux.go#L27 ↩︎