trilha

Tecnologias/01 - Linguagens/01 - Go6 min

Go em Produção

Conceito

Go tem uma reputação de "só funciona" que é merecida no build e enganosa no runtime. As falhas de um serviço Go em produção são quase sempre as mesmas cinco, e nenhuma delas aparece em ambiente de desenvolvimento.

1. O binário e o contêiner

FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/api

FROM gcr.io/distroless/static-debian12   # ou scratch
COPY --from=build /app /app
USER 65532:65532
ENTRYPOINT ["/app"]
  • CGO_ENABLED=0 é o que garante binário estático — sem isso você depende da libc da imagem base e descobre no deploy.
  • distroless/static em vez de scratch quando você precisa de certificados TLS e /etc/passwd.
  • Cross-compile é GOOS=linux GOARCH=arm64 go build. Sem toolchain extra.

2. CPU: GOMAXPROCS e o cgroup

O erro clássico: o contêiner tem limite de 1 CPU, a máquina tem 64 núcleos, e o runtime antigo do Go definia GOMAXPROCS=64. Resultado: 64 threads brigando por 1 CPU de cota, throttling do cgroup, latência p99 horrível — com o gráfico de CPU mostrando "só 40% de uso".

Por anos a correção era a biblioteca automaxprocs da Uber. A partir do Go 1.25 o runtime passou a considerar o limite de CPU do cgroup ao definir GOMAXPROCS. Se você está em Go 1.27, isso já vem resolvido — mas vale conferir, porque muitos serviços ainda fixam a variável na mão.

3. Memória: GOMEMLIMIT é obrigatório em contêiner

O GC do Go decide coletar com base em crescimento relativo (GOGC=100 = coleta quando o heap dobra). Ele não sabe que existe um limite de 512 MB no cgroup. Então:

heap em 300 MB → GC mira em 600 MB → limite é 512 MB → OOMKilled

GOMEMLIMIT (Go 1.19+) dá ao GC um limite flexível de memória total: chegando perto, ele coleta mais agressivamente em vez de estourar.

env:
  - name: GOMEMLIMIT
    value: "450MiB"   # ~85–90% do limite do contêiner

Deixe folga: GOMEMLIMIT cobre o que o runtime gerencia, não stacks de thread do SO nem memória de cgo.

4. O pool de conexões: o bug de produção nº 1

Este é o encontro entre esta seção e a de bancos. O database/sql não é um driver, é um pool — e os padrões dele são hostis:

db.SetMaxOpenConns(25)                  // padrão: ILIMITADO
db.SetMaxIdleConns(25)                  // padrão: 2 (causa churn de conexão)
db.SetConnMaxLifetime(30 * time.Minute) // padrão: infinito (não sobrevive a failover)
db.SetConnMaxIdleTime(5 * time.Minute)

Junte "goroutine é barata" (2. Goroutines e Channels) com "MaxOpenConns é ilimitado" e você tem a receita exata do incidente: um pico de tráfego abre 3.000 conexões contra um PostgreSQL que aceita 100 — e como no Postgres cada conexão é um processo, a máquina do banco cai antes de recusar. Com MaxOpenConns definido, o excesso espera no pool: degradação em vez de queda. É Queue-Based Load Leveling aplicado ao driver.

ConnMaxLifetime é o que faz o serviço sobreviver a um failover: sem ele, o pool segura conexões para um primário que já morreu.

5. Diagnóstico: pprof

import _ "net/http/pprof" // registra em /debug/pprof no DefaultServeMux
Nunca exponha /debug/pprof na porta pública

Suba um segundo http.Server numa porta interna. pprof entrega goroutine dumps e heap — com nomes de função, argumentos e às vezes dados.

Perfil Responde
profile (CPU) onde o tempo de CPU vai
heap quem está alocando e retendo
allocs total alocado desde o início (mesmo já liberado)
goroutine quantas e paradas onde
goroutineleak quais estão permanentemente bloqueadas (Go 1.27)
block / mutex contenção de channel e de lock
trace scheduler, GC e latência no tempo
go tool pprof -http=: http://localhost:6060/debug/pprof/heap
curl -o t.out 'http://localhost:6060/debug/pprof/trace?seconds=5' && go tool trace t.out

Trade-offs

