Operação do back-end Node.js: health checks, encerramento gracioso e robustez

Capítulo 16

Tempo estimado de leitura: 12 minutos

+ Exercício

O que significa “operar” um back-end Node.js

Operar um serviço vai além de “subir o servidor”: envolve garantir que ele responda de forma previsível sob carga, se recupere de falhas, seja desligado sem perder requisições e exponha sinais claros de saúde para orquestradores e balanceadores. Neste capítulo, vamos implementar health checks (liveness/readiness), configurar timeouts e limites, fazer encerramento gracioso (SIGTERM/SIGINT) com drenagem de conexões e finalização de recursos, e definir uma política para exceções não tratadas e rejeições de Promise.

Health checks: liveness, readiness e startup

Conceitos e quando usar

  • Liveness: indica se o processo está “vivo” e não travou. Deve ser rápido e não depender de serviços externos. Se falhar, o orquestrador pode reiniciar o container/processo.
  • Readiness: indica se o serviço está pronto para receber tráfego. Pode depender de recursos essenciais (ex.: conexão com banco, fila, cache). Se falhar, o orquestrador remove o serviço do balanceamento sem necessariamente reiniciar.
  • Startup (quando aplicável): útil quando o serviço demora a iniciar (warm-up, migrações, carregamento de cache). Evita que liveness mate o processo durante a inicialização.

Implementação prática em Express + TypeScript

A ideia é manter um “estado de prontidão” em memória e expor endpoints simples. O readiness deve refletir se dependências críticas estão ok e se o processo não está em modo de drenagem (shutdown).

// src/infra/health/healthState.ts
export type HealthState = {
  draining: boolean;
  ready: boolean;
  startedAt: number;
};

export const healthState: HealthState = {
  draining: false,
  ready: false,
  startedAt: Date.now(),
};

export function markReady() {
  healthState.ready = true;
}

export function markNotReady() {
  healthState.ready = false;
}

export function markDraining() {
  healthState.draining = true;
  healthState.ready = false;
}

Agora os endpoints. Note que liveness não consulta dependências externas; readiness pode consultar (com timeout curto) ou usar um “sinal” atualizado por um monitor interno.

// src/http/routes/healthRoutes.ts
import { Router } from "express";
import { healthState } from "../../infra/health/healthState";

export const healthRoutes = Router();

healthRoutes.get("/health/live", (req, res) => {
  // Não dependa de DB/redis aqui. Resposta rápida.
  res.status(200).json({ status: "ok", uptimeMs: process.uptime() * 1000 });
});

healthRoutes.get("/health/ready", async (req, res) => {
  if (healthState.draining) {
    return res.status(503).json({ status: "draining" });
  }

  if (!healthState.ready) {
    return res.status(503).json({ status: "not_ready" });
  }

  // Se você optar por checar dependências aqui, faça com timeout curto.
  // Ex.: ping no DB/redis/fila (não implementado neste capítulo).

  return res.status(200).json({ status: "ready" });
});

Conecte as rotas no app:

// src/http/app.ts
import express from "express";
import { healthRoutes } from "./routes/healthRoutes";

export const app = express();

app.use(express.json());
app.use(healthRoutes);

Passo a passo recomendado

  • Passo 1: implemente /health/live sem dependências externas.
  • Passo 2: implemente /health/ready baseado em um estado interno (ready e draining).
  • Passo 3: ao final do bootstrap (após conectar recursos essenciais), chame markReady().
  • Passo 4: no início do shutdown, chame markDraining() para parar de receber tráfego.

Timeouts e limites para robustez

Timeouts e limites evitam que uma pequena parcela de requisições “presas” consuma recursos indefinidamente. Em Node.js, é comum precisar configurar: timeouts do servidor HTTP, timeouts por requisição, limites de payload e limites de concorrência.

Continue em nosso aplicativo e ...
  • Ouça o áudio com a tela desligada
  • Ganhe Certificado após a conclusão
  • + de 5000 cursos para você explorar!
ou continue lendo abaixo...
Download App

Baixar o aplicativo

Limite de payload e parsing

Evite aceitar corpos gigantes por padrão. Configure limites no express.json e express.urlencoded.

// src/http/app.ts
import express from "express";

export const app = express();

app.use(express.json({ limit: "1mb" }));
app.use(express.urlencoded({ extended: true, limit: "1mb" }));

Timeout do servidor e keep-alive

