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
scratchoualpine - 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
-
Documentação go build — Referência de todas as flags de compilação. https://pkg.go.dev/cmd/go#hdr-Compile_packages_and_dependencies
-
distroless images — Imagens minimalistas do Google para produção. https://github.com/GoogleContainerTools/distroless
-
Docker multistage builds — Guia oficial de builds multistage. https://docs.docker.com/build/building/multi-stage/
-
Go embed — Documentação do pacote embed. https://pkg.go.dev/embed
-
Profile-Guided Optimization — PGO em Go 1.21+. https://go.dev/doc/pgo
-
Docker buildx — Build multi-plataforma com buildx. https://docs.docker.com/buildx/working-with-buildx/
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.