DEV Community

Cover image for Quem guarda tem: caçando um vazamento de memória em Node.js
Pedro Kiefer
Pedro Kiefer

Posted on

Quem guarda tem: caçando um vazamento de memória em Node.js

"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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

É 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()
})
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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-components e 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
Enter fullscreen mode Exit fullscreen mode

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>
)
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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-storage vaza em rejeição. A biblioteca de A/B usa CLS com async-listener, que faz monkeypatch global de Promise, timers e net. Quando uma promise rejeita dentro de um .then envolvido 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 um await devolvia o grupo de outro request. Trocamos por AsyncLocalStorage3, que vem no Node e não depende de monkeypatch.
  • fetch nativo ignora agent. O cliente do serviço de anúncios passava um http.Agent configurado com limite de conexões, mas o fetch do 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 e AbortSignal.timeout, o mesmo cenário fica em 101 sockets e todas as chamadas terminam em 5 segundos.
  • FATAL que é WARN. O ambiente pedia LOG_LEVEL=FATAL, mas a configuração do logger só aceita DEBUG, INFO, WARN e ERROR e trocava, em silêncio, para WARN. 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-components teria 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.


  1. Harness completo (probe, stub, gerador de carga e diff de snapshots): https://gist.github.com/pedrokiefer/e58f27ab0e7ae12b94db6f0239f95dc2 ↩

  2. TanStack Query v4 — Server Rendering & Hydration ↩

  3. Node.js — Asynchronous context tracking (AsyncLocalStorage) ↩

  4. Node.js — fetch, que é implementado pelo undici e aceita um dispatcher, não um agent. ↩

Top comments (0)