Otimize Imagens Docker com Builds Multiestágio: Um Guia Abrangente

Domine builds multiestágio do Docker para reduzir drasticamente o tamanho das suas imagens, acelerar implantações e melhorar a segurança. Este guia abrangente fornece instruções passo a passo, exemplos práticos para Go e Node.js e práticas recomendadas essenciais. Aprenda a otimizar seus Dockerfiles separando as dependências de build, garantindo que apenas os componentes necessários cheguem à sua imagem de runtime final. Leitura essencial para quem deseja construir aplicações conteinerizadas eficientes e seguras.

Otimize Imagens Docker com Builds Multiestágio: Um Guia Abrangente

Builds multiestágio resolvem um problema muito comum no Docker: as ferramentas necessárias para construir uma aplicação geralmente não são as mesmas necessárias para executá-la.

Um compilador Go, cache de pacotes Node, repositório Maven, framework de teste e cabeçalhos de build são úteis durante a construção da imagem. Eles são peso morto na imagem de runtime. Eles tornam os pulls mais lentos, aumentam a quantidade de software que você precisa corrigir e dificultam a compreensão do que está realmente rodando em produção.

Com um Dockerfile multiestágio, você constrói em um estágio e copia apenas o artefato finalizado para um estágio de runtime menor. A imagem final não herda o estágio de build, a menos que você copie explicitamente arquivos dele.

O Problema com Imagens de Estágio Único

Considere uma aplicação Go típica. Você precisa do toolchain Go para compilá-la. Depois de ter um binário Linux, o compilador não é mais necessário. Uma imagem de estágio único o mantém de qualquer forma:

FROM golang:1.21-alpine

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o myapp

EXPOSE 8080
CMD ["./myapp"]

Isso funciona, mas a imagem final ainda contém o toolchain Go e o cache de build. O mesmo padrão aparece com Node, Java, Rust, pacotes Python com extensões nativas e builds de frontend.

O custo é prático:

  • Aumento do Tamanho da Imagem: Mais camadas, mais dados para puxar e armazenar.
  • Tempos de Implantação Estendidos: Imagens maiores demoram mais para transferir.
  • Introdução de Riscos de Segurança: Uma superfície de ataque maior com software desnecessário.
  • Ofuscamento do Ambiente de Runtime: Dificulta a compreensão do que é realmente necessário.

Imagens menores não são automaticamente mais rápidas em runtime, mas são mais rápidas para transitar por CI, registries e sistemas de implantação. Elas também tornam a revisão de segurança menos ruidosa.

O Que os Builds Multiestágio Fazem

Cada instrução FROM inicia um novo estágio. Você pode nomear um estágio e copiar arquivos dele posteriormente:

FROM golang:1.21-alpine AS builder
# construir arquivos aqui

FROM alpine:3.20
COPY --from=builder /app/myapp /app/myapp

O segundo estágio começa do zero. Ele não contém /usr/local/go, arquivos fonte, caches de pacotes ou ferramentas de build do primeiro estágio, a menos que você os copie.

Um Exemplo Limpo em Go

Aqui está uma pequena aplicação:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "Olá da imagem Docker otimizada!")
}

