virli/tutorial/4/filesystem.md

16 KiB

\newpage

Systèmes de fichiers et couches

Les images de conteneurs sont distribuées en couches, chaque couche contenant les différences apportée 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 fût 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{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élioration 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 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 a créer un nouveau fichier dans la branche accessible en écriture. Il est ardu de réconcilier les pointeurs deux des processus.

D'une manière similaire, il faut penser à la gestion des hard links : tous les pointeurs d'un contenu mis à jour devrait être modifié 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 ont été retenues : 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 :

``` 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 on il 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 :

``` 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 avec 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 remarqué 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

</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'à é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>