Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

Prima che una funzionalità arrivi in produzione, al giorno d'oggi, con orchestratori complessi e CI/CD, c'è un lungo percorso da affrontare, dal commit ai test e alla consegna. In passato era possibile trasferire nuovi file via FTP (ma nessuno lo fa più, giusto?), e il processo di "deploy" richiedeva secondi. Ora, invece, bisogna creare una merge request e attendere un bel po' di tempo prima che la funzionalità raggiunga gli utenti.

Parte di questo percorso è la costruzione dell'immagine Docker. A volte la costruzione richiede minuti, altre volte decine di minuti, il che è difficile da considerare normale. In questo articolo prenderemo come esempio un'applicazione semplice, la confezioneremo in un'immagine, applicheremo diversi metodi per accelerare la costruzione e esamineremo le sfide legate a questi metodi.

Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

Abbiamo una buona esperienza nella creazione e nel supporto di siti per i media: TASS, The Bell, "Novaja Gazeta", Republic… Non molto tempo fa abbiamo ampliato il nostro portafoglio, lanciando un sito in produzione Reminder. E mentre ci affrettavamo a completare nuove funzionalità e risolvere bug vecchi, il deploy lento è diventato un grosso problema.

Effettuiamo il deploy su GitLab. Creiamo immagini, le carichiamo nel GitLab Registry e le distribuiamo in produzione. La parte più lunga di questo processo è la costruzione delle immagini. Ad esempio: senza ottimizzazione, ogni costruzione del backend richiedeva 14 minuti.

Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

Alla fine ci siamo resi conto che non era possibile continuare in questo modo, e ci siamo messi a capire perché le immagini impiegassero così tanto tempo a essere costruite. Alla fine, siamo riusciti a ridurre il tempo di costruzione a 30 secondi!

Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

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

ng n app

Aggiungiamoci PWA (siamo pur sempre progressivi):

ng add @angular/pwa --project app

Mentre si scaricano milioni di pacchetti npm, diamo un'occhiata a come è strutturata l'immagine Docker. Docker offre la possibilità di confezionare applicazioni e farle girare in un ambiente isolato, chiamato contenitore. Grazie all'isolamento, è possibile eseguire molti contenitori contemporaneamente su un singolo server. I contenitori sono significativamente più leggeri delle macchine virtuali, in quanto vengono eseguiti direttamente sul kernel del sistema. Per eseguire un contenitore con la nostra applicazione, dobbiamo prima creare un'immagine in cui confezioniamo tutto il necessario per il funzionamento della nostra applicazione. In sostanza, l'immagine è un'istantanea del file system. Ad esempio, consideriamo il 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 nel file system e le sovrapporrà a quelle precedenti. Ogni comando crea il proprio strato. L'immagine finale è l'insieme degli strati uniti.

Cosa è importante sapere: ogni strato può essere memorizzato nella cache da Docker. Se nulla è cambiato dall'ultima build, Docker utilizzerà lo strato già pronto invece di eseguire il comando. Poiché il principale guadagno in velocità di build deriverebbe dall'uso della cache, nelle misurazioni della velocità di build ci concentreremo sulla costruzione dell'immagine con la cache pronta. Quindi, per passi:

  1. Rimuoviamo le immagini localmente, in modo che le esecuzioni precedenti non influenzino il test.
    docker rmi $(docker images -q)
  2. Eseguiamo la 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 la build per la seconda volta.
    time docker build -t app .

Se l'ambiente per la creazione delle immagini è impostato correttamente (di cui parleremo brevemente dopo), Docker avrà già a bordo vari strati di cache quando avviamo la build. Il nostro obiettivo è imparare a utilizzare la cache in modo che la build si svolga nel modo più rapido possibile. Poiché presumiamo che l'avvio della build senza cache avvenga solo una volta — la prima — possiamo ignorare la lentezza di questa prima esecuzione. È il secondo avvio della build che ci interessa nei test, quando le cache sono già attive e siamo pronti a infornare il nostro dolce. Tuttavia, alcuni suggerimenti influenzeranno anche la prima build.

