Antes que uma função vá para produção, em nossos tempos de orquestradores e CI/CD complexos, ela passa por um longo caminho desde o commit até os testes e entrega. Antes, era possível simplesmente enviar novos arquivos via FTP (o que ninguém faz mais hoje em dia, certo?), e o processo de 'deploy' levava segundos. Agora, é necessário criar um merge request e esperar um bom tempo até que a função chegue aos usuários.
Parte desse caminho é a construção da imagem Docker. Às vezes, a construção leva minutos, outras vezes, dezenas de minutos, o que é difícil de considerar normal. Neste artigo, pegaremos um aplicativo simples, que empacotaremos em uma imagem, aplicaremos alguns métodos para acelerar a construção e discutiremos as nuances do funcionamento desses métodos.

Temos uma boa experiência na criação e manutenção de sites de mídia: , , , ... Recentemente ampliamos nosso portfólio, lançando o site da produção . E enquanto rapidamente aprimorávamos novas funções e corrigíamos velhos bugs, o deploy lento se tornou um grande problema.
Fazemos o deploy no GitLab. Construímos as imagens, fazemos push no GitLab Registry e as distribuímos em produção. O item que leva mais tempo nessa lista é a construção de imagens. Por exemplo: sem otimização, cada construção do backend levava 14 minutos.
![]()
No final, ficou claro que não podíamos continuar assim, e nos sentamos para entender por que as imagens estavam levando tanto tempo para serem construídas. No final, conseguimos reduzir o tempo de construção para 30 segundos!
![]()
Para este artigo, a fim de não nos prendermos ao ambiente do Reminder, vamos considerar um exemplo de construção de um aplicativo vazio no Angular. Então, vamos criar nosso aplicativo:
ng n appAdicionamos a ele PWA (somos progressivos):
ng add @angular/pwa --project appEnquanto um milhão de pacotes npm estão sendo baixados, vamos entender como a imagem do docker é estruturada. O Docker oferece a possibilidade de empacotar aplicativos e executá-los em um ambiente isolado chamado contêiner. Graças à isolação, é possível executar muitos contêineres ao mesmo tempo em um único servidor. Os contêineres são significativamente mais leves do que máquinas virtuais, pois são executados diretamente no núcleo do sistema. Para executar um contêiner com nosso aplicativo, precisamos primeiro criar uma imagem, na qual empacotaremos tudo que é necessário para o funcionamento do nosso aplicativo. Essencialmente, a imagem é uma cópia do sistema de arquivos. Por exemplo, vamos considerar o Dockerfile:
FROM node:12.16.2
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build --prodUm Dockerfile é um conjunto de instruções; ao executar cada uma delas, o Docker salva as alterações no sistema de arquivos e as aplica às anteriores. Cada comando gera sua própria camada. A imagem pronta é a combinação de camadas.
É importante saber: cada camada do Docker pode ser armazenada em cache. Se nada mudou desde a última construção, o Docker usará a camada que já está pronta em vez de executar o comando novamente. Como o ganho principal na velocidade de construção vem do uso do cache, nas medições iremos nos concentrar na construção da imagem com o cache já existente. Assim, em etapas:
- Remova as imagens localmente, para que execuções anteriores não influenciem o teste.
docker rmi $(docker images -q) - Vamos executar a construção pela primeira vez.
time docker build -t app . - Alteramos o arquivo src/index.html — simulamos o trabalho de um programador.
- Vamos executar a construção pela segunda vez.
time docker build -t app .
Se o ambiente para construir imagens estiver configurado corretamente (sobre isso falaremos um pouco mais abaixo), o Docker já terá vários caches prontos na hora de construir. Nossa tarefa é aprender a usar o cache para que a construção ocorra o mais rapidamente possível. Como presumimos que a construção sem cache acontece apenas uma vez — a primeira — podemos desconsiderar quão lenta foi essa primeira vez. Em nossos testes, o que importa é a segunda execução da construção, quando os caches já estão quentes e estamos prontos para assar nosso bolo. No entanto, algumas dicas afetarão também a primeira construção.
Coloque o Dockerfile, descrito acima, na pasta do projeto e inicie a construção. Todos os listagens fornecidas foram abreviadas para facilitar a leitura.
$ time docker build -t app .
Enviando contexto de construção para o daemon Docker 409MB
Passo 1/5 : FROM node:12.16.2
Status: Imagem mais nova baixada para node:12.16.2
Passo 2/5 : WORKDIR /app
Passo 3/5 : COPY . .
Passo 4/5 : RUN npm ci
adicionados 1357 pacotes em 22.47s
Passo 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:20:09.664Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 37581ms
Construído com sucesso c8c279335f46
Marcado com sucesso app:latest
real 5m4.541s
user 0m0.000s
sys 0m0.000sAltere o conteúdo de src/index.html e inicie a segunda execução.
$ time docker build -t app .
Enviando contexto de construção para o daemon Docker 409MB
Passo 1/5 : FROM node:12.16.2
Passo 2/5 : WORKDIR /app
---> Usando cache
Passo 3/5 : COPY . .
Passo 4/5 : RUN npm ci
adicionados 1357 pacotes em 22.47s
Passo 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:26:26.587Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 37902ms
Construído com sucesso 79f335df92d3
Marcado com sucesso app:latest
real 3m33.262s
user 0m0.000s
sys 0m0.000sPara verificar se conseguimos gerar a imagem, execute o comando docker images:
REPOSITÓRIO TAG ID DE IMAGEM CRIADO TAMANHO
app latest 79f335df92d3 Há cerca de um minuto atrás 1.74GBAntes de construir, o Docker pega todos os arquivos no contexto atual e os envia para seu daemon Enviando contexto de construção para o daemon do Docker 409MB. O contexto de construção é especificado como o último argumento do comando build. No nosso caso, este é o diretório atual — ".", e o Docker puxa tudo que temos nesta pasta. 409 Megabytes é muito: vamos pensar em como corrigir isso.
Reduzindo o contexto
Para reduzir o contexto, há duas opções. Ou colocamos todos os arquivos necessários para a construção em uma pasta separada e especificamos o contexto para o Docker exatamente essa pasta. Isso pode não ser sempre conveniente, por isso há a possibilidade de especificar exceções: o que não deve ser incluído no contexto. Para isso, colocamos um arquivo .dockerignore no projeto e especificamos o que não é necessário para a construção:
.git
/node_modulese vamos executar a construção novamente:
$ time docker build -t app .
Enviando contexto de construção para o daemon do Docker 607.2kB
Passo 1/5 : FROM node:12.16.2
Passo 2/5 : WORKDIR /app
---> Usando cache
Passo 3/5 : COPY . .
Passo 4/5 : RUN npm ci
adicionado 1357 pacotes em 22.47s
Passo 5/5 : RUN npm run build --prod
Data: 2020-04-16T19:33:54.338Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 37313ms
Construído com sucesso 4942f010792a
Marcado com sucesso app:latest
real 1m47.763s
user 0m0.000s
sys 0m0.000s607.2 Kbytes — muito melhor do que 409 Megabytes. Além disso, diminuímos o tamanho da imagem de 1.74 para 1.38GB:
REPOSITÓRIO TAG ID DA IMAGEM CRIADO TAMANHO
app latest 4942f010792a há 3 minutos 1.38GBVamos tentar reduzir ainda mais o tamanho da imagem.
Usando Alpine
Outra forma de economizar no tamanho da imagem — é usar uma imagem base menor. A imagem base é a imagem na qual nossa imagem é construída. A camada inferior é especificada pelo comando FROM no Dockerfile. No nosso caso, estamos usando uma imagem baseada no Ubuntu, que já tem nodejs. E pesa …
$ docker images -a | grep node
node 12.16.2 406aa3abbc6c há 17 minutos 916MB… quase um gigabyte. Podemos reduzir bastante o tamanho usando uma imagem baseada no Alpine Linux. Alpine — é um Linux muito pequeno. A imagem Docker para nodejs baseada em Alpine pesa apenas 88.5 MB. Portanto, vamos substituir nossa imagem pesada:
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 --prodTivemos que instalar algumas coisas necessárias para construir o aplicativo. Sim, Angular não se compila sem Python ¯(°_o)\/¯
Mas o tamanho da imagem reduziu em 150 MB:
REPOSITÓRIO TAG ID DA IMAGEM CRIADO TAMANHO
app latest aa031edc315a há 22 minutos 761MBVamos mais longe.
Construção em múltiplas etapas
Nem tudo que está na imagem é necessário em produção.
$ 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.jsonCom docker run app ls -lah lançamos um contêiner com base na nossa imagem app e executamos o comando nele ls -lah, após o qual o contêiner encerrou sua operação.
No ambiente de produção, precisamos apenas da pasta dist. No entanto, os arquivos precisam ser disponibilizados externamente. Podemos iniciar um servidor HTTP usando nodejs. Mas vamos fazer mais simples. Adivinhe a palavra russa que contém quatro letras «ы». Corretamente! Ынжыныксы. Vamos pegar uma imagem com nginx e adicionar a pasta dist e uma pequena configuração:
server {
listen 80 default_server;
server_name localhost;
charset utf-8;
root /app/dist;
location / {
try_files $uri $uri/ /index.html;
}
}Tudo isso será facilitado pelo multi-stage build. Vamos modificar nosso 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 .Agora temos duas instruções FROM no Dockerfile, cada uma delas executa sua própria etapa de construção. A primeira nós chamamos de builder, e a partir da última instrução FROM, nosso contêiner final será preparado. A última etapa é copiar o artefato da nossa construção na etapa anterior para a imagem final com nginx. O tamanho da imagem foi significativamente reduzido:
REPOSITORY TAG IMAGE ID CREATED SIZE
app latest 2c6c5da07802 29 minutes ago 36MBVamos rodar o contêiner com nossa imagem e verificar se tudo está funcionando:
docker run -p8080:80 appCom a opção -p8080:80, conectamos a porta 8080 na nossa máquina host à porta 80 dentro do contêiner, onde o nginx está rodando. Abrimos no navegador e vemos nosso aplicativo. Tudo funcionando!

