Blog

Limite de memória em contêiner: o processo Node.js que morre por OOM sem nunca ter vazado memória

O pod reinicia três vezes por dia com OOMKilled e exit code 137. O time abre o heap snapshot, compara dois instantes, procura o array que cresce para sempre, e não encontra nada: o heapUsed sobe e desce em serra, como deve. Não existe vazamento. O processo morreu porque o V8 e o kernel medem coisas diferentes. O kernel compara o RSS inteiro do processo com o limite do cgroup; o V8 só governa o heap, dimensiona esse heap olhando para a memória que acredita existir, e deixa o lixo acumular até perto desse limite antes de coletar com força. Quando o limite que o V8 enxerga é maior do que o limite que o kernel aplica, o processo é morto cheio de lixo coletável, sem nunca ter vazado um byte. Este artigo mostra como diferenciar essa morte de um vazamento real, como o V8 decide o tamanho do heap e por que a decisão pode olhar para a máquina errada, o que entra na conta do cgroup além do heap, como derivar as flags a partir do limite do contêiner, como limitar o que vive fora do heap e como provar em teste que o processo cabe no limite.

2026-10-21 / Arquitetura / 14 min

01

Exit code 137 não é vazamento: duas mortes com sintomas diferentes

Existem duas maneiras de um processo Node morrer por memória, e elas deixam rastros opostos. Quando o heap do V8 chega ao próprio limite e o coletor não consegue liberar espaço, o Node escreve "FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory" no stderr, imprime um resumo dos espaços do heap e encerra com um código de saída próprio. É uma morte que o processo vê chegando: dá para gerar um heap snapshot perto do limite, dá para registrar o estado. Quando o RSS do processo passa do limite do cgroup, é o kernel que decide, de fora, com SIGKILL. Não há sinal a tratar, não há handler que rode, não há linha de log. O processo simplesmente para de existir, e a única evidência é o exit code 137 no orquestrador e uma linha no dmesg do nó.

SintomaQuem decidiuO que o processo viuCausa mais provável
FATAL ERROR: Reached heap limit no stderrO V8, ao falhar uma alocação no heapTudo: o erro sai no log com estatísticas dos espaçosVazamento de objetos JavaScript ou heap configurado pequeno demais para a carga
OOMKilled, exit code 137, nenhuma linha final no logO kernel, ao comparar o RSS com memory.maxNada: SIGKILL não é entregue ao processoHeap dimensionado acima do limite do contêiner, ou memória fora do heap sem teto
ENOMEM em chamadas nativas, Buffer.alloc lançando erroO alocador, ao pedir memória ao sistemaUma exceção na chamada que falhouLimite de memória virtual ou alocação única grande demais; raro em contêiner

A segunda linha é o caso deste artigo. E o teste que diferencia os dois é simples: no gráfico de heapUsed, um vazamento aparece como vales que sobem ao longo das horas, porque a cada coleta completa sobra mais memória viva. Um processo mal dimensionado mostra vales estáveis e picos altos, porque o lixo acumula até onde o V8 permite, e o V8 permite mais do que o cgroup. Se os vales não sobem e o processo morreu mesmo assim, não adianta procurar o array que cresce. O problema está na configuração.

02

Como o V8 decide o tamanho do heap, e para qual máquina ele olha

Sem flags, o Node pede ao V8 que dimensione o heap a partir da memória física disponível. Em uma máquina de 16 GB, o limite do old space fica em torno de 4 GB, e o coletor usa esse teto para decidir quando vale a pena uma coleta completa: enquanto há folga, ele deixa o lixo acumular, porque coletar menos vezes custa menos CPU. A pergunta que define o incidente é de onde vem o número da memória física. O Node tenta ler o limite do cgroup e usar o menor entre ele e a memória total do host. Quando consegue, o heap padrão fica em cerca de um quarto do limite do contêiner. Quando não consegue, o heap é dimensionado pela memória do nó inteiro.