func main() {
	http.HandleFunc("/", handler)
	log.Println("Servidor iniciando na porta :8080...")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

O Dockerfile multiestágio:

FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags='-w -s' -o myapp

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/myapp /app/myapp

EXPOSE 8080
CMD ["/app/myapp"]

Os arquivos go.mod e go.sum são copiados antes da árvore de código fonte completa para que o Docker possa reutilizar a camada de download de dependências quando apenas o código da aplicação mudar. CGO_ENABLED=0 é útil quando você deseja um binário estático. Se sua aplicação depende de bibliotecas C, você pode precisar de uma imagem de runtime que inclua essas bibliotecas em vez de forçar builds estáticos.

Construa e compare:

docker build -t go-app:multi-stage .
docker images go-app:multi-stage
docker history go-app:multi-stage

Não confie no tamanho do exemplo de um blog. Verifique sua própria imagem. Escolhas de dependências, versões da imagem base, símbolos de depuração, certificados, dados de fuso horário e bibliotecas nativas afetam o resultado.

Escolhas de Imagem Base de Runtime

alpine é popular por ser pequena, mas pequeno nem sempre é sinônimo de compatível. Alpine usa musl libc, enquanto muitas distribuições Linux comuns usam glibc. A maioria dos binários Go estáticos funciona bem. Alguns pacotes Python, Node, Java ou nativos se comportam de forma diferente.

Opções comuns de runtime:

Runtime base Boa adequação Tradeoff
alpine Imagens pequenas, binários simples Diferenças de compatibilidade musl
debian:bookworm-slim Ampla compatibilidade Linux Maior que Alpine
Imagens Distroless Runtimes de produção com menos ferramentas Mais difícil depurar dentro do contêiner
scratch Apenas binários estáticos Sem shell, certificados CA ou gerenciador de pacotes, a menos que copiados

Se a aplicação chama endpoints HTTPS, certifique-se de que a imagem final inclua certificados CA. Uma imagem scratch sem certificados pode falhar de uma forma que parece um problema de rede.

FROM alpine:3.20 AS certs
RUN apk add --no-cache ca-certificates

FROM scratch
COPY --from=builder /app/myapp /myapp
COPY --from=certs /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/myapp"]

Builds Multiestágio para Outras Linguagens/Frameworks

A mesma ideia funciona em qualquer lugar onde haja uma etapa de build.

Para um frontend Node:

FROM node:20-alpine AS builder

WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.27-alpine
COPY --from=builder /app/dist /usr/share/nginx/html

Para uma API Node, não copie node_modules de uma instalação de desenvolvimento se ela incluir dependências de desenvolvimento:

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY . .
CMD ["node", "server.js"]

Para Java:

FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml .
RUN mvn -q -DskipTests dependency:go-offline
COPY src ./src
RUN mvn -q -DskipTests package

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /src/target/app.jar /app/app.jar
CMD ["java", "-jar", "/app/app.jar"]

O Cache de Build Também é Importante

Builds multiestágio reduzem o tamanho final da imagem, mas a ordem do Dockerfile ainda controla o comportamento do cache. Coloque arquivos de dependência estáveis antes de arquivos fonte voláteis. Use npm ci em vez de npm install em builds reproduzíveis. Fixe versões de imagem base em vez de depender de latest em produção.

Com o BuildKit, montagens de cache podem acelerar downloads de pacotes sem incorporar caches na imagem final:

# syntax=docker/dockerfile:1.7
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -o myapp

Esse cache é para a máquina de build, não para a imagem de runtime.

O Que Copiar, e O Que Não Copiar

Copie o menor conjunto completo de runtime. Para um serviço compilado, pode ser um binário mais modelos de configuração e certificados CA. Para um frontend, pode ser um diretório dist. Para Java, pode ser um jar mais um JRE.

Não copie código fonte, caches de gerenciador de pacotes, fixtures de teste, arquivos .env locais, chaves SSH ou saída de build que você não executa. Use um arquivo .dockerignore para que esses arquivos não entrem no contexto de build em primeiro lugar:

.git
node_modules
coverage
dist
*.log
.env

O arquivo .dockerignore não substitui instruções COPY cuidadosas, mas evita inchaço acidental do contexto e vazamento de segredos.

Depurando Builds Multiestágio

Nomeie seus estágios. Um estágio nomeado é mais fácil de segmentar:

docker build --target builder -t app-builder .
docker run --rm -it app-builder sh

Isso é útil quando o build é bem-sucedido, mas a imagem final falha porque um arquivo foi copiado para o caminho errado ou uma biblioteca de runtime está faltando.

Você também pode inspecionar arquivos copiados para a imagem final:

docker run --rm -it --entrypoint sh my-image

Se a imagem não tiver shell, troque temporariamente o estágio final para uma base amigável à depuração enquanto diagnostica, depois coloque a base de produção de volta.

Uma Regra Prática

Use um estágio para cada trabalho distinto: dependências, build, teste, runtime. Mantenha o estágio de runtime simples. Se alguém abrir o estágio final do Dockerfile, deve ser capaz de responder rapidamente a uma pergunta: de quais arquivos este contêiner realmente precisa para executar?

Esse é o verdadeiro valor dos builds multiestágio. Imagens menores são legais. Limites claros de runtime são melhores.