A redução do tamanho da imagem de 1.74 GB para 36 MB diminui significativamente o tempo de entrega do seu aplicativo em produção. Mas vamos voltar ao tempo de construção.
$ 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.000sMudamos a ordem das camadas
Os primeiros três passos foram armazenados em cache (dica Using cache). Na quarta etapa, todos os arquivos do projeto são copiados e na quinta etapa, as dependências são instaladas RUN npm ci — inteiros 47,338s. Por que reinstalar as dependências toda vez, se elas mudam raramente? Vamos entender por que elas não foram armazenadas em cache. A questão é que o Docker verifica cada camada, camada por camada, para ver se o comando e os arquivos associados mudaram. Na quarta etapa, copiamos todos os arquivos do nosso projeto, e entre eles, claro, há mudanças, portanto, o Docker não apenas não utiliza o cache para esta camada, mas também para todas as subsequentes! Vamos fazer pequenas alterações no 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 .Primeiro, os arquivos package.json e package-lock.json são copiados, em seguida, as dependências são instaladas, e só depois disso todo o projeto é copiado. Como resultado:
$ time docker build -t app .
Enviando o contexto de construção para o daemon Docker 608.8kB
Etapa 1/12 : FROM node:12.16.2-alpine3.11 as builder
Etapa 2/12 : RUN apk --no-cache --update --virtual build-dependencies add python make g++
---> Usando cache
Etapa 3/12 : WORKDIR /app
---> Usando cache
Etapa 4/12 : COPY package*.json ./
---> Usando cache
Etapa 5/12 : RUN npm ci
---> Usando cache
Etapa 6/12 : COPY . .
Etapa 7/12 : RUN npm run build --prod
Data: 2020-04-16T21:29:44.770Z - Hash: fffa0fddaa3425c55dd3 - Tempo: 38287ms
---> 1b9448c73558
Etapa 8/12 : FROM nginx:stable-alpine
Etapa 9/12 : WORKDIR /app
---> Usando cache
Etapa 10/12 : RUN rm /etc/nginx/conf.d/default.conf
---> Usando cache
Etapa 11/12 : COPY nginx/static.conf /etc/nginx/conf.d
---> Usando cache
Etapa 12/12 : COPY --from=builder /app/dist/app .
Imagem construída com sucesso a44dd7c217c3
Marcada com sucesso app:latest
real 0m46.497s
user 0m0.000s
sys 0m0.000s46 segundos em vez de 3 minutos — bem melhor! A ordem correta das camadas é importante: primeiro copiamos o que não muda, depois o que muda raramente, e, por último, o que muda frequentemente.
A seguir, algumas palavras sobre a construção de imagens em sistemas CI/CD.
Uso de imagens anteriores como cache
Se usarmos uma solução SaaS para a construção, o cache local do Docker pode estar limpo e fresco. Para que o Docker tenha de onde obter as camadas construídas, forneça-lhe a imagem construída anteriormente.
Vamos considerar, como exemplo, a construção do nosso aplicativo no GitHub Actions. Usaremos esta configuração
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.comA imagem é construída e enviada para o GitHub Packages em duas minutos e 20 segundos:

