Go

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

12 min de leitura

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

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

<h2>Entendendo o Problema: Por Que Multi-stage e Distroless?</h2>

<p>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.</p>

<p>A solução é dupla. Primeiro, você usa <strong>multi-stage builds</strong>, 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 <strong>distroless</strong>, 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.</p>

<h2>Dockerfile Multi-stage: Construindo em Camadas</h2>

<h3>O Conceito Fundamental</h3>

<p>Um Dockerfile multi-stage é simplesmente um arquivo Docker com múltiplos comandos <code>FROM</code>. Cada <code>FROM</code> inicia uma nova etapa, um novo &quot;container de trabalho&quot;. 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.</p>

<p>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 (<code>go mod download</code>), compila seu código (<code>go build</code>) 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.</p>

<h3>Exemplo Prático: Aplicação Go Simples</h3>

<p>Vou mostrar um exemplo real. Suponha que você tenha uma aplicação Go que expõe uma API REST simples:</p>

<pre><code class="language-go">package main

import (

&quot;fmt&quot;

&quot;net/http&quot;

)

func main() {

http.HandleFunc(&quot;/health&quot;, func(w http.ResponseWriter, r *http.Request) {

fmt.Fprintf(w, &quot;OK&quot;)

})

fmt.Println(&quot;Servidor iniciado na porta 8080&quot;)

http.ListenAndServe(&quot;:8080&quot;, nil)

}</code></pre>

<p>Um Dockerfile multi-stage para esta aplicação ficaria assim:</p>

<pre><code class="language-dockerfile"># 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 [&quot;./main&quot;]</code></pre>

<p>Analise linha por linha: no stage builder, você herda de <code>golang:1.21-alpine</code> (cerca de 400MB), copia seus arquivos de módulo, baixa dependências e compila. Note <code>CGO_ENABLED=0</code> — 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.</p>

<p>No stage final, você herda de <code>alpine:latest</code> (apenas 7MB) e copia o binário já compilado com <code>COPY --from=builder</code>. Pronto. Sua imagem final terá por volta de 15-20MB.</p>

<h3>Por Que <code>CGO_ENABLED=0</code> Importa</h3>

<p>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 <code>CGO_ENABLED=0</code> em builds multi-stage, a menos que você realmente saiba que precisa de bibliotecas C dinâmicas.</p>

<h2>Imagens Distroless: Segurança e Leveza Extrema</h2>

<h3>O Que São Imagens Distroless?</h3>

<p>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 <code>docker run -it distroless /bin/sh</code> porque não há shell.</p>

<p>Isso traz dois benefícios imensuráveis: <strong>segurança</strong> (CVEs não-exploráveis sem shell ou ferramentas de ataque) e <strong>tamanho</strong> (geralmente menores que Alpine). Para aplicações Go pura (sem CGO), a imagem distroless é praticamente perfeita.</p>

<h3>Exemplo Prático: Multi-stage com Distroless</h3>

<p>Aqui está a mesma aplicação, mas usando distroless:</p>

<pre><code class="language-dockerfile"># 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 [&quot;/app/main&quot;]</code></pre>

<p>A diferença é sutil mas poderosa: ao invés de <code>FROM alpine:latest</code>, você usa <code>FROM gcr.io/distroless/base-debian11</code>. Essa imagem tem aproximadamente 19MB (comparada aos 7MB de Alpine, mas com muito menos &quot;gordura&quot; real). O binário Go rodará perfeitamente porque Go está linkado estaticamente.</p>

<h3>Escolhendo a Imagem Distroless Correta</h3>

<p>Google oferece várias variantes de distroless. As principais são:</p>

<ul>

<li><strong><code>gcr.io/distroless/base-debian11</code></strong>: Contém libc e outras bibliotecas padrão. Use quando seu programa depende de bibliotecas do sistema.</li>

<li><strong><code>gcr.io/distroless/static-debian11</code></strong>: Ainda mais minimalista. Use apenas se seu programa for 100% static-linked (Go puro).</li>

<li><strong><code>gcr.io/distroless/cc-debian11</code></strong>: Inclui suporte a C++ e C. Use se tiver CGO ou dependências C.</li>

</ul>

<p>Para Go puro, <code>static-debian11</code> é o ideal:</p>

<pre><code class="language-dockerfile"># 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 [&quot;/app/main&quot;]</code></pre>

<p>Essa imagem terá menos de 10MB de overhead, porque <code>static-debian11</code> é apenas um scratch (imagem vazia) com umas camadas mínimas de metadados.</p>

<h2>Otimizações Avançadas e Boas Práticas</h2>

<h3>Cacheando Dependências Correctamente</h3>

