Alternar tema

Build multi-stage no Docker: imagens Go, Java e Rust de GB para MB

Easton editorial illustration: performance tuning console

O que uma imagem de 650 MB me fez repensar

Na sexta-feira passada, à tarde, eu observava no dashboard do K8s um Pod que já levava cinco minutos para baixar a imagem. Uma aplicação Spring Boot simples tinha virado uma imagem Docker de 650 MB. Um estagiário novo na equipe perguntou: “Por que essa imagem é tão grande?” Fiquei sem resposta e percebi que eu também nunca tinha pensado seriamente nisso.

Passei aquela noite pesquisando. Na manhã seguinte, a imagem da mesma aplicação tinha apenas 89 MB. O tempo de implantação caiu de 5 minutos para menos de 1 minuto. Até a forma como o estagiário me olhava mudou.

98%
Otimização da imagem Go
295 MB → 6,47 MB
86%
Otimização da imagem Java
650 MB → 89 MB
99,4%
Otimização da imagem Rust
2 GB → 11 MB
89 MB
Tamanho da imagem otimizada
Redução de 650 MB para 89 MB; implantação de 5 minutos para menos de 1 minuto

Na prática, bastou mudar algumas linhas no Dockerfile. Essa técnica se chama “build multi-stage”.

No começo, eu também achava que isso não faria tanta diferença. As imagens dos projetos Go da equipe tinham 295 MB, e as de Java passavam facilmente de 500 MB. Depois de um tempo, a gente se acostuma e assume que imagens Docker são grandes mesmo. Só quando vi alguém reduzir uma imagem Rust de 2 GB para 11 MB — sim, de GB para MB — percebi que talvez eu estivesse empacotando tudo da forma errada.

O ponto central é simples: linguagens compiladas precisam de compiladores e ferramentas de build para transformar o código-fonte em executáveis, mas esses componentes não servem para nada em tempo de execução. Um Dockerfile tradicional coloca Maven, Gradle e o compilador Go dentro da imagem final. É como levar para a casa nova a furadeira e o cimento usados na reforma — totalmente desnecessário.

Vou usar três casos reais, com Go, Java e Rust, para mostrar como reduzir imagens com builds multi-stage. Os resultados são estes:

  • A aplicação Go caiu de 295 MB para 6,47 MB (redução de 98%)
  • A aplicação Java Spring Boot foi de 650 MB para 89 MB (redução de 86%)
  • A aplicação Rust caiu de 2 GB para 11,2 MB (redução de 99,4%)

Se apenas uma dessas linguagens interessa a você, pode ir direto para a seção correspondente. Cada caso é completo, com código que pode ser copiado e executado.

Por que sua imagem Docker está tão pesada?

Primeiro, veja uma comparação com dados reais dos meus testes anteriores:

LinguagemBuild tradicional em um estágioBuild multi-stageRedução
Aplicação Go295 MB6,47 MB98%
Java Spring Boot650 MB89 MB86%
Aplicação Rust2,1 GB11,2 MB99,4%

Na primeira vez que vi essa tabela, demorei a acreditar. O caso de Rust é especialmente impressionante: passar de 2 GB para 11 MB parece mais mágica do que otimização.

Ao analisar a composição das imagens, o problema ficou claro. Linguagens compiladas precisam de um compilador para transformar o código-fonte em um binário executável. Para Go, usamos o compilador Go; em Java, Maven ou Gradle; em Rust, Cargo. Essas ferramentas não são pequenas:

  • Compilador Go: cerca de 300 MB
  • Maven + OpenJDK: cerca de 500 MB
  • Toolchain do Rust: cerca de 1,5 GB

Um Dockerfile tradicional costuma ser assim:

FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]

Parece correto, não é? Mas há um problema importante. Esse Dockerfile usa toda a imagem golang:1.21 (295 MB) como base. Ela contém o compilador Go, ferramentas de build e utilitários de depuração. Quando a aplicação termina de compilar, nada disso é removido: tudo permanece na imagem final.

É como comprar um apartamento sem acabamento e contratar uma equipe para reformá-lo. Quando o trabalho termina, você tranca dentro dele o cimento, a furadeira, a serra e todas as caixas de ferramentas antes de se mudar. Parece absurdo, mas é exatamente o que muitos Dockerfiles fazem.

Em tempo de execução, o que realmente importa é o binário compilado. Um binário Go costuma ter de alguns MB a algumas dezenas de MB. Um arquivo JAR pode ser um pouco maior, mas ainda fica na casa das dezenas de MB. Binários Rust também são pequenos. As centenas de MB restantes vêm das ferramentas de compilação e da imagem-base.

