325 lines
15 KiB
Markdown
325 lines
15 KiB
Markdown
---
|
||
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`.
|