O Node expõe timeouts no http.Server. Ajustes típicos: requestTimeout (tempo máximo para a requisição inteira), headersTimeout (tempo para receber headers) e keepAliveTimeout (tempo de conexões keep-alive ociosas). Valores exatos dependem do ambiente, mas o importante é não deixar infinito.

// src/server.ts
import http from "http";
import { app } from "./http/app";

export const server = http.createServer(app);

// Valores de exemplo (ajuste conforme seu cenário)
server.requestTimeout = 30_000;     // 30s para a requisição completa
server.headersTimeout = 10_000;     // 10s para receber headers
server.keepAliveTimeout = 65_000;   // comum em ambientes com LB

server.listen(process.env.PORT || 3000);

Timeout por requisição (middleware)

Mesmo com timeouts do servidor, pode ser útil impor um timeout por requisição para abortar processamento interno e responder 504. Um padrão simples é usar AbortController e propagar o sinal para operações que suportem abort (fetch, alguns clientes HTTP, etc.).

// src/http/middlewares/requestTimeout.ts
import type { Request, Response, NextFunction } from "express";

declare global {
  namespace Express {
    interface Request {
      abortController?: AbortController;
    }
  }
}

export function requestTimeout(ms: number) {
  return (req: Request, res: Response, next: NextFunction) => {
    const ac = new AbortController();
    req.abortController = ac;

    const timer = setTimeout(() => {
      ac.abort();
      if (!res.headersSent) {
        res.status(504).json({ error: "request_timeout" });
      }
    }, ms);

    res.on("finish", () => clearTimeout(timer));
    res.on("close", () => clearTimeout(timer));

    next();
  };
}

Uso no app:

// src/http/app.ts
import express from "express";
import { requestTimeout } from "./middlewares/requestTimeout";

export const app = express();

app.use(express.json({ limit: "1mb" }));
app.use(requestTimeout(25_000));

Limites de concorrência (proteção contra overload)

Quando o serviço recebe mais requisições do que consegue processar, é melhor rejeitar cedo (429/503) do que degradar tudo. Uma abordagem simples é um “semaforo” em memória para limitar requisições simultâneas em rotas críticas.

// src/http/middlewares/concurrencyLimit.ts
import type { Request, Response, NextFunction } from "express";

export function concurrencyLimit(maxInFlight: number) {
  let inFlight = 0;

  return async (req: Request, res: Response, next: NextFunction) => {
    if (inFlight >= maxInFlight) {
      return res.status(503).json({ error: "overloaded" });
    }

    inFlight++;
    res.on("finish", () => { inFlight--; });
    res.on("close", () => { inFlight--; });

    next();
  };
}

Aplicação por rota (ex.: endpoints pesados):

// src/http/routes/heavyRoutes.ts
import { Router } from "express";
import { concurrencyLimit } from "../middlewares/concurrencyLimit";

export const heavyRoutes = Router();

heavyRoutes.get("/reports", concurrencyLimit(20), async (req, res) => {
  // processamento pesado
  res.json({ ok: true });
});

Encerramento gracioso (SIGTERM/SIGINT) com drenagem

Conceito

Encerramento gracioso significa: parar de aceitar novas requisições, permitir que as requisições em andamento terminem (até um limite), fechar conexões ociosas e liberar recursos (conexões de banco, consumidores de fila, timers, etc.). Em ambientes com orquestrador, o sinal mais comum é SIGTERM; localmente, SIGINT (Ctrl+C).

Estratégia prática

  • 1) Marcar draining: readiness passa a falhar (503), removendo o serviço do tráfego.
  • 2) Parar de aceitar novas conexões: server.close() para não aceitar novas conexões.
  • 3) Drenar conexões ativas: rastrear sockets e encerrar keep-alives ociosos; opcionalmente forçar fechamento após um deadline.
  • 4) Finalizar recursos: fechar pools, consumidores, producers, etc.
  • 5) Sair do processo: process.exit apenas quando necessário (ou deixe o event loop esvaziar).

Implementação: rastreando conexões e fechando com deadline

// src/infra/shutdown/gracefulShutdown.ts
import type http from "http";
import { markDraining } from "../health/healthState";

type Closable = { close: () => Promise<void> };