Esse excesso não desperdiça apenas armazenamento. Os problemas mais incômodos são:

  1. Download lento da imagem: em um pipeline de CI/CD, cada implantação precisa baixar a imagem. Com uma conexão ruim, esperar uma imagem de 650 MB faz qualquer pessoa perder a paciência
  2. Risco de segurança: uma imagem de produção com compilador, código-fonte e ferramentas de build deixa vários recursos disponíveis para um invasor
  3. Desperdício de cache de build: mudar uma linha de código pode obrigar a reconstruir toda a imagem, porque compilador e código estão acoplados

Lembro de uma implantação na Alibaba Cloud em que cinco pessoas da equipe publicaram uma versão ao mesmo tempo. Cada imagem tinha mais de 500 MB, e a largura de banda interna ficou saturada. Foi quando decidi estudar otimização de imagens de verdade.

Build multi-stage: dois ambientes em um Dockerfile

O build multi-stage foi introduzido no Docker 17.05. A ideia é muito simples: definir vários estágios no mesmo Dockerfile. Os primeiros compilam; o último executa. Entre eles, apenas os artefatos necessários são transferidos.

Em outras palavras, a equipe faz a reforma no primeiro estágio. No segundo, fica apenas a casa pronta; cimento e furadeira não seguem adiante.

Veja o exemplo mais simples:

# Primeiro estágio: build
FROM golang:1.21 AS builder
WORKDIR /app
COPY . .
RUN go build -o myapp

# Segundo estágio: runtime
FROM alpine:3.18
WORKDIR /app
COPY --from=builder /app/myapp .
CMD ["./myapp"]

Os pontos importantes são:

  1. O primeiro FROM termina com AS builder, que dá o nome builder ao estágio
  2. O segundo FROM inicia outro estágio, com a imagem leve Alpine
  3. COPY --from=builder copia o binário compilado do estágio builder e deixa todo o resto para trás

A imagem golang:1.21 do primeiro estágio tem 295 MB. A imagem final, porém, contém apenas os 5 MB do Alpine e alguns MB do binário, ficando com pouco mais de dez MB.

Quando entendi esse princípio, pensei: é literalmente “ficar com o resultado e descartar o processo”. Compilador, código-fonte e arquivos intermediários fazem parte do processo; o binário é o resultado. O Docker descarta automaticamente a imagem do primeiro estágio e mantém apenas a última.

E se você precisar depurar o primeiro estágio? O Docker oferece um comando muito útil:

docker build --target builder -t myapp:debug .

--target builder instrui o Docker a construir somente até o estágio builder, sem executar o segundo. Assim, é possível abrir um contêiner baseado no primeiro estágio e depurá-lo.

Esse desenho também permite três, quatro ou mais estágios:

FROM node:18 AS frontend-builder
# Build do frontend

FROM golang:1.21 AS backend-builder
# Build do backend

FROM nginx:alpine
# Copia os artefatos do frontend e do backend
COPY --from=frontend-builder /app/dist /usr/share/nginx/html
COPY --from=backend-builder /app/api /usr/local/bin/api

Cada estágio executa sua própria tarefa, e o estágio de runtime reúne os artefatos finais. Isso deixa a lógica do Dockerfile muito mais clara.

Hoje, quando escrevo um Dockerfile para uma linguagem compilada, começo pelo build multi-stage. Virou hábito.

Como reduzir ao máximo uma imagem Go

Go é minha linguagem favorita para otimização de imagens. O motivo é que o binário pode ser vinculado estaticamente, sem depender de bibliotecas do sistema, e executado até em uma imagem vazia.

Primeiro, veja o método tradicional — não o copie:

FROM golang:1.21
WORKDIR /app
COPY . .
RUN go build -o myapp
CMD ["./myapp"]

Depois do build:

docker build -t myapp:old .
docker images myapp:old
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        old    abc123def456   295MB

São 295 MB para um serviço HTTP simples.

Agora, a versão otimizada com múltiplos estágios:

# Estágio de build
FROM golang:1.21-alpine AS builder
WORKDIR /app

# Copia os arquivos de dependências e baixa as dependências (aproveita o cache)
COPY go.mod go.sum ./
RUN go mod download

# Copia o código-fonte e compila
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o myapp .

# Estágio de runtime
FROM scratch
WORKDIR /app
COPY --from=builder /app/myapp .
EXPOSE 8080
CMD ["./myapp"]

E o novo resultado:

docker build -t myapp:new .
docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    def456ghi789   6.47MB

6,47 MB. De 295 MB para 6,47 MB, uma redução de 98%.

Há quatro pontos importantes nesse Dockerfile.

1. CGO_ENABLED=0