Esse "quando consegue" depende de três coisas que mudam de cluster para cluster: a versão do Node, a versão do libuv embutida nele e se o nó usa cgroup v1 ou v2. Versões do libuv anteriores ao suporte a cgroup v2 devolvem zero em nós v2, que hoje são o padrão em distribuições recentes e em Kubernetes atual, e o Node cai para a memória do host. O resultado é um processo com heap de 4 GB dentro de um contêiner de 512 MiB: o coletor vai deixar o lixo chegar a centenas de megabytes antes de uma coleta completa, e o kernel mata antes. Em vez de decorar a matriz de versões, meça na própria imagem, com o mesmo limite que a produção usa.

docker run --rm --memory=512m node:22-alpine node -e "
  const v8 = require('node:v8');
  console.log('heap_size_limit MiB:', Math.round(v8.getHeapStatistics().heap_size_limit / 1048576));
  console.log('constrainedMemory MiB:', Math.round((process.constrainedMemory?.() || 0) / 1048576));
"

Dois números importam na saída. Se constrainedMemory vem zerado, o Node não enxergou o limite e o heap foi dimensionado pelo host. Se heap_size_limit vem perto ou acima de 512, o heap sozinho já pode ocupar o contêiner inteiro. E mesmo quando o Node lê o limite certo, um quarto dele é um palpite conservador que não considera a aplicação: um serviço que mantém cache em memória pode precisar de mais, e um que lida com uploads precisa de menos heap e mais espaço fora dele. A heurística é um ponto de partida, nunca a configuração de produção.

Contêiner com memory.max = 512 MiB. O kernel compara o RSS inteiro com esse número; o V8 só conhece o heap.

+------------------------------------------------------------------+
| heap do V8                                                        |
|   old space  ....... limitado por --max-old-space-size            |
|   espaço jovem ..... 3 x --max-semi-space-size (até 192 MiB por   |
|                      padrão em versões recentes de 64 bits)       |
|   dentro do heap: objetos vivos + lixo que o GC ainda não coletou |
+------------------------------------------------------------------+
| fora do heap (nenhuma flag limita)                                |
|   Buffers e ArrayBuffers: uploads, zlib, TLS, crypto              |
|   código compilado, metadados do V8, pilhas de threads            |
|   módulos nativos, malloc do próprio processo                     |
|   cerca de 40 MiB num processo vazio, sem nenhum tráfego          |
+------------------------------------------------------------------+
  RSS acima de memory.max: SIGKILL, exit code 137, nenhum log do Node

03

O que entra na conta do cgroup e nenhuma flag do V8 controla

O erro de modelo mais comum é tratar --max-old-space-size como limite do processo. Ele limita um espaço do heap. O RSS que o kernel mede inclui o espaço jovem, que em versões recentes do Node em 64 bits pode chegar a 192 MiB por padrão, e tudo o que vive fora do heap: Buffers, estado nativo de streams, código compilado, pilhas das threads e as alocações do próprio alocador de C. Um processo Node vazio, antes de receber a primeira requisição, já tem cerca de 40 MiB fora do heap. Cada componente cresce com um tipo diferente de carga, e nenhum aparece em heapUsed.

ComponenteOnde aparece em process.memoryUsage()O que o faz crescer
Old space do V8heapUsed e heapTotalObjetos de vida longa e lixo acumulado entre coletas completas; limitado por --max-old-space-size
Espaço jovem do V8heapTotalTaxa de alocação de objetos curtos; cresce até 3 x --max-semi-space-size
Buffers e ArrayBuffersexternal e arrayBuffersCorpos de requisição lidos inteiros, arquivos em memória, respostas grandes de banco
Estado nativo de zlib, TLS e cryptoParte em external, o resto só no rssCada stream de deflate reserva cerca de 256 KiB de janela e tabelas assim que começa a comprimir; cada conexão TLS tem seus próprios buffers
Código compilado e metadados do V8Só no rssTamanho do código carregado e quantidade de funções otimizadas
Pilhas de threads e workersSó no rss, e cada worker_thread tem um heap inteiro à parteThread pool do libuv, módulos nativos, cada new Worker sem resourceLimits

