Compilação cruzada, binários estáticos e Docker com Go

Compilação cruzada, binários estáticos e Docker com Go

Compilação cruzada e Docker com Go: -ldflags e o que o debug.ReadBuildInfo já grava, binários estáticos e o stub do go-sqlite3, imagem multi-arquitetura com TARGETARCH, ARG por estágio, distroless, workflow de release coerente e Makefile com tabulações.
Golang

• • 17 min de leitura

Uma das vantagens mais práticas do Go é sua capacidade de produzir binários completamente autocontidos. Um binário Go não precisa de runtime instalado, não depende de bibliotecas compartilhadas do sistema e pode ser compilado para qualquer plataforma a partir de qualquer outra. Essa característica transforma o deploy em algo fundamentalmente simples: copie o arquivo, execute.

Docker amplifica essa vantagem. Com builds multistage, a imagem final contém apenas o binário e suas dependências mínimas de sistema — tipicamente menos de 20 MB, contra centenas de MB de uma imagem com runtime Java ou Python.

Compilação básica

# Compilar para a plataforma atual
go build -o bin/app ./cmd/api

# Com otimizações para produção
go build -ldflags="-w -s" -o bin/app ./cmd/api

As flags -w e -s removem informações de debug e a tabela de símbolos, reduzindo o tamanho do binário em 30-40% sem impacto na funcionalidade.

Embutindo metadados via ldflags

// cmd/api/main.go
package main

import (
    "fmt"
    "os"
)

// Variáveis preenchidas em tempo de build via -ldflags
var (
    Versao    = "desenvolvimento"
    Commit    = "desconhecido"
    BuildTime = "desconhecido"
    GoVersion = "desconhecido"
)

func exibirVersao() {
    fmt.Printf("Versão:    %s\n", Versao)
    fmt.Printf("Commit:    %s\n", Commit)
    fmt.Printf("Build:     %s\n", BuildTime)
    fmt.Printf("Go:        %s\n", GoVersion)
}

func main() {
    if len(os.Args) > 1 && os.Args[1] == "version" {
        exibirVersao()
        return
    }
    // ... resto da aplicação
}
# Build com metadados
go build \
    -ldflags="-w -s \
        -X main.Versao=1.2.3 \
        -X main.Commit=$(git rev-parse --short HEAD) \
        -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
        -X main.GoVersion=$(go version | awk '{print $3}')" \
    -o bin/app ./cmd/api

# Verificando
./bin/app version
# Versão:    1.2.3
# Commit:    a1b2c3d
# Build:     2024-03-15T10:30:00Z
# Go:        go1.22.0

Parte desses metadados o Go já grava sozinho. A versão do Go está em runtime.Version(), e, quando o build acontece dentro de um repositório Git, debug.ReadBuildInfo() traz o commit e a data dele nas chaves vcs.revision e vcs.time: num teste, um binário compilado sem nenhum -X informou os dois e go1.27.1. O -X continua útil para o que o Git não sabe, como o número da release.

Compilação cruzada

Go suporta compilação cruzada nativamente — sem ferramentas externas, sem containers, sem configurações complexas. As variáveis GOOS e GOARCH controlam o alvo:

# Linux AMD64 (servidores na nuvem)
GOOS=linux GOARCH=amd64 go build -o bin/app-linux-amd64 ./cmd/api

# Linux ARM64 (AWS Graviton, Raspberry Pi 4)
GOOS=linux GOARCH=arm64 go build -o bin/app-linux-arm64 ./cmd/api

# macOS Intel
GOOS=darwin GOARCH=amd64 go build -o bin/app-darwin-amd64 ./cmd/api

# macOS Apple Silicon
GOOS=darwin GOARCH=arm64 go build -o bin/app-darwin-arm64 ./cmd/api

# Windows 64-bit
GOOS=windows GOARCH=amd64 go build -o bin/app-windows-amd64.exe ./cmd/api

# Windows ARM64
GOOS=windows GOARCH=arm64 go build -o bin/app-windows-arm64.exe ./cmd/api

Plataformas suportadas:

# Lista todas as combinações GOOS/GOARCH suportadas
go tool dist list

Script de release multi-plataforma

#!/bin/bash
# scripts/release.sh
set -euo pipefail  # sem isto, um build que falha não interrompe o script

