"Something is rotten in the state of Denmark."
— William Shakespeare, Hamlet, Act I, Scene 4
Eu sempre gostei de investigar coisas, e vazamento de memória é uma das investigações mais divertidas (e frustrantes) que existem: o sistema funciona, os testes passam, o código parece certo, e mesmo assim o processo engorda devagar até alguém matá-lo. Ninguém escreveu leak() em lugar nenhum; o vazamento é a soma de decisões razoáveis que, juntas, guardam coisa demais por tempo demais.
Toda essa introdução para chegar no assunto: a página de anúncio da OLX (o adview), um servidor Node.js que faz SSR com React, estava reiniciando entre 160 e 230 vezes por dia. Não é um texto sobre a solução mágica — a correção final é uma linha — e sim sobre como chegar nela sem chutar, medindo cada hipótese até sobrar só uma. Bom, vamos ao causo!
O sintoma
Tudo começou com erros 503 no gateway. Olhando as métricas do Envoy, metade deles vinha de falhas de conexão com os pods do adview, e o motivo era simples: os pods estavam morrendo. Não por falta de memória (o Kubernetes registrava Error como motivo, e não OOMKilled), mas pelo liveness probe: o /health não respondia em 4 segundos, duas vezes seguidas, e o kubelet reiniciava o container.
Se um /health que só devolve { status: 'ok' } leva mais de 4 segundos para responder, só pode significar uma coisa: o event loop estava travado. O gráfico de memória contava o resto da história, um serrote perfeito subindo de ~230 MB até ~2,1 GB e despencando a cada restart, e o tempo até encher caía de quase duas horas, pela manhã, para uns 45 minutos no horário de pico.
Opa! Mas se o limite do container é 4 GB, por que o teto fica em 2,1 GB? Porque ninguém configurou --max-old-space-size, e o Node 22 dimensiona o heap do V8 a partir da memória do container. Subi um node:22 no Docker com --memory=4g e conferi:
$ docker run --rm --memory=4g node:22.22.0-alpine \
node -e 'console.log(v8.getHeapStatistics().heap_size_limit / 2**20)'
1955
1.955 MB de heap, metade da memória que o pod tem disponível. Perto desse limite o GC entra em espiral, fazendo mark-compact atrás de mark-compact, a CPU vai para 2,5–3,5 cores (com limite de 2), o event loop para de responder e o probe mata o pod. O OOM nunca chega; quem mata o pod é o GC.
"Mas isso já não tinha sido corrigido?"
Em parte, sim. Algumas semanas antes tínhamos corrigido um vazamento clássico: listeners registrados no socket de keep-alive, que é reutilizado entre requests, seguravam o req e o res de cada request até a conexão morrer. A correção funcionou, o pico diário de memória caiu de 2,8–3,4 GB para 2,15 GB cravados, mas os reinícios continuaram.
Minha leitura: o bug do keep-alive segurava memória fora do heap (buffers de socket), e por isso o processo passava dos 2 GB. Com ele resolvido, sobrou um vazamento puro de heap JavaScript, batendo exatamente no teto do V8. Um vazamento escondia o outro.
A teoria bonita (e errada)
Nesse ponto eu estava usando agentes no Cursor para ler código e cruzar métricas, e a primeira teoria que surgiu era linda. O SSR usa styled-components 4 sem ServerStyleSheet, então todo estilo gerado por request vai para a master sheet global, que nunca é limpa. Tinha mecanismo, tinha trecho do código-fonte da biblioteca como prova, tinha até uma proposta de PR com collectStyles e seal().
Antes de abrir o PR, pedi ao agente uma revisão adversarial da própria proposta: tentar provar que ela estava errada e dizer como medir se a correção funcionava. A teoria não sobreviveu ao primeiro teste. As interpolações dinâmicas dos componentes só assumiam valores de conjuntos fechados — booleanos, breakpoints, tokens do tema —, e não preços ou URLs, como eu tinha suposto sem conferir. Num teste isolado, só com styled-components 4.2.0 e React 18, renderizando 20 mil vezes, o resultado foi este:
| Cenário | Crescimento do heap | Classes na sheet global | Tempo por render |
|---|---|---|---|
| Código atual | ~0 MB | 167, estável | 0,41 ms |
| 5.000 classes únicas forçadas | ~1–2 MB | 5.154, estável | 0,41–0,55 ms |
| "Correção" proposta | ~0 MB | 0 | ~0,63 ms |
"Correção" com um seal() esquecido |
1,5 GB | 0 | ~0,65 ms |
Cada classe custa uns 400 bytes, então para chegar a 2 GB seriam necessários uns 5 milhões de classes, coisa que esse código não gera nem com muito esforço. E a "correção" ainda custava 50% a mais de CPU por render (cada ServerStyleSheet clona a sheet global) e criava um modo de falha pior que o problema original. Ufa, ainda bem que não foi para produção.
A impressão digital
Sem suspeito, voltei aos números de produção. Se o heap cresce ~1,9 GB entre 45 e 110 minutos, e cada pod atende 8–9 requests por segundo, a conta é simples:
1,9 GB / (45 a 110 min × 60 s × 8,5 req/s) ≈ 30 a 70 KB por request
É uma conta grosseira (working set não é heap vivo, e a taxa de requests é uma média), mas o tamanho já diz muito. Uma classe CSS tem centenas de bytes; 30–70 KB é do tamanho de um anúncio inteiro, ou de uma árvore de render, ou de um req/res com tudo pendurado. Isso descarta uma pilha de suspeitos pequenos e diz o que procurar: algo que guarda o estado inteiro de cada request.
O harness
Hora de parar de ler código e medir. O harness tem quatro peças, e a graça está em como elas se combinam.
O bundle de produção, não o código-fonte. Rodamos o server.js gerado pelo mesmo webpack do deploy, com as mesmas variáveis de ambiente da plataforma de deploy (copiadas da configuração da aplicação, sem os segredos). O serviço de anúncios foi trocado por um stub local que devolve anúncios sintéticos realistas — 10 categorias, galeria de fotos, descrição de 2 a 6 KB, 5% de anúncios inexistentes. Nada de NODE_ENV=development, nada de render isolado em teste unitário, porque o vazamento pode estar justamente na cola entre as partes.
Um probe carregado com -r. Um arquivo de poucas linhas, carregado antes do servidor com node --expose-gc -r ./probe.js server.js, que ao receber SIGUSR2 força o GC duas vezes e registra a memória:
// probe.js
const fs = require('fs')
const v8 = require('v8')
process.on('SIGUSR2', () => {
global.gc()
global.gc()
const { heapUsed, rss, external } = process.memoryUsage()
fs.appendFileSync(process.env.PROBE_LOG, JSON.stringify({ t: Date.now(), heapUsed, rss, external }) + '\n')
if (process.env.PROBE_SNAPSHOT === '1') v8.writeHeapSnapshot()
})
Por que dois GCs? Porque uma passada só pode deixar para trás objetos que dependem de finalizadores ou de referências fracas, e o que queremos é o heap que sobrevive, não o lixo que ainda não foi recolhido. Medir heapUsed sem forçar o GC é medir o humor do coletor naquele instante.
Carga em fases com keep-alive. Um gerador de carga simples com 20 conexões keep-alive (como o Envoy faz), user agents de desktop e mobile e headers de A/B realistas. Ele faz um aquecimento de 2.000 requests e depois três fases de 10.000, mandando SIGUSR2 para o servidor ao fim de cada fase. A métrica que importa é uma só:
bytes por request = (heapUsed depois da fase − heapUsed antes) / requests da fase
O aquecimento existe para tirar da conta o que é legítimo — caches de módulo, JIT, pools de conexão. Depois dele, a curva de um servidor saudável fica plana: ±100 bytes por request é ruído. Um vazamento aparece como um número positivo e estável, fase após fase.
Sempre em par. Toda medição roda duas variantes ao mesmo tempo, na mesma máquina, em portas diferentes: o main e a variante com a hipótese aplicada. Assim CPU, latência e memória são comparadas sob a mesma carga, e qualquer interferência da máquina afeta os dois lados igualmente.
E um truque que vale ouro: rodar com IDs únicos (cada request pede um anúncio novo) e com IDs reciclados (um conjunto fixo de 10 mil anúncios). Se o heap cresce linearmente nos dois, algo guarda cada request. Se cresce com IDs únicos e estabiliza com IDs reciclados, é um cache indexado por ID que nunca expira — e foi exatamente o que vimos. Isso explica também por que o vazamento acompanha o tráfego: em produção, o servidor vê milhões de anúncios diferentes.
Hipóteses em paralelo
Com o harness pronto, o resto virou método científico em escala: listar hipóteses, escrever para cada uma um experimento que possa refutá-la, e rodar. Aqui os agentes ajudaram de verdade: cada hipótese foi para um agente diferente, em paralelo, com a mesma instrução — medir bytes retidos por request contra um controle e devolver confirmado, refutado ou inconclusivo, com os números:
- o middleware de contexto de A/B (baseado em
continuation-local-storage), isolado num servidor mínimo; - estado de módulo no caminho de render (
adview-componentse bibliotecas internas), renderizando cada vertical separadamente; - o servidor inteiro sob carga, com diff de heap snapshots;
- caminhos de erro, o cliente HTTP do serviço de anúncios e o logging, em micro-testes.
Também rodamos cenários de falha, porque alguns vazamentos só aparecem quando as coisas dão errado: upstream que nunca responde, 50% das conexões resetadas, exceção dentro de um .then. Ao final, a lista de inocentes ficou maior que a de culpados — styled-components, keep-alive vs Connection: close, o tracer do Datadog, dns-cache, os middlewares do Express, o proxy para o novo front-end, a proporção de 404s e os QueryClients de outras bibliotecas foram todos medidos e descartados.
O culpado
Dois agentes, por caminhos diferentes, chegaram ao mesmo lugar. O que renderizava cada vertical isoladamente encontrou 14–16 KB retidos por render, subindo para 26–28 KB com um anúncio maior. O que testava o servidor inteiro mediu 26 KB por request, e o diff de heap snapshots (antes e depois de uma fase) apontava a cadeia de retenção sem ambiguidade:
queryClient (escopo de módulo)
→ queryCache.queries[] 107 mil queries depois de 32 mil requests
→ Query.options.queryFn uma closure por query
→ ad o anúncio inteiro
Pra quem não conhece, o diff de snapshots é a forma mais direta de achar um vazamento: tire um snapshot depois do aquecimento, outro depois de N requests, e compare quais construtores cresceram. O Chrome DevTools faz isso na visão Comparison, e para snapshots menores um script curto que soma self_size por nome de construtor já resolve1.
O código era este, repetido nos 10 componentes-raiz da página:
const queryClient = new QueryClient()
const AutosAdview = ({ ad, ... }) => (
<QueryClientProvider client={queryClient}>
...
</QueryClientProvider>
)
Parece inofensivo, e no browser é: um cliente por página, a página morre, o cliente morre junto. No servidor, o módulo vive enquanto o processo viver, e o TanStack Query v4 tem um detalhe que está no código-fonte, mas de que ninguém se lembra:
// @tanstack/query-core 4.35.3, build/lib/removable.js
updateCacheTime(newCacheTime) {
// Default to 5 minutes (Infinity for server-side) if no cache time is set
this.cacheTime = Math.max(this.cacheTime || 0, newCacheTime != null ? newCacheTime : utils.isServer ? Infinity : 5 * 60 * 1000);
}
No servidor, cacheTime é infinito. Cada request registrava quatro ou cinco queries (favorito, conta do vendedor, botão de oferta, avaliação do vendedor) com o listId na chave, e cada queryFn capturava o anúncio inteiro numa closure. Um cliente compartilhado por todos os requests, sem expiração, indexado por ID de anúncio: quem guarda tem — no caso, tem 2 GB de anúncios que ninguém vai pedir de novo.
A correção é a recomendada na própria documentação do TanStack para SSR2, um cliente por árvore renderizada:
export function AdviewQueryClientProvider({ children }) {
const [queryClient] = useState(() => new QueryClient())
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
E o resultado no harness, com o bundle de produção antes e depois da correção e 32 mil requests com IDs únicos:
main |
correção | |
|---|---|---|
| Heap após aquecimento | 243 MB | 192 MB |
| Heap após as fases 1 / 2 / 3 | 492 / 744 / 994 MB | 188 / 189 / 190 MB |
| Retido por request | ~26 KB | ~0 |
| RSS no final | 1,6 GB | 318 MB |
| CPU por request | 6,1–7,5 ms | 5,8–6,6 ms |
Com 26 KB por anúncio, um pod chega ao teto de 1.955 MB depois de uns 68 mil anúncios diferentes. Os anúncios sintéticos são menores que os reais, o que bate com os 30–70 KB estimados a partir de produção. E de brinde, menos GC significa menos CPU por request.
Os coadjuvantes
Com o culpado principal encontrado, ainda sobraram outros achados das hipóteses em paralelo que mereciam correção, cada um com seu PR e seus números:
-
continuation-local-storagevaza em rejeição. A biblioteca de A/B usa CLS comasync-listener, que faz monkeypatch global dePromise, timers enet. Quando uma promise rejeita dentro de um.thenenvolvido por ele, o contexto nunca é encerrado, e cada contexto novo nasce em cima do que ficou preso. São 650–1.500 bytes por request com erro, e pior: num script isolado, com um upstream que derrubava metade das conexões, metade das leituras de grupo de A/B feitas depois de umawaitdevolvia o grupo de outro request. Trocamos porAsyncLocalStorage3, que vem no Node e não depende de monkeypatch. -
fetchnativo ignoraagent. O cliente do serviço de anúncios passava umhttp.Agentconfigurado com limite de conexões, mas ofetchdo Node (undici) simplesmente ignora essa opção4. Sem limite e sem timeout, 2.000 chamadas para um upstream travado seguraram 61 MB e 2.001 sockets por mais de 15 segundos sem resolver nenhuma. Com pool de verdade eAbortSignal.timeout, o mesmo cenário fica em 101 sockets e todas as chamadas terminam em 5 segundos. -
FATALque éWARN. O ambiente pediaLOG_LEVEL=FATAL, mas a configuração do logger só aceitaDEBUG,INFO,WARNeERRORe trocava, em silêncio, paraWARN. Com isso, o access log escrevia uma linha por request no stdout, e o appender não respeita backpressure. - Página de erro renderizada à toa. O handler de exceções montava a página de 404 para qualquer erro, mais que dobrando o custo de CPU de um 5xx.
- Poller sem timeout. A configuração da galeria de recomendações era buscada a cada 30 segundos sem timeout nem guarda contra chamadas sobrepostas, e o servidor nem começava a escutar enquanto a primeira não voltasse.
Nenhum desses explica 2 GB, mas todos aparecem justamente quando o sistema já está mal: upstream lento, erros em cascata, pod sob pressão.
O que fica
Vazamento de memória não se acha lendo código, se acha medindo. Ler código gera hipóteses (e a primeira teoria vai ser bonita, convincente e provavelmente errada), mas quem decide é o número. Algumas coisas que vou levar para as próximas:
- Compare o teto com o limite. Se a memória para bem abaixo do limite do container, sem OOM, é o V8 sufocando no próprio heap; o sintoma aparece como CPU alta e probe falhando, não como falta de memória.
- Divida o crescimento pelo tráfego. Bytes por request é a impressão digital do vazamento, e o tamanho já descarta metade dos suspeitos.
- Meça heap depois do GC, em fases, contra um controle. Sem isso, qualquer número é ruído.
- IDs únicos vs reciclados. Crescimento que estabiliza com IDs reciclados é cache sem expiração.
-
Tente refutar a sua própria correção antes do PR. A teoria do
styled-componentsteria custado CPU, um deploy e uma semana de "será que melhorou?".
E desconfie de qualquer coisa criada no escopo de módulo num servidor SSR: no browser ela vive o tempo de uma página, no servidor vive o tempo do processo. Quem guarda tem, mas, no servidor, quem guarda tudo reinicia.
-
Harness completo (probe, stub, gerador de carga e diff de snapshots): https://gist.github.com/pedrokiefer/e58f27ab0e7ae12b94db6f0239f95dc2 ↩
-
Node.js — Asynchronous context tracking (
AsyncLocalStorage) ↩ -
Node.js —
fetch, que é implementado pelo undici e aceita umdispatcher, não umagent. ↩
Top comments (0)