A última linha costuma ser a surpresa do pós-incidente. Um worker_thread é um isolate completo, com heap próprio dimensionado pela mesma heurística do processo principal. Quatro workers sem resourceLimits em um contêiner de 1 GiB são cinco heaps, cada um acreditando que pode crescer até o teto do host. O mesmo vale para o módulo cluster: cada fork é um processo inteiro, com o mesmo NODE_OPTIONS, e o limite do cgroup é compartilhado por todos. Em um pod com quatro forks, a flag precisa ser calculada para um quarto do limite, não para o limite.

04

Derivar as flags do limite do contêiner, não do tamanho do nó

A correção é deixar de depender da heurística e calcular as flags a partir do número que o kernel vai usar. O lugar certo para isso é o entrypoint do contêiner: ele lê o limite do cgroup, reserva uma fatia para o que vive fora do heap, desconta o espaço jovem e entrega o resto ao old space. Fazer isso em shell, antes de o Node subir, resolve um problema que não dá para resolver em JavaScript: as flags do V8 precisam existir antes do isolate ser criado, e nenhuma chamada de dentro do processo muda o tamanho do heap depois disso.

#!/bin/sh
# entrypoint.sh: deriva as flags de heap do V8 a partir do limite de memoria do cgroup
# e so entao sobe o Node. No Dockerfile: ENTRYPOINT ["sh", "./entrypoint.sh", "server.js"]
set -eu

