Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Prima che una funzione arrivi in produzione, al giorno d'oggi, tra orchestratori complessi e CI/CD, è necessario affrontare un lungo percorso, dal commit ai test e alla consegna. Un tempo era possibile caricare nuovi file via FTP (ma non si fa più così, giusto?), e il processo di "deploy" richiedeva solo pochi secondi. Ora invece è necessario creare una richiesta di merge e aspettare un bel po' di tempo affinché la funzione arrivi agli utenti.

Una parte di questo percorso è la costruzione dell'immagine Docker. A volte la costruzione dura minuti, altre volte decine di minuti, il che è difficile da definire normale. In questo articolo prenderemo un'applicazione semplice da impacchettare in un'immagine, applicheremo alcuni metodi per velocizzare la costruzione e esamineremo le peculiarità di questi metodi.

Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Abbiamo una buona esperienza nella creazione e manutenzione di siti per i media: ТАСС, The Bell, "Новая газета", Republic… Non molto tempo fa abbiamo ampliato il nostro portafoglio, lanciando in produzione il sito Reminder. E mentre affinavamo rapidamente nuove funzionalità e sistemavamo bug preesistenti, il lento deploy è diventato un grande problema.

Effettuiamo il deploy su GitLab. Creiamo immagini, le pubblichiamo nel GitLab Registry e le distribuiamo in produzione. In questo elenco, la cosa più lunga è la costruzione delle immagini. Per esempio: senza ottimizzazione, ogni costruzione del backend richiedeva 14 minuti.

Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Alla fine è diventato chiaro che non si poteva continuare così, e ci siamo messi a capire perché le immagini venivano costruite in così tanto tempo. Alla fine siamo riusciti a ridurre il tempo di costruzione a 30 secondi!

Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Per questo articolo, al fine di non legarci all'ambiente di Reminder, considereremo un esempio di costruzione di un'applicazione vuota su Angular. Quindi, creiamo la nostra applicazione:

ng n app

Aggiungiamo il PWA (siamo pur sempre progressivi):

ng add @angular/pwa --project app

Mentre si scaricano milioni di pacchetti npm, vediamo come è strutturata un'immagine docker. Docker consente di impacchettare applicazioni e di eseguirle in un ambiente isolato, chiamato contenitore. Grazie all'isolamento, è possibile eseguire più contenitori contemporaneamente su un server. I contenitori sono significativamente più leggeri delle macchine virtuali, poiché vengono eseguiti direttamente sul kernel del sistema. Per avviare un contenitore con la nostra applicazione, prima dobbiamo creare un'immagine, in cui impacchettiamo tutto ciò che è necessario per far funzionare la nostra applicazione. In sostanza, l'immagine è un'istantanea del file system. Ad esempio, consideriamo questo Dockerfile:

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

Il Dockerfile è un insieme di istruzioni; eseguendo ciascuna di esse, Docker salverà le modifiche nella file system e le sovrapporrà a quelle precedenti. Ogni comando crea il proprio layer. Un'immagine finale è quindi un'unione di questi layer.

È importante sapere che Docker può memorizzare in cache ciascun layer. Se nulla è cambiato rispetto alla costruzione precedente, Docker utilizzerà il layer già pronto invece di eseguire di nuovo il comando. Poiché il principale incremento nella velocità di costruzione si basa sull'uso della cache, durante le misurazioni della velocità di costruzione ci concentreremo sulla costruzione dell'immagine con la cache già pronta. Procediamo passo dopo passo:

  1. Eliminiamo le immagini localmente, affinché i precedenti avvii non influenzino il test.
    docker rmi $(docker images -q)
  2. Eseguiamo il build per la prima volta.
    time docker build -t app .
  3. Modifichiamo il file src/index.html per simulare il lavoro di uno sviluppatore.
  4. Eseguiamo il build per la seconda volta.
    time docker build -t app .

Se l'ambiente per la costruzione delle immagini è configurato correttamente (di questo parlerò tra poco), Docker avrà già una serie di cache pronte al momento di avviare la costruzione. Il nostro obiettivo è imparare a utilizzare la cache in modo tale che la costruzione avvenga il più rapidamente possibile. Poiché si presume che l'avvio della costruzione senza cache avvenga solo una volta - la prima volta, possiamo ignorare quanto sia stato lento quel primo avvio. Nei test, è importante per noi il secondo avvio della costruzione, quando le cache sono già attive e siamo pronti a sfornare la nostra torta. Tuttavia, alcuni consigli influenzeranno anche la prima costruzione.

Posizioniamo il Dockerfile, descritto sopra, nella cartella del progetto e avviamo la costruzione. Tutti i listati sopra sono stati abbreviati per facilitare la lettura.

$ 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

Modifichiamo il contenuto di src/index.html e avviamo per la seconda volta.

$ 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

Per controllare se abbiamo ottenuto l'immagine, eseguiamo il comando docker images:

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

