Entendendo o Problema: Por Que Multi-stage e Distroless?
Quando você conteineriza uma aplicação Go, há um dilema clássico que todo desenvolvedor enfrenta: o tamanho final da imagem Docker. Uma imagem padrão, construída de forma ingênua, pode chegar facilmente a 1GB ou mais. Isso ocorre porque você precisa de todas as ferramentas de compilação (Go SDK, dependências de build, cache do compilador) apenas para gerar um único binário executável.
A solução é dupla. Primeiro, você usa multi-stage builds, que permite compilar seu código em um container com todas as ferramentas necessárias, mas depois copia apenas o binário resultante para um container final. Segundo, você substitui a imagem base tradicional (como Ubuntu ou Debian) por uma imagem distroless, que contém apenas o essencial para executar seu programa: o binário, algumas bibliotecas críticas e nada mais. O resultado? Imagens de apenas 20-50MB, mais rápidas de transferir, mais seguras (menos superfície de ataque) e mais baratas de armazenar.
Dockerfile Multi-stage: Construindo em Camadas
O Conceito Fundamental
Um Dockerfile multi-stage é simplesmente um arquivo Docker com múltiplos comandos FROM. Cada FROM inicia uma nova etapa, um novo "container de trabalho". As etapas anteriores são descartadas, a menos que você copie explicitamente artefatos delas. Isso resolve o problema de bloat porque você não precisa enviar para produção as ferramentas de compilação.
Considere o fluxo: na primeira etapa (stage 1), você usa uma imagem grande contendo Go, git e outras dependências de build. Você baixa as dependências do seu projeto (go mod download), compila seu código (go build) e gera um binário. Na segunda etapa (stage 2), você começa do zero com uma imagem minúscula e copia apenas o binário compilado da etapa anterior.
Exemplo Prático: Aplicação Go Simples
Vou mostrar um exemplo real. Suponha que você tenha uma aplicação Go que expõe uma API REST simples:
package main
import (
"fmt"
"net/http"
)
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "OK")
})
fmt.Println("Servidor iniciado na porta 8080")
http.ListenAndServe(":8080", nil)
}
Um Dockerfile multi-stage para esta aplicação ficaria assim:
# Stage 1: Build
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 -a -installsuffix cgo -o main .
# Stage 2: Runtime
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]
Analise linha por linha: no stage builder, você herda de golang:1.21-alpine (cerca de 400MB), copia seus arquivos de módulo, baixa dependências e compila. Note CGO_ENABLED=0 — isso força Go a compilar estaticamente, sem ligações dinâmicas com a libc do sistema, porque na segunda etapa você pode não ter as mesmas bibliotecas.
No stage final, você herda de alpine:latest (apenas 7MB) e copia o binário já compilado com COPY --from=builder. Pronto. Sua imagem final terá por volta de 15-20MB.
Por Que CGO_ENABLED=0 Importa
Go pode ser compilado de duas formas: estaticamente (CGO_ENABLED=0) ou dinamicamente (CGO_ENABLED=1, o padrão). Se você compilar com CGO ativado e depois tentar rodar em uma imagem distroless ou alpine que não tenha a libc esperada, seu programa falhará com erros de biblioteca não encontrada. Sempre use CGO_ENABLED=0 em builds multi-stage, a menos que você realmente saiba que precisa de bibliotecas C dinâmicas.
Imagens Distroless: Segurança e Leveza Extrema
O Que São Imagens Distroless?
Imagens distroless são construídas pelo Google especificamente para rodar linguagens compiladas. Diferente de Alpine (que ainda é um Linux completo, com shell, apt, etc.), uma imagem distroless contém apenas: um glibc minimalista, bibliotecas de tempo de execução essenciais e nada mais. Sem shell, sem gerenciador de pacotes, sem utilitários. Você não consegue nem fazer docker run -it distroless /bin/sh porque não há shell.
Isso traz dois benefícios imensuráveis: segurança (CVEs não-exploráveis sem shell ou ferramentas de ataque) e tamanho (geralmente menores que Alpine). Para aplicações Go pura (sem CGO), a imagem distroless é praticamente perfeita.
Exemplo Prático: Multi-stage com Distroless
Aqui está a mesma aplicação, mas usando distroless:
# Stage 1: Build
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 -a -installsuffix cgo -o main .
# Stage 2: Runtime com Distroless
FROM gcr.io/distroless/base-debian11
WORKDIR /app
COPY --from=builder /app/main .
EXPOSE 8080
ENTRYPOINT ["/app/main"]
A diferença é sutil mas poderosa: ao invés de FROM alpine:latest, você usa FROM gcr.io/distroless/base-debian11. Essa imagem tem aproximadamente 19MB (comparada aos 7MB de Alpine, mas com muito menos "gordura" real). O binário Go rodará perfeitamente porque Go está linkado estaticamente.
Escolhendo a Imagem Distroless Correta
Google oferece várias variantes de distroless. As principais são:
gcr.io/distroless/base-debian11: Contém libc e outras bibliotecas padrão. Use quando seu programa depende de bibliotecas do sistema.gcr.io/distroless/static-debian11: Ainda mais minimalista. Use apenas se seu programa for 100% static-linked (Go puro).gcr.io/distroless/cc-debian11: Inclui suporte a C++ e C. Use se tiver CGO ou dependências C.
Para Go puro, static-debian11 é o ideal:
# Stage 1: Build
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 -a -installsuffix cgo -o main .
# Stage 2: Runtime com Distroless Static
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/main /app/main
EXPOSE 8080
ENTRYPOINT ["/app/main"]
Essa imagem terá menos de 10MB de overhead, porque static-debian11 é apenas um scratch (imagem vazia) com umas camadas mínimas de metadados.
Otimizações Avançadas e Boas Práticas
Cacheando Dependências Correctamente
Go usa módulos e o comando go mod download pode ser custoso (especialmente com muitas dependências). Um erro comum é copiar todo o código primeiro e depois fazer download das dependências. Melhor prática: copie go.mod e go.sum antes de copiar o código-fonte. Assim, se o código mudar mas as dependências não, Docker reutiliza aquela camada do cache.
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Copiar apenas os arquivos de módulo
COPY go.mod go.sum ./
RUN go mod download
# Depois copiar o código (muda frequentemente)
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
Build Args para Versionamento
É bom incluir informações de build (versão, commit hash) no seu binário. Use build args:
FROM golang:1.21-alpine AS builder
ARG VERSION=dev
ARG COMMIT=unknown
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-X main.Version=${VERSION} -X main.Commit=${COMMIT}" \
-a -installsuffix cgo -o main .
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/main /app/main
EXPOSE 8080
ENTRYPOINT ["/app/main"]
No seu código Go:
package main
var (
Version = "dev"
Commit = "unknown"
)
func main() {
fmt.Printf("Versão: %s, Commit: %s\n", Version, Commit)
// ... resto do código
}
Para buildar: docker build --build-arg VERSION=1.0.0 --build-arg COMMIT=abc123def .
Reduzindo Ainda Mais com .dockerignore
Certifique-se de que você não está copiando arquivos desnecessários. Crie um arquivo .dockerignore:
.git
.gitignore
README.md
.env
vendor
test
*.test
Isso evita que você copie gigabytes de histórico git ou diretórios de teste para a camada de build.
Exemplo Real: Aplicação Completa com Go Modules
Para um projeto mais realista com dependências externas:
# Stage 1: Build
FROM golang:1.21-alpine AS builder
WORKDIR /app
# Cache de dependências
COPY go.mod go.sum ./
RUN go mod download
# Cópia do código
COPY . .
# Build com flags otimizadas
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
-ldflags="-s -w" \
-o main .
# Stage 2: Runtime Distroless
FROM gcr.io/distroless/static-debian11
COPY --from=builder /app/main /app/main
EXPOSE 8080
ENTRYPOINT ["/app/main"]
Note a flag -ldflags="-s -w": ela remove symbols e debug info do binário final, reduzindo seu tamanho em até 30%. Use isso em produção, mas não em desenvolvimento (perderá a capacidade de debugar).
Conclusão
Você aprendeu que containers Docker para Go não precisam ser pesados. Usando multi-stage builds, você separa o environment de compilação do de execução, eliminando gigabytes de ferramentas desnecessárias. Combinado com imagens distroless, você chega a imagens finais menores que 20MB sem sacrificar funcionalidade ou segurança.
A segunda lição é que cada detalhe importa: CGO_ENABLED=0 garante que você não depende de bibliotecas dinâmicas, .dockerignore impede cópias desnecessárias, e o cache correto de módulos economiza tempo de rebuild. Por fim, distroless não é apenas um luxo, é uma prática recomendada de segurança em produção. Menos código desnecessário significa menos superfície de ataque e menos atualizações de segurança para gerenciar.