O GC do Go é otimizado para latência, não para throughput. Pausas de sub-milissegundo são a promessa cumprida; o custo é mais CPU gasto em coleta e um footprint de memória maior que o mínimo teórico. Não há como escolher outro GC — o único botão real é alocar menos (reuso via sync.Pool, strings.Builder, pré-alocar slices com make([]T, 0, n), evitar boxing em interface{}).

Binário estático facilita o deploy e engorda a imagem. Um "hello world" HTTP dá ~8–10 MB. É o preço de não ter runtime instalado.

Observabilidade é sua responsabilidade. Não há agente automático. Você instrumenta com log/slog, OpenTelemetry e expvar — o que dá controle e trabalho.

O trade-off central: Go entrega excelentes padrões de linguagem e péssimos padrões de operação. MaxOpenConns ilimitado, ConnMaxLifetime infinito e GC sem noção de cgroup são todos padrões que só funcionam fora de contêiner. Em produção, a configuração explícita não é otimização — é requisito.

Exemplo prático

Um main que erra menos: timeouts, pool, shutdown limpo e porta de diagnóstico separada.

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
	if err != nil { log.Fatal(err) }
	db.SetMaxOpenConns(25)
	db.SetMaxIdleConns(25)
	db.SetConnMaxLifetime(30 * time.Minute)
	defer db.Close()

	// verifique de fato: sql.Open não conecta, só valida a string
	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if err := db.PingContext(pingCtx); err != nil { log.Fatal(err) }

	// porta interna: métricas e pprof, fora do tráfego público
	go func() { _ = http.ListenAndServe("127.0.0.1:6060", nil) }()

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           rotas(db),
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("listen", "err", err)
		}
	}()

	<-ctx.Done() // SIGTERM do orquestrador
	slog.Info("drenando conexões")
	shutCtx, cancel2 := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel2()
	_ = srv.Shutdown(shutCtx) // termina requisições em voo; para de aceitar novas
}
Shutdown e o terminationGracePeriodSeconds

O timeout do Shutdown precisa ser menor que o período de graça do orquestrador. Se o Kubernetes espera 30s e você drena por 60s, o SIGKILL chega no meio e a requisição do cliente morre de qualquer forma. Ver Fail-Over.

Checklist de produção

  • CGO_ENABLED=0, imagem distroless, usuário não-root
  • GOMEMLIMIT ~85% do limite de memória do contêiner
  • Todos os timeouts do http.Server definidos (o padrão é sem timeout)
  • context.WithTimeout em toda chamada de saída (banco, HTTP, Kafka)
  • Pool do database/sql configurado nos 4 parâmetros
  • pprof em porta interna; goroutineleak no runbook
  • Shutdown gracioso ligado ao SIGTERM
  • go test -race e govulncheck na CI

Aprofundamento aplicado

Orçamento de tempo de uma requisição

Suponha um prazo total de 800 ms: a requisição espera no pool, consulta o banco e chama um serviço externo. Dar 800 ms novos a cada etapa permite ultrapassar o prazo total. Propague o contexto original e, quando útil, crie prazos menores para etapas específicas.

Uma consulta rápida depois de esperar 700 ms por uma conexão continua sendo uma requisição lenta. Registre separadamente espera no pool, execução e serialização. O número máximo de conexões deve considerar todas as réplicas da aplicação: 20 instâncias com limite de 30 podem disputar até 600 conexões.

Encerrar sem perder trabalho em andamento

  1. Retire a instância da recepção de novas requisições conforme o balanceador.
  2. Inicie a drenagem do servidor e avise workers de longa duração.
  3. Aguarde operações aceitas dentro de um prazo total.
  4. Encerre dependências depois que seus usuários terminarem.

O Shutdown do servidor HTTP não gerencia automaticamente todos os workers próprios nem conexões sequestradas pelo handler; esses recursos precisam de ciclo de vida explícito. Referência: Server.Shutdown.

Exercício: envie sinal de encerramento durante uma chamada lenta. Resultado esperado: a requisição termina dentro da janela planejada ou recebe cancelamento observável; o banco não é fechado enquanto ainda está em uso.

Relacionado


Parte de MOC Go · go.dev/doc/diagnostics

Buscar

Busca por título, seção e texto das notas