Go Admin

O que Todo Dev Deve Saber sobre Containerizando Aplicações Go: Dockerfile Multi-stage e Distroless Já leu

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

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.

Referências


Artigos relacionados