export function setupGracefulShutdown(params: {
  server: http.Server;
  resources?: Closable[];
  shutdownTimeoutMs?: number;
}) {
  const { server, resources = [], shutdownTimeoutMs = 25_000 } = params;

  const sockets = new Set<import("net").Socket>();

  server.on("connection", (socket) => {
    sockets.add(socket);
    socket.on("close", () => sockets.delete(socket));
  });

  let shuttingDown = false;

  async function shutdown(signal: string) {
    if (shuttingDown) return;
    shuttingDown = true;

    // 1) readiness -> 503
    markDraining();

    // 2) parar de aceitar novas conexões
    const closeServer = new Promise<void>((resolve) => {
      server.close(() => resolve());
    });

    // 3) encerrar conexões keep-alive ociosas (e eventualmente as ativas)
    // Primeiro, peça para encerrar conexões ociosas.
    for (const s of sockets) {
      // Se não houver requisição em andamento, destruir é aceitável.
      // Em Node puro não há flag oficial "idle" aqui; esta é uma abordagem pragmática.
      s.end();
    }

    // 4) deadline: após timeout, força destroy
    const force = new Promise<never>((_, reject) => {
      setTimeout(() => {
        for (const s of sockets) s.destroy();
        reject(new Error(`shutdown_timeout_after_${shutdownTimeoutMs}ms`));
      }, shutdownTimeoutMs).unref();
    });

    try {
      await Promise.race([
        (async () => {
          await closeServer;
          for (const r of resources) await r.close();
        })(),
        force,
      ]);
    } catch (err) {
      // Em operação real, registre o erro.
    } finally {
      // Se ainda houver algo pendurado, finalize o processo.
      process.exit(0);
    }
  }

  process.on("SIGTERM", () => void shutdown("SIGTERM"));
  process.on("SIGINT", () => void shutdown("SIGINT"));
}

Conectando no server.ts:

// src/server.ts
import http from "http";
import { app } from "./http/app";
import { setupGracefulShutdown } from "./infra/shutdown/gracefulShutdown";
import { markReady } from "./infra/health/healthState";

const server = http.createServer(app);

server.requestTimeout = 30_000;
server.headersTimeout = 10_000;
server.keepAliveTimeout = 65_000;

server.listen(process.env.PORT || 3000, async () => {
  // Aqui você inicializaria recursos essenciais (DB, fila etc.)
  // e só então marcaria como pronto.
  markReady();
});

setupGracefulShutdown({
  server,
  resources: [
    // Exemplo: { close: async () => db.close() },
    // Exemplo: { close: async () => queueConsumer.stop() },
  ],
  shutdownTimeoutMs: 25_000,
});

Drenagem de requisições em andamento (controle por contador)

Se você quiser esperar requisições em andamento terminarem antes de fechar recursos, mantenha um contador global. Isso ajuda a evitar fechar DB/fila enquanto handlers ainda executam.

// src/http/middlewares/inFlightCounter.ts
import type { Request, Response, NextFunction } from "express";

export const inFlight = {
  count: 0,
};

export function inFlightCounter(req: Request, res: Response, next: NextFunction) {
  inFlight.count++;
  const done = () => { inFlight.count--; };
  res.on("finish", done);
  res.on("close", done);
  next();
}

Você pode usar esse contador no shutdown para aguardar um curto período (polling) antes de fechar recursos:

// trecho ilustrativo dentro do shutdown
async function waitForInFlight(getCount: () => number, maxWaitMs: number) {
  const start = Date.now();
  while (getCount() > 0 && Date.now() - start < maxWaitMs) {
    await new Promise((r) => setTimeout(r, 100));
  }
}

Finalização de recursos: padrões práticos

Recursos típicos que precisam de finalização:

  • Servidor HTTP: server.close() para parar de aceitar conexões.
  • Pool de banco: encerrar pool para liberar conexões.
  • Consumidores de fila: parar consumo e aguardar mensagens em processamento (se aplicável).
  • Producers: flush de buffers e fechamento de conexões.
  • Timers/intervals: limpar setInterval que manteria o event loop vivo.

Um padrão útil é encapsular cada recurso em uma interface close(): Promise<void> (como no exemplo do shutdown). Isso reduz acoplamento e facilita garantir que tudo seja encerrado.

Exceções não tratadas e rejeições de Promise

O problema

Erros não tratados podem deixar o processo em estado inconsistente (ex.: parte do request foi processada, conexões ficaram em estado desconhecido, invariantes quebraram). Em muitos serviços, a política mais segura é fail-fast: ao detectar um erro fatal fora do fluxo normal, iniciar shutdown gracioso e reiniciar o processo (via supervisor/orquestrador).

