Graceful Shutdown com Node.js e Kubernetes

Quando se trata de aplicações em produção, é fundamental garantir que o serviço seja encerrado de maneira apropriada para evitar perda de dados ou operações não finalizadas. Isso é especialmente importante ao trabalhar com Kubernetes, onde as instâncias podem ser escaladas ou removidas frequentemente.
O que é Graceful Shutdown
Graceful shutdown é uma técnica para finalizar uma aplicação de maneira controlada, permitindo que, antes da aplicação ser desligada, haja a conclusão de tarefas em andamento. O graceful shutdown pode ser realizado devido a sinais de sistema que são disparados quando o fechamento da aplicação é solicitado.
Por que você deveria utilizar
- Prevenção a finalização de requisições HTTP: Com a utilização desse processo, é possível garantir que novas requisições sejam bloqueadas e as que estiverem sendo processadas continuem até sua finalização total.
- Garantia de término de operações críticas: Operações de banco de dados, por exemplo, são finalizadas antes do encerramento do serviço, assegurando a consistência de dados esperada.
Entendendo sinais de sistema Unix e seu uso no Kubernetes
Em sistemas Unix, um processo pode receber uma notificação pelo sistema operacional, chamada de sinal. Esses sinais têm o intuito de notificar a ocorrência de um evento. O processo pode registrar uma rotina de tratamento de sinal (signal handler) para lidar com determinado sinal (aqui que entra o graceful shutdown!).
Temos três sinais mais comuns de encerramento:
- SIGINT: Gerado ao tentar encerrar um processo manual, usando
CTRL + C
no terminal, por exemplo. - SIGTERM: Indica que o processo deve ser encerrado. É o sinal para encerrar um pod dentro do Kubernetes.
- SIGKILL: O sinal enviado para finalizar o processo de forma forçada.
O Kubernetes, após enviar o SIGTERM ao processo do pod, esperará por um determinado tempo, chamado de grace period (por padrão é de 30 segundos). Se, após o período, o processo ainda não estiver finalizado, o SIGKILL é enviado ao processo.
Implementando Graceful Shutdown com Node.js
Setup inicial de um servidor HTTP
Bom, primeiramente vamos configurar um servidor HTTP. Para facilitar a exemplificação, vou utilizar o Express.
Vamos criar um endpoint básico que vai apenas servir para nos enviar o status de nossa aplicação.
import process from "process";
import express from "express";
const PORT = 3333;
const app = express();
app.get("/status", (req, res) => {
res.status(200).json({ status: "ok" });
});
const server = app.listen(PORT, () => {
console.log(`Server listening to port: ${PORT}`);
console.log(`PID: ${process.pid}`);
});
Agora, nosso próximo passo será criar uma necessidade da implantação do graceful shutdown, que nesse caso será uma operação de salvamento de um log no formato de um arquivo que será salvo na própria máquina. Entretanto, antes disso, iremos criar um endpoint para criação e listagem de usuários, sem banco de dados mesmo, os usuários serão salvos apenas em memória.
Criando um endpoints de usuário
Primeiramente vamos criar uma classe simples apenas para gerenciar os usuários, que vai ser responsável por criar e salvar os usuários em uma array.
import { randomUUID, UUID } from "crypto";
export interface User {
id: UUID;
createdAt: Date;
}
export class UsersManager {
savedUsers: User[] = [];
create() {
const id = randomUUID();
const user = {
id,
createdAt: new Date(),
};
this.savedUsers.push(user);
return user;
}
getUsers() {
return this.savedUsers;
}
}
Agora, só nos resta registrar os endpoints no arquivo principal index.ts. Dessa forma a lógica da nossa aplicação ficará disponível através de requisições HTTP.
import express from "express";
import { UsersManager } from "./user";
const PORT = 3333;
const app = express();
const usersManager = new UsersManager();
// Método para simular uma operação assíncrona
function withDelay(value: any, delay: number) {
return new Promise<any>((resolve) => {
setTimeout(() => resolve(value), delay);
});
}
app.get("/status", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.post("/users", async (req, res) => {
// Delay de 5 segundos
const user = await withDelay(usersManager.create(), 5000);
res.status(201).json(user);
});
app.get("/users", async (req, res) => {
// Delay de 2,5 segundos
const users = await withDelay(usersManager.getUsers(), 2500);
res.status(200).json(users);
});
const server = app.listen(PORT, () => {
console.log(`Server listening to port: ${PORT}`);
console.log(`PID: ${process.pid}`);
});
Criando um arquivo de logs
Para cada novo cliente, vamos atualizar (ou criar, se ainda não existir) nosso arquivo de log da execução.
Para essa implementação, criei uma nova classe para facilitar o gerenciamento desse arquivo.
import path from "path";
import { existsSync } from "fs";
import { writeFile, mkdir, readFile } from "fs/promises";
export class LogFile {
EXECUTION_DATE = new Date();
private getLogFilePath() {
const time = this.EXECUTION_DATE.getTime();
return path.join(__dirname, "../files", `log_${time}.txt`);
}
private readLogFile() {
return readFile(this.getLogFilePath(), { encoding: "utf8" });
}
async updateLogFile(log: string) {
let currentLogs = "";
try {
currentLogs = (await this.readLogFile()) + "\n";
} catch {
// Caso arquivo de log ainda não esteja criado
}
const data = currentLogs + log;
const filePath = this.getLogFilePath();
const folderPath = path.dirname(filePath);
// Checa se a pasta raiz já existe (files), se não cria
if (!existsSync(folderPath)) {
await mkdir(path.dirname(filePath), { recursive: true });
}
return await writeFile(filePath, data, {
flag: "w+",
});
}
}
No nosso arquivo index.ts
, instanciamos essa nova classe e já podemos fazer o uso dela.
import express from "express";
import { LogFile } from "./logFile";
import { User, UsersManager } from "./user";
const PORT = 3333;
const app = express();
// Instanciação da classe de log
const logFile = new LogFile();
const usersManager = new UsersManager();
function withDelay(value: any, delay: number) {
return new Promise<any>((resolve) => {
setTimeout(() => resolve(value), delay);
});
}
app.get("/status", (req, res) => {
res.status(200).json({ status: "ok" });
});
app.post("/users", async (req, res) => {
// Delay de 5 segundos
const user = await withDelay(usersManager.create(), 5000);
// Um log é salvo ao registrar um novo usuário
await logFile.updateLogFile(
`Novo usuário com ID: ${
user.id
} criado. Timestamp de criação: ${user.createdAt.getTime()}`
);
res.status(201).json(user);
});
app.get("/users", async (req, res) => {
// Delay de 2,5 segundos
const users = await withDelay(usersManager.getUsers(), 2500);
res.status(200).json(users);
});
const server = app.listen(PORT, () => {
console.log(`Server listening to port: ${PORT}`);
console.log(`PID: ${process.pid}`);
});
Resolvendo o problema
Agora todos os usuários criados durante uma execução do nosso app têm um log salvo em um arquivo local. Vamos supor que foi solicitado o desligamento do processo da aplicação, mas há uma requisição de criação de usuário rodando. Como muitas operações assíncronas, o processo de criação de usuários pode ser interrompido, resultando em inconsistências como o não registro de logs ou usuários incompletos no sistema. Esse tipo de situação é comum em servidores web, onde operações pendentes podem não ser finalizadas corretamente antes do encerramento.
Agora sim, vamos implementar uma lógica para impedir que esse problema ocorra com graceful shutdown.
No arquivo index.ts
, logo após a implementação das rotas, adicionamos esse código, que irá tratar os sinais de desligamento do sistema.
const server = app.listen(PORT, () => {
console.log(`Server listening to port: ${PORT}`);
console.log(`PID: ${process.pid}`);
});
const shutdown = () => {
// Bloqueia novos requests e, após a finalização das requisições atuais, fecha o servidor HTTP
server.close(() => {
// Fecha o processo, finalizando totalmente nossa aplicação
process.exit();
});
// Após um tempo limite de 30 segundos, iremos forçar o desligamento, igual ao grace period do Kubernetes
setTimeout(() => {
process.exit(1);
}, 30000);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
Validando se o problema foi resolvido
Primeiramente, vamos iniciar nosso servidor e capturar seu PID, que está sendo enviado no console.
Server listening to port: 3000
PID: 2584
Agora iremos fazer uma requisição para criação de um usuário.
curl -X POST http://localhost:3333/users
Antes mesmo da resposta da requisição, já enviamos o sinal para o processo através do comando kill do Linux.
kill 2584
Com a execução dessas etapas, é possível verificar que o processo só foi derrubado depois de cumprir com sua tarefa, que era finalizar sua requisição de criação de usuário.
Configuração do Kubernetes para Graceful Shutdown
Alterando o grace period de um pod
Caso 30 segundos não seja o bastante para o encerramento de todas as tarefas após a solicitação de finalização da aplicação, você pode alterá-lo no manifest do seu pod, alterando o campo terminationGracePeriodSeconds
.
apiVersion: v1
kind: Pod
metadata:
name: my-app
spec:
containers:
- name: my-container
image: my-image
terminationGracePeriodSeconds: 60
Readiness e Liveness do pod
As readiness e liveness probes do Kubernetes ajudam a identificar quando a aplicação está pronta para receber tráfego e quando está saudável, respectivamente.
Durante o shutdown, irá parar de enviar tráfego para seu pod assim que o readiness probe falhar, ou seja, novas requisições nem sequer vão chegar ao seu pod.
Possível implementação dos readiness e liveness probes
readinessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /healthz
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
Conclusão
Implementar o graceful shutdown em uma aplicação Node dentro de um cluster Kubernetes garante alta disponibilidade e confiabilidade. A utilização das configurações certas que dialoguem com sua necessidade garante que sua aplicação seja encerrada de maneira segura e confortável, sem provocar erros indesejados em requisições dos usuários de sua API e nem cancelar operações importantes.
Bom, já que chegamos ao fim, aproveito também para deixar aqui o código elaborado durante a construção desse artigo.
Comments ()