Conteúdo

Tutorial de builds em múltiplos estágios no Docker: reduza suas imagens

Muitos programas precisam de compiladores, pacotes de desenvolvimento e ferramentas de build durante a compilação, mas só precisam do resultado compilado durante a execução. Se você colocar tudo na mesma imagem, a imagem final levará um monte de coisas desnecessárias.

Um build em múltiplos estágios permite usar vários FROM em um Dockerfile, e cada FROM inicia um estágio. Somente o conteúdo do último estágio é mantido; os estágios anteriores servem apenas para gerar arquivos.

Build em múltiplos estágios: o primeiro estágio inclui ferramentas de compilação e código-fonte, e apenas o resultado compilado é copiado para a imagem final menor
Apenas os arquivos copiados para o último estágio com COPY entram na imagem

Experimento: um estágio vs. múltiplos estágios

Vamos comparar usando um programa web simples em Go (main.go):

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "Hello from a distroless container!")
	})
	fmt.Println("Listening on :8080")
	http.ListenAndServe(":8080", nil)
}

Um estágio (Dockerfile.single): compile na imagem oficial do Go e use a mesma imagem para executar:

FROM golang:1.27
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server .
CMD ["/server"]

Múltiplos estágios (Dockerfile):

# Estágio um: compilação
FROM golang:1.27 AS builder
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .

# Estágio dois: execução
FROM gcr.io/distroless/static-debian13
COPY --from=builder /out/server /server
USER nonroot
ENTRYPOINT ["/server"]
  • AS builder dá um nome ao primeiro estágio.
  • COPY --from=builder copia arquivos daquele estágio.
  • gcr.io/distroless/static-debian13 é uma imagem mínima mantida pelo Google: não tem shell nem gerenciador de pacotes, apenas os arquivos essenciais para executar programas estáticos.

Faça o build da versão em múltiplos estágios:

$ docker build -t go-multi .

[+] Building 51.7s (14/14) FINISHED                                                  docker:default
 => [internal] load build definition from Dockerfile                                           0.0s
 => => transferring dockerfile: 326B                                                           0.0s
 => [internal] load metadata for gcr.io/distroless/static-debian13:latest                      2.1s
 => [internal] load metadata for docker.io/library/golang:1.27                                 0.4s
 => [internal] load .dockerignore                                                              0.0s
 => => transferring context: 2B                                                                0.0s
 => [builder 1/6] FROM docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be047681  0.0s
 => => resolve docker.io/library/golang:1.27@sha256:e432b43af23a9328d56a7c499be0476810aa344ac  0.0s
 => [stage-1 1/2] FROM gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0  3.4s
 => => resolve gcr.io/distroless/static-debian13:latest@sha256:58133991db06659feaabe0f4e97a35  0.0s
 => => … (download and extraction of layers omitted)
 => [internal] load build context                                                              0.0s
 => => transferring context: 120B                                                              0.0s
 => CACHED [builder 2/6] WORKDIR /src                                                          0.0s
 => [builder 3/6] COPY go.mod ./                                                               0.0s
 => [builder 4/6] RUN go mod download                                                          0.5s
 => [builder 5/6] COPY . .                                                                     0.0s
 => [builder 6/6] RUN CGO_ENABLED=0 go build -o /out/server .                                 47.6s
 => [stage-1 2/2] COPY --from=builder /out/server /server                                      0.0s
 => exporting to image                                                                         1.1s
 => => exporting layers                                                                        0.6s
 => => exporting manifest sha256:8b506df2e51c2cbf7df7b452669cde4652ac2d83d924e8e5b67ed8f37b6a  0.0s
 => => exporting config sha256:f1efccd3fdd70ffd1054b9ed0ff4154e3be0d93324c196bd46438e12a4b372  0.0s
 => => exporting attestation manifest sha256:4cf5049d1a21697ca953ccd67da3874da40c466c9db43ac9  0.0s
 => => exporting manifest list sha256:463874676a48cc064e399fd1968bcd5e301d77f20909360c40fff1c  0.0s
 => => naming to docker.io/library/go-multi:latest                                             0.0s
 => => unpacking to docker.io/library/go-multi:latest                                          0.4s

Você pode ver as etapas dos dois estágios identificadas como [builder …] e [stage-1 …]. O BuildKit baixa ao mesmo tempo as imagens base necessárias para os dois estágios. Compare os tamanhos das duas imagens:

$ docker images --filter reference=go-single --filter reference=go-multi
IMAGE              ID             DISK USAGE   CONTENT SIZE   EXTRA
go-multi:latest    463874676a48       20.2MB         5.77MB
go-single:latest   c6c844f9076e       1.43GB          343MB

É o mesmo programa: 1.43 GB em um estágio e 20.2 MB em múltiplos estágios, uma diferença de 70 vezes. A imagem de um estágio leva todo o compilador Go e o sistema Debian; a imagem final em múltiplos estágios contém apenas o executável compilado e os arquivos básicos do distroless.