Política recomendada

  • Erros esperados (validação, regra de negócio, recurso inexistente): tratar e responder adequadamente.
  • Erros inesperados dentro de request: middleware de erro do Express para responder 500 e evitar crash imediato.
  • Erros fora do request (eventos, callbacks, timers) ou unhandledRejection/uncaughtException: considerar fatal, marcar draining e encerrar com deadline.

Middleware de erro do Express (sem repetir logging)

Garanta que erros em handlers cheguem ao middleware de erro. Em rotas async, use um wrapper para encaminhar exceções para next.

// src/http/utils/asyncHandler.ts
import type { Request, Response, NextFunction, RequestHandler } from "express";

export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler {
  return (req, res, next) => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
}
// src/http/middlewares/errorHandler.ts
import type { Request, Response, NextFunction } from "express";

export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction) {
  if (res.headersSent) return next(err);
  res.status(500).json({ error: "internal_server_error" });
}

Uso:

// src/http/app.ts
import express from "express";
import { errorHandler } from "./middlewares/errorHandler";
import { inFlightCounter } from "./middlewares/inFlightCounter";

export const app = express();
app.use(express.json({ limit: "1mb" }));
app.use(inFlightCounter);

// ...rotas

app.use(errorHandler);

Capturando eventos globais e iniciando shutdown

Use os eventos do processo para capturar falhas fatais. Em vez de tentar “continuar”, inicie shutdown gracioso. Importante: uncaughtException pode ocorrer em estado corrompido; a ação mais segura é encerrar.

// src/infra/process/fatalHandlers.ts
export function setupFatalHandlers(onFatal: (reason: unknown) => void) {
  process.on("unhandledRejection", (reason) => {
    onFatal(reason);
  });

  process.on("uncaughtException", (err) => {
    onFatal(err);
  });
}

Integração com o shutdown:

// src/server.ts (trecho)
import { setupFatalHandlers } from "./infra/process/fatalHandlers";

setupFatalHandlers((reason) => {
  // Em operação real, registre o motivo.
  // Inicie o mesmo fluxo de shutdown usado em SIGTERM.
  process.kill(process.pid, "SIGTERM");
});

Observação: em alguns ambientes, você pode preferir chamar diretamente a função shutdown() em vez de reenviar sinal para o próprio processo. O importante é ter um único caminho de desligamento para evitar duplicação e estados inconsistentes.

Guia de verificação operacional (checklist)

ÁreaVerificaçãoCritério prático
Health checks/health/live responde rápido e sem dependências200 em < 50ms na maioria dos casos
Health checks/health/ready reflete prontidão real503 durante bootstrap e durante draining
TimeoutsTimeouts do servidor configuradosheadersTimeout, requestTimeout, keepAliveTimeout definidos
TimeoutsTimeout por requisição em rotas críticas504 quando exceder limite; operações internas respeitam abort quando possível
LimitesLimite de payloadexpress.json({ limit }) e urlencoded com limites coerentes
LimitesProteção contra overloadLimite de concorrência em endpoints pesados (429/503)
ShutdownCaptura de SIGTERM/SIGINTMarca draining, fecha servidor, finaliza recursos
ShutdownDrenagem com deadlineEspera requisições em andamento e força fechamento após timeout
RecursosEncerramento de pools/consumidoresCada recurso expõe close(): Promise<void> e é chamado no shutdown
Falhas fataisunhandledRejection/uncaughtExceptionInicia fail-fast com shutdown gracioso
AmbientesConfiguração por variáveis de ambientePorta, timeouts, limites e flags ajustáveis sem alterar código

Agora responda o exercício sobre o conteúdo:

Durante um encerramento gracioso de um serviço Node.js com Express, qual ação ajuda a fazer o orquestrador parar de enviar tráfego para a instância antes de fechar conexões e recursos?

Você acertou! Parabéns, agora siga para a próxima página

Você errou! Tente novamente.

No início do shutdown, marcar draining faz o readiness falhar (503), sinalizando ao orquestrador/balanceador para parar de rotear novas requisições para a instância antes de fechar servidor e recursos.

Capa do Ebook gratuito Node.js Essencial: Construindo um Back-end com Express e TypeScript
100%

Node.js Essencial: Construindo um Back-end com Express e TypeScript

Novo curso

16 páginas

Baixe o app para ganhar Certificação grátis e ouvir os cursos em background, mesmo com a tela desligada.