Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 30 segundos.

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.

Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 30 segundos.

Temos uma boa experiência na criação e manutenção de sites de mídia: TASS, The Bell, "Nova Gazeta", Republic... Recentemente ampliamos nosso portfólio, lançando o site da produção Reminder. 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.

Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 30 segundos.

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!

Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 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 app

Adicionamos a ele PWA (somos progressivos):

ng add @angular/pwa --project app

Enquanto 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 --prod

Um 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:

  1. Remova as imagens localmente, para que execuções anteriores não influenciem o teste.
    docker rmi $(docker images -q)
  2. Vamos executar a construção pela primeira vez.
    time docker build -t app .
  3. Alteramos o arquivo src/index.html — simulamos o trabalho de um programador.
  4. 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.000s

Altere 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.000s

Para 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.74GB

Antes 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_modules

e 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.000s

607.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.38GB

Vamos 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 --prod

Tivemos 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   761MB

Vamos 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.json

Com 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   36MB

Vamos rodar o contêiner com nossa imagem e verificar se tudo está funcionando:

docker run -p8080:80 app

Com 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 http://localhost:8080/ e vemos nosso aplicativo. Tudo funcionando!

Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 30 segundos.

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.000s

Mudamos 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.000s

46 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.com

A imagem é construída e enviada para o GitHub Packages em duas minutos e 20 segundos:

Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 30 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.com

Para 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.

Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 30 segundos.

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:latest

Agora, 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.

Dicas sobre como acelerar a construção de imagens Docker. Por exemplo, em até 30 segundos.

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. https://github.com/devopsprodigy/test-docker-build.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster