Memória do Node.js que só sobe: como achar o cache sem teto antes do OOM
Descubra se o processo vaza ou só segura demais: meça o heap ao longo do dia, compare snapshots, ponha limite e expiração no cache e solte listeners. Restart diário não é correção.
A API de cotação de frete sobe às 8h usando 120 MB. Às 14h está em 600 MB. Às 19h o container é morto por falta de memória, reinicia sozinho e durante 40 segundos os pedidos de cotação falham. Alguém agenda um restart às 3h "para dar folga" e o problema some do painel, mas não do código. A resposta curta: memória que só cresce e não volta ao patamar depois que o tráfego cai é referência que nunca é solta, quase sempre um cache sem limite, uma coleção global ou um listener registrado a cada requisição. Meça o heap ao longo do tempo, compare dois snapshots para ver o que cresceu, ponha teto e expiração no que guarda dados e remova o que é registrado sem nunca sair.
O exemplo aqui é Node.js, mas o raciocínio vale para qualquer runtime com coletor de lixo. Se o seu caso é ASP.NET e um objeto criado a cada requisição, veja memória por requisição no ASP.NET.
Por que o coletor de lixo não resolve sozinho
O coletor de lixo do V8 libera objetos que ninguém mais alcança. Ele não libera objetos que você ainda referencia, mesmo que nunca mais vá usá-los. Um Map no topo do módulo, que recebe uma chave nova a cada CEP consultado, é perfeitamente alcançável: está pendurado no módulo, que está pendurado no processo. Para o coletor, aquilo é dado em uso.
Por isso existe a distinção que guia o diagnóstico. Um gráfico em serra, que sobe e cai a cada coleta mas volta sempre ao mesmo patamar, é normal. Um gráfico em escada, em que cada vale fica mais alto que o anterior, é retenção. E retenção tem poucas origens comuns:
- Cache sem limite de tamanho nem expiração, indexado por algo com variedade infinita (CEP, id de usuário, URL com query string).
- Coleção global que só recebe, como um array de "últimas requisições" para debug que ninguém esvazia.
- Listener registrado a cada requisição num emissor que vive o processo inteiro, mantendo vivo tudo o que o closure referencia.
- Timer que nunca é cancelado, com
setIntervalcriado por conexão e semclearInterval.
O código que vazava
A API de frete tinha dois problemas, ambos com cara de inofensivos:
const EventEmitter = require('node:events');
const tabelaAtualizada = new EventEmitter();
const cacheCotacao = new Map();
app.get('/frete', async (req, res) => {
const chave = req.query.cep + ':' + req.query.peso;
tabelaAtualizada.on('mudou', () => cacheCotacao.delete(chave));
if (cacheCotacao.has(chave)) return res.json(cacheCotacao.get(chave));
const cotacao = await consultarTransportadora(req.query.cep, req.query.peso);
cacheCotacao.set(chave, cotacao);
res.json(cotacao);
});
O Map recebe uma entrada para cada combinação de CEP e peso, e o Brasil tem dezenas de milhares de faixas de CEP. Nada sai dele, a não ser quando a tabela muda. E cada requisição registra mais um listener em tabelaAtualizada, que vive para sempre. Depois do décimo listener, o Node escreve no log o aviso MaxListenersExceededWarning: Possible EventEmitter memory leak detected. O aviso estava lá desde a primeira manhã, ignorado.
Como diagnosticar, passo a passo
- Registre o uso de memória ao longo do dia. Um log por minuto com
process.memoryUsage()basta para ver se o padrão é serra ou escada. - Procure avisos no log.
MaxListenersExceededWarningé uma pista direta. - Confirme a morte por memória. Em Docker,
docker inspectmostra se o container foi encerrado por OOM, e o código de saída 137 indica que o processo recebeu SIGKILL. - Tire dois heap snapshots em horários diferentes, com tráfego entre eles.
- Compare os snapshots no Chrome DevTools, na visão de comparação, ordenando pelo crescimento.
- Corrija a origem e repita a medição por um dia inteiro, sem restart agendado.
setInterval(() => {
const m = process.memoryUsage();
console.log(JSON.stringify({
rssMB: Math.round(m.rss / 1048576),
heapUsedMB: Math.round(m.heapUsed / 1048576),
cacheCotacao: cacheCotacao.size,
listeners: tabelaAtualizada.listenerCount('mudou')
}));
}, 60000).unref();
O unref() impede que esse timer segure o processo vivo no desligamento. Logar o tamanho do cache e a contagem de listeners junto com a memória é o que transforma suspeita em evidência: se heapUsedMB cresce na mesma proporção que cacheCotacao, o culpado está nomeado.
docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' api-frete
node --heapsnapshot-signal=SIGUSR2 server.js
kill -USR2 <pid-do-node>
Com --heapsnapshot-signal, o Node grava um arquivo .heapsnapshot no diretório de trabalho a cada sinal recebido. Tirar o snapshot pausa o processo e pode usar bastante memória extra, então faça fora do pico ou numa réplica. Na comparação, você vai ver milhares de objetos de cotação e de closures retidos, apontando para cacheCotacao e para a lista de listeners de tabelaAtualizada.
A correção: teto, expiração e um listener só
O cache ganha limite de entradas e tempo de vida. O Map do JavaScript mantém a ordem de inserção, o que permite remover a entrada mais antiga sem biblioteca. Em produção, um pacote de cache LRU maduro faz o mesmo com mais recursos; o importante é o teto existir.
const LIMITE = 5000;
const TTL_MS = 10 * 60 * 1000;
const cacheCotacao = new Map();
function lerCache(chave) {
const item = cacheCotacao.get(chave);
if (!item) return undefined;
if (Date.now() > item.expiraEm) {
cacheCotacao.delete(chave);
return undefined;
}
return item.valor;
}
function gravarCache(chave, valor) {
cacheCotacao.delete(chave);
cacheCotacao.set(chave, { valor, expiraEm: Date.now() + TTL_MS });
if (cacheCotacao.size > LIMITE) {
const maisAntiga = cacheCotacao.keys().next().value;
cacheCotacao.delete(maisAntiga);
}
}
tabelaAtualizada.on('mudou', () => cacheCotacao.clear());
app.get('/frete', async (req, res) => {
const chave = req.query.cep + ':' + req.query.peso;
const emCache = lerCache(chave);
if (emCache) return res.json(emCache);
const cotacao = await consultarTransportadora(req.query.cep, req.query.peso);
gravarCache(chave, cotacao);
res.json(cotacao);
});
O listener agora é registrado uma vez, fora da rota, e limpa o cache inteiro quando a tabela muda. Com 5.000 entradas de poucos kilobytes cada, o teto do cache fica em alguns megabytes, um número que você consegue calcular e defender. No dia seguinte à correção, a API ficou entre 140 e 190 MB das 8h às 22h, em serra, sem restart.
Limite de memória como alarme, não como plano
Defina o limite de memória do container e, se fizer sentido, o tamanho máximo do heap do V8 com --max-old-space-size, deixando folga para o que fica fora do heap (buffers, código nativo). O limite serve para a falha ser previsível e para o alerta disparar antes, por exemplo quando o uso passa de 80% do limite. Restart automático é a rede de segurança que mantém o serviço de pé enquanto você investiga; restart agendado para esconder a escada é dívida com juros, porque o vazamento continua e a janela de erro só muda de horário.
Se o crescimento for de cache distribuído, como Redis, o raciocínio é o mesmo: chave sem expiração acumula até o limite. E se a memória sobe só em pico de concorrência e volta depois, o problema é outro, o de quanto cada requisição segura ao mesmo tempo; está em pool e buffer sem limite.
Erros comuns
- Cache em
Mapglobal sem limite nem expiração. Cresce com a variedade de chaves até o processo morrer. - Registrar listener dentro da rota. Cada requisição deixa mais um closure preso a um emissor que vive o processo inteiro.
- Ignorar
MaxListenersExceededWarning. O Node avisou do vazamento e o aviso virou ruído no log. - Agendar restart diário como solução. A escada continua, o OOM apenas muda de horário, e a janela de erro fica mais difícil de explicar.
- Aumentar a memória do container sem diagnóstico. Compra horas, não resolve, e a conta de infraestrutura sobe.
- Tirar heap snapshot no pico de produção. O processo pausa e pode estourar a memória justamente pelo snapshot.
Checklist
- Existe um gráfico ou log de memória do processo cobrindo pelo menos um dia de tráfego.
- Todo cache em memória tem limite de entradas e tempo de expiração definidos.
- Nenhum listener, timer ou assinatura é criado por requisição sem ser removido.
- O log não tem
MaxListenersExceededWarning. - O container tem limite de memória e alerta antes de chegar nele.
- Não existe restart agendado escondendo crescimento de memória.
- Você sabe gerar e comparar dois heap snapshots do serviço.
Olhe o gráfico de memória do seu serviço principal nas últimas 24 horas: depois do pico da tarde, ele volta ao patamar da manhã ou cada vale fica mais alto que o anterior?
Referência: https://www.infoq.com/br/news/2018/03/Memory-Optimization/