Agora vamos modificar a construção para que use o cache baseado nas imagens construídas anteriormente:
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.comPara começar, precisamos explicar por que são executados dois comandos build. O fato é que, em uma construção de múltiplos estágios, a imagem resultante será um conjunto de camadas do último estágio. As camadas dos estágios anteriores não estarão na imagem. Portanto, ao usar a imagem final da construção anterior, o Docker não conseguirá encontrar as camadas preparadas para a construção da imagem com nodejs (estágio builder). Para resolver esse problema, uma imagem intermediária é criada $IMAGE_NAME-builder-stage e enviada para o GitHub Packages, para que possa ser usada em construções futuras como fonte de cache.

O tempo total de construção foi reduzido para um minuto e meio. Trinta segundos são gastos puxando as imagens anteriores.
Pré-criação de imagens
Outra maneira de resolver o problema do cache limpo do Docker é mover parte das camadas para outro Dockerfile, construí-lo separadamente, enviar para o Container Registry e usá-lo como pai.
Criamos nossa imagem Node.js para compilar aplicativos Angular. Criamos um Dockerfile.node no projeto.
FROM node:12.16.2-alpine3.11
RUN apk --no-cache --update --virtual build-dependencies add
python
make
g++Construímos e enviamos a imagem pública para o Docker Hub:
docker build -t exsmund/node-for-angular -f Dockerfile.node .
docker push exsmund/node-for-angular:latestAgora, em nosso Dockerfile principal, usamos a imagem pronta:
FROM exsmund/node-for-angular:latest as builder
...No nosso exemplo, o tempo de construção não diminuiu, mas imagens pré-criadas podem ser úteis se você tiver muitos projetos e tiver que instalar dependências iguais em cada um deles.

Examinamos vários métodos para acelerar a construção de imagens Docker. Se você deseja que o deploy ocorra rapidamente, tente aplicar em seu projeto:
- redução do contexto;
- uso de imagens pai menores;
- construção em múltiplas etapas;
- mudança na ordem das instruções no Dockerfile para usar o cache de forma eficaz;
- configuração do cache em sistemas CI/CD;
- criação prévia de imagens.
Espero que, com o exemplo, fique mais claro como o Docker funciona e que você possa configurar seu deploy de forma otimizada. Para brincar com os exemplos do artigo, foi criado um repositório. .
Fonte: habr.com