Quanto menor a imagem, mais rápido o download e a implantação, e menos pacotes potencialmente vulneráveis. Vamos executar:

$ docker run -d --name goapp -p 8080:8080 go-multi
d474e2de30d65e21a1f6395aac76c185fcfc8d4d6caa1240ab6ef3ebd7406e25

$ curl -s http://localhost:8080
Hello from a distroless container!

$ docker exec goapp sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

$ docker rm -f goapp
goapp

A imagem distroless nem sequer tem sh (executable file not found). Mesmo que um invasor comprometa o programa, será difícil fazer algo dentro do contêiner. A desvantagem é que a depuração fica menos prática; quando precisar, você pode usar a versão :debug, que inclui ferramentas de depuração.

Exemplos em múltiplos estágios para outras linguagens

Frontend Node.js: use Nginx depois do build

Depois do build, projetos frontend como React, Vue e Astro têm apenas arquivos estáticos e não precisam do ambiente de execução do Node.js:

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:1.30-alpine
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80

As ferramentas de build e todo o node_modules ficam fora da imagem final.

Backend Node.js: mantenha apenas os pacotes de produção

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

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json .
USER node
CMD ["node", "dist/index.js"]

Os estágios deps e build são independentes, então o BuildKit os compila em paralelo.

Java: compile com o JDK e execute com o JRE

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

FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
CMD ["java", "-jar", "app.jar"]

Faça o build de um estágio específico: --target

Com os estágios, você pode criar versões de desenvolvimento e de produção no mesmo Dockerfile:

FROM node:24-alpine AS base
WORKDIR /app
COPY package.json ./

FROM base AS dev
ENV NODE_ENV=development
COPY . .
CMD ["npm", "run", "dev"]

FROM base AS prod
ENV NODE_ENV=production
COPY . .
CMD ["node", "server.js"]
$ docker build -q --target dev -t target-demo:dev .
sha256:3d0fdd2a77233426c90dfbc575aafaef652a1393cb2730d066e627362860ec09

$ docker build -q --target prod -t target-demo:prod .
sha256:9e14e3363d26d46ed628e49b4cd806a29e9699a5be2e6e4078976bd380632568

$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:dev
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=development] [npm run dev]

$ docker inspect --format '{{.Config.Env}} {{.Config.Cmd}}' target-demo:prod
[PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin NODE_VERSION=24.21.0 YARN_VERSION=1.22.22 NODE_ENV=production] [node server.js]

--target dev faz o build somente até o estágio dev; --target prod ignora dev. As variáveis de ambiente e os comandos de inicialização das duas imagens são diferentes. -q faz com que docker build imprima apenas o ID da imagem.

Copie de uma imagem externa

Com COPY --from, você também pode especificar diretamente qualquer imagem, sem precisar usar um estágio deste arquivo:

COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf /etc/nginx/nginx.conf
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

A segunda linha é um uso comum: copie o executável diretamente da imagem oficial do uv sem instalá-lo separadamente. Veja aplicação Python.

Resumo

  • Cada FROM inicia um novo estágio, que você nomeia com AS nome.
  • O último estágio é a imagem de saída e contém apenas o que foi copiado com COPY --from.
  • Deixe ferramentas de compilação, código-fonte e pacotes de desenvolvimento nos estágios anteriores para reduzir a imagem em dezenas de vezes.
  • --target faz o build somente até o estágio indicado, como um estágio de testes.

Exercícios

Exercício 1: adicione um estágio FROM base AS test ao Dockerfile em múltiplos estágios para executar os testes. Como executar apenas os testes no CI sem fazer o build da imagem final?

Resposta
docker build --target test .

Veja um exemplo prático na seção --target de docker build.

Exercício 2: use docker history para comparar as imagens Go de um estágio e de múltiplos estágios deste capítulo. A camada do compilador Go está na imagem final?

Dica

A imagem final em múltiplos estágios contém apenas a camada base do distroless e a camada COPY --from=build; a camada do conjunto de ferramentas do Go não aparece.

Perguntas frequentes

Um build em múltiplos estágios deixa o build mais lento?

Não. O BuildKit compila em paralelo os estágios que não dependem uns dos outros e só compila os estágios necessários para o estágio final. Veja na prática o exemplo de TypeScript em [aplicação Node.js](/pt/docker/nodejs/).

As imagens dos estágios intermediários ficam no computador?

Os estágios intermediários não geram imagens com nome, mas suas camadas ficam no cache de build e podem ser removidas com docker builder prune.

Posso usar outra imagem com COPY --from?

Sim. Por exemplo, COPY --from=nginx:1.30-alpine /etc/nginx/nginx.conf . copia arquivos diretamente de uma imagem externa. Há um exemplo neste capítulo.

Atualizado 14 de outubro de 2026