Escrever um Dockerfile é mais do que simplesmente empilhar comandos: é uma arte que equilibra eficiência, segurança e manutenibilidade. Nesta aula, vamos explorar as boas práticas que separam um Dockerfile comum de um profissional. Você entenderá por que imagens menores são melhores, como o multi-stage build pode simplificar seu fluxo de trabalho e como a ordem das instruções afeta o cache e o desempenho. Ao final, terá um repertório de técnicas para aplicar imediatamente em seus projetos.

Essas práticas não são apenas estéticas; elas impactam diretamente o tempo de deploy, a superfície de ataque da imagem e a experiência dos desenvolvedores que consomem sua imagem. Vamos mergulhar em cada uma delas com exemplos práticos e explicações detalhadas.

Imagens pequenas

Imagens menores são mais rápidas de transferir, ocupam menos espaço em disco e reduzem a superfície de ataque, pois contêm menos binários e bibliotecas potencialmente vulneráveis. Uma imagem enxuta também acelera o pull em ambientes de orquestração como Kubernetes, melhorando o tempo de escalonamento.

Uma das estratégias mais eficazes é usar imagens base Alpine, que são extremamente pequenas (cerca de 5 MB) devido ao uso do musl libc e de um conjunto mínimo de utilitários. Por exemplo, uma imagem Ubuntu pode ter 50 MB ou mais, enquanto Alpine tem apenas alguns MB. No entanto, é preciso estar ciente de que alguns pacotes podem não estar disponíveis nos repositórios Alpine ou podem ter nomes diferentes, exigindo ajustes.

Outra técnica é remover arquivos temporários e caches dentro do mesmo comando, usando && para encadear comandos e rm -rf para limpar. Isso evita que camadas intermediárias armazenem dados desnecessários.

FROM alpine:3.19