Prima della costruzione, Docker prende tutti i file nel contesto attuale e li invia al suo demone Sending build context to Docker daemon 409MB. Il contesto per la build viene specificato come ultimo argomento del comando build. Nel nostro caso, si tratta della directory corrente — «.», — e Docker trascina tutto ciò che abbiamo in questa cartella. 409 MB sono molti: pensiamo insieme a come correggere questo.

Riduciamo il contesto

Per ridurre il contesto, ci sono due opzioni. Possiamo mettere tutti i file necessari per la build in una cartella separata e indicare a Docker di usare proprio quella per il contesto. Questo potrebbe non essere sempre comodo, quindi abbiamo la possibilità di indicare delle eccezioni: cosa non deve essere inclusa nel contesto. A questo scopo, creiamo un file .dockerignore nel progetto e indichiamo ciò che non serve per la build:

.git
/node_modules

e rilanciamo la build ancora una volta:

$ time docker build -t app .
Invio del contesto di build al demone Docker 607.2kB
Passaggio 1/5 : FROM node:12.16.2
Passaggio 2/5 : WORKDIR /app
 ---> Utilizzando la cache
Passaggio 3/5 : COPY . .
Passaggio 4/5 : RUN npm ci
aggiunti 1357 pacchetti in 22.47s
Passaggio 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 37313ms
Build riuscito 4942f010792a
Tagging riuscito app:latest

reale 1m47.763s
utente 0m0.000s
sys 0m0.000s

607.2 KB — molto meglio di 409 MB. Inoltre, abbiamo ridotto le dimensioni dell'immagine da 1.74 a 1.38 GB:

REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
app          latest   4942f010792a   3 minuti fa   1.38GB

Proviamo a ridurre ulteriormente le dimensioni dell'immagine.

Utilizziamo Alpine

Un altro modo per risparmiare sulle dimensioni dell'immagine è utilizzare un'immagine base più piccola. L'immagine base è l'immagine sulla quale viene costruita la nostra immagine. Lo strato inferiore è specificato con il comando DA nel Dockerfile. Nel nostro caso, stiamo usando un'immagine basata su Ubuntu, in cui è già presente nodejs. E pesa …

$ docker images -a | grep node
node 12.16.2 406aa3abbc6c 17 minuti fa 916MB

… quasi un gigabyte. Possiamo ridurre notevolmente il peso utilizzando un'immagine basata su Alpine Linux. Alpine è un Linux molto leggero. L'immagine Docker per nodejs basata su alpine pesa solo 88.5 MB. Quindi, sostituiamo la nostra immagine pesante:

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

Abbiamo dovuto installare alcune cose necessarie per la build dell'applicazione. Sì, Angular non si compila senza Python ¯(°_o)/¯

Ma, ancora, le dimensioni dell'immagine sono diminuite di 150 MB:

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   aa031edc315a   22 minuti fa   761MB

Andiamo ancora oltre.

Build multi-stadio

Non tutto ciò che è presente nell'immagine ci serve in produzione.

$ 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

Con docker run app ls -lah è stato eseguito un contenitore basato sulla nostra immagine app e abbiamo eseguito al suo interno il comando ls -lah, dopo di che il contenitore ha terminato la sua esecuzione.

In produzione ci serve solo la cartella dist. A questo punto, dobbiamo fornire i file all'esterno. Possiamo avviare un qualche server HTTP su nodejs. Ma lo faremo in modo più semplice. Indovina una parola russa che ha quattro lettere «ы». Esatto! Ынжыныксы. Prendiamo un'immagine con nginx, mettiamo dentro la cartella dist e un piccolo file di configurazione:

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

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

Per eseguire tutto questo ci aiuterà la build multi-stadio. Modifichiamo il nostro 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 .

Ora abbiamo due istruzioni DA nel Dockerfile, ognuna delle quali avvia la propria fase di build. La prima l'abbiamo chiamata builder, e da quel momento in poi l'ultimo FROM verrà utilizzato per preparare la nostra immagine finale. Nell'ultimo passaggio, copiamo l'artefatto della nostra build dalla fase precedente nell'immagine finale con nginx. Le dimensioni dell'immagine sono diminuite notevolmente:

REPOSITORY   TAG      IMAGE ID       CREATED          SIZE
app          latest   2c6c5da07802   29 minuti fa   36MB

Eseguiamo un contenitore con la nostra immagine e assicuriamoci che tutto funzioni:

docker run -p8080:80 app

Con l'opzione -p8080:80 abbiamo esportato la porta 8080 sulla nostra macchina host alla porta 80 all'interno del contenitore, dove gira nginx. Apriamo nel browser http://localhost:8080/ e vediamo la nostra applicazione. Funziona tutto!

Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Ridurre le dimensioni dell'immagine da 1.74 GB a 36 MB accorcia significativamente il tempo necessario per portare la tua applicazione in produzione. Ma torniamo al tempo di build.

$ time docker build -t app .
Invio del contesto di build al demone Docker 608.8kB
Passaggio 1/11 : FROM node:12.16.2-alpine3.11 as builder
Passaggio 2/11 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
 ---> Utilizzando la cache
Passaggio 3/11 : WORKDIR /app
 ---> Utilizzando la cache
