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.

Abbiamo una buona esperienza nella creazione e nel supporto di siti per i media: , , , … Non molto tempo fa abbiamo ampliato il nostro portafoglio, lanciando un sito in produzione . 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.
![]()
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!
![]()
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 appAggiungiamoci PWA (siamo pur sempre progressivi):
ng add @angular/pwa --project appMentre 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 --prodIl 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:
- Rimuoviamo le immagini localmente, in modo che le esecuzioni precedenti non influenzino il test.
docker rmi $(docker images -q) - Eseguiamo la build per la prima volta.
time docker build -t app . - Modifichiamo il file src/index.html per simulare il lavoro di uno sviluppatore.
- 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.000sModifichiamo 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.000sPer 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.74GBPrima 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_modulese 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.000s607.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.38GBProviamo 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 --prodAbbiamo 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 761MBAndiamo 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.jsonUtilizzando 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 36MBFacciamo partire il container con la nostra immagine e assicuriamoci che tutto funzioni:
docker run -p8080:80 appCon 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 e vediamo la nostra applicazione. Funziona tutto!

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.000sCambiando 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.000s46 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.comL'immagine viene costruita e caricata in GitHub Packages in due minuti e 20 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.comPer 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.

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:latestOra 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.

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 .
Fonte: habr.com