Essa variável instrui o compilador Go a desativar o CGO e gerar um binário totalmente estático. Se o código usa uma biblioteca C, como SQLite, essa opção não funciona e será necessário escolher outra imagem-base.

2. -ldflags="-w -s"

Essas opções reduzem o binário:

  • -w: remove informações de depuração
  • -s: remove a tabela de símbolos

Isso pode diminuir o binário em mais 20% a 30%. Como essas informações quase nunca são usadas em produção, removê-las costuma ser aceitável.

3. FROM scratch

scratch é a imagem vazia do Docker: não contém nada e ocupa 0 byte. Um binário Go estático pode ser executado diretamente nela, sem bibliotecas do sistema.

4. Cache de dependências

Observe que primeiro copiamos go.mod e go.sum e só então executamos go mod download. Enquanto esses dois arquivos não mudarem, o Docker reutilizará a camada de dependências em cache. Alterações no código da aplicação não obrigam a baixar tudo novamente, acelerando bastante o build.

Nos meus testes, essa organização reduziu a reconstrução após uma mudança de código de 2 minutos para 15 segundos.

Avançado: fusos horários e certificados CA

A imagem scratch é limpa demais: nem dados de fuso horário nem certificados CA estão presentes. Se a aplicação faz requisições HTTPS ou trata fusos horários, ela pode falhar. Uma solução é copiar esses arquivos do estágio builder:

FROM scratch
WORKDIR /app
# Copia os dados de fuso horário
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copia os certificados CA
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/myapp .
ENV TZ=Asia/Shanghai
CMD ["./myapp"]

Outra opção é usar gcr.io/distroless/static-debian11 no lugar de scratch. Essa imagem tem apenas 2 MB, mas já inclui dados de fuso horário e certificados CA:

FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/myapp /app/myapp
CMD ["/app/myapp"]

Hoje eu costumo usar Distroless porque dá menos trabalho.

Como reduzir uma imagem Java/Spring Boot

Otimizar uma imagem Java é um pouco mais complicado do que otimizar uma imagem Go. Java precisa do JRE e não pode usar uma imagem scratch da mesma forma. Ainda assim, com a estratégia correta, a redução é grande.

O método tradicional, ainda comum em muitos projetos Java, é este:

FROM maven:3.8-openjdk-17
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/myapp.jar"]

A imagem resultante tem 650 MB. Só a imagem do Maven já ocupa cerca de 500 MB; somados o JAR e os caches, é fácil perder o controle do tamanho.

Esta é a versão otimizada com múltiplos estágios:

# Estágio de build
FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app

# Copia primeiro o pom.xml e baixa as dependências (aproveita o cache)
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Copia o código-fonte e empacota
COPY src ./src
RUN mvn package -DskipTests

# Estágio de runtime
FROM openjdk:17-jre-slim
WORKDIR /app

