Docker et les layers
Explications très succincte des layers
Docker fonctionne grâce à Overlayfs* et des layers. Une image est composée de layers, assemblés au lancement d’un container pour former le filesystem final.
* Overlayfs dans la plupart des cas. Docker peut également utiliser d’autre engines, mais overlayfs est celui par défaut et le plus répandu.
Lors de la création d’une image, nous créons ces layers, et nous y ajoutons des métadonnées. Chaque instruction* d’un Dockerfile ajoute un Layer, ou une métadonnées à l’image finale.
Prenons le Dockerfile suivant comme exemple :
FROM python:alpine
LABEL key=value
ENV key=value
RUN echo 1 > 1
RUN echo 2 > 2
RUN echo 3 > 3
Cette image possède au moins 4 layers :
- au moins 1 via
python:alpine
, image de base utilisée ici (en réalité probablement plus) - 1 layer par instruction
RUN
Les instructions ENV et LABEL ne génèrent pas de layers, mais uniquement des métadonnées
On peut apercevoir ces layers avec les commandes docker push/pull qui indiquent quels layers sont pushed/pulled
Pour réellement voir les layers, on utilisera la commande docker inspect <id/name> | jq '.[0]'.RootFS.Layers
Plutôt que d’écrire 3 RUN
différents ici, regroupons les en un pour économiser les layers :
FROM python:alpine
LABEL key=value
ENV key=value
RUN echo 1 > 1 && \
echo 2 > 2 && \
echo 3 > 3
Pourquoi est-ce important ?
Plusieurs raisons :
- Plus on a de layers, moins notre image sera efficace (plus lente à push/pull, léger overhead au runtime, …)
- L’image peut s’en retrouver plus lourde :
$ cat Dockerfile-cache
FROM python:3.12-alpine
RUN dd if=/dev/zero of=./file bs=100M count=1
RUN echo do something
RUN rm ./file
$ cat Dockerfile-no-cache
FROM python:3.12-alpine
RUN dd if=/dev/zero of=./file bs=100M count=1 && \
echo do something && \
rm ./file
$ docker build -t no-cache -f Dockerfile-no-cache .
[...]
$ docker build -t cache -f Dockerfile-cache .
[...]
$ docker image ls | grep cache
no-cache latest b7649a20b96b 30 seconds ago 57.5MB
cache latest 9a36cf7702b2 30 seconds ago 162MB
- L’image peut leaker des secrets :
$ cat Dockerfile
FROM python:3.12-alpine
RUN echo "secret password" > /password
RUN echo do something with the file /password [...]
RUN rm /password
$ docker build -t password .
[...]
$ docker run --rm --entrypoint cat password /password
cat: can\'t open '/password': No such file or directory
$ # /password file seems to be removed from the image, yay !
$ cat $(docker inspect password | jq -r '.[0].GraphDriver.Data.LowerDir' | cut -d ':' -f2)/password"
secret password
$ # Actually isn't, still in the image
- Permet d’économiser de l’espace disque si les images partagent des layers (voir juste en dessous)
- Permet de build des images plus rapidement et efficacement (voir juste en dessous)
Ordonnons les layers
Pourquoi ordonner les layers ?
Ordonner ses layers a 2 énormes impacts pour tout le monde :
-
Si 2 images différentes ont toutes les deux besoins de python 3.12 et d’avoir git, elles peuvent partager des layers. Tous les layers partagés ne dupliquent pas l’espace disque pris. Ainsi, si deux images pour deux applications A et B, basées sur python 3.12 et git, sont bien construite, l’espace disque sera de
sizeof(python 3.12 + git) + sizeof(A) + sizeof(B)
.Si elles sont mal construites, l’espace disque total sera au mieux de
sizeof(python 3.12) + 2*sizeof(git) + sizeof(A) + sizeof(B)
, et au pire2*sizeof(python 3.12) + 2*sizeof(git) + sizeof(A) + sizeof(B)
. L’espace disques est donc significativement impacté, le temps de push/pull également, voire le startup time (si on doit pull l’image avant). -
Un développeur qui travaille sur une application avec son image va vouloir rebuild l’image fréquemment, pour tester. Il est fort probable que les dépendances de l’image évoluent rarement, que les dépendances de l’application évoluent peu fréquemment et que l’application évoluent fréquemment.
Si pour un changement minime de code, par exemple ajouter un commentaire, il faut re-télécharger toutes les dépendances, le temps de build en devient catastrophiquement long et les serveurs des associations et volontaires qui hébergent les dépendances se font contacter inutilement. En résulte un temps de build trop long, un développeur frustré, et de la bande passante gâchée.
Si au contraire les instructions du Dockerfile sont bien ordonnées, nous n’avons pas ce problème grâce à la réutilisation du cache de build.
Comment faire ?
La rule of thumb pour écrire son Dockerfile est de chercher à minimiser les layers. Mais il faut également garder en tête le cache, et l’utiliser avec habileté.
Dans ce billet, on va prendre pour exemple la construction d’une image docker pour une application en Python, image qui nécessite git
et des dépendances pythons listées dans un pyproject.toml
.
Le contenu de l’application app.py
et du pyproject.toml
n’ont que peu d’importance ici.
Voici un Dockerfile naïf :
FROM python:3.12-alpine
WORKDIR /app
ENV POETRY_CACHE_DIR=/tmp/poetry_cache
COPY . .
RUN apk add git && \
pip3 install poetry && \
poetry install --only main --no-root && rm -rf $POETRY_CACHE_DIR
ENTRYPOINT ["python3", "/app/app.py"]
Un seul run qui regroupe donc plusieurs layers en un, pas de layer de cache supprimé par la suite qui traîne dans l’image finale, sela semble intelligent en apparence.
Mais si on ajoute un commentaire basique dans le code de l’application, absolument toutes les dépendances python et git seront réinstallées, et on perd environ mille ans.
Une meilleure manière de faire :
FROM python:3.12-alpine
WORKDIR /app
ENV POETRY_CACHE_DIR=/tmp/poetry_cache
RUN apk add git && pip3 install poetry
COPY pyproject.toml poetry.lock ./
RUN poetry install --only main --no-root && rm -rf $POETRY_CACHE_DIR
COPY app.py ./
ENTRYPOINT ["python3", "/app/app.py"]
Il y a ici plus de layers. Mais le changement le plus fréquent, celui du code source, n’entraîne que la re-copie du code et non pas l’install des dépendances, grâce au cache du builder Docker. L’opération prend très peu de temps.
L’opération moins fréquente de changement des dépendances python ne provoque pas la réinstallation de git, uniquement le COPY pyproject.toml
(et la suite).
Et seul le changement le plus rare, celui des dépendances d’image/d’OS (git par exemple), entraîne le rebuild complet.
Si l’on possède 2 applications A et B qui dépendent toutes les deux de python:3.12-alpine
et de git
, si le début des deux Dockerfile est identique, les deux images résultantes vont partager leurs premiers layers. Tout bénef !
Multi stage build
Si l’on est amateur de multi stage build, et que l’on cherche à avoir un comportement similaire, il faut changer légèrement les choses.
Voici un exemple :
FROM python:3.12-alpine as base
WORKDIR /app
RUN apk add git
FROM python:3.12-alpine as builder
ENV POETRY_CACHE_DIR=/tmp/poetry_cache
RUN pip3 install poetry
COPY pyproject.toml poetry.lock ./
RUN poetry install --only main --no-root && rm -rf $POETRY_CACHE_DIR
FROM base
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY app.py ./
ENTRYPOINT ["python3", "/app/app.py"]
L’exemple est plus complexe, mais permet d’utiliser efficacement le cache et les layers.
Leave a Comment
Your email address will not be published. Required fields are marked *