Posizioniamo il Dockerfile descritto sopra nella cartella del progetto e avviamo la build. Tutti gli elenchi forniti sono abbreviati per facilitare la lettura.

$ time docker build -t app .
Invio del contesto di build al demone Docker 409MB
Passo 1/5 : FROM node:12.16.2
Stato: Immagine più recente scaricata per node:12.16.2
Passo 2/5 : WORKDIR /app
Passo 3/5 : COPY . .
Passo 4/5 : RUN npm ci
aggiunti 1357 pacchetti in 22.47s
Passo 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 37581ms
Costruzione avvenuta con successo c8c279335f46
Tagging avvenuto con successo app:latest

reale 5m4.541s
utente 0m0.000s
sistema 0m0.000s

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

$ time docker build -t app .
Invio del contesto di build al demone Docker 409MB
Passo 1/5 : FROM node:12.16.2
Passo 2/5 : WORKDIR /app
 ---> Utilizzo della cache
Passo 3/5 : COPY . .
Passo 4/5 : RUN npm ci
aggiunti 1357 pacchetti in 22.47s
Passo 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 37902ms
Costruzione avvenuta con successo 79f335df92d3
Tagging avvenuto con successo app:latest

reale 3m33.262s
utente 0m0.000s
sistema 0m0.000s

Per vedere se siamo riusciti a creare l'immagine, eseguiamo il comando docker images:

REPOSITORY   TAG      IMAGE ID       CREATED              SIZE
app          latest   79f335df92d3   Circa un minuto fa   1.74GB

Prima di costruire, Docker prende tutti i file nel contesto corrente e li invia al suo demone Invio del contesto di build al demone Docker 409MB. Il contesto per la build viene specificato come ultimo argomento del comando build. Nel nostro caso, si tratta della directory corrente - «.», - e Docker porta tutto ciò che abbiamo in questa cartella. 409 MB sono molti: pensiamo a come risolvere questo problema.

Riduciamo il contesto

Per ridurre il contesto, ci sono due opzioni. O mettiamo tutti i file necessari per la costruzione in una cartella separata e indichiamo a Docker di utilizzare proprio quella cartella come contesto. Questo potrebbe non essere sempre comodo, quindi c'è la possibilità di specificare delle eccezioni: cosa non deve essere portato nel contesto. Per farlo, mettiamo nel progetto un file .dockerignore e indichiamo cosa non serve per la build:

.git
/node_modules

e avviamo nuovamente la costruzione:

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

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

607.2 Kbyte - molto meglio rispetto a 409 MB. Inoltre, abbiamo ridotto la dimensione dell'immagine da 1.74 a 1.38GB:

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

Proviamo a ridurre ulteriormente la dimensione dell'immagine.

Utilizziamo Alpine

Un altro modo per risparmiare sulla dimensione dell'immagine è utilizzare una piccola immagine madre. L'immagine madre è l'immagine sulla quale viene costruita la nostra immagine. Lo strato inferiore è specificato nel comando DA nel Dockerfile. Nel nostro caso, utilizziamo un'immagine basata su Ubuntu, già dotata di nodejs. E pesa ...

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

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

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 costruire l'applicazione. Sì, Angular non si costruisce senza Python ¯(°_o)\/¯

Ma la dimensione dell'immagine è scesa di 150 MB:

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

Andiamo oltre.

Build multi-stage

Non tutto ciò che c'è 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

Utilizzando docker run app ls -lah abbiamo avviato un container basato sulla nostra immagine app e abbiamo eseguito al suo interno il comando ls -lah, dopo di che il container ha terminato il suo lavoro.

In produzione abbiamo bisogno solo della cartella dist. Dobbiamo comunque trasferire i file all'esterno. Possiamo avviare un server HTTP su nodejs. Ma faremo qualcosa di più semplice. Indovinate la parola russa che ha quattro lettere «ы». Giusto! Ынжыныксы. Prendiamo l'immagine con nginx, ci mettiamo dentro la cartella dist e una piccola configurazione:

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

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