<p>Go usa módulos e o comando <code>go mod download</code> 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 <code>go.mod</code> e <code>go.sum</code> <em>antes</em> de copiar o código-fonte. Assim, se o código mudar mas as dependências não, Docker reutiliza aquela camada do cache.</p>

<pre><code class="language-dockerfile">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 .</code></pre>

<h3>Build Args para Versionamento</h3>

<p>É bom incluir informações de build (versão, commit hash) no seu binário. Use build args:</p>

<pre><code class="language-dockerfile">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=&quot;-X main.Version=${VERSION} -X main.Commit=${COMMIT}&quot; \

-a -installsuffix cgo -o main .

FROM gcr.io/distroless/static-debian11

COPY --from=builder /app/main /app/main

EXPOSE 8080

ENTRYPOINT [&quot;/app/main&quot;]</code></pre>

<p>No seu código Go:</p>

<pre><code class="language-go">package main

var (

Version = &quot;dev&quot;

Commit = &quot;unknown&quot;

)

func main() {

fmt.Printf(&quot;Versão: %s, Commit: %s\n&quot;, Version, Commit)

// ... resto do código

}</code></pre>

<p>Para buildar: <code>docker build --build-arg VERSION=1.0.0 --build-arg COMMIT=abc123def .</code></p>

<h3>Reduzindo Ainda Mais com <code>.dockerignore</code></h3>

<p>Certifique-se de que você não está copiando arquivos desnecessários. Crie um arquivo <code>.dockerignore</code>:</p>

<pre><code>.git

.gitignore

README.md

.env

vendor

test

*.test</code></pre>

<p>Isso evita que você copie gigabytes de histórico git ou diretórios de teste para a camada de build.</p>

<h3>Exemplo Real: Aplicação Completa com Go Modules</h3>

<p>Para um projeto mais realista com dependências externas:</p>

<pre><code class="language-dockerfile"># 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=&quot;-s -w&quot; \

-o main .

Stage 2: Runtime Distroless

FROM gcr.io/distroless/static-debian11

COPY --from=builder /app/main /app/main

EXPOSE 8080

ENTRYPOINT [&quot;/app/main&quot;]</code></pre>

<p>Note a flag <code>-ldflags=&quot;-s -w&quot;</code>: 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).</p>

<h2>Conclusão</h2>

<p>Você aprendeu que <strong>containers Docker para Go não precisam ser pesados</strong>. 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.</p>

<p>A segunda lição é que <strong>cada detalhe importa</strong>: <code>CGO_ENABLED=0</code> garante que você não depende de bibliotecas dinâmicas, <code>.dockerignore</code> impede cópias desnecessárias, e o cache correto de módulos economiza tempo de rebuild. Por fim, <strong>distroless não é apenas um luxo</strong>, é 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.</p>

<h2>Referências</h2>

<ul>

<li><a href="https://github.com/GoogleContainerTools/distroless" target="_blank" rel="noopener noreferrer">Google Distroless Documentation</a></li>

<li><a href="https://docs.docker.com/build/building/multi-stage/" target="_blank" rel="noopener noreferrer">Docker Multi-stage Builds</a></li>

<li><a href="https://golang.org/cmd/go/#hdr-Build_constraints" target="_blank" rel="noopener noreferrer">Go Build Constraints and Build Tags</a></li>

<li><a href="https://www.docker.com/blog/containerized-golang-applications/" target="_blank" rel="noopener noreferrer">Building Docker Images for Go Applications</a></li>

<li><a href="https://www.digitalocean.com/community/tutorials/how-to-build-go-executables-for-multiple-platforms-on-ubuntu-and-windows" target="_blank" rel="noopener noreferrer">CGO and Static Linking in Go</a></li>

</ul>

<p>&lt;!-- FIM --&gt;</p>

Comentários

Mais em Go

O que Todo Dev Deve Saber sobre PostgreSQL com Go: pgx Driver, Transactions e Connection Pool
O que Todo Dev Deve Saber sobre PostgreSQL com Go: pgx Driver, Transactions e Connection Pool

Introdução ao pgx: Por que ele é a melhor escolha para Go Quando você começar...

Migrations em Go com golang-migrate: Versionando o Banco de Dados: Do Básico ao Avançado
Migrations em Go com golang-migrate: Versionando o Banco de Dados: Do Básico ao Avançado

O que são Migrations e Por que Você Precisa Delas Migrations são scripts vers...

Stack vs Heap em Go: Escape Analysis e Alocação Eficiente: Do Básico ao Avançado
Stack vs Heap em Go: Escape Analysis e Alocação Eficiente: Do Básico ao Avançado

Fundamentos de Stack e Heap em Go A memória em qualquer programa está organiz...