Todo engenheiro que opera sistemas em produção por tempo suficiente aprende uma lição inevitável: sistemas complexos falham. Não é uma questão de se, mas de quando e como. A questão relevante não é construir sistemas que nunca falhem — isso é impossível em qualquer escala significativa — mas construir sistemas que falhem de maneira previsível, controlada e recuperável.
A diferença entre um sistema resiliente e um frágil não está na ausência de falhas — está em como o sistema se comporta quando as falhas ocorrem. Um sistema resiliente degrada graciosamente: quando um componente falha, os demais continuam funcionando com capacidade reduzida, sem colapso em cascata. Um sistema frágil, ao contrário, amplifica falhas locais em interrupções sistêmicas.
O Chaos Engineering é a disciplina de experimentar intencionalmente falhas em sistemas de produção para descobrir fraquezas antes que elas causem incidentes reais. A premissa é contraintuitiva mas poderosa: se o sistema vai falhar de qualquer forma, é melhor que a primeira falha aconteça em condições controladas, com o time de plantão disponível, do que às 3 da manhã durante um pico de tráfego.
Os Padrões de Resiliência Fundamentais
Antes de experimentar caos, é necessário implementar os padrões que tornam um sistema capaz de tolerar falhas. Esses padrões são os blocos de construção de qualquer arquitetura resiliente.
Circuit Breaker
O Circuit Breaker protege um serviço de continuar tentando chamar um serviço dependente que está falhando. Funciona como um disjuntor elétrico: quando o número de falhas ultrapassa um limiar, o circuito "abre" e todas as chamadas subsequentes são rejeitadas imediatamente sem tentar o serviço downstream. Após um período de espera, o circuito entra em estado "half-open" e permite uma chamada de teste — se bem-sucedida, o circuito fecha; se não, continua aberto.
// src/resilience/circuit-breaker.js
class CircuitBreaker {
constructor(options = {}) {
this.nome = options.nome || 'circuit-breaker';
this.limiarFalhas = options.limiarFalhas || 5;
this.timeoutAbertura = options.timeoutAbertura || 60000; // 60s
this.timeoutRequisicao = options.timeoutRequisicao || 5000; // 5s
// Estados: FECHADO (normal) | ABERTO (rejeitando) | HALF_OPEN (testando)
this.estado = 'FECHADO';
this.contagemFalhas = 0;
this.ultimaFalha = null;
this.contagemSuccessos = 0;
// Métricas para o Prometheus
this._inicializarMetricas();
}
_inicializarMetricas() {
const { Counter, Gauge } = require('prom-client');
this.metricaEstado = new Gauge({
name: `circuit_breaker_state`,
help: 'Estado do circuit breaker (0=fechado, 1=half-open, 2=aberto)',
labelNames: ['nome'],
});
this.metricaChamadas = new Counter({
name: `circuit_breaker_calls_total`,
help: 'Total de chamadas pelo circuit breaker',
labelNames: ['nome', 'resultado'],
});
}
async executar(funcao, fallback = null) {
// Circuito aberto — rejeita imediatamente
if (this.estado === 'ABERTO') {
const agora = Date.now();
if (agora - this.ultimaFalha < this.timeoutAbertura) {
this.metricaChamadas.inc({ nome: this.nome, resultado: 'rejeitado' });
if (fallback) return fallback();
throw new Error(
`Circuit breaker ${this.nome} aberto — aguardando recuperação`
);
}
// Timeout expirou — tenta half-open
this._transicionarPara('HALF_OPEN');
}
try {
// Executa com timeout
const resultado = await Promise.race([
funcao(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Timeout da requisição')),
this.timeoutRequisicao
)
),
]);
this._registrarSuccesso();
this.metricaChamadas.inc({ nome: this.nome, resultado: 'sucesso' });
return resultado;
} catch (erro) {
this._registrarFalha(erro);
this.metricaChamadas.inc({ nome: this.nome, resultado: 'falha' });
if (fallback) return fallback();
throw erro;
}
}
_registrarSuccesso() {
if (this.estado === 'HALF_OPEN') {
this.contagemSuccessos++;
// Requer 2 sucessos consecutivos para fechar
if (this.contagemSuccessos >= 2) {
this._transicionarPara('FECHADO');
}
}
this.contagemFalhas = 0;
}
_registrarFalha(erro) {
this.ultimaFalha = Date.now();
this.contagemFalhas++;
this.contagemSuccessos = 0;
console.warn(JSON.stringify({
level: 'warn',
msg: `Circuit breaker ${this.nome}: falha registrada`,
contagemFalhas: this.contagemFalhas,
limiar: this.limiarFalhas,
erro: erro.message,
}));
if (
this.estado !== 'ABERTO' &&
this.contagemFalhas >= this.limiarFalhas
) {
this._transicionarPara('ABERTO');
}
}
_transicionarPara(novoEstado) {
const estadoAnterior = this.estado;
this.estado = novoEstado;
const estadoNumerico = { FECHADO: 0, HALF_OPEN: 1, ABERTO: 2 };
this.metricaEstado.set(
{ nome: this.nome },
estadoNumerico[novoEstado]
);
if (novoEstado === 'FECHADO') {
this.contagemFalhas = 0;
this.contagemSuccessos = 0;
}
console.info(JSON.stringify({
level: 'info',
msg: `Circuit breaker ${this.nome}: transição de estado`,
de: estadoAnterior,
para: novoEstado,
}));
}
obterEstado() {
return {
nome: this.nome,
estado: this.estado,
contagemFalhas: this.contagemFalhas,
ultimaFalha: this.ultimaFalha
? new Date(this.ultimaFalha).toISOString()
: null,
};
}
}
module.exports = { CircuitBreaker };
Retry com Backoff Exponencial e Jitter
Tentar novamente uma operação que falhou é razoável — mas tentar novamente imediatamente, com todos os clientes ao mesmo tempo, pode transformar uma falha temporária em uma cascata de sobrecarga. O backoff exponencial aumenta o intervalo entre tentativas exponencialmente. O jitter adiciona aleatoriedade para evitar que múltiplos clientes tentem ao mesmo tempo (problema do "thundering herd"):
// src/resilience/retry.js
async function comRetry(funcao, opcoes = {}) {
const {
tentativasMaximas = 3,
delayBase = 1000, // 1s
delayMaximo = 30000, // 30s
multiplicador = 2,
jitter = true,
retryableErros = null, // null = tenta qualquer erro
onRetry = null, // callback antes de cada retry
} = opcoes;
let tentativa = 0;
while (true) {
try {
return await funcao();
} catch (erro) {
tentativa++;
// Verifica se o erro é recuperável
if (retryableErros && !retryableErros.some(e => erro instanceof e)) {
throw erro;
}
// Verifica se deve tentar status HTTP específicos
if (erro.status && ![429, 500, 502, 503, 504].includes(erro.status)) {
throw erro;
}
if (tentativa >= tentativasMaximas) {
console.error(JSON.stringify({
level: 'error',
msg: 'Todas as tentativas esgotadas',
tentativas: tentativa,
erro: erro.message,
}));
throw erro;
}
// Calcula delay com backoff exponencial
const delayExponencial = Math.min(
delayBase * Math.pow(multiplicador, tentativa - 1),
delayMaximo
);
// Adiciona jitter para evitar thundering herd
// Estratégia "full jitter": delay aleatório entre 0 e o delay calculado
const delay = jitter
? Math.random() * delayExponencial
: delayExponencial;
console.warn(JSON.stringify({
level: 'warn',
msg: 'Tentativa falhou, tentando novamente',
tentativa,
tentativasMaximas,
proximaTentativaMs: Math.round(delay),
erro: erro.message,
}));
if (onRetry) {
await onRetry({ tentativa, erro, delay });
}
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
// Uso combinado com circuit breaker
class ClienteHTTPResilient {
constructor(baseUrl, opcoes = {}) {
this.baseUrl = baseUrl;
this.circuitBreaker = new CircuitBreaker({
nome: opcoes.nome || new URL(baseUrl).hostname,
limiarFalhas: opcoes.limiarFalhas || 5,
timeoutAbertura: opcoes.timeoutAbertura || 30000,
});
}
async get(caminho, opcoes = {}) {
return this.circuitBreaker.executar(
() => comRetry(
() => fetch(`${this.baseUrl}${caminho}`, {
method: 'GET',
signal: AbortSignal.timeout(5000),
...opcoes,
}).then(async res => {
if (!res.ok) {
const erro = new Error(`HTTP ${res.status}`);
erro.status = res.status;
throw erro;
}
return res.json();
}),
{ tentativasMaximas: 3, delayBase: 500 }
),
opcoes.fallback
);
}
}
module.exports = { comRetry, ClienteHTTPResilient };
Bulkhead: Isolamento de Recursos
O padrão Bulkhead — referência às divisórias estanques de um navio — isola recursos entre diferentes partes do sistema para que a sobrecarga em uma parte não afete as demais. Na prática, significa ter pools de threads, conexões ou workers separados por funcionalidade:
// src/resilience/bulkhead.js
class Bulkhead {
constructor(opcoes = {}) {
this.nome = opcoes.nome;
this.concorrenciaMaxima = opcoes.concorrenciaMaxima || 10;
this.tamanhoFila = opcoes.tamanhoFila || 20;
this.timeoutFila = opcoes.timeoutFila || 5000;
this.executando = 0;
this.fila = [];
const { Gauge, Counter } = require('prom-client');
this.metricaExecutando = new Gauge({
name: 'bulkhead_executing',
help: 'Chamadas em execução no bulkhead',
labelNames: ['nome'],
});
this.metricaRejeitadas = new Counter({
name: 'bulkhead_rejected_total',
help: 'Chamadas rejeitadas pelo bulkhead',
labelNames: ['nome'],
});
}
async executar(funcao) {
// Verifica se pode executar imediatamente
if (this.executando < this.concorrenciaMaxima) {
return this._executarAgora(funcao);
}
// Verifica se a fila tem espaço
if (this.fila.length >= this.tamanhoFila) {
this.metricaRejeitadas.inc({ nome: this.nome });
throw new Error(
`Bulkhead ${this.nome} saturado: fila cheia (${this.tamanhoFila})`
);
}
// Adiciona à fila e aguarda
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
const index = this.fila.findIndex(item => item.resolve === resolve);
if (index !== -1) this.fila.splice(index, 1);
reject(new Error(`Timeout de fila no bulkhead ${this.nome}`));
}, this.timeoutFila);
this.fila.push({
funcao,
resolve,
reject,
timeout,
});
});
}
async _executarAgora(funcao) {
this.executando++;
this.metricaExecutando.set({ nome: this.nome }, this.executando);
try {
return await funcao();
} finally {
this.executando--;
this.metricaExecutando.set({ nome: this.nome }, this.executando);
this._processarFila();
}
}
_processarFila() {
if (this.fila.length === 0) return;
if (this.executando >= this.concorrenciaMaxima) return;
const proximo = this.fila.shift();
clearTimeout(proximo.timeout);
this._executarAgora(proximo.funcao)
.then(proximo.resolve)
.catch(proximo.reject);
}
}
module.exports = { Bulkhead };
AWS Fault Injection Simulator
O AWS FIS — Fault Injection Simulator — é o serviço gerenciado da AWS para Chaos Engineering. Permite injetar falhas reais em recursos AWS — terminar instâncias EC2, introduzir latência em chamadas de rede, falhar chamadas de API do SSM, degradar bancos de dados RDS — de forma controlada e com mecanismos de parada de emergência.
Experimento: Terminação de Instâncias EC2
# fis.tf
# IAM Role para o FIS executar ações nos recursos
resource "aws_iam_role" "fis" {
name = "${var.project_name}-${var.environment}-fis-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "fis.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "fis" {
role = aws_iam_role.fis.name
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ec2:TerminateInstances",
"ec2:StopInstances",
"ec2:DescribeInstances",
"ecs:StopTask",
"ecs:DescribeTasks",
"rds:RebootDBInstance",
"rds:FailoverDBCluster",
"elasticache:RebootCacheCluster",
"ssm:SendCommand",
"ssm:GetCommandInvocation",
]
Resource = "*"
Condition = {
StringEquals = {
"aws:ResourceTag/Environment" = var.environment
"aws:ResourceTag/ChaosElegible" = "true"
}
}
},
{
Effect = "Allow"
Action = ["cloudwatch:DescribeAlarms"]
Resource = "*"
}
]
})
}
# Experimento 1: Termina 33% das instâncias do Auto Scaling Group
resource "aws_fis_experiment_template" "terminar_instancias" {
description = "Termina 1/3 das instâncias EC2 para testar recuperação do ASG"
role_arn = aws_iam_role.fis.arn
# Stop condition — aborta o experimento se o alarme disparar
stop_condition {
source = "aws:cloudwatch:alarm"
value = aws_cloudwatch_alarm.disponibilidade_critica.arn
}
# Alvo: instâncias EC2 com tag Environment e ChaosElegible
target {
name = "instancias-app"
resource_type = "aws:ec2:instance"
selection_mode = "PERCENT(33)" # Termina 33%
resource_tag {
key = "Environment"
value = var.environment
}
resource_tag {
key = "ChaosElegible"
value = "true"
}
}
action {
name = "terminar-instancias"
action_id = "aws:ec2:terminate-instances"
target {
key = "Instances"
value = "instancias-app"
}
}
tags = local.tags_comuns
}
# Experimento 2: Introduz latência de rede via SSM
resource "aws_fis_experiment_template" "latencia_rede" {
description = "Introduz 200ms de latência nas chamadas de rede para testar timeouts"
role_arn = aws_iam_role.fis.arn
stop_condition {
source = "aws:cloudwatch:alarm"
value = aws_cloudwatch_alarm.latencia_critica.arn
}
target {
name = "instancias-app"
resource_type = "aws:ec2:instance"
selection_mode = "PERCENT(50)"
resource_tag {
key = "ChaosElegible"
value = "true"
}
}
action {
name = "injetar-latencia"
action_id = "aws:ssm:send-command"
parameter {
key = "documentArn"
value = "arn:aws:ssm:us-east-1::document/AWSFIS-Run-Network-Latency"
}
parameter {
key = "documentParameters"
value = jsonencode({
Interface = "eth0"
DelayMilliseconds = "200"
JitterMilliseconds = "50"
DurationSeconds = "120"
InstallDependencies = "True"
})
}
parameter {
key = "duration"
value = "PT3M" # Duração máxima do experimento: 3 minutos
}
target {
key = "Instances"
value = "instancias-app"
}
}
tags = local.tags_comuns
}
# Experimento 3: Falha no banco de dados RDS Multi-AZ
resource "aws_fis_experiment_template" "failover_rds" {
description = "Força failover do RDS Multi-AZ para testar tempo de recuperação"
role_arn = aws_iam_role.fis.arn
stop_condition {
source = "none"
}
target {
name = "banco-dados"
resource_type = "aws:rds:db"
selection_mode = "ALL"
resource_tag {
key = "Environment"
value = var.environment
}
resource_tag {
key = "ChaosElegible"
value = "true"
}
}
action {
name = "failover-rds"
action_id = "aws:rds:reboot-db-instances"
parameter {
key = "forceFailover"
value = "true"
}
target {
key = "DBInstances"
value = "banco-dados"
}
}
tags = local.tags_comuns
}
# Alarme de parada de emergência
resource "aws_cloudwatch_alarm" "disponibilidade_critica" {
alarm_name = "${var.project_name}-${var.environment}-disponibilidade-critica"
comparison_operator = "LessThanThreshold"
evaluation_periods = 2
metric_name = "HealthyHostCount"
namespace = "AWS/ApplicationELB"
period = 60
statistic = "Average"
threshold = 1
dimensions = {
LoadBalancer = aws_lb.aplicacao.arn_suffix
TargetGroup = aws_lb_target_group.aplicacao.arn_suffix
}
alarm_description = "Para o experimento FIS se não houver hosts saudáveis"
tags = local.tags_comuns
}
Executando Experimentos com a CLI
#!/bin/bash
# scripts/executar-experimento-chaos.sh
# Executa um experimento de Chaos Engineering com observação em tempo real
set -euo pipefail
TEMPLATE_ID="${1:?Uso: $0 <experiment-template-id>}"
REGIAO="us-east-1"
log() { echo "[$(date -u +%H:%M:%S)] $*"; }
erro() { echo "[$(date -u +%H:%M:%S)] ERRO: $*" >&2; exit 1; }
log "=== Iniciando Experimento de Chaos Engineering ==="
log "Template: $TEMPLATE_ID"
# Verifica que os sistemas estão saudáveis antes de começar
log "Verificando saúde dos sistemas antes do experimento..."
HOSTS_SAUDAVEIS=$(aws elbv2 describe-target-health \
--target-group-arn "$TARGET_GROUP_ARN" \
--query 'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)' \
--output text)
if [ "$HOSTS_SAUDAVEIS" -lt 2 ]; then
erro "Sistema não está saudável o suficiente para chaos (${HOSTS_SAUDAVEIS} hosts). Abortando."
fi
log "Sistema saudável: $HOSTS_SAUDAVEIS hosts antes do experimento"
# Registra o início para correlacionar com métricas
INICIO_EXPERIMENTO=$(date -u +%Y-%m-%dT%H:%M:%SZ)
# Inicia o experimento
log "Iniciando experimento..."
EXPERIMENTO_ID=$(aws fis start-experiment \
--experiment-template-id "$TEMPLATE_ID" \
--region "$REGIAO" \
--query 'experiment.id' \
--output text)
log "Experimento iniciado: $EXPERIMENTO_ID"
# Monitora o experimento
log "Monitorando estado do experimento..."
while true; do
STATUS=$(aws fis get-experiment \
--id "$EXPERIMENTO_ID" \
--region "$REGIAO" \
--query 'experiment.state.status' \
--output text)
case "$STATUS" in
"running")
HOSTS_AGORA=$(aws elbv2 describe-target-health \
--target-group-arn "$TARGET_GROUP_ARN" \
--query 'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)' \
--output text 2>/dev/null || echo "N/A")
log "Status: $STATUS | Hosts saudáveis: $HOSTS_AGORA"
sleep 15
;;
"completed")
log "Experimento concluído com sucesso!"
break
;;
"stopped")
log "AVISO: Experimento parado (stop condition ativada)"
break
;;
"failed")
erro "Experimento falhou. Verificar logs do FIS."
;;
*)
log "Status inesperado: $STATUS"
sleep 5
;;
esac
done
# Aguarda recuperação e verifica estado final
log "Aguardando recuperação completa do sistema..."
sleep 60
HOSTS_FINAL=$(aws elbv2 describe-target-health \
--target-group-arn "$TARGET_GROUP_ARN" \
--query 'TargetHealthDescriptions[?TargetHealth.State==`healthy`] | length(@)' \
--output text)
log "Estado final: $HOSTS_FINAL hosts saudáveis"
log "Início: $INICIO_EXPERIMENTO"
log "Fim: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
if [ "$HOSTS_FINAL" -ge "$HOSTS_SAUDAVEIS" ]; then
log "✅ Sistema se recuperou completamente"
else
log "⚠️ Sistema recuperou parcialmente: ${HOSTS_FINAL}/${HOSTS_SAUDAVEIS} hosts"
fi
Game Days: Praticando a Falha
O Game Day é a prática de simular falhas e incidentes em ambiente controlado com o time completo presente — uma forma de treino operacional que combina o Chaos Engineering técnico com o treinamento de resposta a incidentes.
Um Game Day típico segue a estrutura:
Pré-Game Day — definição do cenário, hipóteses a testar, métricas de sucesso e plano de rollback. O time documenta o comportamento esperado do sistema.
Execução — um facilitador conduz os experimentos enquanto o time observa métricas, toma decisões e responde como faria em um incidente real.
Revisão — análise do que aconteceu versus o que foi esperado, identificação de fraquezas e definição de ações corretivas.
## Game Day — Plano de Execução
**Data:** 2025-03-15
**Duração:** 4 horas (09h–13h)
**Facilitador:** Ana Costa
**Participantes:** Time de Plataforma + On-call
### Cenário 1: Falha de uma AZ inteira (09h00–10h00)
**Hipótese:** O sistema continua funcionando com latência < 500ms
se a AZ us-east-1b ficar indisponível.
**Ação:** Adicionar regra de NACL bloqueando todo o tráfego para a
subnet privada em us-east-1b.
**Métricas observadas:**
- Taxa de erro HTTP 5xx (alvo: < 0.1%)
- Latência p99 (alvo: < 500ms)
- Tempo de detecção do health check
- Tempo até rebalanceamento do ASG
**Rollback:** Remover a regra de NACL
---
### Cenário 2: Esgotamento do pool de conexões RDS (10h15–11h15)
**Hipótese:** A aplicação degrada graciosamente e retorna HTTP 503
ao invés de travar quando o pool de conexões é esgotado.
**Ação:** Injetar carga de 10x via k6 apontando para endpoint
que abre transações longas no banco.
**Métricas observadas:**
- Comportamento do circuit breaker
- Resposta HTTP da aplicação (503 vs travamento)
- DatabaseConnections no CloudWatch RDS
- Tempo de recuperação após a carga diminuir
---
### Cenário 3: Rollback de deploy com bug crítico (11h30–12h30)
**Hipótese:** O time consegue detectar e reverter um deploy
problemático em menos de 5 minutos.
**Ação:** Deploy de versão com bug que aumenta latência p99 em 10x.
Time precisa detectar, decidir reverter e executar.
**Métricas observadas:**
- MTTD (tempo até detecção via alerta)
- MTTR (tempo até rollback completo)
- Eficácia dos runbooks
Healthchecks e Graceful Shutdown
Resiliência não é apenas sobre sobreviver a falhas externas — é também sobre falhar graciosamente quando o próprio processo precisa ser encerrado:
// src/server.js — servidor com healthchecks e graceful shutdown completos
const express = require('express');
const app = express();
// Estado de prontidão do servidor
const estado = {
pronto: false, // readiness: aceita tráfego?
vivo: true, // liveness: processo está OK?
encerrandoGrace: false,
};
// Endpoint de liveness — monitora se o processo está vivo
// Falha apenas em casos extremos: deadlock, memória corrompida
app.get('/health/live', (req, res) => {
if (!estado.vivo) {
return res.status(503).json({ status: 'unhealthy', motivo: 'processo-degradado' });
}
res.json({ status: 'healthy', uptime: process.uptime() });
});
// Endpoint de readiness — monitora se está pronto para receber tráfego
// Falha durante inicialização, encerramento ou quando dependências estão indisponíveis
app.get('/health/ready', async (req, res) => {
if (estado.encerrandoGrace) {
return res.status(503).json({
status: 'not-ready',
motivo: 'encerrando',
});
}
if (!estado.pronto) {
return res.status(503).json({
status: 'not-ready',
motivo: 'inicializando',
});
}
// Verifica dependências críticas
try {
await Promise.all([
verificarBancoDados(),
verificarRedis(),
]);
res.json({ status: 'ready' });
} catch (erro) {
res.status(503).json({
status: 'not-ready',
motivo: 'dependencia-indisponivel',
detalhe: erro.message,
});
}
});
// Graceful shutdown — garante que requisições em andamento completam
function configurarGracefulShutdown(servidor) {
const TIMEOUT_GRACE = 30000; // 30 segundos
async function encerrar(sinal) {
console.info(JSON.stringify({
level: 'info',
msg: `Recebido sinal ${sinal} — iniciando encerramento gracioso`,
}));
// 1. Marca como não-pronto — load balancer para de enviar tráfego
estado.encerrandoGrace = true;
// 2. Aguarda o load balancer detectar o unhealthy (geralmente 10-30s)
await new Promise(resolve => setTimeout(resolve, 10000));
// 3. Para de aceitar novas conexões
servidor.close(async () => {
console.info(JSON.stringify({
level: 'info',
msg: 'Servidor HTTP fechado — encerrando conexões com banco',
}));
// 4. Fecha conexões com banco e cache graciosamente
try {
await pool.end();
await redisClient.quit();
console.info(JSON.stringify({
level: 'info',
msg: 'Encerramento gracioso concluído',
}));
process.exit(0);
} catch (erro) {
console.error(JSON.stringify({
level: 'error',
msg: 'Erro no encerramento gracioso',
erro: erro.message,
}));
process.exit(1);
}
});
// Timeout forçado — se demorar mais que TIMEOUT_GRACE, força encerramento
setTimeout(() => {
console.error(JSON.stringify({
level: 'error',
msg: 'Timeout de encerramento gracioso — forçando saída',
}));
process.exit(1);
}, TIMEOUT_GRACE);
}
process.on('SIGTERM', () => encerrar('SIGTERM'));
process.on('SIGINT', () => encerrar('SIGINT'));
}
// Inicialização com verificação de dependências
async function inicializar() {
console.info('Inicializando servidor...');
// Aguarda dependências ficarem disponíveis
await aguardarBancoDados();
await aguardarRedis();
// Executa migrations pendentes
await executarMigrations();
// Marca como pronto para receber tráfego
estado.pronto = true;
console.info('Servidor pronto para receber tráfego');
}
const servidor = app.listen(process.env.PORT || 3000, async () => {
await inicializar();
});
configurarGracefulShutdown(servidor);
O Que Vem a Seguir
O próximo artigo aborda Platform Engineering — a disciplina emergente que consolida as práticas de DevOps em plataformas internas que permitem que times de desenvolvimento sejam autônomos sem precisar ser especialistas em infraestrutura. Serão cobertos o conceito de Internal Developer Platform, o Backstage da Spotify como portal de desenvolvedores e os princípios de "paved roads" que equilibram autonomia e padronização.
Referências para Aprofundamento
Chaos Engineering
- Principles of Chaos Engineering — principlesofchaos.org — Manifesto original dos princípios de Chaos Engineering, definindo o método científico aplicado a sistemas distribuídos.
- AWS Fault Injection Simulator — docs.aws.amazon.com — Documentação completa do AWS FIS com guia de criação de experimentos, ações disponíveis e integração com CloudWatch.
- Chaos Engineering — O'Reilly — oreilly.com — Livro referência sobre Chaos Engineering por engenheiros da Netflix, cobrindo desde os fundamentos até implementações avançadas.
Resiliência
- Release It! — Pragmatic Bookshelf — pragprog.com — Livro fundamental sobre padrões de resiliência em sistemas de produção, incluindo Circuit Breaker, Bulkhead, Timeout e Back Pressure.
- AWS Resilience Hub — docs.aws.amazon.com — Documentação do AWS Resilience Hub, serviço que avalia automaticamente a resiliência de aplicações AWS contra RTO e RPO definidos.
Exercícios
Exercício 1
Siga o estado do CircuitBreaker neste cenário: o limiar é 5 e o circuito está FECHADO. Chegam 4 falhas seguidas, depois 1 sucesso, depois mais 4 falhas. O circuito abre? E se as 9 chamadas tivessem sido 4 falhas, 4 falhas e 1 sucesso ao final?
Ver resposta
✓ Resposta: No primeiro cenário o circuito não abre. A chave está em _registrarSuccesso(), que termina com this.contagemFalhas = 0: um único sucesso zera o contador. As 4 primeiras falhas levam a contagem a 4, o sucesso devolve a zero, e as 4 seguintes chegam de novo só a 4. O limiar de 5 nunca é alcançado, ainda que 8 das 9 chamadas tenham falhado — uma taxa de erro de 89%.
No segundo cenário o circuito abre na oitava chamada: as 8 falhas são consecutivas, a contagem chega a 5 na quinta e _registrarFalha dispara a transição para ABERTO. O sucesso final nem chega a ser tentado — o circuito já está rejeitando.
A diferença entre os dois cenários é apenas a ordem das mesmas 9 chamadas. Isso expõe a limitação do contador consecutivo: ele detecta bem a falha total de uma dependência, e é praticamente cego para a degradação parcial — o caso em que o serviço responde a uma fração das chamadas, que é como falhas reais costumam se manifestar. Basta um sucesso esporádico para segurar o circuito fechado indefinidamente.
A correção é trocar contagem consecutiva por taxa de erro em janela deslizante: abrir quando, digamos, mais de 50% das chamadas dos últimos 30 segundos falharem, exigindo um volume mínimo de chamadas para que a taxa seja significativa (sem isso, 1 falha em 1 chamada é 100% de erro). É assim que bibliotecas maduras como Hystrix, Resilience4j e o opossum implementam o padrão — e é a razão para preferi-las a uma implementação própria em produção.
Exercício 2
Observe a composição em ClienteHTTPResilient.get(): o circuitBreaker.executar() envolve o comRetry(), que por sua vez faz o fetch. Por que essa ordem — e não a inversa, com o retry por fora do circuit breaker?
Ver resposta
✓ Resposta: Porque a ordem inversa anularia o circuit breaker justamente quando ele é necessário.
Com o retry por fora, cada tentativa seria uma chamada nova ao circuit breaker. Com o circuito ABERTO, ele rejeita imediatamente lançando erro — e o retry, vendo um erro, faria exatamente o que foi programado para fazer: tentar de novo, esperar o backoff, tentar de novo. O resultado é o retry insistindo contra um circuito aberto, gastando o tempo do usuário para nada. Pior: a rejeição rápida do circuito faz as tentativas se esgotarem em milissegundos, sem nem o alívio do backoff.
Na ordem correta — circuit breaker por fora, retry por dentro —, cada elemento faz o seu papel na camada certa: o retry absorve a falha transitória (um 503 isolado, um timeout esporádico) e o circuit breaker enxerga o resultado final daquela operação, contando como uma única falha o conjunto das 3 tentativas. Quando a dependência cai de vez, o circuito abre e corta todo o ciclo de retry de uma vez.
Há um efeito colateral a dimensionar: os tempos se multiplicam. Com tentativasMaximas: 3 e timeoutRequisicao: 5000 no circuit breaker, três tentativas de 5s mais os backoffs ultrapassam facilmente o timeout do circuito, e o Promise.race aborta a operação inteira no meio do ciclo. O timeout externo precisa ser maior que a soma do que ele envolve — ou os dois brigam entre si.
Repare ainda que o fetch tem seu próprio AbortSignal.timeout(5000), idêntico ao do circuit breaker. Vale notar que o Promise.race do circuit breaker não cancela a função perdedora: quando o timeout vence, a requisição HTTP continua em andamento, consumindo conexão — é o AbortSignal que efetivamente a interrompe.
Exercício 3
O Bulkhead tem concorrenciaMaxima: 10 e tamanhoFila: 20. Chegam 40 chamadas simultâneas, cada uma levando 2 segundos. Descreva o destino das 40. Por que rejeitar as excedentes é melhor do que simplesmente enfileirar todas?
Ver resposta
✓ Resposta: 10 executam de imediato, 20 entram na fila, e as 10 restantes são rejeitadas na hora com Bulkhead saturado: fila cheia. Das 20 enfileiradas, ainda há uma segunda peneira: o timeoutFila: 5000. Como cada chamada leva 2 s e a vazão é de 10 por vez, as 10 primeiras da fila começam por volta dos 2 s e as 10 seguintes por volta dos 4 s — ou seja, todas conseguem entrar antes dos 5 s, mas com pouquíssima margem. Um pico um pouco maior faria a cauda da fila estourar por timeout.
Rejeitar é melhor que enfileirar tudo por causa do fail fast. Uma fila ilimitada não aumenta a capacidade do sistema — ela apenas converte "recusa imediata" em "espera longa seguida de erro". O cliente que aguardaria 40 segundos por uma resposta já terá desistido, mas o servidor continua ocupando memória com o pedido dele e ainda vai processá-lo, gastando recurso em trabalho que ninguém mais quer. É a fila da morte: o sistema passa a trabalhar exclusivamente em requisições já expiradas e nunca se recupera, mesmo depois que a carga cai.
Com um limite explícito, o excedente vira um erro rápido e barato, sobre o qual dá para agir: devolver 503 com Retry-After, servir uma resposta degradada de cache, ou acionar o fallback do circuit breaker. E as métricas bulkhead_executing e bulkhead_rejected_total tornam a saturação visível — um contador de rejeições subindo é um sinal claro para escalar, enquanto uma fila crescendo em silêncio só aparece como "o site está lento".
O nome vem das divisórias estanques de um navio, e a metáfora explica o propósito maior: com pools separados por funcionalidade, o endpoint de relatórios que satura seu bulkhead não consome as conexões do endpoint de checkout. O compartimento alaga, o navio segue.
Exercício 4
Compare as stop_condition dos três experimentos do fis.tf. O terceiro, failover_rds, declara source = "none". O que isso significa na prática, e por que é especialmente arriscado neste experimento?
Ver resposta
✓ Resposta: source = "none" significa nenhuma parada de emergência: o experimento roda até o fim, independentemente do que aconteça com o sistema. Os outros dois se protegem com alarmes do CloudWatch — disponibilidade_critica e latencia_critica — que abortam o experimento automaticamente se a situação sair do controle.
É especialmente arriscado aqui porque o failover de RDS é, dos três, o de recuperação menos elástica. Terminar instâncias EC2 é reversível em minutos — o Auto Scaling Group repõe sozinho, e é justamente isso que o experimento quer medir. Latência de rede via SSM é temporária e tem DurationSeconds: 120 embutido. Já a promoção do standby leva de 60 a 120 segundos durante os quais o banco não aceita escrita, e nada disso é acelerável: não há como "cancelar" um failover em andamento. Toda conexão do pool cai e precisa ser restabelecida; aplicações que não tratam a reconexão ficam fora do ar bem depois do banco já ter voltado.
Some-se o alvo: selection_mode = "ALL", ou seja, todas as instâncias RDS com as tags — não uma amostra. Os outros experimentos usam PERCENT(33) e PERCENT(50), contendo o raio de alcance. O experimento sem freio é o único que atinge 100% do alvo.
Isso contraria o princípio central do Chaos Engineering: minimizar o raio de alcance e ter sempre como abortar. Um experimento que não pode ser interrompido não é um experimento controlado — é uma aposta. Vale notar que a proteção real aqui não está só no arquivo do FIS: a condição no aws_iam_role_policy exige as tags Environment e ChaosElegible = "true", o que impede que o experimento alcance qualquer recurso que não tenha sido explicitamente marcado como elegível. É a diferença entre escolher onde o caos pode agir e descobrir depois onde ele agiu.
Um detalhe do mesmo arquivo, no nível de rigor que compliance costuma cobrar: a tag está escrita ChaosElegible — Eligible em inglês, ou Elegível em português. Como o nome precisa bater exatamente entre a política IAM, o resource_tag do experimento e a tag aplicada ao recurso, um erro assim funciona enquanto for repetido igual em todos os lugares, e falha silenciosamente no dia em que alguém escrever certo.
Exercício 5
No graceful shutdown do server.js, o passo 2 é um setTimeout de 10 segundos antes de o servidor parar de aceitar conexões. Por que esperar em vez de fechar imediatamente? E o que acontece se o orquestrador conceder menos tempo do que os 30 s do TIMEOUT_GRACE?
Ver resposta
✓ Resposta: A espera existe para dar tempo ao load balancer perceber que este processo saiu de circulação. A sequência é deliberada: o passo 1 marca encerrandoGrace = true, o que faz /health/ready passar a responder 503; o load balancer só descobre isso no próximo ciclo de health check, e ainda leva alguns segundos para tirar o alvo da rotação. Os 10 segundos cobrem essa janela.
Fechar imediatamente produziria o erro clássico de deploy: o processo recusa conexões enquanto o balanceador ainda está mandando tráfego, e o usuário recebe 502. É o mesmo problema que o preStop: sleep 5 do Kubernetes resolve — a diferença é que ali a espera é externa ao processo, e aqui está dentro dele.
Sobre o tempo concedido, há uma armadilha concreta. Em Kubernetes, o terminationGracePeriodSeconds padrão é 30 segundos, contados a partir do SIGTERM — o mesmo valor do TIMEOUT_GRACE. Só que este código gasta 10 desses 30 segundos apenas esperando, deixando 20 para fechar conexões e encerrar o pool. Quando o grace period se esgota, vem o SIGKILL: o setTimeout de 30 s nunca chega a disparar, o pool.end() pode ser interrompido no meio, e transações em andamento morrem sem rollback limpo — o banco só as libera quando o próprio timeout de conexão dele expirar.
A regra é que o tempo total do processo seja menor que o do orquestrador, com folga: 10 s de espera mais um TIMEOUT_GRACE de 15 s cabem confortavelmente em 30 s de grace period. E o inverso também precisa valer — se as requisições mais longas da aplicação levam 25 s, nenhum desses números funciona, e é o grace period que tem de subir.
Repare que o /health/live foi escrito corretamente para não verificar dependências, ao contrário do /health/ready, que checa banco e Redis. É a separação exata que evita o efeito cascata: uma queda do banco tira os pods de rotação (readiness) sem que o kubelet os reinicie em massa (liveness).