ler_limite_bytes() {
  if [ -r /sys/fs/cgroup/memory.max ]; then
    cat /sys/fs/cgroup/memory.max                    # cgroup v2: bytes ou "max"
  elif [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
    cat /sys/fs/cgroup/memory/memory.limit_in_bytes  # cgroup v1: bytes; sem limite vira um numero gigante
  else
    echo max
  fi
}

SEMI_SPACE_MB="${SEMI_SPACE_MB:-16}"   # espaco jovem = 3 x semi-space
limite="$(ler_limite_bytes)"

if [ -z "${MAX_OLD_SPACE_MB:-}" ]; then
  if [ "$limite" = "max" ] || [ "${#limite}" -gt 15 ]; then
    MAX_OLD_SPACE_MB=512                           # sem limite de cgroup: valor fixo e explicito
  else
    limite_mb=$((limite / 1024 / 1024))
    reserva_mb=$((limite_mb / 4))                  # 25% para tudo que vive fora do heap...
    if [ "$reserva_mb" -lt 96 ]; then reserva_mb=96; fi   # ...e nunca menos que 96 MiB
    MAX_OLD_SPACE_MB=$((limite_mb - reserva_mb - SEMI_SPACE_MB * 3))
  fi
fi

export NODE_OPTIONS="${NODE_OPTIONS:-} --max-old-space-size=${MAX_OLD_SPACE_MB} --max-semi-space-size=${SEMI_SPACE_MB}"
echo "memoria: limite_cgroup=${limite} max_old_space=${MAX_OLD_SPACE_MB}MiB semi_space=${SEMI_SPACE_MB}MiB" >&2

exec node "$@"

Três decisões no script merecem explicação. A reserva fora do heap é 25% do limite com piso de 96 MiB: o piso cobre os 40 MiB do processo vazio mais código e pilhas em contêineres pequenos, e a porcentagem acompanha o crescimento de Buffers e conexões em contêineres grandes. O semi-space é fixado em 16 MiB em vez de confiar no padrão, porque o padrão varia entre versões e pode consumir sozinho 192 MiB; serviços de alta taxa de alocação podem subir para 32 ou 64 e descontar o triplo do old space. E as duas variáveis de ambiente permitem sobrescrever o cálculo por serviço, sem trocar a imagem, para o caso em que a proporção padrão não serve.

Limite do contêinerReserva fora do heapEspaço jovem (semi 16 MiB)max-old-space-size resultante
256 MiB96 MiB48 MiB112 MiB
512 MiB128 MiB48 MiB336 MiB
1 GiB256 MiB48 MiB720 MiB
2 GiB512 MiB48 MiB1488 MiB

No Kubernetes, o que aparece em memory.max dentro do contêiner é o limite do contêiner, não o request. Por isso request e limit de memória devem ser iguais: com request menor, o pod entra na classe Burstable e pode ser despejado por pressão de memória do nó antes mesmo de chegar ao próprio limite, e o cálculo do entrypoint, que só vê o limit, passa a estar errado para o pior caso. Para workers, a mesma conta vale com resourceLimits.maxOldGenerationSizeMb e maxYoungGenerationSizeMb no construtor do Worker, descontando cada heap da fatia do processo principal.

05

Ver a configuração na subida e a pressão antes da morte

Como a morte por cgroup não deixa log, a observação precisa acontecer antes. Dois momentos importam. Na subida, o processo deve comparar o limite de heap que o V8 realmente adotou com o limite do cgroup que o Node consegue ler, e registrar qualquer desproporção: um heap acima de 80% do contêiner vai morrer sob carga, e um heap abaixo de 40% vai falhar com "heap out of memory" com metade do contêiner livre. Em execução, a única métrica que antecipa o SIGKILL é a razão entre RSS e limite, porque é exatamente a conta que o kernel faz.

// memoria.js
import v8 from 'node:v8';

const MiB = 1024 * 1024;
const emMiB = (bytes) => Math.round(bytes / MiB);

// Limite imposto pelo cgroup, quando o Node consegue ler (18.15 ou mais novo).
// Zero significa "sem limite" ou "esta versao nao sabe ler".
const limiteDoConteiner = () =>
  typeof process.constrainedMemory === 'function' ? process.constrainedMemory() || 0 : 0;

export function retratoDeMemoria() {
  const uso = process.memoryUsage();
  const heap = v8.getHeapStatistics();
  const limite = limiteDoConteiner();
  return {
    limiteConteinerMiB: limite ? emMiB(limite) : null,
    limiteHeapMiB: emMiB(heap.heap_size_limit), // old space + espaco jovem
    rssMiB: emMiB(uso.rss), // o unico numero que o kernel compara com o limite
    heapUsadoMiB: emMiB(uso.heapUsed),
    heapTotalMiB: emMiB(uso.heapTotal),
    externoMiB: emMiB(uso.external),
    arrayBuffersMiB: emMiB(uso.arrayBuffers),
    foraDoHeapMiB: emMiB(uso.rss - uso.heapTotal),
  };
}

// Roda uma vez na subida: configuracao errada tem que aparecer no log do deploy,
// nao no pos-incidente.
export function verificarConfiguracao(retrato = retratoDeMemoria()) {
  const problemas = [];
  const { limiteConteinerMiB, limiteHeapMiB, rssMiB } = retrato;
  if (!limiteConteinerMiB) {
    problemas.push('sem limite de cgroup visivel: o heap foi dimensionado pela memoria do host');
  } else if (limiteHeapMiB > limiteConteinerMiB * 0.8) {
    problemas.push(
      `heap_size_limit de ${limiteHeapMiB} MiB passa de 80% do limite de ${limiteConteinerMiB} MiB`,
    );
  } else if (limiteHeapMiB < limiteConteinerMiB * 0.4) {
    problemas.push(
      `heap_size_limit de ${limiteHeapMiB} MiB usa menos de 40% do limite de ${limiteConteinerMiB} MiB`,
    );
  }
  if (limiteConteinerMiB && rssMiB > limiteConteinerMiB * 0.85) {
    problemas.push(`rss de ${rssMiB} MiB ja esta a menos de 15% do limite`);
  }
  return problemas;
}

// Gauges para o coletor de metricas. O alerta que importa e a razao rss/limite:
// e o unico sinal que existe antes de o kernel matar o processo.
export function registrarMetricas(gauge, intervaloMs = 15_000) {
  const coletar = () => {
    const r = retratoDeMemoria();
    gauge('node_memoria_rss_mib', r.rssMiB);
    gauge('node_memoria_heap_usado_mib', r.heapUsadoMiB);
    gauge('node_memoria_fora_do_heap_mib', r.foraDoHeapMiB);
    if (r.limiteConteinerMiB) {
      gauge('node_memoria_uso_do_limite', r.rssMiB / r.limiteConteinerMiB);
    }
  };
  coletar();
  return setInterval(coletar, intervaloMs).unref();
}
  • Alertar quando a razão rss/limite passa de 85% por mais de dois minutos: é o aviso que o kernel não vai dar.
  • Alertar quando o número de reinícios com motivo OOMKilled cresce, lido do orquestrador, e não só quando o pod está fora: um reinício a cada seis horas passa despercebido no gráfico de disponibilidade.
  • Gravar a diferença rss menos heapTotal como série própria: se ela sobe enquanto heapUsed fica estável, o crescimento está em Buffers ou em código nativo, e o heap snapshot não vai mostrar nada.
  • Usar --heapsnapshot-near-heap-limit=1 com --diagnostic-dir apontando para um volume: ele só dispara para a morte por heap, não para a morte por cgroup, mas é o que transforma a primeira em evidência.

Em nós com cgroup v2 existe ainda um recurso intermediário: memory.high, um teto abaixo de memory.max a partir do qual o kernel passa a reclamar memória e frear o processo em vez de matá-lo. Orquestradores recentes expõem isso como qualidade de serviço de memória. Ele não substitui o dimensionamento, porque um processo freado por reclamação de página fica lento de uma forma difícil de diagnosticar, mas transforma uma morte instantânea em uma degradação com tempo para o alerta disparar.

06

Limitar o que vive fora do heap

Depois de dimensionar o heap, a parte sem teto é a memória nativa. Um endpoint que lê o corpo da requisição inteiro em um Buffer, para passar a um parser de ZIP ou PDF que precisa de acesso aleatório, aloca fora do heap e fora de qualquer flag. Com 20 uploads simultâneos de 30 MB, são 600 MB que o coletor não vê, que heapUsed não mostra e que o cgroup conta. A proteção é tratar bytes fora do heap como um recurso finito com orçamento explícito: a requisição reserva o que vai precisar, espera se não houver espaço, e libera quando o processamento termina.

// orcamento-de-bytes.js
// O heap tem --max-old-space-size. Buffers, streams de zlib e TLS nao tem nada:
// o unico limite deles e o do cgroup. Este orcamento cria esse limite em espaco
// de usuario e faz a requisicao esperar em vez de deixar o processo morrer.
export class OrcamentoDeBytes {
  constructor({ limiteBytes }) {
    this.limiteBytes = limiteBytes;
    this.emUsoBytes = 0;
    this.esperando = [];
  }

  async reservar(bytes) {
    if (bytes > this.limiteBytes) {
      throw new Error(`reserva de ${bytes} bytes maior que o orcamento de ${this.limiteBytes}`);
    }
    while (this.emUsoBytes + bytes > this.limiteBytes) {
      await new Promise((resolve) => this.esperando.push(resolve));
    }
    this.emUsoBytes += bytes;
    let liberado = false;
    return () => {
      if (liberado) return;
      liberado = true;
      this.emUsoBytes -= bytes;
      const acordar = this.esperando;
      this.esperando = [];
      for (const resolve of acordar) resolve(); // todos reavaliam a condicao
    };
  }
}

// upload.js: parsers de ZIP e PDF precisam do arquivo inteiro em memoria.
// A reserva garante que N uploads simultaneos nunca somam mais que o orcamento,
// e cobre o processamento, nao so a leitura.
const orcamento = new OrcamentoDeBytes({ limiteBytes: 96 * 1024 * 1024 });

export async function processarArquivo(req, processar) {
  const tamanho = Number(req.headers['content-length']);
  if (!Number.isFinite(tamanho) || tamanho <= 0) {
    throw new Error('Content-Length obrigatorio');
  }
  const liberar = await orcamento.reservar(tamanho);
  try {
    const partes = [];
    let lidos = 0;
    for await (const parte of req) {
      lidos += parte.length;
      if (lidos > tamanho) throw new Error('corpo maior que o Content-Length declarado');
      partes.push(parte);
    }
    return await processar(Buffer.concat(partes, lidos));
  } finally {
    liberar();
  }
}

O orçamento precisa cobrir o processamento, não só a leitura, porque o Buffer continua vivo enquanto o parser trabalha; por isso o callback roda dentro do try e a liberação fica no finally. O tamanho do orçamento sai da mesma conta do entrypoint: ele é uma fração da reserva fora do heap, nunca a reserva inteira, porque TLS, zlib e o próprio runtime também vivem ali. E o Content-Length mentiroso é cortado em duas camadas: a leitura rejeita o corpo que passa do declarado, e o limite de tamanho de payload na borda rejeita o declarado que passa do aceitável. O mesmo padrão se aplica a qualquer coisa que aloque fora do heap por requisição, de imagens redimensionadas a respostas de banco materializadas inteiras.

07

Como provar que o processo cabe no limite

A prova que importa não roda na máquina do desenvolvedor, onde o heap é dimensionado por 32 GB de RAM e nada morre. Ela roda dentro de um contêiner com o mesmo limite da produção e sem swap, executando um script que aloca muito mais do que o limite enquanto mantém um conjunto vivo modesto. Se o entrypoint estiver certo, o coletor segura o lixo abaixo do teto e o processo termina com sucesso; se estiver errado, o docker devolve 137. O script de churn simula o padrão mais comum em serviços de integração: lotes de objetos parseados que são processados e descartados.

// scripts/churn.js: simula lotes de pedidos parseados que viram lixo depois de
// processados. Mantem VIVOS lotes na memoria e descarta o resto; o total alocado
// e LOTES vezes o tamanho do lote, muito acima de qualquer limite de conteiner.
import v8 from 'node:v8';

const LOTES = Number(process.env.LOTES || 300);
const VIVOS = Number(process.env.VIVOS || 6);
const MiB = 1024 * 1024;

const janela = [];
let picoRssMiB = 0;

for (let i = 0; i < LOTES; i += 1) {
  janela.push(
    Array.from({ length: 100_000 }, (_, j) => ({
      id: i * 100_000 + j,
      status: 'pago',
      total: (j % 500) * 1.5,
    })),
  );
  if (janela.length > VIVOS) janela.shift();
  picoRssMiB = Math.max(picoRssMiB, process.memoryUsage.rss() / MiB);
}

console.log(
  JSON.stringify({
    picoRssMiB: Math.round(picoRssMiB),
    heapLimiteMiB: Math.round(v8.getHeapStatistics().heap_size_limit / MiB),
    lotesVivos: janela.length,
  }),
);
// memoria.test.js: roda o churn dentro de um conteiner com 256 MiB e sem swap.
// Exige Docker no ambiente de CI e leva alguns segundos: e um teste de integracao.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';

const exec = promisify(execFile);
const LIMITE_MIB = 256;

const rodarNoConteiner = (...comando) =>
  exec('docker', [
    'run', '--rm',
    `--memory=${LIMITE_MIB}m`, `--memory-swap=${LIMITE_MIB}m`, // sem swap: ou cabe, ou morre
    '-v', `${process.cwd()}:/app`, '-w', '/app',
    'node:22-alpine', ...comando,
  ]);

test('o entrypoint deixa o heap abaixo de 80% do limite e o processo sobrevive', async () => {
  const { stdout } = await rodarNoConteiner('sh', './entrypoint.sh', 'scripts/churn.js');
  const resultado = JSON.parse(stdout.trim().split('\n').at(-1));

  assert.ok(resultado.heapLimiteMiB >= 128, `heap pequeno demais: ${resultado.heapLimiteMiB} MiB`);
  assert.ok(
    resultado.heapLimiteMiB <= LIMITE_MIB * 0.8,
    `heap grande demais: ${resultado.heapLimiteMiB} MiB`,
  );
  assert.ok(resultado.picoRssMiB < LIMITE_MIB, `rss chegou a ${resultado.picoRssMiB} MiB`);
  assert.equal(resultado.lotesVivos, 6);
});

test('o mesmo churn com heap maior que o conteiner nao termina com sucesso', async () => {
  // Quando o kernel mata o processo, o docker devolve 137 (128 + SIGKILL).
  await assert.rejects(
    rodarNoConteiner('node', '--max-old-space-size=2048', 'scripts/churn.js'),
    (erro) => erro.code === 137,
  );
});

O segundo teste é o que mostra o problema original: o mesmo script, com heap configurado acima do contêiner, é morto pelo kernel sem nenhum vazamento. Ele depende da heurística de crescimento do V8, que deixa o heap chegar a algumas vezes o conjunto vivo antes de uma coleta completa, por isso a janela de lotes vivos é mantida pequena e o total alocado grande. Vale rodar esse par de testes na pipeline sempre que a imagem base, a versão do Node ou o limite do pod mudarem: são exatamente as três mudanças que invalidam a configuração sem alterar uma linha de código da aplicação.

FAQ

Perguntas frequentes

Se o Node já lê o limite do cgroup, ainda preciso das flags?

Precisa, por dois motivos. O primeiro é que a leitura depende da versão do Node, do libuv e do tipo de cgroup do nó, e uma atualização de cluster pode fazer um processo que lia o limite passar a ler a memória do host. O segundo é que, mesmo lendo certo, a heurística usa um quarto do limite, sem saber se a aplicação mantém cache em memória ou processa uploads. As flags explícitas tornam o tamanho do heap uma decisão versionada junto com o limite do pod, e a verificação na subida denuncia quando as duas deixam de combinar.

Por que o RSS não cai depois que o coletor roda?

Porque o V8 e o alocador de C devolvem memória ao sistema com preguiça. Depois de uma coleta completa, heapUsed cai, mas heapTotal pode ficar reservado para a próxima rajada de alocação, e páginas liberadas pelo malloc podem continuar mapeadas por fragmentação. Um RSS em patamar estável abaixo do limite é normal e não indica problema. O sinal de vazamento é heapUsed logo após coletas completas subindo ao longo das horas; o sinal de memória nativa crescendo é rss menos heapTotal subindo enquanto heapUsed fica estável.

Não é mais simples aumentar o limite do contêiner?

Aumentar o limite sem ajustar o heap só muda o momento da morte, porque o V8 continua dimensionando o heap pela memória que enxerga e deixando o lixo acumular até perto desse teto. Em um nó onde o Node lê a memória do host, um contêiner de 2 GiB morre do mesmo jeito que um de 512 MiB, apenas mais tarde e com mais lixo acumulado. O limite deve ser definido pelo conjunto vivo real da aplicação mais a reserva nativa, e o heap derivado dele. Aumentar o limite é a resposta certa quando a medição mostra conjunto vivo alto, não quando mostra lixo acumulado.

O kernel mede o processo inteiro; o V8 só governa o heap

Um processo Node sem vazamento morre por OOM quando o limite que o V8 usa para adiar a coleta é maior do que o limite que o cgroup aplica, ou quando a memória fora do heap cresce sem nenhum teto. A correção é derivar as flags do V8 do limite do contêiner no entrypoint, fixar o espaço jovem, dar orçamento explícito ao que vive fora do heap, alertar pela razão entre RSS e limite, e provar em um contêiner com o mesmo limite da produção que o processo sobrevive a um churn maior do que a memória disponível.