RUN apk add --no-cache curl \
    && curl -sL https://example.com/install.sh | sh \
    && rm -rf /var/cache/apk/*

No exemplo acima, usamos --no-cache para evitar o cache do gerenciador de pacotes e removemos qualquer resíduo. Isso mantém a camada final limpa. Além disso, prefira imagens oficiais e específicas (como python:3.12-slim em vez de python:latest) para garantir consistência e menor tamanho.

Multi-stage builds

O multi-stage build é um recurso poderoso que permite usar múltiplas instruções FROM no mesmo Dockerfile, cada uma criando um estágio. Você pode compilar e testar em um estágio com todas as ferramentas necessárias e, em seguida, copiar apenas os artefatos de build para um estágio final leve. Isso é ideal para linguagens compiladas como Go, Java, ou para projetos que exigem dependências de build pesadas.

Considere um exemplo com uma aplicação Go: o estágio de build usa a imagem golang:1.22 com o compilador e as bibliotecas, e o estágio final usa alpine com apenas o binário compilado. O resultado é uma imagem final com dezenas de MB a menos.

# Estágio de build
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o meuapp .

# Estágio final
FROM alpine:3.19
RUN adduser -D appuser
USER appuser
COPY --from=builder /app/meuapp /usr/local/bin/meuapp
CMD ["meuapp"]

Além de reduzir o tamanho, o multi-stage permite que você não exponha ferramentas de desenvolvimento na imagem final, melhorando a segurança. Você também pode usar alvos intermediários para testes, como um estágio de teste que roda go test antes de compilar. Isso é uma prática recomendada para pipelines de CI/CD.

Ordem de camadas

O Docker armazena cada instrução do Dockerfile como uma camada, e essas camadas são cacheadas. Se uma camada não mudar, ela é reutilizada em builds subsequentes, acelerando o processo. Portanto, a ordem das instruções é crucial para maximizar o cache.

A regra de ouro é colocar as instruções que mudam com menos frequência no início (como a instalação de dependências) e as que mudam com mais frequência no final (como a cópia do código-fonte). Isso significa que, se você alterar apenas o código, o cache das camadas de dependência será reutilizado, e o build será rápido.

Um erro comum é copiar o código antes de instalar as dependências, o que invalida o cache de dependências a cada alteração no código. Veja um exemplo ruim e um bom:

# Ruim: copia o código antes de instalar dependências
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
# Bom: instala dependências antes de copiar o código
FROM node:20
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .

No exemplo bom, se package.json não mudar, a camada RUN npm install será cacheada, e apenas a cópia do código será refeita. Isso economiza tempo significativo em projetos com muitas dependências.

.dockerignore

O arquivo .dockerignore funciona como um .gitignore para o build do Docker. Ele especifica quais arquivos e diretórios devem ser excluídos do contexto de build, que é enviado ao daemon do Docker. Isso reduz o tamanho do contexto, acelerando o envio e evitando que arquivos sensíveis ou desnecessários sejam copiados para a imagem.

Um exemplo típico inclui diretórios como node_modules, .git, pastas de build, arquivos temporários e ambientes virtuais. Excluí-los não só melhora a performance, mas também evita que segredos (como .env) sejam acidentalmente incluídos na imagem.

# .dockerignore
.git
node_modules
npm-debug.log
.env
Dockerfile*
docker-compose*
README.md
dist
coverage

É importante lembrar que o .dockerignore não afeta as instruções COPY e ADD dentro do Dockerfile, pois essas já são aplicadas após o contexto ser enviado. No entanto, ele influencia o que está disponível no contexto, então se você excluir um diretório, não poderá copiá-lo.

Uma boa prática é sempre criar um .dockerignore mesmo em projetos pequenos, pois isso evita surpresas e mantém o build limpo.

Referências

Exercícios

  1. Crie um Dockerfile para uma aplicação Node.js que use multi-stage build para reduzir o tamanho da imagem final. O estágio de build deve instalar as dependências e compilar o código, e o estágio final deve apenas rodar a aplicação.

    ✓ Resposta:
    # Estágio de build
    FROM node:20 AS builder
    WORKDIR /app
    COPY package*.json ./
    RUN npm install
    COPY . .
    RUN npm run build
    
    # Estágio final
    FROM node:20-slim
    WORKDIR /app
    COPY --from=builder /app/package*.json ./
    RUN npm install --omit=dev
    COPY --from=builder /app/dist ./dist
    EXPOSE 3000
    CMD ["node", "dist/server.js"]
    
  2. Explique por que a ordem das camadas no Dockerfile é importante e dê um exemplo de um Dockerfile com boa ordem para uma aplicação Python.

    ✓ Resposta: A ordem é importante porque o Docker cacheia cada camada. Se uma camada não mudar, ela é reutilizada. Portanto, devemos colocar instruções que mudam raramente (como instalar dependências) antes das que mudam frequentemente (como copiar o código). Exemplo:
    FROM python:3.12-slim
    WORKDIR /app
    COPY requirements.txt .
    RUN pip install --no-cache-dir -r requirements.txt
    COPY . .
    CMD ["python", "app.py"]
    
  3. Escreva um arquivo .dockerignore para um projeto Node.js que ignore pelo menos 5 itens desnecessários.

    ✓ Resposta:
    # .dockerignore
    node_modules
    .git
    .env
    npm-debug.log
    Dockerfile*
    docker-compose*
    README.md
    
  4. Qual é a vantagem de usar uma imagem base Alpine em vez de uma imagem Ubuntu para uma aplicação simples? Cite duas vantagens e uma desvantagem.

    ✓ Resposta: Vantagens: 1) Tamanho muito menor (cerca de 5 MB vs 50+ MB), resultando em downloads mais rápidos e menor uso de disco. 2) Menor superfície de ataque, pois há menos pacotes instalados. Desvantagem: Algumas bibliotecas podem não estar disponíveis ou ter comportamento diferente devido ao musl libc, exigindo mais ajustes.
  5. No Dockerfile a seguir, identifique o problema relacionado à ordem de camadas e reescreva-o seguindo as boas práticas.

    FROM node:20
    WORKDIR /app
    COPY . .
    RUN npm install
    

    ✓ Resposta: O problema é que COPY . . copia todo o código antes de instalar as dependências, invalidando o cache de RUN npm install sempre que o código mudar. A versão corrigida:
    FROM node:20
    WORKDIR /app
    COPY package*.json ./
    RUN npm install
    COPY . .
    

Boas práticas adicionais

Além das técnicas abordadas, considere usar usuários não-root nas imagens finais, definir ENV e EXPOSE claramente, e evitar instalar pacotes desnecessários. Use também o recurso de HEALTHCHECK para monitorar a saúde do contêiner e mantenha o Dockerfile legível com comentários. Essas práticas tornam suas imagens mais seguras, confiáveis e fáceis de manter.