404 lines
17 KiB
Markdown
404 lines
17 KiB
Markdown
\newpage
|
||
|
||
Systèmes de fichiers et couches
|
||
===============================
|
||
|
||
Les images de conteneurs sont distribuées en couches, chaque couche contenant
|
||
les différences apportées par rapport à la couche précédente : l'ajout d'un
|
||
fichier, la suppression d'un dossier, ...
|
||
|
||
L'intérêt principal est bien entendu d'optimiser l'espace de stockage, en
|
||
favorisant la réutilisation des couches d'un conteneur à l'autre. Cela permet
|
||
en outre d'accélérer le processus de création des conteneurs, puisqu'il n'y a
|
||
pas besoin de commencer par recopier les fichiers de l'image avant de pouvoir
|
||
lancer le conteneur.
|
||
|
||
Pour réaliser ces actions, Docker dispose de plusieurs techniques, implémentées
|
||
sous forme de *storage drivers*. Beaucoup de ces *drivers* s'appuient sur des
|
||
mécanismes existant dans le noyau, mais la diversité des configurations impose
|
||
d'avoir plusieurs solutions pour ce problème. D'ailleurs si aucun mécanisme
|
||
n'est disponible, Docker utilise le driver `vfs`. Ce *driver* va alors
|
||
recopier, au moment du lancement d'un conteneur, chacune des couches ; la
|
||
méthode n'est alors pas très optimale, mais a le mérite d'exister
|
||
indépendamment des implémentations.
|
||
|
||
## Union de systèmes de fichiers
|
||
|
||
Le principe général de ce système de couches repose sur l'union de système de
|
||
fichiers : il s'agit de faire une combinaison logique de deux couches (ou plus,
|
||
selon l'implémentation), afin de créer un unique système de fichiers qui est la
|
||
combinaison de chaque couche.
|
||
|
||
### Historique
|
||
|
||
Les premières implémentations de ce type de systèmes de fichiers est apparu
|
||
avec les LiveCD : on disposait d'une distribution Linux complètement
|
||
opérationnelle sur un support en lecture seule, mais on pouvait dédier un
|
||
espace de stockage sur son disque dur (ou en RAM, au travers d'un `tmpfs`) pour
|
||
modifier artificiellement le contenu du CD, notamment pour mettre à jour les
|
||
paquets, ou ajouter ses propres applications, documents, photos, ...
|
||
|
||
Historiquement, le noyau Linux devait être *patché* pour supporter ce type de
|
||
système de fichiers (que ce soit `unionfs` ou `aufs`, les deux principaux
|
||
*patch* apportant cette fonctionnalité). Les systèmes BSD disposent d'une
|
||
implémentation depuis au moins 1995 et c'est SunOS qui fut le premier OS à
|
||
développer cette technique dès 1986 (pour un système de fichier appelé
|
||
*Translucent File Service*). Pour Linux, il aura fallu attendre 2014 pour voir
|
||
l'arrivée du système de fichier OverlayFS dans un noyau sans *patch*.
|
||
|
||
### Usages
|
||
|
||
En dehors de l'exemple des LiveCD que l'on vient de décrire, les unions de
|
||
systèmes de fichiers trouvent leur intérêt également dans les systèmes
|
||
embarqués : on peut garder le système de base en lecture seule (entre deux
|
||
mises à jour du système) et rajouter une couche pour l'utilisateur en
|
||
lecture/écriture, ce qui donne la possibilité de faire facilement une
|
||
réinitialisation à la demande de l'utilisateur, ou en cas de corruption du
|
||
système de fichiers en écriture.
|
||
|
||
On trouve également usage de cette fonctionnalité pour réaliser des sauvegardes
|
||
en place des données.
|
||
|
||
|
||
### Généralités
|
||
|
||
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){height=6cm}
|
||
|
||
On voit 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 pour indiquer que ce fichier ne doit plus être
|
||
affiché dans la couche *merged*. Le même concept existe pour les dossiers, mais
|
||
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*.
|
||
|
||
::::: {.question}
|
||
|
||
#### Pourrait-on se contenter d'un *Copy-on-Write* au niveau des blocs ? {-}
|
||
\
|
||
|
||
C'est en effet une solution qui existe (les *snapshots* LVM par exemple, que
|
||
Docker peut utiliser au travers du *driver* `device-mapper`). Dans ce cas,
|
||
seuls les blocs modifiés seront réécrits, cela peut sembler être une
|
||
alternative performante. Il faut noter cependant qu'outre les blocs liés au
|
||
fichier modifié, il faut également mettre à jour les métadonnées (*inodes*,
|
||
...).
|
||
|
||
Dans les scénarios d'écriture intensive, il s'avère que ce type de système perd
|
||
beaucoup en performance face à une union de système de fichiers.
|
||
|
||
Il est généralement admis également que le *Copy-on-Write* tend à occuper
|
||
davantage de place au fil du temps et des modifications, que l'union.
|
||
|
||
:::::
|
||
|
||
|
||
## OverlayFS
|
||
|
||
OverlayFS 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.
|
||
|
||
::::: {.question}
|
||
|
||
#### 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/>.
|
||
|
||
:::::
|
||
|
||
Afin de satisfaire les contraintes d'intégration au noyau, le minimum de
|
||
fonctionnalités a été retenu : on ne peut notamment avoir qu'une seule couche
|
||
en écriture, qui se positionne nécessairement au sommet, en superposition des
|
||
autres. C'est de là que vient le nom du système de fichiers, puisqu'il s'agit
|
||
davantage d'une superposition (*overlay*) d'un système de fichiers sur un
|
||
autre, plutôt qu'une union de plusieurs systèmes aux politiques d'écritures
|
||
potentiellement plus variées.
|
||
|
||
|
||
### Utilisation
|
||
|
||
L'usage d'OverlayFS est plus complexe que la plupart des autres systèmes de
|
||
fichiers. Il faut bien évidemment indiquer le/les systèmes de fichiers à
|
||
utiliser comme branches basses, ainsi que l'éventuelle couche en
|
||
lecture/écriture, mais il faut aussi disposer d'un dossier de travail, qui
|
||
permettra à l'implémentation de préparer certaines actions qui nécessitent
|
||
d'être atomiques.
|
||
|
||
On peut réaliser une opération atomique en déplaçant un fichier préalablement
|
||
créé et rempli (plutôt qu'en le créant et en l'écrivant en place). Afin de
|
||
pouvoir satisfaire à l'atomicité, le répertoire *upper* et le dossier de
|
||
travail doivent être obligatoirement sur le même système de fichiers. Dans le
|
||
cas contraire, un appel à `rename(2)` retournerait `EXDEV` et l'opération ne
|
||
pourrait alors pas être atomique.
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
mount -t overlay -olowerdir=/lower,upperdir=/upper,workdir=/work ignored /merged
|
||
```
|
||
</div>
|
||
|
||
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.
|
||
\
|
||
|
||
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` :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
42sh$ docker info | grep "Storage Driver"
|
||
Storage Driver: overlay2
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
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)
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
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"
|
||
}
|
||
```
|
||
</div>
|
||
|
||
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 :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
incntr$ echo "newfile" > /root/foobar
|
||
```
|
||
</div>
|
||
|
||
<div lang="en-US">
|
||
```
|
||
42sh$ tree /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
|
||
/var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
|
||
└── root
|
||
└── foobar
|
||
```
|
||
</div>
|
||
|
||
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é :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
incntr$ echo "Bienvenue dans le conteneur" >> /etc/issue
|
||
```
|
||
</div>
|
||
|
||
<div lang="en-US">
|
||
```
|
||
42sh$ tree /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
|
||
/var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
|
||
└── etc
|
||
└── issue
|
||
```
|
||
</div>
|
||
|
||
<div lang="en-US">
|
||
```
|
||
42sh$ cat /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/etc/issue
|
||
Debian GNU/Linux 11 \n \l
|
||
Bienvenue dans le conteneur
|
||
```
|
||
</div>
|
||
|
||
|
||
### 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.
|
||
|
||
<div lang="en-US">
|
||
```
|
||
incntr$ rm /etc/adduser.conf
|
||
```
|
||
</div>
|
||
|
||
<div lang="en-US">
|
||
```
|
||
42sh$ tree /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff
|
||
/var/lib/docker/overlay2/1531651afa872006a4b2b9b913d5d8ee317cf12be7883517ba77f3d094f871b4/diff
|
||
└── etc
|
||
└── adduser.conf
|
||
```
|
||
</div>
|
||
|
||
<div lang="en-US">
|
||
```
|
||
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
|
||
```
|
||
</div>
|
||
|
||
Notons ici `Device type: 0,0`.
|
||
|
||
Pour créer nous-mêmes un fichier similaire, il faudrait utiliser :
|
||
|
||
<div lang="en-US">
|
||
```
|
||
42sh$ mkdir /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/bin
|
||
42sh$ mknod /var/lib/docker/overlay2/2a353708e5...91b230f9d700/diff/bin/sh c 0 0
|
||
```
|
||
</div>
|
||
|
||
::::: {.warning}
|
||
|
||
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 à 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>
|