Passaggio 4/11 : COPY . .
Passaggio 5/11 : RUN npm ci
aggiunti 1357 pacchetti in 47.338s
Passaggio 6/11 : RUN npm run build --prod
Data: 2020-04-16T21:16:03.899Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 39948ms
 ---> 27f1479221e4
Passaggio 7/11 : FROM nginx:stable-alpine
Passaggio 8/11 : WORKDIR /app
 ---> Utilizzando la cache
Passaggio 9/11 : RUN rm /etc/nginx/conf.d/default.conf
 ---> Utilizzando la cache
Passaggio 10/11 : COPY nginx/static.conf /etc/nginx/conf.d
 ---> Utilizzando la cache
Passaggio 11/11 : COPY --from=builder /app/dist/app .
Build riuscito d201471c91ad
Tagging riuscito app:latest

reale 2m17.700s
utente 0m0.000s
sys 0m0.000s

Cambiamo l'ordine dei layer

I primi tre passaggi sono stati memorizzati nella cache (indicazione Utilizzando la cache). Nel quarto passaggio vengono copiati tutti i file del progetto e nel quinto vengono installate le dipendenze RUN npm ci — un intero 47.338s. Perché reinstallare ogni volta le dipendenze, se cambiano molto raramente? Analizziamo perché non sono state memorizzate nella cache. Il fatto è che Docker controlla layer per layer, se il comando e i file associati a esso sono cambiati. Nel quarto passaggio copiamo tutti i file del nostro progetto e tra questi, naturalmente, ci sono cambiamenti, quindi Docker non solo non utilizza dalla cache questo layer, ma neppure tutti i successivi! Apportiamo alcune piccole modifiche al 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 .

Inizialmente vengono copiati package.json e package-lock.json, quindi vengono installate le dipendenze e solo dopo viene copiato l'intero progetto. Risultato:

$ 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 secondi invece di 3 minuti — notevolmente meglio! È importante seguire l'ordine corretto dei layer: prima copiamo ciò che non cambia, poi ciò che cambia raramente e infine ciò che cambia spesso.

Parliamo ora brevemente della creazione di immagini nei sistemi CI/CD.

Utilizzo delle immagini precedenti per la cache

Se stiamo utilizzando una soluzione SaaS per la creazione, la cache locale di Docker può risultare pulita e aggiornata. Per consentire a Docker di avere da dove recuperare i layer già creati, fornitegli un'immagine precedente costruita.

Prendiamo ad esempio la build della nostra app in GitHub Actions. Utilizziamo questa configurazione

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'immagine viene costruita e inviata a GitHub Packages in due minuti e 20 secondi:

Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Ora modifichiamo la build in modo che utilizzi la cache basata su immagini precedentemente costruite:

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

Per iniziare, è necessario spiegare perché vengono eseguite due comandi build. Il fatto è che in una build multi-stadio, l'immagine risultante sarà un insieme di layer dell'ultimo stadio. In questo modo, i layer degli stadi precedenti non verranno inclusi nell'immagine. Pertanto, quando si utilizza l'immagine finale dalla precedente build, Docker non sarà in grado di trovare i layer già pronti per costruire l'immagine con nodejs (stadio builder). Per risolvere questo problema, si crea un'immagine intermedia $IMAGE_NAME-builder-stage e viene inviata a GitHub Packages, in modo che possa essere utilizzata nella build successiva come fonte di cache.

Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Il tempo totale di costruzione è stato ridotto a un minuto e mezzo. Trenta secondi vengono spesi per caricare le immagini precedenti.

Creazione preliminare delle immagini

Un altro modo per risolvere il problema della cache pulita di Docker è spostare alcuni layer in un altro Dockerfile, costruirlo separatamente, inviarlo al Container Registry e utilizzarlo come base.

Creiamo la nostra immagine nodejs per costruire applicazioni Angular. Creiamo un Dockerfile.node nel progetto

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

Costruiamo e pubblichiamo l'immagine pubblica su Docker Hub:

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

Ora nel nostro Dockerfile principale utilizziamo l'immagine pronta:

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

Nel nostro esempio, il tempo di build non è diminuito, ma le immagini pre-create possono essere utili se hai molti progetti e in ciascuno di essi devi installare le stesse dipendenze.

Alcuni consigli per accelerare la creazione di immagini Docker. Ad esempio, fino a 30 secondi

Abbiamo esaminato diversi metodi per accelerare la build delle immagini Docker. Se desideri che il deploy avvenga rapidamente, prova ad applicare nel tuo progetto:

  • riduzione del contesto;
  • utilizzo di immagini genitore più piccole;
  • build multistadio;
  • cambiamento dell'ordine delle istruzioni nel Dockerfile per utilizzare il cache in modo efficace;
  • configurazione della cache nei sistemi CI/CD;
  • creazione anticipata delle immagini.

Spero che con l'esempio sarà più chiaro come funziona Docker e potrai configurare il tuo deploy in modo ottimale. Per provare gli esempi dell'articolo, è stato creato un repository https://github.com/devopsprodigy/test-docker-build.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS 🔥 Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS | ProHoster