# Copia somente o JAR
COPY --from=builder /app/target/*.jar app.jar

# Ajusta os parâmetros da JVM
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"

EXPOSE 8080
CMD java $JAVA_OPTS -jar app.jar

Depois do build:

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    xyz789abc012   89MB

A imagem caiu de 650 MB para 89 MB, uma redução de 86%.

Pontos importantes:

1. JDK vs. JRE

O estágio de build usa openjdk-17, que contém o compilador. O estágio de runtime usa openjdk-17-jre-slim, que contém somente o runtime. O JRE ocupa menos da metade do JDK:

  • OpenJDK 17: cerca de 500 MB
  • OpenJDK 17 JRE: cerca de 200 MB
  • OpenJDK 17 JRE Slim: cerca de 80 MB

2. Cache de dependências do Maven

Primeiro copiamos pom.xml e executamos mvn dependency:go-offline para baixar todas as dependências. Enquanto o pom.xml não mudar, essa camada ficará em cache. Mudar o código da aplicação não dispara outro download.

Esse detalhe é importante. Antes de adotá-lo, cada alteração de uma linha fazia o build baixar tudo novamente. Como o repositório Maven ficava fora do país, o processo frequentemente travava por mais de dez minutos. Agora ele leva poucos segundos.

3. Ajuste dos parâmetros da JVM

-XX:+UseContainerSupport permite que a JVM reconheça o limite de memória do contêiner. Sem essa opção, ela pode dimensionar o heap com base na memória do host e levar o contêiner a um OOM.

-Xms128m -Xmx512m define o intervalo de memória do heap. Ajuste esses valores à necessidade real da aplicação, em vez de escolher 1 GB sem medir.

Versão com Gradle

Se o projeto usa Gradle, a mudança é pequena:

FROM gradle:8.5-jdk17 AS builder
WORKDIR /app

# Copia os arquivos de configuração do Gradle
COPY build.gradle settings.gradle ./
COPY gradle ./gradle

# Baixa as dependências
RUN gradle dependencies --no-daemon

# Copia o código-fonte e executa o build
COPY src ./src
RUN gradle bootJar --no-daemon

FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=builder /app/build/libs/*.jar app.jar
ENV JAVA_OPTS="-Xms128m -Xmx512m -XX:+UseContainerSupport"
CMD java $JAVA_OPTS -jar app.jar

Uma armadilha: build em camadas do Spring Boot

O Spring Boot 2.3+ oferece JARs em camadas, separando dependências e código da aplicação para melhorar ainda mais o cache. É uma opção um pouco mais avançada:

FROM maven:3.8-openjdk-17-slim AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests
RUN java -Djarmode=layertools -jar target/*.jar extract

FROM openjdk:17-jre-slim
WORKDIR /app
# Copia as camadas em ordem; a camada de dependências muda com menos frequência
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
CMD ["java", "org.springframework.boot.loader.JarLauncher"]

Assim, mesmo quando o código da aplicação muda, a camada de dependências continua válida, e o build fica mais rápido. Na prática, eu quase nunca uso essa abordagem: ela aumenta o custo de manutenção, e a versão simples acima já resolve a maior parte dos casos.

Implantação mínima para uma aplicação Rust

Rust produz o resultado mais radical. O toolchain ocupa mais de 1,5 GB, mas os binários compilados são muito pequenos. Na primeira vez que vi uma imagem cair de 2,1 GB para 11,2 MB, também achei difícil acreditar.

O método tradicional:

FROM rust:1.75
WORKDIR /app
COPY . .
RUN cargo build --release
CMD ["./target/release/myapp"]

A imagem resultante ocupa 2,1 GB. O toolchain do Rust é grande: compilador rustc, Cargo e todas as dependências acabam empacotados.

Esta é a versão otimizada com múltiplos estágios:

# Estágio de build
FROM rust:1.75 AS builder
WORKDIR /app

# Copia os arquivos de dependências e compila primeiro as dependências (aproveita o cache)
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src

# Copia o código real e compila a aplicação
COPY src ./src
RUN touch src/main.rs  # Atualiza o timestamp para forçar a recompilação
RUN cargo build --release

# Estágio de runtime
FROM gcr.io/distroless/cc-debian11
WORKDIR /app
COPY --from=builder /app/target/release/myapp .
CMD ["./myapp"]

Depois do build:

docker images myapp:new
# REPOSITORY   TAG    IMAGE ID       SIZE
# myapp        new    rst345uvw678   11.2MB

São 11,2 MB, uma redução de 99,4%.

Cache específico do Rust

Compilar dependências em Rust pode levar mais de dez minutos. O truque acima cria primeiro um main.rs falso, permitindo que o Cargo compile as dependências. Depois, o arquivo falso é removido, o código real é copiado e a aplicação é compilada.

Enquanto Cargo.toml e Cargo.lock não mudarem, a camada das dependências ficará em cache. Alterações no código da aplicação recompilam apenas esse código, sem recompilar todas as dependências.

Em um projeto de médio porte, essa organização reduziu o tempo de reconstrução após uma mudança de 15 minutos para 2 minutos.

Vinculação estática vs. dinâmica

O binário padrão do Rust pode depender da glibc. Se o código for inteiramente Rust e não usar bibliotecas C, é possível compilar com musl, gerar um binário totalmente estático e usar uma imagem scratch:

FROM rust:1.75 AS builder
WORKDIR /app

# Instala o toolchain do musl
RUN rustup target add x86_64-unknown-linux-musl

COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl
RUN rm -rf src

COPY src ./src
RUN touch src/main.rs
RUN cargo build --release --target x86_64-unknown-linux-musl

# Estágio de runtime
FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
CMD ["/myapp"]

Assim, a imagem pode ficar ainda menor, com algo entre 5 e 8 MB.

Na prática, eu normalmente uso gcr.io/distroless/cc. A compatibilidade é melhor, e o custo de poucos MB a mais compensa.

Uma armadilha: a localização do cache do Cargo

Alguns artigos recomendam armazenar /usr/local/cargo em cache. Evite isso. O diretório contém muitos artefatos intermediários e pode produzir uma camada de cache enorme, que torna o processo mais lento. Quando caí nessa armadilha, a camada chegou a 800 MB e cada build demorava muito.

Técnicas avançadas para builds mais rápidos e estáveis

Até aqui, vimos o uso básico de múltiplos estágios. Agora entram algumas técnicas para acelerar e estabilizar o build.

1. Arquivo .dockerignore

Esse arquivo é muito importante e costuma ser ignorado. Assim como .gitignore, .dockerignore informa ao Docker quais arquivos não devem entrar no contexto de build.

Antes de usá-lo, cada build empacotava o projeto inteiro — incluindo node_modules, .git e target — e enviava tudo ao daemon do Docker. Em projetos com centenas de MB, só esse envio levava meio minuto.

Agora crio um .dockerignore em todos os projetos:

# Controle de versão
.git
.gitignore

# Diretórios de dependências
node_modules
target
dist
build

# IDE
.vscode
.idea
*.swp

# Testes e documentação
**/*_test.go
**/*_test.rs
*.md
docs/

# Variáveis de ambiente e chaves
.env
.env.local
*.key
*.pem

Com esse arquivo, meus builds ficaram de três a cinco vezes mais rápidos. O ganho é especialmente visível em pipelines de CI/CD.

2. Depuração de builds multi-stage

Já mencionei a opção --target; agora veja como usá-la na prática. Se o build falhar no estágio builder, como investigar?

# Constrói somente até o estágio builder
docker build --target builder -t myapp:debug .

# Abre um contêiner a partir dessa imagem
docker run -it myapp:debug sh

# Dentro do contêiner, execute manualmente o comando de build para localizar o erro

Essa técnica é muito útil. Certa vez, o build de um projeto Go falhou com um erro estranho. Entrei no contêiner builder, executei go build manualmente e descobri uma incompatibilidade entre a versão do Go e uma dependência.

3. Dê nomes claros aos estágios

Use nomes que expliquem a função de cada estágio, em vez de stage1 e stage2:

# Ruim
FROM golang:1.21 AS stage1
FROM node:18 AS stage2
FROM nginx AS stage3

# Bom
FROM golang:1.21 AS backend-builder
FROM node:18 AS frontend-builder
FROM nginx AS runtime

Quando você voltar ao código meses depois, agradecerá por ter escolhido bons nomes.

4. Builds paralelos com BuildKit

Docker BuildKit é o mecanismo de build de nova geração, com execução paralela e cache melhor. Para ativá-lo:

export DOCKER_BUILDKIT=1
docker build .

Ou apenas para um build:

DOCKER_BUILDKIT=1 docker build .

Entre as vantagens do BuildKit estão:

  • Estágios independentes podem ser construídos em paralelo
  • A estratégia de cache é mais inteligente
  • A saída do build é mais clara

Hoje uso BuildKit por padrão e também configuro DOCKER_BUILDKIT=1 nos pipelines de CI/CD.

5. Fixe a versão da imagem-base

Evite FROM golang:latest: isso pode causar surpresas. Em produção, fixe a versão:

# Ruim: comportamento imprevisível
FROM golang:latest

# Bom: versão explícita
FROM golang:1.21.5-alpine3.18

# Melhor: versão fixada com SHA256
FROM golang@sha256:abc123...

Já tive uma implantação que começou a falhar sem nenhuma alteração no projeto. Depois de muito investigar, descobri que golang:latest tinha sido atualizada e a nova versão era incompatível com uma dependência. Desde então, sempre fixo a versão.

6. Organize COPY para aproveitar o cache

A ordem das instruções COPY importa. Arquivos que mudam pouco devem entrar primeiro; os que mudam muito, depois:

# Ordem adequada
FROM golang:1.21-alpine AS builder
WORKDIR /app

# 1. Primeiro, copia os arquivos de dependências (mudam pouco)
COPY go.mod go.sum ./
RUN go mod download

# 2. Depois, copia o código-fonte (muda com frequência)
COPY . .
RUN go build -o myapp

# Ordem inadequada
COPY . .  # Copia todos os arquivos de uma vez
RUN go mod download && go build -o myapp

Na primeira forma, alterações no código não causam um novo download das dependências. Na segunda, qualquer arquivo modificado invalida a camada e baixa tudo novamente.

Esse princípio apareceu várias vezes nos exemplos anteriores porque é a base da otimização de cache.

Como escolher a imagem-base

Escolher uma imagem-base pode ser confuso. Alpine, Slim, Distroless ou Scratch: qual usar? Esta tabela resume minha experiência prática:

Tipo de imagemTamanhoConteúdoVantagensDesvantagensIndicação
scratch0 MBTotalmente vaziaMenor tamanho e menor superfície de ataqueSem shell, ferramentas de depuração ou certificados CAGo e Rust compilados estaticamente
distroless2–20 MBBibliotecas de runtime e certificados CASem shell, mais segura e pequenaDifícil de depurarGo, Java, Rust e Node.js
alpine5–40 MBmusl libc e gerenciador de pacotesPequena e com shellProblemas de compatibilidade com musl libc e DNSAplicações sem dependência de glibc
slim70–120 MBDebian/Ubuntu reduzidoglibc completa e boa compatibilidadeUm pouco maiorAplicações com dependências em bibliotecas C
Imagem completa200 MB+Sistema completoInclui todas as ferramentasGrande e com mais risco de segurançaNão recomendada para produção

Minha estratégia é a seguinte.

Aplicações Go

  • Primeira opção: gcr.io/distroless/static-debian11 (inclui certificados CA)
  • Alternativa: scratch (exige copiar certificados CA e dados de fuso horário)
  • Com CGO: gcr.io/distroless/base-debian11 ou alpine

Aplicações Java

  • Primeira opção: openjdk:17-jre-slim (JRE completo e boa compatibilidade)
  • Opção avançada: gcr.io/distroless/java17-debian11 (menor e mais segura, mas difícil de depurar)
  • Evite: openjdk:17-alpine (a JVM pode apresentar alguns problemas no Alpine)

Aplicações Rust

  • Primeira opção: gcr.io/distroless/cc-debian11 (inclui as bibliotecas de runtime C)
  • Com compilação totalmente estática: scratch
  • Evite: imagem rust completa (desnecessária em produção)

Armadilhas do Alpine

Muitos artigos recomendam Alpine, mas já encontrei alguns problemas:

  1. Compatibilidade com musl libc: o Alpine usa musl libc, não glibc. Alguns programas, especialmente em Java e Python, podem apresentar comportamentos estranhos
  2. Resolução de DNS: programas Go no Alpine às vezes têm timeout de DNS e exigem configuração especial
  3. Fuso horário: os dados de fuso não vêm instalados; é preciso adicionar o pacote tzdata

Minha recomendação é simples: se você não conhece bem o Alpine, prefira uma imagem -slim. Ela pode acrescentar algumas dezenas de MB, mas evita várias armadilhas.

Sobre Distroless

Distroless é uma família de imagens mínimas criada pelo Google. Sua característica principal é não ter shell nem gerenciador de pacotes: ela contém apenas o necessário para executar a aplicação.

Vantagens:

  • Superfície de ataque muito pequena; sem shell, fica mais difícil executar comandos arbitrários
  • Tamanho reduzido
  • Manutenção oficial e atualizações regulares de segurança

Desvantagens:

  • Depuração difícil: não é possível simplesmente usar docker exec -it para entrar no contêiner
  • Todos os arquivos precisam estar preparados durante o build

Hoje uso Distroless na maior parte dos ambientes de produção. Para depurar, uso --target builder e entro no contêiner do estágio de build; o runtime de produção continua em Distroless.

Uma árvore de decisão

Se ainda houver dúvida, siga este fluxo:

Qual é a linguagem da sua aplicação?
├─ Go
│  ├─ Código Go puro → distroless/static ou scratch
│  └─ Usa CGO → distroless/base ou alpine
├─ Java
│  ├─ Prioridade para estabilidade → openjdk:jre-slim
│  └─ Prioridade para tamanho → distroless/java
├─ Rust
│  ├─ Rust puro → distroless/cc ou scratch
│  └─ Usa biblioteca C → distroless/cc
└─ Outra
   └─ Comece com slim; mude se houver algum problema

Essa árvore veio de projetos reais e cobre aproximadamente 90% dos casos.

O essencial cabe em três pontos

A ideia do build multi-stage é simples:

  1. Estágio de build: compile o código em uma imagem completa
  2. Estágio de runtime: execute a aplicação em uma imagem mínima
  3. COPY --from: transfira somente os artefatos necessários

É só isso. O efeito, porém, é enorme: o tamanho pode cair de 70% a 90%; com cache bem organizado, o build fica duas a três vezes mais rápido; e a segurança melhora bastante.

Meu hábito hoje é escrever um Dockerfile multi-stage assim que inicio um projeto. Seja Go, Java ou Rust, esse é o padrão.

Algumas ações que você pode executar agora:

Para fazer hoje:

  • Escolha um projeto existente e teste um build multi-stage
  • Use docker images para comparar o tamanho antes e depois
  • Se o resultado for bom, aplique o padrão aos outros projetos

Para estudar com mais profundidade:

  • Consulte as boas práticas na documentação oficial do Docker
  • Use a ferramenta dive para analisar as camadas da imagem (docker run --rm -it wagoodman/dive:latest your-image)
  • Explore os recursos avançados do BuildKit

Otimização no longo prazo:

  • Configure cache de imagens no pipeline de CI/CD
  • Atualize regularmente as versões das imagens-base para corrigir vulnerabilidades
  • Analise as imagens com ferramentas de segurança, como o Trivy

Otimizar imagens Docker não é tecnicamente difícil; o desafio é criar esse hábito. Muitas equipes usam imagens configuradas anos atrás, que continuam crescendo sem manutenção. Só quando a implantação fica lenta demais alguém se lembra de otimizá-las.

Não espere chegar a esse ponto. Teste um build multi-stage hoje. Você verá como uma imagem pode ficar pequena e como o processo de build pode acelerar.

Conte nos comentários: qual é o tamanho das imagens dos seus projetos? Quanto elas diminuíram após a otimização? Que problemas você encontrou? Tenho curiosidade para saber.

Processo completo para otimizar imagens Docker com builds multi-stage

Redução extrema de imagens de Go, Java e Rust de GB para MB, com Dockerfiles completos e armadilhas encontradas na prática

⏱️ Estimated time: 1 hr

  1. 1

    Step 1: Entender o princípio do build multi-stage

    Princípio do build multi-stage:
    • Linguagens compiladas precisam de compiladores e ferramentas de build para transformar o código-fonte em um executável
    • Esses componentes, porém, não são necessários em tempo de execução
    • Dockerfiles tradicionais colocam Maven, Gradle e o compilador Go dentro da imagem final
    • É como levar para a casa nova a furadeira e o cimento usados na reforma — totalmente desnecessário

    O ponto central do problema:
    • Linguagens compiladas precisam de compiladores e ferramentas de build para gerar executáveis
    • Esses componentes não são necessários em tempo de execução

    Etapas do build multi-stage:
    • Primeiro estágio (build): usa uma imagem completa, com compilador e ferramentas de build, para compilar o código-fonte
    • Segundo estágio (runtime): usa uma imagem mínima, apenas com o runtime, e copia o executável do primeiro estágio
    • A imagem final contém somente o runtime e o executável
  2. 2

    Step 2: Aplicar build multi-stage em Go

    Build multi-stage em Go:

    Primeiro estágio (build):
    • FROM golang:1.21 AS builder
    • WORKDIR /app
    • COPY go.mod go.sum ./
    • RUN go mod download
    • COPY . .
    • RUN go build -o app

    Segundo estágio (runtime):
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/app .
    • CMD ["./app"]

    Resultado da otimização:
    • Apenas o binário compilado é copiado
    • A imagem cai de 295 MB para 6,47 MB (redução de 98%)
    • A implantação passa de 5 minutos para menos de 1 minuto
    • O tamanho da imagem diminui drasticamente
  3. 3

    Step 3: Aplicar builds multi-stage em Java e Rust

    Build multi-stage em Java:

    Primeiro estágio (build):
    • FROM maven:3.9 AS builder
    • WORKDIR /app
    • COPY pom.xml .
    • RUN mvn dependency:go-offline
    • COPY src ./src
    • RUN mvn clean package -DskipTests

    Segundo estágio (runtime):
    • FROM eclipse-temurin:17-jre-alpine
    • WORKDIR /app
    • COPY --from=builder /app/target/app.jar app.jar
    • CMD ["java", "-jar", "app.jar"]

    Resultado: a imagem cai de 650 MB para 89 MB (redução de 86%)

    Build multi-stage em Rust:

    Primeiro estágio (build):
    • FROM rust:1.75 AS builder
    • WORKDIR /app
    • COPY Cargo.toml Cargo.lock .
    • RUN cargo fetch
    • COPY src ./src
    • RUN cargo build --release

    Segundo estágio (runtime):
    • FROM alpine:latest
    • RUN apk --no-cache add ca-certificates
    • WORKDIR /root/
    • COPY --from=builder /app/target/release/app .
    • CMD ["./app"]

    Resultado: a imagem cai de 2 GB para 11 MB (redução de 99,4%)
  4. 4

    Step 4: Adotar boas práticas e otimização contínua

    Boas práticas:
    1. Escolha uma imagem de runtime adequada
    • Para Go, use distroless/static ou scratch
    • Para Java, use openjdk:jre-slim ou distroless/java
    • Para Rust, use distroless/cc ou scratch

    2. Use .dockerignore para excluir arquivos desnecessários

    3. Aproveite o cache de build, copiando primeiro os arquivos de dependências e depois o código-fonte

    4. Atualize regularmente as versões das imagens-base para corrigir vulnerabilidades

    Otimização no longo prazo:
    • Configure cache de imagens no pipeline de CI/CD
    • Atualize regularmente as imagens-base para corrigir vulnerabilidades
    • Analise as imagens com ferramentas de segurança, como o Trivy

    Otimizar imagens Docker não é tecnicamente difícil; o desafio é criar esse hábito. Muitas equipes usam imagens configuradas anos atrás, que continuam crescendo sem manutenção. Só quando a implantação fica lenta demais alguém se lembra de otimizá-las.

FAQ

O que é um build multi-stage no Docker e por que usá-lo?
Linguagens compiladas precisam de compiladores e ferramentas de build para transformar o código-fonte em executáveis, mas esses componentes não são necessários em tempo de execução. Dockerfiles tradicionais incluem Maven, Gradle e o compilador Go na imagem final. É como levar para a casa nova a furadeira e o cimento usados na reforma — totalmente desnecessário.

O ponto central é que compiladores e ferramentas de build são necessários para gerar o executável, não para executá-lo.

Etapas do build multi-stage:
• O primeiro estágio, de build, usa uma imagem completa com compilador e ferramentas para compilar o código-fonte
• O segundo estágio, de runtime, usa uma imagem mínima e copia o executável do primeiro estágio
• A imagem final contém somente o runtime e o executável
Quanto um build multi-stage pode reduzir uma imagem Docker?
Resultados da otimização:
• A aplicação Go caiu de 295 MB para 6,47 MB (redução de 98%)
• A aplicação Java Spring Boot foi de 650 MB para 89 MB (redução de 86%)
• A imagem Rust passou de 2 GB para 11 MB (redução de 99,4%)
• A implantação passou de 5 minutos para menos de 1 minuto

Em um caso real, uma aplicação Spring Boot simples gerava uma imagem Docker de 650 MB. Com o build multi-stage, ela ficou com apenas 89 MB, e a implantação caiu de 5 minutos para menos de 1 minuto.
Como criar um build multi-stage para Go?
Build multi-stage em Go:

O primeiro estágio compila com a imagem golang:1.21:
• FROM golang:1.21 AS builder
• WORKDIR /app
• COPY go.mod go.sum ./
• RUN go mod download
• COPY . .
• RUN go build -o app

O segundo estágio executa com a imagem alpine:latest:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/app .
• CMD ["./app"]

Apenas o binário compilado é copiado, reduzindo a imagem de 295 MB para 6,47 MB, ou 98%. A implantação passa de 5 minutos para menos de 1 minuto e a imagem fica muito menor.
Como criar builds multi-stage para Java e Rust?
Build multi-stage em Java:

O primeiro estágio compila com a imagem maven:3.9:
• FROM maven:3.9 AS builder
• WORKDIR /app
• COPY pom.xml .
• RUN mvn dependency:go-offline
• COPY src ./src
• RUN mvn clean package -DskipTests

O segundo estágio executa com eclipse-temurin:17-jre-alpine:
• FROM eclipse-temurin:17-jre-alpine
• WORKDIR /app
• COPY --from=builder /app/target/app.jar app.jar
• CMD ["java", "-jar", "app.jar"]

A imagem cai de 650 MB para 89 MB, uma redução de 86%.

Build multi-stage em Rust:

O primeiro estágio compila com a imagem rust:1.75:
• FROM rust:1.75 AS builder
• WORKDIR /app
• COPY Cargo.toml Cargo.lock .
• RUN cargo fetch
• COPY src ./src
• RUN cargo build --release

O segundo estágio executa com alpine:latest:
• FROM alpine:latest
• RUN apk --no-cache add ca-certificates
• WORKDIR /root/
• COPY --from=builder /app/target/release/app .
• CMD ["./app"]

A imagem cai de 2 GB para 11 MB, uma redução de 99,4%.
Quais são as boas práticas para builds multi-stage?
Boas práticas:
1) Escolha uma imagem de runtime adequada:
• Para Go, use distroless/static ou scratch
• Para Java, use openjdk:jre-slim ou distroless/java
• Para Rust, use distroless/cc ou scratch

2) Use .dockerignore para excluir arquivos desnecessários

3) Aproveite o cache de build, copiando primeiro os arquivos de dependências e depois o código-fonte

4) Atualize regularmente as versões das imagens-base para corrigir vulnerabilidades

Otimização no longo prazo:
• Configure cache de imagens no pipeline de CI/CD
• Atualize regularmente as imagens-base para corrigir vulnerabilidades
• Analise as imagens com ferramentas de segurança, como o Trivy

Otimizar imagens Docker não é tecnicamente difícil; o desafio é criar esse hábito. Muitas equipes usam imagens configuradas anos atrás, que continuam crescendo sem manutenção. Só quando a implantação fica lenta demais alguém se lembra de otimizá-las.

20 min de leitura · Publicado em: 17 dez 2025 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog