As imagens Docker são o coração do ecossistema de containers, pois definem o ambiente exato em que sua aplicação será executada. Nesta aula, você vai mergulhar na construção de imagens personalizadas, entendendo como o Docker as organiza em camadas, como escrever um Dockerfile eficiente e como usar os comandos de build e tag para gerenciar versões. Além disso, vamos explorar o cache de build, um mecanismo que pode acelerar drasticamente o processo de desenvolvimento.

Dominar a criação de imagens é essencial para qualquer profissional de DevOps, pois permite padronizar ambientes, garantir consistência entre desenvolvimento e produção, e facilitar a escalabilidade. Vamos começar desvendando a arquitetura por trás das imagens Docker.

Camadas

Uma imagem Docker é composta por uma série de camadas (layers) empilhadas. Cada camada representa uma alteração no sistema de arquivos, como a adição de um arquivo, a execução de um comando ou a configuração de uma variável de ambiente. Essa estrutura é fundamental para a eficiência do Docker: as camadas são imutáveis e podem ser compartilhadas entre diferentes imagens, reduzindo o uso de disco e acelerando o download e o upload.

Quando você executa um container a partir de uma imagem, o Docker adiciona uma camada de leitura e escrita (container layer) sobre as camadas da imagem. Isso permite que o container tenha seu próprio sistema de arquivos, enquanto a imagem original permanece intacta. Cada instrução no Dockerfile que modifica o sistema de arquivos cria uma nova camada. Por isso, é importante entender que o número e o tamanho das camadas afetam o desempenho e o tamanho final da imagem.

Para visualizar as camadas de uma imagem, você pode usar o comando docker history. Por exemplo:

docker history nginx:latest

Esse comando mostra as camadas da imagem, incluindo o tamanho de cada uma e o comando que a gerou. Isso ajuda a identificar possíveis otimizações, como reduzir o número de camadas ou combinar comandos.

Dockerfile

O Dockerfile é um arquivo de texto que contém as instruções para construir uma imagem. Cada instrução é executada em ordem, e o resultado é uma nova camada. É o blueprint da sua imagem, e escrevê-lo bem é crucial para criar imagens eficientes e seguras.

A instrução FROM define a imagem base, que pode ser uma distribuição Linux, uma imagem oficial de uma linguagem ou uma imagem minimalista como alpine. Em seguida, usamos outras instruções como RUN para executar comandos, COPY e ADD para copiar arquivos, ENV para definir variáveis de ambiente, EXPOSE para expor portas e CMD ou ENTRYPOINT para definir o comando padrão.

Exemplo de um Dockerfile simples para uma aplicação Python:

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["python", "app.py"]

Aqui, cada instrução cria uma camada. Note que copiamos o requirements.txt antes do código fonte: isso aproveita o cache de build, pois as dependências mudam com menos frequência que o código. Assim, ao reconstruir a imagem, o Docker pode reutilizar a camada de instalação das dependências, economizando tempo.

Boas práticas na escrita de Dockerfile incluem: usar imagens oficiais e específicas (com tags), minimizar o número de camadas, instalar apenas o necessário, e usar .dockerignore para excluir arquivos desnecessários do contexto de build.

build e tag

O comando docker build é usado para construir uma imagem a partir de um Dockerfile e um contexto. O contexto é o conjunto de arquivos que o Docker envia para o daemon durante o build, geralmente o diretório onde o Dockerfile está localizado. Você especifica o contexto com um ponto (.) no final do comando.

A sintaxe básica é:

docker build -t nome-da-imagem:tag .

A opção -t (ou --tag) atribui um nome e uma tag à imagem. As tags são rótulos que identificam versões ou variantes da imagem. Por exemplo, meuapp:1.0, meuapp:latest. É uma boa prática usar tags semânticas ou o hash do commit para rastreabilidade.

Se você não especificar uma tag, o Docker usará automaticamente latest. Mas evite depender de latest em produção, pois não é determinístico.

Exemplo de build com tag:

docker build -t meuapp:1.0 .

Você também pode listar as imagens locais com docker images e remover imagens não utilizadas com docker rmi.

Além disso, você pode usar o comando docker tag para adicionar uma nova tag a uma imagem existente, sem criar uma nova. Por exemplo:

docker tag meuapp:1.0 meuapp:latest

Isso é útil para marcar uma versão específica como a mais recente antes de enviar para um registro.

Cache de build