APP="taskapi"
VERSAO="${1:-dev}"
COMMIT=$(git rev-parse --short HEAD)
BUILD=$(date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS="-w -s -X main.Versao=${VERSAO} -X main.Commit=${COMMIT} -X main.BuildTime=${BUILD}"

PLATAFORMAS=(
    "linux/amd64"
    "linux/arm64"
    "darwin/amd64"
    "darwin/arm64"
    "windows/amd64"
)

mkdir -p dist

for PLATAFORMA in "${PLATAFORMAS[@]}"; do
    OS="${PLATAFORMA%/*}"
    ARCH="${PLATAFORMA#*/}"
    NOME="${APP}-${VERSAO}-${OS}-${ARCH}"
    EXT=""

    if [ "$OS" = "windows" ]; then
        EXT=".exe"
    fi

    echo "Compilando ${NOME}${EXT}..."

    GOOS="$OS" GOARCH="$ARCH" go build \
        -ldflags="$LDFLAGS" \
        -trimpath \
        -o "dist/${NOME}${EXT}" \
        ./cmd/api

    # Comprime com tar/zip
    if [ "$OS" = "windows" ]; then
        zip -j "dist/${NOME}.zip" "dist/${NOME}${EXT}"
    else
        tar -czf "dist/${NOME}.tar.gz" -C dist "${NOME}"
    fi

    # Remove binário não comprimido
    rm "dist/${NOME}${EXT}"
done

echo "Binários gerados em dist/"
ls -lh dist/

Binários estáticos: CGo e suas implicações

Por padrão, Go produz binários que dependem da libc do sistema para algumas operações — DNS e usuários do sistema em particular. Para binários verdadeiramente estáticos:

# Desabilita CGo completamente
CGO_ENABLED=0 go build -o bin/app ./cmd/api

# Verifica se o binário tem dependências dinâmicas
ldd bin/app
# ldd: bin/app: não é um executável dinâmico  ← binário estático

Com CGO_ENABLED=0, o Go usa implementações puras em Go para DNS e outras operações. Na compilação cruzada, quando GOOS ou GOARCH diferem da máquina, o cgo já vem desligado por padrão. Isso é essencial para:

  • Imagens Docker baseadas em scratch ou alpine
  • Deploy em ambientes sem libc compatível
  • Garantia de portabilidade máxima

Atenção com drivers de banco de dados que usam cgo. O driver SQLite mattn/go-sqlite3 depende do cgo, e com CGO_ENABLED=0 ele não falha na compilação: compila uma versão de fachada e só falha ao abrir o banco, com Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub. O CI fica verde e o serviço cai ao subir. O modernc.org/sqlite, escrito em Go puro, é a alternativa.

Dockerfile multistage: do básico ao otimizado

Versão básica

FROM golang:1.27-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-w -s" \
    -o taskapi ./cmd/api

FROM alpine:3.24
COPY --from=builder /app/taskapi /usr/local/bin/taskapi
EXPOSE 8080
ENTRYPOINT ["taskapi"]

Versão otimizada com cache de layers

# syntax=docker/dockerfile:1
# --platform=$BUILDPLATFORM: o compilador roda na arquitetura da máquina de build,
# e o Go faz a compilação cruzada para a arquitetura de cada imagem
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS deps

WORKDIR /app

# Cache separado para dependências — só rebuilda se go.mod/go.sum mudarem
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

FROM deps AS builder

ARG VERSAO=dev
ARG COMMIT=desconhecido
ARG BUILD_TIME=desconhecido
# Preenchidos pelo buildx com a plataforma de cada imagem (linux/amd64, linux/arm64...)
ARG TARGETOS
ARG TARGETARCH

COPY . .

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build \
        -ldflags="-w -s \
            -X main.Versao=${VERSAO} \
            -X main.Commit=${COMMIT} \
            -X main.BuildTime=${BUILD_TIME}" \
        -trimpath \
        -o /app/taskapi \
        ./cmd/api

# Imagem de produção
FROM gcr.io/distroless/static-debian13 AS producao

# Cada estágio tem os seus ARG: sem repetir, VERSAO estaria vazio aqui
ARG VERSAO=dev

# Metadados da imagem
LABEL org.opencontainers.image.title="Task API"
LABEL org.opencontainers.image.version="${VERSAO}"
LABEL org.opencontainers.image.source="https://github.com/usuario/taskapi"

# Certificados TLS já incluídos na imagem distroless
COPY --from=builder /app/taskapi /taskapi

# Não executar como root
USER nonroot:nonroot

EXPOSE 8080
ENTRYPOINT ["/taskapi"]

A imagem distroless/static contém apenas:

  • Certificados CA para HTTPS
  • Arquivos de timezone
  • Usuário nonroot

Sem shell, sem gerenciador de pacotes, sem utilidades Unix. Menor superfície de ataque possível.

Comparativo de tamanhos de imagem

# Construindo variantes
docker build --target producao -t taskapi:distroless .
docker build -f Dockerfile.alpine -t taskapi:alpine .

# Comparando tamanhos
docker images taskapi

Os tamanhos dependem sobretudo do binário, porque as bases distroless/static e alpine têm poucos megabytes. Num servidor net/http mínimo, medido com Go 1.27, o binário tinha 5,6 MB sem flags e 3,8 MB com -ldflags="-w -s", 32,7% menor. Uma imagem com o toolchain inteiro, sem multistage, passa de centenas de megabytes; vale conferir os números do seu projeto com docker images.

Build com GitHub Actions

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-binarios:
    name: Build Binários
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v7

      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod

      # O script gera os pacotes em dist/, que o próximo passo envia;
      # o make release compila em bin/ e ainda tenta o docker build
      - name: Build multi-plataforma
        run: ./scripts/release.sh ${{ github.ref_name }}

      - name: Upload artefatos
        uses: actions/upload-artifact@v7
        with:
          name: binarios
          path: dist/

  build-docker:
    name: Build e Push Docker
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - uses: actions/checkout@v7

      # O buildx é que permite platforms com mais de uma arquitetura
      - uses: docker/setup-buildx-action@v4

      - name: Login no GHCR
        uses: docker/login-action@v4
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Metadados da imagem
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=ref,event=tag
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha

      - name: Build e push multi-arch
        uses: docker/build-push-action@v7
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          build-args: |
            VERSAO=${{ github.ref_name }}
            COMMIT=${{ github.sha }}
            BUILD_TIME=${{ github.event.head_commit.timestamp }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

Build multi-arquitetura com buildx

# Criar builder com suporte multi-arch
docker buildx create --name multiarch --use
docker buildx inspect --bootstrap

# Build para múltiplas arquiteturas simultaneamente
docker buildx build \
    --platform linux/amd64,linux/arm64 \
    --build-arg VERSAO=1.2.3 \
    -t ghcr.io/usuario/taskapi:1.2.3 \
    -t ghcr.io/usuario/taskapi:latest \
    --push \
    .

# Verificar manifests
docker buildx imagetools inspect ghcr.io/usuario/taskapi:1.2.3

Embed: arquivos estáticos no binário

O pacote embed permite incluir arquivos estáticos diretamente no binário:

package main

import (
    "embed"
    "fmt"
    "io/fs"
    "net/http"
)

//go:embed migrations/*.sql
var migrationFiles embed.FS

//go:embed web/dist
var staticFiles embed.FS

//go:embed config/defaults.yaml
var configPadrao []byte

func main() {
    // Acessa as migrations embutidas
    entries, _ := migrationFiles.ReadDir("migrations")
    for _, e := range entries {
        conteudo, _ := migrationFiles.ReadFile("migrations/" + e.Name())
        fmt.Println(e.Name(), len(conteudo), "bytes")
    }

    // Serve arquivos estáticos do frontend
    webFS, _ := fs.Sub(staticFiles, "web/dist")
    http.Handle("/", http.FileServer(http.FS(webFS)))

    // Usa configuração padrão embutida
    fmt.Println("Config padrão:", string(configPadrao))
}

Com embed, o deploy é um único arquivo — sem diretórios extras, sem preocupação com caminhos relativos.

Otimizações adicionais de build

# -trimpath remove caminhos do sistema de build do binário
# (importante para builds reproduzíveis e privacidade)
go build -trimpath ./cmd/api

# Gzip no binário para distribuição; num servidor net/http mínimo,
# 3,8 MB viraram 1,65 MB
gzip -9 -k bin/taskapi

# UPX comprime o executável, que se descompacta a cada execução
# (cuidado: pode ser detectado como suspeito por antivírus, e o
# processo passa a ocupar a memória do binário inteiro descompactado)
upx --best bin/taskapi

# Build com PGO (Profile-Guided Optimization) — Go 1.21+
# Coleta perfil de CPU de produção, usa para otimizar o próximo build.
# Um arquivo default.pgo no diretório do pacote main é usado sem a flag
go build -pgo=cpu.pprof -o bin/taskapi-pgo ./cmd/api

Makefile completo de build

# As linhas de comando começam com TAB; com espaços, o make para com "missing separator"
APP     := taskapi
VERSAO  := $(shell git describe --tags --always --dirty)
COMMIT  := $(shell git rev-parse --short HEAD)
BUILD   := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -ldflags "-w -s -X main.Versao=$(VERSAO) -X main.Commit=$(COMMIT) -X main.BuildTime=$(BUILD)"

.PHONY: build build-linux build-linux-arm build-all docker release clean

build:
	CGO_ENABLED=0 go build $(LDFLAGS) -trimpath -o bin/$(APP) ./cmd/api

build-linux:
	CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -trimpath -o bin/$(APP)-linux-amd64 ./cmd/api

build-linux-arm:
	CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -trimpath -o bin/$(APP)-linux-arm64 ./cmd/api

build-all: build-linux build-linux-arm
	CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -trimpath -o bin/$(APP)-darwin-amd64 ./cmd/api
	CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -trimpath -o bin/$(APP)-darwin-arm64 ./cmd/api
	CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -trimpath -o bin/$(APP)-windows-amd64.exe ./cmd/api

docker:
	docker build \
	    --build-arg VERSAO=$(VERSAO) \
	    --build-arg COMMIT=$(COMMIT) \
	    --build-arg BUILD_TIME=$(BUILD) \
	    -t $(APP):$(VERSAO) \
	    -t $(APP):latest \
	    .

release: build-all docker
	@echo "Release $(VERSAO) pronto"

clean:
	rm -rf bin/ dist/

Compilar Go para outra plataforma é trocar duas variáveis de ambiente, e um binário sem cgo roda numa imagem que tem pouco mais que ele. O trabalho está em manter as peças coerentes entre si: a arquitetura que o Dockerfile compila e a que o workflow publica, a versão do Go da imagem e a do go.mod, os ARG de cada estágio, a pasta que o script gera e a que o CI envia. Esses erros não impedem o build; aparecem numa imagem arm64 que não roda, num rótulo de versão vazio ou num artefato que não foi enviado. Conferir o resultado de cada release, com docker buildx imagetools inspect e rodando a imagem de cada arquitetura, é o que fecha o ciclo.

Fontes e leituras recomendadas

Exercícios

Exercício 1

O Dockerfile otimizado da primeira versão deste artigo compilava com GOOS=linux GOARCH=amd64 fixos, e o workflow publicava a imagem para linux/amd64,linux/arm64. O build terminava sem erro. O que acontece ao rodar a imagem num servidor ARM, como uma instância Graviton?

Ver resposta

✓ Resposta: O buildx monta uma imagem para cada plataforma pedida, mas quem decide a arquitetura do binário é o go build, e ele recebia GOARCH=amd64 nas duas. A imagem marcada como arm64 levava um executável x86-64, e o contêiner falha ao iniciar com exec format error, o erro do kernel para um binário de outra arquitetura. Nada no build acusa, porque compilar para amd64 é sempre possível. A versão do artigo usa os argumentos que o buildx preenche, TARGETOS e TARGETARCH, e roda o estágio de compilação em --platform=$BUILDPLATFORM: o compilador executa na arquitetura da máquina de build, sem emulação, e o Go faz a compilação cruzada, o que é muito mais rápido do que compilar dentro de um ARM emulado. O workflow também passou a configurar o buildx com docker/setup-buildx-action, necessário para mais de uma plataforma.

Exercício 2

Na primeira versão do Dockerfile, o estágio builder declarava ARG VERSAO=dev, e o estágio final usava LABEL org.opencontainers.image.version="${VERSAO}". A imagem publicada com --build-arg VERSAO=1.2.3 mostrava o rótulo vazio. Por quê?

Ver resposta

✓ Resposta: Num Dockerfile, cada FROM começa um estágio novo, e um ARG vale só no estágio em que foi declarado. O --build-arg entrega o valor a todo estágio que declara o argumento; o estágio final não declarava VERSAO, e para ele a variável simplesmente não existia, resultando num texto vazio, sem aviso. O binário recebia a versão certa, porque o go build roda no estágio builder, e só os metadados da imagem ficavam errados, o que costuma ser notado tarde, ao inspecionar uma imagem em produção. A correção é repetir ARG VERSAO=dev depois do FROM final, como no artigo. Um ARG declarado antes do primeiro FROM também não resolveria sozinho: ele vale só nas linhas FROM, e precisa ser declarado de novo, sem valor, dentro de cada estágio que o usa.

Exercício 3

O texto anterior deste artigo dizia que o mattn/go-sqlite3 "não compila" com CGO_ENABLED=0. Um time confia nisso e conclui que, se a imagem foi gerada, o driver funciona. O que acontece de fato?

Ver resposta

✓ Resposta: O pacote tem dois conjuntos de arquivos, escolhidos por build tags: com cgo, o invólucro da biblioteca C; sem cgo, uma versão de fachada que compila e devolve erro em qualquer uso. Medido no artigo sobre SQLite desta série, o programa compilou com CGO_ENABLED=0 e, no primeiro acesso ao banco, devolveu Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub. A imagem é gerada, o CI fica verde e o serviço cai ao subir. A conclusão "compilou, logo funciona" não vale para dependências que trocam de implementação conforme o ambiente de build. As saídas são usar o modernc.org/sqlite, em Go puro, ou manter o cgo ligado, com um compilador C no estágio de build e uma imagem final com a libc certa; em qualquer caso, um teste de fumaça que abre o banco a partir da imagem final pega o problema antes do deploy.

Exercício 4

O job build-binarios da primeira versão do workflow rodava make release e enviava dist/ como artefato. A release saiu sem nenhum binário anexado. Siga o caminho dos arquivos e encontre o defeito.

Ver resposta

✓ Resposta: O alvo release do Makefile depende de build-all e docker. O build-all grava os binários em bin/, e não em dist/; o docker tenta montar a imagem, outra responsabilidade, que já tem um job próprio. Quem gera os pacotes em dist/ é o scripts/release.sh. O passo de upload encontrava a pasta vazia ou inexistente, e a ação de upload trata isso como aviso, por padrão, e não como erro. A versão do artigo chama o script direto. O script também ganhou set -euo pipefail: sem ele, um go build que falha numa plataforma não interrompe o laço, e a release sai com um pacote faltando. Num teste, o script da página gerou os cinco pacotes, de 1,5 MB a 1,7 MB, a partir de um servidor net/http mínimo. Para que uma pasta vazia derrube o job, a ação de upload aceita if-no-files-found: error.

Exercício 5

O artigo grava commit, data e versão do Go com -X. Um colega propõe tirar tudo isso, porque "o Go já sabe". Até que ponto ele tem razão?

Ver resposta

✓ Resposta: Em boa parte. Desde o Go 1.18, um build feito dentro de um repositório Git grava no binário o commit, a data do commit e se havia alterações não commitadas, lidos com debug.ReadBuildInfo(). A versão do Go está em runtime.Version(). Num teste, um binário compilado sem nenhum -X informou vcs.revision, vcs.time e go1.27.1; o comando go version -m binario mostra os mesmos dados sem executar o programa. Há limites, porém. A data gravada é a do commit, e não a do build. O número da release, como 1.2.3, não existe no Git a menos que se leia a tag, e é o caso mais claro para o -X. E, num build dentro do Docker, a pasta .git geralmente não é copiada, por causa do .dockerignore, e as informações de VCS ficam de fora: aí os -X com os ARG do Dockerfile voltam a ser necessários.

Comentários

Mais em Golang

Structs: definição, inicialização e campos embutidos
Structs: definição, inicialização e campos embutidos

Structs em Go: literais com nome e posicionais, valor zero, ponteiros, structs…

Composição vs herança: o jeito Go de reutilizar código
Composição vs herança: o jeito Go de reutilizar código

Composição em vez de herança em Go: embedding de structs e de interfaces…

Variáveis de ambiente e configuração com Viper
Variáveis de ambiente e configuração com Viper

Configuração em Go com os.LookupEnv, godotenv e Viper: arquivo YAML, variáveis…