Contexte

Un des objectifs des pratiques DevOps est de raccourcir les cycles de livraison. Là où c’est possible, cela passe souvent par la mise en place de mécanismes destinés à limiter les interventions manuelles qui viennent interrompre la fluidité des processus.

Il ne faut pas non plus oublier toutes les optimisations qui permettent aussi de gagner du temps sur l’exécution de ces processus.

L’optimisation de la taille d’une image Docker en fait partie. Dans le cadre d’un pipeline de build, en la réduisant, on gagne du temps lorsqu’on fait la demande d’un push de cette image vers le repository (Docker Hub ou Azure Container Registry, par exemple).

Dans le contexte d’une application .NET Core, sans y prêter attention, on peut se retrouver avec une image qui dépasse largement le giga… Évidemment, ce principe d’optimisation de la taille d’une image Docker peut s’appliquer à tous les contextes où des outils de compilation (ou de transpilation) sont nécessaires.
Bannière livre blanc devops

Web API .NET Core

Partons d’un exemple de Web API .NET Core (version 2.2) proposé par la documentation de Microsoft et que l’on peut trouver ici. Il est inutile de mettre en oeuvre la partie cliente (fichiers index.html et site.js).

A la fin de cet exemple, nous avons donc une solution Visual Studio TodoApi contenant notre projet de Web API s’appuyant sur .NET Core.

Docker pour Windows

Pour pouvoir construire l’image Docker de notre Web API, nous avons besoin de l’outil Docker pour Windows. Il est téléchargeable ici. Récupérez-le et installez-le.

Pour que Docker pour Windows puisse être exploité, il est nécessaire que la virtualisation soit disponible et activée. Il est possible de s’en assurer en accédant au Task Manager de Windows :

Il peut également être nécessaire d’accéder au BIOS de votre hôte pour procéder à l’activation de la virtualisation.

Dockerfile

Création du fichier Dockerfile

Pour que notre Web API puisse être embarquée au sein d’une image Docker, nous avons besoin d’un fichier Dockerfile. Il faut ajouter ce fichier à côté du fichier .csproj de notre solution Visual Studio.

Commençons par y placer le contenu suivant :

FROM microsoft/dotnet:2.2-sdk
WORKDIR /src

# Copy csproj file and restore as distinct layers
COPY *.csproj ./
RUN dotnet restore

# Copy and build everything else
COPY . ./
RUN dotnet publish -c Release -o build
ENTRYPOINT ["dotnet", "build/TodoApi.dll"]

Pour pouvoir procéder à la récupération des dépendances, à la compilation et au packaging de notre Web API .NET Core, nous devons nous appuyer sur l’image de base microsoft/dotnet:2.2-sdk disponible sur le Docker Hub. Cette image embarque tous les outils nécessaires à ces opérations ainsi que le runtime .NET Core.

Construction de l’image Docker

Pour construire notre image Docker, il faut exécuter la commande suivante (en étant dans le répertoire qui contient le fichier Dockerfile) :

docker build -t todoapi .

Exécution du conteneur Docker

A partir de l’image Docker que nous venons de construire, nous allons exécuter un conteneur Docker avec la commande suivante :

docker run -d -p 9090:80 --name todoapi todoapi

Avec l’option -p, nous demandons un mapping du port 80 exposé à l’intérieur du conteneur Docker par notre Web API vers le port 9090 de notre hôte. Pour tester, utilisez votre navigateur en lui passant l’URL : http://localhost:9090/todo/api. Cela doit produire le résultat suivant :

Exécutez ensuite la commande permettant d’obtenir la liste des images Docker présentes sur notre hôte :
docker images

On obtient le résultat suivant :

On s’aperçoit que la taille de notre image Docker est conséquente : 1,74GB. Cette taille est liée au fait que nous ayons dû nous baser sur l’image contenant le SDK de .NET Core.

Pour réduire sa taille, il faudrait trouver une solution permettant d’embarquer dans notre image Docker une version compilée de notre Web API. Cela nous permettrait de nous appuyer sur une image ne contenant plus que le runtime .NET Core. L’image de base à utiliser pour cela est également disponible sur le Docker Hub : microsoft/dotnet:2.2-aspnetcore-runtime.

Optimisation de la taille de l’image

Solution 1 : procéder en deux étapes

Une première solution consiste à d’abord exécuter la commande dotnet permettant de packager notre Web API (toujours en étant dans le répertoire de notre projet) :

dotnet publish -c Release -o build

Avec l’option -c, on indique le type de configuration à utiliser et avec l’option -o, on indique le répertoire de sortie des fichiers générés.

Ensuite, on remplace le contenu du fichier Dockerfile par ce qui suit :