O Docker tem um mecanismo de cache de build que acelera reconstruções. Quando você executa docker build, o Docker verifica cada instrução do Dockerfile e, se a instrução não mudou desde o último build e o contexto não foi alterado, ele reutiliza a camada correspondente do cache. Isso pode reduzir drasticamente o tempo de build.

O cache é invalidado quando uma instrução muda ou quando os arquivos copiados por COPY ou ADD são alterados. Por isso, a ordem das instruções é crucial. Colocar instruções que mudam com frequência (como copiar o código fonte) no final do Dockerfile permite aproveitar o cache para etapas anteriores, como instalação de dependências.

Exemplo de Dockerfile otimizado para cache:

FROM node:18-alpine

WORKDIR /app

COPY package*.json ./
RUN npm install

COPY . .

CMD ["npm", "start"]

Aqui, as dependências são instaladas antes de copiar o código, então, se você alterar apenas o código, o Docker reutilizará a camada de instalação, economizando tempo. Se você alterar o package.json, o cache da camada npm install será invalidado, e as dependências serão reinstaladas.

Para ver o cache em ação, execute um build e observe as mensagens: camadas reutilizadas aparecem como Using cache. Você pode desabilitar o cache com --no-cache se necessário, mas isso é raro.

Outra técnica é usar multistage builds, que também se beneficiam do cache, permitindo construir imagens menores e mais seguras.

Boas práticas e observações finais

Ao criar imagens Docker, lembre-se de que menos camadas não é necessariamente melhor; o importante é a ordem e o conteúdo. Use imagens base pequenas (como Alpine) para reduzir o tamanho e a superfície de ataque. Evite incluir segredos no Dockerfile; use variáveis de ambiente ou segredos do Docker. E sempre teste suas imagens localmente antes de enviar para um registro.

O cache de build é uma ferramenta poderosa, mas também pode causar problemas se você não entender quando ele é invalidado. Sempre que duvidar, use --no-cache para forçar uma reconstrução limpa.

Referências

Exercícios

  1. Explique o conceito de camadas em imagens Docker e como elas afetam o tamanho e o desempenho das imagens.

    ✓ Resposta: As camadas são unidades imutáveis do sistema de arquivos que compõem uma imagem. Cada instrução que modifica o sistema de arquivos cria uma nova camada. Elas permitem o compartilhamento entre imagens, reduzem o espaço em disco e aceleram transferências. No entanto, muitas camadas grandes podem aumentar o tamanho da imagem e o tempo de build. Por isso, é importante equilibrar o número de camadas e o tamanho de cada uma.
  2. Escreva um Dockerfile para uma aplicação Node.js simples (usando o express) que escute na porta 3000. Inclua a instalação das dependências e o comando de inicialização.

    ✓ Resposta:
    FROM node:18-alpine
    
    WORKDIR /app
    
    COPY package*.json ./
    RUN npm install
    
    COPY . .
    
    EXPOSE 3000
    
    CMD ["node", "index.js"]
  3. Explique como o cache de build funciona e por que a ordem das instruções no Dockerfile é importante. Dê um exemplo de ordem ruim e um exemplo de ordem boa.

    ✓ Resposta: O cache de build reutiliza camadas de builds anteriores quando as instruções correspondentes não mudaram. A ordem importa porque, se uma instrução muda, todas as subsequentes são invalidadas. Exemplo ruim: copiar todo o código antes de instalar dependências; qualquer alteração no código invalida a camada de instalação, forçando reinstalação. Exemplo bom: copiar apenas os arquivos de dependência primeiro, instalar, depois copiar o código; alterações no código não afetam a camada de instalação.
  4. Qual é a diferença entre docker build e docker tag? Dê um exemplo de uso de cada um.

    ✓ Resposta: docker build cria uma nova imagem a partir de um Dockerfile e um contexto. docker tag adiciona uma nova tag a uma imagem existente sem criar uma nova. Exemplo de build: docker build -t meuapp:1.0 .. Exemplo de tag: docker tag meuapp:1.0 meuapp:latest.
  5. Suponha que você tem um Dockerfile que instala dependências Python e depois copia o código. Se você alterar apenas o código da aplicação, o que acontece com o cache? E se você alterar o arquivo de requisitos? Explique.

    ✓ Resposta: Se você alterar apenas o código, a camada de instalação das dependências será reutilizada do cache, pois a instrução COPY requirements.txt não mudou (o arquivo não foi alterado) e a instrução RUN pip install também não mudou. Apenas a camada que copia o código será reconstruída. Se você alterar o arquivo de requisitos, a instrução COPY requirements.txt será invalidada, e a camada de instalação será reconstruída, reinstalando as dependências.