Tutto questo ci aiuterà a fare un multi-stage build. 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 il proprio stadio di costruzione. Il primo lo abbiamo chiamato builder, mentre a partire dall'ultimo FROM verrà preparata la nostra immagine finale. L'ultimo passo è copiare l'artefatto della nostra build nella fase precedente nell'immagine finale con nginx. La dimensione dell'immagine è diminuita significativamente:

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

Facciamo partire il container con la nostra immagine e assicuriamoci che tutto funzioni:

docker run -p8080:80 app

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

Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

Ridurre la dimensione dell'immagine da 1.74 GB a 36 MB abbrevia significativamente il tempo di consegna della tua applicazione in produzione. Ma torniamo al tempo di costruzione.

$ 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

Cambiando l'ordine dei layer

I primi tre passaggi sono stati memorizzati nella cache (suggerimento Using cache). Nel quarto passaggio vengono copiati tutti i file del progetto e nel quinto passaggio vengono installate le dipendenze RUN npm ci — un totale di 47.338 secondi. Perché ricominciare ogni volta a installare le dipendenze, se cambiano molto raramente? Vediamo perché non sono state memorizzate nella cache. Il fatto è che Docker controlla strato per strato se il comando e i file ad esso correlati sono cambiati. Nel quarto passaggio copiamo tutti i file del nostro progetto e tra di essi ci sono naturalmente delle modifiche, quindi Docker non solo non prende dalla cache questo strato, ma nemmeno 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 .

Prima si copiano package.json e package-lock.json, poi si installano le dipendenze e solo dopo si copia l'intero progetto. Come 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 anziché 3 minuti — decisamente meglio! È importante il giusto ordine degli strati: prima copiamo quello che non cambia, poi quello che cambia raramente e infine quello che cambia spesso.

Passiamo ora a dire qualcosa sulla creazione di immagini nei sistemi CI/CD.

Utilizzo di immagini precedenti per la cache

Se utilizziamo per la costruzione una soluzione SaaS, allora la cache locale di Docker potrebbe essere pulita e fresca. Per consentire a Docker di recuperare gli strati costruiti, fornitegli l'immagine precedentemente creata.

Prendiamo ad esempio la costruzione della nostra applicazione 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 caricata in GitHub Packages in due minuti e 20 secondi:

Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

Ora modifichiamo la build per utilizzare la cache basata sulle 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. buildLa questione è che in una build multi-stadio, l'immagine risultante sarà un insieme di livelli dell'ultimo stadio. In questo modo, i livelli degli stadi precedenti non verranno inclusi nell'immagine. Pertanto, utilizzando l'immagine finale proveniente da una build precedente, Docker non sarà in grado di trovare i livelli già pronti per costruire l'immagine con nodejs (stadio builder). Per risolvere questo problema, viene creato un'immagine intermedia. $IMAGE_NAME-builder-stage e viene caricata in GitHub Packages, affinché possa essere utilizzata in una build successiva come sorgente della cache.

Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

Il tempo totale di build è stato ridotto a un minuto e mezzo. Mezzo minuto viene speso per recuperare le immagini precedenti.

Creazione preventiva delle immagini

Un altro modo per risolvere il problema della cache pulita di Docker è spostare parte degli strati in un altro Dockerfile, compilarlo separatamente, caricarlo nel Container Registry e usarlo come genitore.

Creiamo la nostra immagine Node.js per la compilazione dell'applicazione Angular. Creiamo nel progetto Dockerfile.node

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

Costruiamo e carichiamo un'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 costruzione non è diminuito, ma le immagini create in precedenza possono essere utili se hai molti progetti e in ognuno di essi devi installare le stesse dipendenze.

Alcuni consigli su come velocizzare la costruzione delle immagini Docker. Ad esempio, fino a 30 secondi

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

  • riduzione del contesto;
  • utilizzo di piccole immagini genitorial;
  • build multi-stadio;
  • modifica dell'ordine delle istruzioni nel Dockerfile per utilizzare efficacemente la cache;
  • configurazione della cache nei sistemi CI/CD;
  • creazione preventiva delle immagini.

Spero che con questo esempio diventi più chiaro come funziona Docker e che tu possa ottimizzare il tuo deploy. 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, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster