Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

Avant qu'une fonctionnalité n'entre en production, à l'heure des orchestrateurs complexes et du CI/CD, il y a un long chemin à parcourir entre la validation, les tests et la livraison. Auparavant, vous pouviez télécharger de nouveaux fichiers via FTP (personne ne le fait plus, n'est-ce pas ?), et le processus de « déploiement » prenait quelques secondes. Vous devez maintenant créer une demande de fusion et attendre longtemps que la fonctionnalité atteigne les utilisateurs.

Une partie de ce chemin consiste à créer une image Docker. Parfois, l'assemblée dure des minutes, parfois des dizaines de minutes, ce qui peut difficilement être qualifié de normal. Dans cet article, nous prendrons une application simple que nous regrouperons dans une image, appliquerons plusieurs méthodes pour accélérer la construction et examinerons les nuances du fonctionnement de ces méthodes.

Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

Nous avons une bonne expérience dans la création et le support de sites Web médiatiques : TASS, The Bell, "Nouveau journal", Centrafricaine… Il n'y a pas si longtemps, nous avons élargi notre portefeuille en lançant un site Web de produits Rappel. Et même si de nouvelles fonctionnalités ont été rapidement ajoutées et d’anciens bugs corrigés, la lenteur du déploiement est devenue un gros problème.

Nous déployons sur GitLab. Nous collectons des images, les transférons vers le registre GitLab et les déployons en production. La chose la plus longue sur cette liste est l’assemblage d’images. Par exemple : sans optimisation, chaque build backend prenait 14 minutes.

Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

En fin de compte, il est devenu clair que nous ne pouvions plus vivre ainsi, et nous nous sommes assis pour comprendre pourquoi la collecte des images prenait autant de temps. Résultat, nous avons réussi à réduire le temps de montage à 30 secondes !

Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

Pour cet article, afin de ne pas être lié à l'environnement de Reminder, regardons un exemple d'assemblage d'une application Angular vide. Alors, créons notre application :

ng n app

Ajoutez-y du PWA (nous sommes progressistes) :

ng add @angular/pwa --project app

Pendant qu'un million de packages npm sont en cours de téléchargement, voyons comment fonctionne l'image Docker. Docker offre la possibilité de regrouper des applications et de les exécuter dans un environnement isolé appelé conteneur. Grâce à l'isolation, vous pouvez exécuter plusieurs conteneurs simultanément sur un seul serveur. Les conteneurs sont beaucoup plus légers que les machines virtuelles car ils s'exécutent directement sur le noyau du système. Pour exécuter un conteneur avec notre application, nous devons d'abord créer une image dans laquelle nous regrouperons tout ce qui est nécessaire à l'exécution de notre application. Essentiellement, une image est une copie du système de fichiers. Par exemple, prenons le Dockerfile :

FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

Un Dockerfile est un ensemble d'instructions ; En effectuant chacune d'elles, Docker enregistrera les modifications apportées au système de fichiers et les superposera aux précédentes. Chaque équipe crée sa propre couche. Et l’image finale est constituée de calques combinés entre eux.

Ce qu'il est important de savoir : chaque couche Docker peut mettre en cache. Si rien n'a changé depuis la dernière build, alors au lieu d'exécuter la commande, le docker prendra une couche prête à l'emploi. Étant donné que la principale augmentation de la vitesse de construction sera due à l'utilisation du cache, lors de la mesure de la vitesse de construction, nous accorderons une attention particulière à la construction d'une image avec un cache prêt à l'emploi. Alors, étape par étape :

  1. Nous supprimons les images localement afin que les exécutions précédentes n'affectent pas le test.
    docker rmi $(docker images -q)
  2. Nous lançons le build pour la première fois.
    time docker build -t app .
  3. Nous modifions le fichier src/index.html - nous imitons le travail d'un programmeur.
  4. Nous exécutons la construction une deuxième fois.
    time docker build -t app .

Si l'environnement de création d'images est configuré correctement (plus d'informations ci-dessous), alors lorsque la construction démarrera, Docker aura déjà un certain nombre de caches à bord. Notre tâche est d'apprendre à utiliser le cache pour que la construction se déroule le plus rapidement possible. Puisque nous supposons que l’exécution d’une build sans cache n’a lieu qu’une seule fois – la toute première fois – nous pouvons donc ignorer la lenteur de cette première fois. Lors des tests, la deuxième exécution de la construction est importante pour nous, lorsque les caches sont déjà réchauffés et que nous sommes prêts à cuire notre gâteau. Cependant, certains conseils affecteront également la première version.

Mettons le Dockerfile décrit ci-dessus dans le dossier du projet et démarrons la construction. Toutes les listes ont été condensées pour faciliter la lecture.

$ time docker build -t app .
Sending build context to Docker daemon 409MB
Step 1/5 : FROM node:12.16.2
Status: Downloaded newer image for node:12.16.2
Step 2/5 : WORKDIR /app
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
added 1357 packages in 22.47s
Step 5/5 : RUN npm run build --prod
Date: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Time: 37581ms
Successfully built c8c279335f46
Successfully tagged app:latest

real 5m4.541s
user 0m0.000s
sys 0m0.000s

Nous modifions le contenu de src/index.html et l'exécutons une seconde fois.

$ time docker build -t app .
Sending build context to Docker daemon 409MB
Step 1/5 : FROM node:12.16.2
Step 2/5 : WORKDIR /app
 ---> Using cache
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
added 1357 packages in 22.47s
Step 5/5 : RUN npm run build --prod
Date: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Time: 37902ms
Successfully built 79f335df92d3
Successfully tagged app:latest

real 3m33.262s
user 0m0.000s
sys 0m0.000s

Pour voir si nous avons l'image, exécutez la commande docker images:

REPOSITORY   TAG      IMAGE ID       CREATED              SIZE
app          latest   79f335df92d3   About a minute ago   1.74GB

Avant la construction, Docker prend tous les fichiers dans le contexte actuel et les envoie à son démon Sending build context to Docker daemon 409MB. Le contexte de build est spécifié comme dernier argument de la commande build. Dans notre cas, il s'agit du répertoire actuel - ".", - et Docker fait glisser tout ce que nous avons dans ce dossier. 409 Mo, c'est beaucoup : réfléchissons à la manière d'y remédier.

Réduire le contexte

Pour réduire le contexte, il existe deux options. Ou placez tous les fichiers nécessaires à l'assemblage dans un dossier séparé et pointez le contexte Docker vers ce dossier. Cela n'est pas toujours pratique, il est donc possible de spécifier des exceptions : ce qui ne doit pas être glissé dans le contexte. Pour cela, placez le fichier .dockerignore dans le projet et indiquez ce qui n'est pas nécessaire pour le build :

.git
/node_modules

et exécutez à nouveau le build :

$ time docker build -t app .
Sending build context to Docker daemon 607.2kB
Step 1/5 : FROM node:12.16.2
Step 2/5 : WORKDIR /app
 ---> Using cache
Step 3/5 : COPY . .
Step 4/5 : RUN npm ci
added 1357 packages in 22.47s
Step 5/5 : RUN npm run build --prod
Date: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Time: 37313ms
Successfully built 4942f010792a
Successfully tagged app:latest

real 1m47.763s
user 0m0.000s
sys 0m0.000s

607.2 Ko est bien mieux que 409 Mo. Nous avons également réduit la taille de l'image de 1.74 à 1.38 Go :

REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
app          latest   4942f010792a   3 minutes ago   1.38GB

Essayons de réduire davantage la taille de l'image.

Nous utilisons Alpine

Une autre façon d’économiser sur la taille de l’image consiste à utiliser une petite image parent. L'image parentale est l'image à partir de laquelle notre image est préparée. La couche inférieure est spécifiée par la commande FROM dans Dockerfile. Dans notre cas, nous utilisons une image basée sur Ubuntu sur laquelle nodejs est déjà installé. Et ça pèse...

$ docker images -a | grep node
node 12.16.2 406aa3abbc6c 17 minutes ago 916MB

... presque un gigaoctet. Vous pouvez réduire considérablement le volume en utilisant une image basée sur Alpine Linux. Alpine est un très petit Linux. L'image docker pour nodejs basée sur alpine ne pèse que 88.5 Mo. Remplaçons donc notre image vivante dans les maisons :

FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

Nous avons dû installer certaines choses nécessaires à la création de l'application. Oui, Angular ne construit pas sans Python ¯(°_o)/¯

Mais la taille de l'image est tombée à 150 Mo :

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   aa031edc315a   22 minutes ago   761MB

Allons encore plus loin.

Assemblage en plusieurs étapes

Tout ce qui est dans l’image n’est pas ce dont nous avons besoin en production.

$ docker run app ls -lah
total 576K
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 .
drwxr-xr-x 1 root root 4.0K Apr 16 20:00 ..
-rwxr-xr-x 1 root root 19 Apr 17 2020 .dockerignore
-rwxr-xr-x 1 root root 246 Apr 17 2020 .editorconfig
-rwxr-xr-x 1 root root 631 Apr 17 2020 .gitignore
-rwxr-xr-x 1 root root 181 Apr 17 2020 Dockerfile
-rwxr-xr-x 1 root root 1020 Apr 17 2020 README.md
-rwxr-xr-x 1 root root 3.6K Apr 17 2020 angular.json
-rwxr-xr-x 1 root root 429 Apr 17 2020 browserslist
drwxr-xr-x 3 root root 4.0K Apr 16 19:54 dist
drwxr-xr-x 3 root root 4.0K Apr 17 2020 e2e
-rwxr-xr-x 1 root root 1015 Apr 17 2020 karma.conf.js
-rwxr-xr-x 1 root root 620 Apr 17 2020 ngsw-config.json
drwxr-xr-x 1 root root 4.0K Apr 16 19:54 node_modules
-rwxr-xr-x 1 root root 494.9K Apr 17 2020 package-lock.json
-rwxr-xr-x 1 root root 1.3K Apr 17 2020 package.json
drwxr-xr-x 5 root root 4.0K Apr 17 2020 src
-rwxr-xr-x 1 root root 210 Apr 17 2020 tsconfig.app.json
-rwxr-xr-x 1 root root 489 Apr 17 2020 tsconfig.json
-rwxr-xr-x 1 root root 270 Apr 17 2020 tsconfig.spec.json
-rwxr-xr-x 1 root root 1.9K Apr 17 2020 tslint.json

Avec docker run app ls -lah nous avons lancé un conteneur à notre image app et j'ai exécuté la commande dedans ls -lah, après quoi le conteneur a terminé son travail.

En production, nous n'avons besoin que d'un dossier dist. Dans ce cas, les fichiers doivent d’une manière ou d’une autre être remis à l’extérieur. Vous pouvez exécuter un serveur HTTP sur nodejs. Mais nous allons rendre les choses plus faciles. Devinez un mot russe contenant quatre lettres « y ». Droite! Ynzhynyksy. Prenons une image avec nginx, mettons-y un dossier dist et une petite config :

server {
    listen 80 default_server;
    server_name localhost;
    charset utf-8;
    root /app/dist;

    location / {
        try_files $uri $uri/ /index.html;
    }
}

La construction en plusieurs étapes nous aidera à faire tout cela. Modifions notre Dockerfile :

FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prod

FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .

Nous avons maintenant deux instructions FROM dans le Dockerfile, chacun d'eux exécute une étape de construction différente. Nous avons appelé le premier builder, mais à partir du dernier FROM, notre image finale sera préparée. La dernière étape consiste à copier l'artefact de notre assemblage à l'étape précédente dans l'image finale avec nginx. La taille de l'image a considérablement diminué :

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   2c6c5da07802   29 minutes ago   36MB

Exécutons le conteneur avec notre image et assurons-nous que tout fonctionne :

docker run -p8080:80 app

À l'aide de l'option -p8080:80, nous avons transféré le port 8080 de notre machine hôte vers le port 80 à l'intérieur du conteneur dans lequel nginx s'exécute. Ouvrir dans le navigateur http://localhost:8080/ et nous voyons notre application. Tout fonctionne !

Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

La réduction de la taille de l'image de 1.74 Go à 36 Mo réduit considérablement le temps nécessaire pour livrer votre application en production. Mais revenons au temps du montage.

$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/11 : FROM node:12.16.2-alpine3.11 as builder
Step 2/11 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
 ---> Using cache
Step 3/11 : WORKDIR /app
 ---> Using cache
Step 4/11 : COPY . .
Step 5/11 : RUN npm ci
added 1357 packages in 47.338s
Step 6/11 : RUN npm run build --prod
Date: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Time: 39948ms
 ---> 27f1479221e4
Step 7/11 : FROM nginx:stable-alpine
Step 8/11 : WORKDIR /app
 ---> Using cache
Step 9/11 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Using cache
Step 10/11 : COPY nginx/static.conf /etc/nginx/conf.d
 ---> Using cache
Step 11/11 : COPY --from=builder /app/dist/app .
Successfully built d201471c91ad
Successfully tagged app:latest

real 2m17.700s
user 0m0.000s
sys 0m0.000s

Changer l'ordre des calques

Nos trois premières étapes ont été mises en cache (indice Using cache). À la quatrième étape, tous les fichiers du projet sont copiés et à la cinquième étape, les dépendances sont installées RUN npm ci - jusqu'à 47.338s. Pourquoi réinstaller les dépendances à chaque fois si elles changent très rarement ? Voyons pourquoi ils n'ont pas été mis en cache. Le fait est que Docker vérifiera couche par couche si la commande et les fichiers qui lui sont associés ont changé. À la quatrième étape, nous copions tous les fichiers de notre projet, et parmi eux, bien sûr, il y a des modifications, donc Docker non seulement ne retire pas cette couche du cache, mais aussi toutes les suivantes ! Apportons quelques petites modifications au Dockerfile.

FROM node:12.16.2-alpine3.11 as builder
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build --prod

FROM nginx:1.17.10-alpine
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx/static.conf /etc/nginx/conf.d
COPY --from=builder /app/dist/app .

Tout d'abord, package.json et package-lock.json sont copiés, puis les dépendances sont installées, et seulement après cela, l'intégralité du projet est copiée. Par conséquent:

$ time docker build -t app .
Sending build context to Docker daemon 608.8kB
Step 1/12 : FROM node:12.16.2-alpine3.11 as builder
Step 2/12 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
 ---> Using cache
Step 3/12 : WORKDIR /app
 ---> Using cache
Step 4/12 : COPY package*.json ./
 ---> Using cache
Step 5/12 : RUN npm ci
 ---> Using cache
Step 6/12 : COPY . .
Step 7/12 : RUN npm run build --prod
Date: 2020-04-16T21:29:44.770Z - Hash: fffa0fddaa3425c55dd3 - Time: 38287ms
 ---> 1b9448c73558
Step 8/12 : FROM nginx:stable-alpine
Step 9/12 : WORKDIR /app
 ---> Using cache
Step 10/12 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Using cache
Step 11/12 : COPY nginx/static.conf /etc/nginx/conf.d
 ---> Using cache
Step 12/12 : COPY --from=builder /app/dist/app .
Successfully built a44dd7c217c3
Successfully tagged app:latest

real 0m46.497s
user 0m0.000s
sys 0m0.000s

46 secondes au lieu de 3 minutes – bien mieux ! Le bon ordre des calques est important : nous copions d’abord ce qui ne change pas, puis ce qui change rarement et enfin ce qui change souvent.

Ensuite, quelques mots sur l'assemblage d'images dans les systèmes CI/CD.

Utiliser des images précédentes pour le cache

Si nous utilisons une sorte de solution SaaS pour la construction, le cache Docker local peut être propre et frais. Pour donner au docker un endroit où récupérer les couches cuites, donnez-lui l'image construite précédente.

Prenons un exemple de création de notre application dans GitHub Actions. Nous utilisons cette configuration

on:
  push:
    branches:
      - master

name: Test docker build

jobs:
  deploy:
    name: Build
    runs-on: ubuntu-latest
    env:
      IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
      IMAGE_TAG: ${{ github.sha }}

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Login to GitHub Packages
      env:
        TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN

    - name: Build
      run: |
        docker build 
          -t $IMAGE_NAME:$IMAGE_TAG 
          -t $IMAGE_NAME:latest 
          .

    - name: Push image to GitHub Packages
      run: |
        docker push $IMAGE_NAME:latest
        docker push $IMAGE_NAME:$IMAGE_TAG

    - name: Logout
      run: |
        docker logout docker.pkg.github.com

L'image est assemblée et poussée vers les packages GitHub en deux minutes et 20 secondes :

Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

Modifions maintenant la construction afin qu'un cache soit utilisé en fonction des images construites précédentes :

on:
  push:
    branches:
      - master

name: Test docker build

jobs:
  deploy:
    name: Build
    runs-on: ubuntu-latest
    env:
      IMAGE_NAME: docker.pkg.github.com/${{ github.repository }}/app
      IMAGE_TAG: ${{ github.sha }}

    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: Login to GitHub Packages
      env:
        TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        docker login docker.pkg.github.com -u $GITHUB_ACTOR -p $TOKEN

    - name: Pull latest images
      run: |
        docker pull $IMAGE_NAME:latest || true
        docker pull $IMAGE_NAME-builder-stage:latest || true

    - name: Images list
      run: |
        docker images

    - name: Build
      run: |
        docker build 
          --target builder 
          --cache-from $IMAGE_NAME-builder-stage:latest 
          -t $IMAGE_NAME-builder-stage 
          .
        docker build 
          --cache-from $IMAGE_NAME-builder-stage:latest 
          --cache-from $IMAGE_NAME:latest 
          -t $IMAGE_NAME:$IMAGE_TAG 
          -t $IMAGE_NAME:latest 
          .

    - name: Push image to GitHub Packages
      run: |
        docker push $IMAGE_NAME-builder-stage:latest
        docker push $IMAGE_NAME:latest
        docker push $IMAGE_NAME:$IMAGE_TAG

    - name: Logout
      run: |
        docker logout docker.pkg.github.com

Nous devons d’abord vous expliquer pourquoi deux commandes sont lancées build. Le fait est que dans un assemblage en plusieurs étapes, l'image résultante sera un ensemble de calques de la dernière étape. Dans ce cas, les calques des calques précédents ne seront pas inclus dans l’image. Par conséquent, lors de l'utilisation de l'image finale d'une version précédente, Docker ne pourra pas trouver les couches prêtes à créer l'image avec nodejs (étape du constructeur). Afin de résoudre ce problème, une image intermédiaire est créée $IMAGE_NAME-builder-stage et est poussé vers les packages GitHub afin qu'il puisse être utilisé dans une version ultérieure en tant que source de cache.

Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

Le temps total de montage a été réduit à une minute et demie. Une demi-minute est consacrée à extraire les images précédentes.

Préimagerie

Une autre façon de résoudre le problème d'un cache Docker propre consiste à déplacer certaines couches dans un autre fichier Docker, à le créer séparément, à le transférer dans Container Registry et à l'utiliser comme parent.

Nous créons notre propre image nodejs pour créer une application angulaire. Créez Dockerfile.node dans le projet

FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add 
    python 
    make 
    g++

Nous collectons et diffusons une image publique dans Docker Hub :

docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latest

Maintenant, dans notre Dockerfile principal, nous utilisons l'image terminée :

FROM exsmund/node-for-angular:latest as builder
...

Dans notre exemple, le temps de construction n'a pas diminué, mais les images prédéfinies peuvent être utiles si vous avez de nombreux projets et devez installer les mêmes dépendances dans chacun d'eux.

Quelques conseils pour accélérer la création d’images Docker. Par exemple, jusqu'à 30 secondes

Nous avons examiné plusieurs méthodes pour accélérer la création d'images Docker. Si vous souhaitez que le déploiement soit rapide, essayez d'utiliser ceci dans votre projet :

  • réduire le contexte ;
  • utilisation de petites images parentales ;
  • assemblage en plusieurs étapes ;
  • changer l'ordre des instructions dans le Dockerfile pour utiliser efficacement le cache ;
  • mise en place d'un cache dans les systèmes CI/CD ;
  • création préliminaire d'images.

J'espère que l'exemple clarifiera le fonctionnement de Docker et que vous pourrez configurer votre déploiement de manière optimale. Afin de jouer avec les exemples de l'article, un référentiel a été créé https://github.com/devopsprodigy/test-docker-build.

Source: habr.com

Ajouter un commentaire