FROM microsoft/dotnet:2.2-aspnetcore-runtime
WORKDIR /src
COPY ./build ./build
ENTRYPOINT ["dotnet", "build/TodoApi.dll"]

On remarque que la référence à l’image de base (première ligne démarrant avec la commande FROM) a changé. On fait désormais appel à l’image ne contenant que le runtime .NET Core.

Ensuite, on demande un nouveau build de notre image Docker :

docker build -t todoapi .

En récupérant à nouveau la liste des images Docker, on obtient le résultat suivant :

On observe que la taille de l’image a largement diminué : 260MB.

Inconvénient

Cette solution impose de penser à exécuter systématiquement la commande dotnet de packaging de notre Web API. On risque tout simplement de l’oublier… Cela ne rentre pas vraiment dans les pratiques DevOps !

Intégration continue

Dans le cadre d’une intégration continue, il pourrait être intéressant de produire un script (Bash ou PowerShell) exécutant d’abord la commande dotnet de packaging, puis la commande docker de build de notre image.

Il faudrait ensuite modifier la définition du pipeline de build pour remplacer l’étape qui exécutait la commande docker de build par l’exécution de ce script.

Solution 2 : utiliser le Builder Pattern

Cette solution consiste à créer deux fichiers supplémentaires (en plus du fichier Dockerfile existant).

Le premier fichier à créer doit s’appeler Dockerfile.build. Il doit être placé à côté du fichier .csproj. Il contient des commandes de type Dockerfile. Il va permettre d’assurer toute la partie compilation et packaging de notre Web API.

Placer le contenu suivant dans ce fichier Dockerfile.build :

FROM microsoft/dotnet:2.2-sdk
WORKDIR /src

# Copy csproj file and restore as distinct layers
COPY *.csproj ./
RUN dotnet restore

# Copy and build everything else
COPY . ./
RUN dotnet publish -c Release -o build

Comme dans la première version de notre fichier Dockerfile, on s’appuie sur l’image Docker contenant le SDK .NET Core. Le build d’une image Docker à partir de ce fichier Dockerfile.build produira donc une image lourde.

Le second fichier à créer un est un script Bash ou PowerShell pour orchestrer la production de l’image Docker optimisée.

Le rôle de ce fichier va être de :

  1. Créer une image Docker (nommée todoapi:build) à partir du fichier Dockerfile.build
  2. Instancier un conteneur Docker (nommé extract) à partir de cette image todoapi:build
  3. Copier sur notre hôte (dans un répertoire build) le contenu du répertoire /src/build du conteneur extract
  4. Supprimer le conteneur extract
  5. Créer l’image Docker finale à partir du fichier Dockerfile

Nous nommons ce fichier build.ps1 et le plaçons à côté du fichier .csproj.

Placer le contenu suivant dans ce fichier build.ps1 :

# Build intermediate builder Docker image
docker build -t todoapi:build . -f Dockerfile.build

# Create a container from this builder Docker image
# with extract name
docker create --name extract todoapi:build

# Copy files inside /src/build from previously created container
# to local host build directory
docker cp extract:/src/build ./build

# Delete extract container
docker rm -f extract

# Build final and size optimized Docker image
docker build --no-cache -t todoapi .

Après l’exécution de la première commande, on obtient une nouvelle image Docker todoapi tagguée build, construite à partir du fichier Dockerfile.build :

Après l’exécution de la seconde commande, un conteneur Docker est instancié à partir de l’image todoapi:build :

La commande qui suit récupère le contenu du répertoire build du conteneur extract pour le positionner dans le répertoire build de notre projet .NET Core :

Ensuite, le conteneur extract est supprimé puis l’image Docker finale de notre Web API est construite à partir du fichier Dockerfile :

Inconvénient

Cette solution impose la maintenance de trois fichiers au lieu d’un seul.

Intégration continue

Dans le cadre d’une intégration continue, il faudrait ensuite modifier la définition du pipeline de build pour remplacer l’étape qui exécutait la commande docker de build par l’exécution du script build.ps1.

Solution 3 : version multi-stage du Dockerfile

A partir de la version 17.05 du serveur et du client Docker, il est possible d’écrire des fichiers Dockerfile multi-stage.

Chaque stage commence avec une commande FROM. Il est fortement conseillé de nommer chaque stage en utilisant le mot-clé AS. Cela permet d’y faire référence dans un autre stage. Dans notre cas, nous allons faire appel au stage nommé builder à partir de la commande COPY du second stage en utilisant le paramètre –from=builder.

Cette solution n’exploite pas les fichiers créés lors de la solution 2 (Dockerfile.build et build.ps1). Ils peuvent donc être supprimés.

Voici le contenu à placer dans notre fichier Dockerfile :

