Escalabilidade
Escala vertical e horizontal, e o que precisa mudar no sistema para ele aceitar mais máquinas.
Com várias cópias e escala automática, cópias entram e saem o tempo todo: a cada deploy, a cada pico, a cada madrugada. Esta página trata das três peças que tornam isso seguro: verificações de saúde, encerramento gracioso e tarefas agendadas que rodam uma vez só.
Verificações de saúde: viva e pronta
O orquestrador e o balanceador perguntam a cada cópia, de tempos em tempos, como ela está. São duas perguntas diferentes:
| Verificação | Pergunta | Se falhar |
|---|---|---|
| Vivacidade (liveness) | O processo está funcionando, ou travou? | O orquestrador reinicia a cópia |
| Prontidão (readiness) | A cópia pode receber pedidos agora? | O balanceador para de mandar pedidos para ela, sem reiniciá-la |
A prontidão responde "não" enquanto a cópia ainda está iniciando, quando uma dependência essencial está fora do ar e, como veremos, quando ela está encerrando. A vivacidade deve ser simples e não depender do banco: se o banco cair, reiniciar todas as cópias da aplicação não ajuda e ainda piora a situação.
Encerramento gracioso
Quando uma cópia vai ser removida, o orquestrador envia o sinal SIGTERM e espera um tempo antes de matá-la à força. Uma aplicação que ignora o sinal é encerrada no meio dos pedidos, e os usuários recebem erros a cada deploy.
O encerramento correto tem quatro passos:
- Marcar a prontidão como "não pronta", para o balanceador parar de mandar pedidos novos.
- Esperar alguns segundos, até o balanceador perceber, continuando a atender o que chegar nesse intervalo.
- Parar de aceitar conexões e terminar os pedidos em andamento.
- Fechar o pool do banco e as outras conexões, e sair.
Testamos esse roteiro com a versão em Node, num container: um pedido de dois segundos estava em andamento quando o SIGTERM chegou.
prontidão logo após o SIGTERM: 503 encerrando
pedido novo durante a espera: 200 pedido concluído
pedido que já estava em andamento: 200 pedido concluído, em 2,0 s
pedido depois de fechar: conexão recusada
processo: saiu com código 0O passo 2 parece dispensável, e foi exatamente o que a primeira versão do teste mostrou. Sem a espera, o servidor fecha a porta no mesmo instante do sinal. A verificação de prontidão nem chega a responder 503: a conexão é recusada. Os pedidos que o balanceador ainda enviar antes de perceber a saída recebem erro.
import http from "node:http";
let encerrando = false;
const servidor = http.createServer(async (req, res) => {
if (req.url === "/saude/vivo") return res.end("ok");
if (req.url === "/saude/pronto") {
res.statusCode = encerrando ? 503 : 200;
return res.end(encerrando ? "encerrando" : "pronto");
}
// ... rotas da aplicação
});
servidor.listen(3000);
// Tempo para o balanceador perceber a saída; ajuste ao intervalo da verificação
const ESPERA_MS = Number(process.env.ESPERA_MS ?? 5000);
process.on("SIGTERM", async () => {
encerrando = true;
await new Promise((r) => setTimeout(r, ESPERA_MS));
servidor.close(async () => {
// feche aqui o pool do banco, o cliente do Redis etc.
process.exit(0);
});
servidor.closeIdleConnections();
setTimeout(() => process.exit(1), 25_000).unref(); // limite de segurança
});from contextlib import asynccontextmanager
import uvicorn
from fastapi import FastAPI, Response
@asynccontextmanager
async def ciclo_de_vida(app: FastAPI):
# abra aqui o pool do banco
yield
# Roda depois que o Uvicorn parou de aceitar conexões e terminou
# os pedidos em andamento: feche o pool, o cliente do Redis etc.
app = FastAPI(lifespan=ciclo_de_vida)
@app.get("/saude/vivo")
def vivo():
return {"status": "ok"}
@app.get("/saude/pronto")
async def pronto(resposta: Response):
if not await banco_responde():
resposta.status_code = 503
return {"status": "indisponível"}
return {"status": "pronto"}
if __name__ == "__main__":
# No SIGTERM, o Uvicorn para de aceitar conexões e espera os pedidos
# em andamento por até 20 s. A espera pelo balanceador fica no
# orquestrador (preStop), porque o Uvicorn fecha a porta na hora.
uvicorn.run(app, host="0.0.0.0", port=3000, timeout_graceful_shutdown=20)package main
import (
"context"
"errors"
"log"
"net/http"
"os"
"os/signal"
"sync/atomic"
"syscall"
"time"
)
func main() {
var encerrando atomic.Bool
mux := http.NewServeMux()
mux.HandleFunc("GET /saude/vivo", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
mux.HandleFunc("GET /saude/pronto", func(w http.ResponseWriter, r *http.Request) {
if encerrando.Load() {
http.Error(w, "encerrando", http.StatusServiceUnavailable)
return
}
w.Write([]byte("pronto"))
})
servidor := &http.Server{Addr: ":3000", Handler: mux}
go func() {
if err := servidor.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
ctx, parar := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
defer parar()
<-ctx.Done()
encerrando.Store(true)
time.Sleep(5 * time.Second) // tempo para o balanceador perceber
limite, cancelar := context.WithTimeout(context.Background(), 20*time.Second)
defer cancelar()
// Para de aceitar conexões e espera os pedidos em andamento
if err := servidor.Shutdown(limite); err != nil {
log.Println("encerramento incompleto:", err)
}
// feche aqui o pool do banco
}import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicBoolean;
public class Servidor {
private static final AtomicBoolean ENCERRANDO = new AtomicBoolean(false);
public static void main(String[] args) throws IOException {
var servidor = HttpServer.create(new InetSocketAddress(3000), 0);
servidor.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
servidor.createContext("/saude/vivo", ex -> responder(ex, 200, "ok"));
servidor.createContext("/saude/pronto", ex -> {
if (ENCERRANDO.get()) responder(ex, 503, "encerrando");
else responder(ex, 200, "pronto");
});
servidor.start();
// A JVM executa os ganchos de desligamento ao receber SIGTERM
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
ENCERRANDO.set(true);
try {
Thread.sleep(5_000); // tempo para o balanceador perceber
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Fecha a porta e espera até 20 s pelos pedidos em andamento
servidor.stop(20);
// feche aqui o pool do banco
}));
}
static void responder(HttpExchange ex, int status, String corpo) throws IOException {
var dados = corpo.getBytes();
ex.sendResponseHeaders(status, dados.length);
try (var saida = ex.getResponseBody()) {
saida.write(dados);
}
}
}var builder = WebApplication.CreateBuilder(args);
// Tempo máximo para terminar os pedidos em andamento depois do SIGTERM
builder.Services.Configure<HostOptions>(o => o.ShutdownTimeout = TimeSpan.FromSeconds(25));
var app = builder.Build();
var encerrando = false;
app.MapGet("/saude/vivo", () => Results.Ok("ok"));
app.MapGet("/saude/pronto", () =>
encerrando ? Results.StatusCode(503) : Results.Ok("pronto"));
// Executado no SIGTERM, antes de o servidor parar de aceitar conexões
app.Lifetime.ApplicationStopping.Register(() =>
{
encerrando = true;
Thread.Sleep(TimeSpan.FromSeconds(5)); // tempo para o balanceador perceber
});
app.Run();O que muda de uma linguagem para outra
Quem trata o SIGTERM | Quem faz a espera pelo balanceador | |
|---|---|---|
| Node | O seu código, com process.on("SIGTERM") | O seu código, antes de server.close |
| Python (Uvicorn) | O Uvicorn, que fecha a porta na hora | O orquestrador, com um preStop |
| Go | O seu código, com signal.NotifyContext | O seu código, antes de Shutdown |
| Java | Um gancho de desligamento da JVM | O seu código, no gancho |
| C# (ASP.NET) | O host, que chama ApplicationStopping | O seu código, no ApplicationStopping |
Em frameworks maiores, parte disso é configuração: o Spring Boot tem server.shutdown=graceful, e o ASP.NET já espera os pedidos em andamento até o ShutdownTimeout. O que nenhum deles faz sozinho é o passo 2, a espera para o balanceador perceber a saída.
Dois detalhes que derrubam o encerramento gracioso na prática:
- O processo precisa receber o sinal. Num container iniciado por um script
sh, o sinal vai para o shell, e não para a aplicação. Use a forma de lista noCMDdo Dockerfile (CMD ["node", "server.js"]) ouexecno script. - Os tempos precisam caber no limite do orquestrador. No Kubernetes, o padrão é 30 segundos entre o
SIGTERMe a morte forçada. A espera mais o tempo dos pedidos precisa ficar abaixo disso.
A configuração no Kubernetes
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
image: loja/api:1.4.2
readinessProbe:
httpGet: { path: /saude/pronto, port: 3000 }
periodSeconds: 5
failureThreshold: 1
livenessProbe:
httpGet: { path: /saude/vivo, port: 3000 }
periodSeconds: 10
failureThreshold: 3
lifecycle:
preStop:
exec:
command: ["sleep", "5"]O preStop faz a espera pelo balanceador fora da aplicação, e é a saída para servidores que fecham a porta no mesmo instante do sinal, como o Uvicorn. Com o preStop, a espera dentro do código pode ser menor ou nenhuma. Imagens sem o comando sleep, como as distroless, usam a ação sleep do próprio Kubernetes nas versões que a oferecem.
Tarefas agendadas que rodam uma vez
Com cinco cópias, uma tarefa agendada dentro da aplicação roda cinco vezes. Quando já existe um PostgreSQL, uma trava consultiva resolve com poucas linhas: todas as cópias tentam, e só uma consegue.
// Um número fixo identifica a tarefa; todas as cópias usam o mesmo
const TRAVA_RELATORIO_DIARIO = 42_001;
export async function gerarRelatorioDiario() {
const conexao = await pool.connect();
try {
const { rows } = await conexao.query(
"SELECT pg_try_advisory_lock($1) AS obteve",
[TRAVA_RELATORIO_DIARIO],
);
if (!rows[0].obteve) return; // outra cópia já está gerando
try {
await montarEEnviarRelatorio();
} finally {
await conexao.query("SELECT pg_advisory_unlock($1)", [TRAVA_RELATORIO_DIARIO]);
}
} finally {
conexao.release();
}
}A trava pertence à conexão que a obteve. Por isso o exemplo usa uma conexão dedicada do pool do começo ao fim, e não pool.query, que poderia obter a trava numa conexão e tentar liberá-la em outra. Se a cópia cair no meio, a conexão fecha e a trava é liberada sozinha.
A trava evita execuções simultâneas, mas não impede que uma cópia que dispare atrasada rode a tarefa de novo depois que a primeira terminou. Para tarefas que não podem se repetir no mesmo dia, registre a execução numa tabela com chave única pela data.
Lista de revisão
Estado
- Nenhuma sessão, carrinho, contador ou cache importante fica só na memória do processo?
- Arquivos enviados vão para armazenamento de objetos, e não para o disco local?
- Tarefas agendadas rodam uma vez, por agendador externo ou por trava?
- A configuração vem do ambiente, igual em todas as cópias?
Ciclo de vida
- Existem verificações de vivacidade e de prontidão separadas?
- A vivacidade não depende do banco nem de outros serviços?
- O processo trata o
SIGTERM: marca não pronto, espera, termina os pedidos e fecha conexões? - O processo recebe o sinal dentro do container?
- A espera mais o tempo dos pedidos cabem no limite do orquestrador?
Capacidade
- O gargalo atual foi medido antes de acrescentar cópias?
- A soma dos pools de conexão de todas as cópias cabe no limite do banco?
- A escala automática tem mínimo de duas cópias e máximo calculado?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Ver o problema do estado com duas cópias
Quero ver na prática o que acontece com estado em memória quando a aplicação roda em duas cópias. Pergunte qual linguagem eu uso. Vamos em etapas: 1. Escreva comigo um servidor mínimo com um carrinho guardado num mapa na memória do processo. 2. Monte com Docker duas cópias desse servidor atrás de um Nginx que alterna os pedidos entre elas. 3. Peça que eu adicione um item e consulte o carrinho várias vezes, e explique o resultado. 4. Mova o carrinho para o Redis e repita o teste. 5. Acrescente uma tarefa agendada que roda a cada minuto e mostre que ela executa nas duas cópias. 6. Resolva com uma trava no banco ou no Redis e confira que passa a rodar uma vez. Em cada etapa, me dê os comandos e explique o que observar.
Encerramento gracioso e verificações de saúde
Quero implementar o encerramento gracioso e as verificações de saúde da minha aplicação. Pergunte qual linguagem, framework e orquestrador eu uso. Explique, com código para o meu caso: 1. A diferença entre as verificações de vivacidade e de prontidão, e o que cada uma deve conferir. 2. Os passos do encerramento: marcar não pronto, esperar o balanceador perceber, parar de aceitar conexões, terminar os pedidos e fechar recursos. 3. Por que a espera do segundo passo é necessária, e se o meu framework fecha a porta imediatamente ao receber o sinal. 4. Como garantir que o processo receba o SIGTERM dentro do container. 5. Como ajustar os tempos ao limite do orquestrador. No final, me ajude a testar: um pedido demorado em andamento, o envio do SIGTERM e a conferência de que nenhum pedido recebeu erro.
Revisar a prontidão para escalar
Vou colar abaixo código, Dockerfile e configuração de implantação da minha aplicação. Revise se ela está pronta para rodar em várias cópias com escala automática. Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho: 1. Há estado guardado na memória ou no disco da instância: sessões, uploads, cache, contadores, conexões em tempo real? 2. Tarefas agendadas rodam uma vez só com várias cópias? 3. Existem verificações de vivacidade e prontidão separadas, e a vivacidade evita depender do banco? 4. O processo trata o SIGTERM com espera para o balanceador, término dos pedidos e fechamento de conexões? 5. O processo recebe o sinal dentro do container? 6. Os tempos de encerramento cabem no limite do orquestrador? 7. A soma dos pools de conexão de todas as cópias cabe no limite do banco? 8. A escala automática tem mínimo, máximo e métrica adequados? Para cada problema, descreva o que acontece em produção e mostre a correção. Não reescreva o que já está correto. Código e configuração: [cole aqui]