nemunai.re/content/fr/post/unveiling-whiteout-files/index.md

325 lines
15 KiB
Markdown
Raw Normal View History

2023-11-09 15:37:04 +00:00
---
title: "Explorons les Whiteout files: tout savoir sur la suppression de fichiers dans nos conteneurs Docker"
date: !!timestamp '2023-11-09 15:35:00'
image: /post/unveiling-whiteout-files/og.webp
tags:
- kernel
- container
- docker
- linux
---
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.
<!-- more -->
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](overlayfs.png)
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**](http://mcvoy.com/lm/papers/SunOS.tfs.pdf) 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.
{{% card color="info" title="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/>.
{{% /card %}}
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
```
```json
{
"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
```
{{% card color="danger" title="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.
{{% /card %}}
### 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 à convertir[^MOBYWHITEOUT] les *whiteout files* qu'il rencontre dans le
format attendu.
[^MOBYWHITEOUT]: Voir le code
<https://github.com/moby/moby/blob/master/pkg/archive/archive_linux.go#L27>
## 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 :
```dockerfile
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`.