FROM microsoft/dotnet:2.2-sdk AS builder
WORKDIR /src

# Copy csproj file and restore as distinct layers
COPY *.csproj ./
RUN dotnet restore

# Copy and build everything else
COPY . ./
RUN dotnet publish -c Release -o build

# Build runtime image
FROM microsoft/dotnet:2.2-aspnetcore-runtime
WORKDIR /src
COPY --from=builder /src/build .
ENTRYPOINT ["dotnet", "TodoApi.dll"]

Cette nouvelle version multi-stage du fichier Dockerfile permet également d’obtenir une version de taille optimisée de notre image Docker :

Intégration continue

Ce fichier Dockerfile multi-stage permet de reproduire les opérations de la solution précédente, mais sans ajouter de fichier supplémentaire. Dans le cadre d’une intégration continue, cette solution n’a donc pas d’incidence sur la définition du pipeline de build.

Il n’y aurait que le contenu du fichier Dockerfile à adapter. Il faudrait tout de même s’assurer que la version du serveur Docker de l’agent build est suffisante.

Image Docker pour Windows Containers

L’intérêt d’une application .NET Core est qu’elle est multiplateforme. Cependant, si elle est embarquée au sein d’une image Docker, il est nécessaire que son build se fasse dans le contexte d’exécution attendu.

Jusque-là, nous avons contruit des images Docker destinées à une exécution au sein de conteneurs Linux (c’est la configuration par défaut de Docker pour Windows).

Si on souhaite construire des images Docker embarquant une application .NET Core et destinées à une exécution au sein de conteneurs pour Windows, il faut procéder à la modification de Docker pour Windows pour basculer dans la configuration Windows containers :

Après avoir exécuté la commande de build de notre image Docker dans le contexte des conteneurs pour Windows, on obtient une image qui reste plus légère que l’image s’appuyant sur le SDK de .NET Core, mais qui est plus lourde que la version pour conteneurs Linux :

http://old.softfluent.fr/blog/expertise/DevOps,-Docker-et-.NET-Core--optimiser-la-taille-de-limage-Docker_docker-images-006.png

Utiliser l’option d’activation du support de Docker de Visual Studio

En créant votre projet avec Visual Studio, vous avez peut-être remarqué qu’une option « Enable Docker Support » était présente. En l’activant, vous aurez la possibilité d’indiquer le système d’exploitation à cibler (Linux ou Windows) :

Cette option va notamment créer pour vous, au sein de votre projet, un fichier Dockerfile de type multi-stage.

En sélectionnant « Windows » comme système d’exploitation, le fichier Dockerfile créé va s’appuyer sur l’image Dockermicrosoft/dotnet:2.2-aspnetcore-runtime-nanoserver-1803. Cela signifie qu’on produira finalement une image Docker qui embarquera le runtime .NET Core 2.2 uniquement pour Windows Nano Server.

En sélectionnant « Linux » comme système d’exploitation, le fichier Dockerfile créé s’appuiera sur l’image Docker microsoft/dotnet:2.2-aspnetcore-runtime. Cette image est multi-architecture. Comme nous l’avons vu précédemment, elle permet de produire, en fonction de l’hôte Docker, une image destinée à une exécution soit sur Linux, soit sur Windows (Windows Nano Server).

Il existe également des images destinées aux environnements Linux uniquement. Par exemple, l’image microsoft/dotnet:2.2-aspnetcore-runtime-alpine est une image qui permet une exécution uniquement sur la distribution Linux Alpine. Elle a la particularité de proposer une version légère de Linux. Si vous avez la certitude que le système d’exploitation ciblé est Linux, vous pouvez opter pour cette image Docker de base et réduire encore la taille de votre image finale :

Conclusion

Comme nous l’avons vu, optimiser la taille d’une image Docker prend du sens dans le cadre de l’intégragion continue. Il faut cependant aussi être attentif à l’impact sur le pipeline de build. L’idéal étant de trouver une solution qui soit un compromis entre les avantages apportées par une taille optimisée d’image Docker et les modifications que cela imposerait à la définition du pipeline de build.

La solution d’un Dockerfile multi-stage répond assez bien à cette problématique. Elle n’impose que deux choses : la modification du contenu du Dockerfile et la vérification de la version de Docker sur les agents de build.

Si vous exploitez des agents hébergés sur Azure, les versions installées de Docker sont suffisantes pour exploiter les Dockerfile multi-stage. Actuellement, la majorité des agents de build disponibles sur Azure propose au moins une version de Docker supérieure ou égale à 17.06.

Si vous utilisez un agent de build self-hosted, il faut alors s’assurer que la version installée de Docker est au moins égale à 17.05.