Graceful Shutdown em Python Puro: O Protocolo Que Faz Seu Processo Morrer Sem Perder Dados (Nem Corromper o Estado)
Seu Processo Morre. Seus Dados Morrem Junto?
Você tem um worker Python processando uma fila. Recebe SIGTERM. O que acontece? Se você não implementou graceful shutdown, a resposta é brutal: o processo morre no meio de uma transação, conexões ficam abertas, arquivos ficam pela metade, e aquele dado que estava sendo processado? Perdido.
Graceful shutdown não é luxo de produção. É a diferença entre um deploy tranquilo e uma madrugada apagando dados corrompidos. E a implementação em Python puro leva menos de 60 linhas.
O Que Acontece Quando o Python Morre Sem Aviso
Quando o sistema operacional manda SIGTERM pro seu processo Python (via kill, deploy, Docker stop, Kubernetes pod eviction), o comportamento padrão é:
- O interpretador Python recebe o sinal
- Lança
SystemExitse não houver handler - O processo encerra imediatamente
- Todos os recursos abertos são abandonados
Na prática, isso significa:
import time
import sqlite3
# Código sem graceful shutdown — receita pro desastre
conn = sqlite3.connect("jobs.db")
cursor = conn.cursor()
while True:
job = buscar_proximo_job(cursor)
if job:
processar(job) # Se morrer aqui, o job fica pela metade
marcar_concluido(cursor, job)
conn.commit() # Ou nunca chega aqui
time.sleep(1)
# conn.close() nunca é chamado
# Transações ficam abertas
# Locks no banco permanecem
Cada vez que esse processo é reiniciado sem graceful shutdown, você acumula jobs em estado inconsistente. Depois de 10 deploys, tem um banco de dados com dezenas de registros órfãos que ninguém sabe se foram processados ou não.
O Padrão: Graceful Shutdown em 3 Fases
Um shutdown graceful segue três etapas claras:
- Parar de aceitar trabalho novo — o processo não pega mais jobs da fila
- Terminar o trabalho atual — o job em andamento é completado (ou tem timeout)
- Limpar recursos — conexões fechadas, arquivos flushados, estado persistido
Isso parece óbvio. Mas a implementação precisa de cuidado porque sinais Unix são assíncronos — chegam a qualquer momento, inclusive no meio de uma operação crítica.
A Implementação: 60 Linhas Que Salvam Seus Dados
import signal
import sys
import time
import logging
import threading
from typing import Optional, Callable, List
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
class GracefulShutdown:
def __init__(self, timeout: float = 30.0):
self.timeout = timeout
self._shutdown_requested = False
self._active_tasks: List[threading.Thread] = []
self._lock = threading.Lock()
self._cleanup_callbacks: List[Callable] = []
# Registra handlers para os sinais de terminação
signal.signal(signal.SIGTERM, self._handle_signal)
signal.signal(signal.SIGINT, self._handle_signal)
def _handle_signal(self, signum: int, frame):
sig_name = signal.Signals(signum).name
logger.info(f"Recebido {sig_name}. Iniciando shutdown graceful...")
self._shutdown_requested = True
@property
def should_stop(self) -> bool:
"""Verifica se shutdown foi requisitado. Use no loop principal."""
return self._shutdown_requested
def on_cleanup(self, callback: Callable):
"""Registra função de limpeza para executar no shutdown."""
self._cleanup_callbacks.append(callback)
def track_task(self, task: threading.Thread):
"""Registra uma task ativa para aguardar no shutdown."""
with self._lock:
self._active_tasks.append(task)
def untrack_task(self, task: threading.Thread):
"""Remove uma task completada da lista."""
with self._lock:
if task in self._active_tasks:
self._active_tasks.remove(task)
def wait_and_cleanup(self):
"""Aguarda tasks ativas e executa callbacks de limpeza."""
if self._active_tasks:
logger.info(
f"Aguardando {len(self._active_tasks)} tasks "
f"(timeout: {self.timeout}s)..."
)
deadline = time.time() + self.timeout
for task in self._active_tasks[:]:
remaining = max(0.1, deadline - time.time())
task.join(timeout=remaining)
if task.is_alive():
logger.warning(f"Task {task.name} não completou no timeout!")
for callback in self._cleanup_callbacks:
try:
callback()
except Exception as e:
logger.error(f"Erro no cleanup callback: {e}")
logger.info("Shutdown completo.")
Uso Prático: Worker de Fila com Shutdown Seguro
Agora vamos aplicar isso num cenário real — um worker que processa jobs de uma fila com SQLite:
import sqlite3
import threading
def criar_worker():
shutdown = GracefulShutdown(timeout=15.0)
conn = sqlite3.connect("jobs.db", check_same_thread=False)
cursor = conn.cursor()
# Callback de limpeza: fecha conexão com o banco
def fechar_conexao():
logger.info("Fechando conexão com banco de dados...")
conn.close()
shutdown.on_cleanup(fechar_conexao)
job_atual = None
job_lock = threading.Lock()
while not shutdown.should_stop:
# Buscar próximo job
cursor.execute(
"SELECT id, payload FROM jobs WHERE status = 'pendente' "
"ORDER BY created_at LIMIT 1"
)
row = cursor.fetchone()
if not row:
time.sleep(2) # Polling interval
continue
job_id, payload = row
# Marcar como processando (transação atômica)
cursor.execute(
"UPDATE jobs SET status = 'processando' WHERE id = ? AND status = 'pendente'",
(job_id,)
)
conn.commit()
if cursor.rowcount == 0:
continue # Outro worker pegou primeiro
# Processar o job (com tracking para shutdown)
with job_lock:
job_atual = job_id
try:
resultado = processar_payload(payload)
cursor.execute(
"UPDATE jobs SET status = 'concluido', resultado = ? WHERE id = ?",
(resultado, job_id)
)
conn.commit()
logger.info(f"Job {job_id} concluído com sucesso.")
except Exception as e:
cursor.execute(
"UPDATE jobs SET status = 'erro', erro = ? WHERE id = ?",
(str(e), job_id)
)
conn.commit()
logger.error(f"Job {job_id} falhou: {e}")
finally:
with job_lock:
job_atual = None
# Se há job em andamento quando shutdown foi pedido,
# a flag should_stop garante que não pega novo job
logger.info("Worker parou de aceitar novos jobs.")
shutdown.wait_and_cleanup()
if __name__ == "__main__":
criar_worker()
Por Que signal.signal() Funciona (E Quando Não Funciona)
O módulo signal do Python registra handlers que são executados no thread principal. Isso tem implicações importantes:
- Funciona: scripts single-threaded, loops de polling, servidores simples
- Problemas: handlers de signal só rodam na thread principal. Se sua lógica está em threads secundárias, o handler ainda é chamado na main thread — o que é bom, porque a flag
should_stopé visível para todas as threads.
A flag booleana é thread-safe em Python por causa do GIL (Global Interpreter Lock). Ler e escrever um booleano é uma operação atômica no CPython. Se você precisa de garantias mais fortes, use threading.Event():
class GracefulShutdownComEvent(GracefulShutdown):
def __init__(self, timeout: float = 30.0):
super().__init__(timeout)
self._event = threading.Event()
def _handle_signal(self, signum, frame):
super()._handle_signal(signum, frame)
self._event.set() # Acorda threads bloqueadas no wait()
@property
def should_stop(self) -> bool:
return self._event.is_set()
def wait_for_stop(self, timeout: float = None) -> bool:
"""Bloqueia até shutdown ou timeout. Retorna True se shutdown."""
return self._event.wait(timeout=timeout)
Timeout: Quando o Job Não Quer Morrer
O maior risco do graceful shutdown é um job que nunca termina. Um upload de 2GB, uma query que trava, um deadlock. Por isso o timeout existe — e quando ele estoura, você tem uma decisão:
- SIGKILL (forçado): o processo morre na hora. Dados podem corromper. Mas pelo menos você sabe que o job ficou como “processando” e pode retomar.
- Marcar como interrompido: antes do timeout, atualize o status do job para que o próximo worker saiba que precisa retomar.
def wait_and_cleanup_com_fallback(self, conn, cursor):
"""Shutdown com marcação de jobs interrompidos."""
deadline = time.time() + self.timeout
# Aguarda tasks ativas
for task in self._active_tasks[:]:
remaining = max(0.1, deadline - time.time())
task.join(timeout=remaining)
if task.is_alive():
logger.warning(f"Task {task.name} excedeu timeout!")
# Marca jobs como interrompidos para retomar depois
cursor.execute(
"UPDATE jobs SET status = 'pendente' "
"WHERE status = 'processando'"
)
conn.commit()
logger.info("Jobs em andamento marcados como pendentes para retry.")
# Executa callbacks de limpeza
for callback in self._cleanup_callbacks:
try:
callback()
except Exception as e:
logger.error(f"Erro no cleanup: {e}")
Docker, Systemd e Kubernetes: Sinais na Prática
Cada orquestrador tem seu comportamento de shutdown:
- Docker:
docker stopmanda SIGTERM, espera 10s (padrão), depois SIGKILL. Configure--stop-timeout 30no Dockerfile ou compose. - Kubernetes: manda SIGTERM, espera
terminationGracePeriodSeconds(padrão 30s), depois SIGKILL. - Systemd: manda SIGTERM, espera
TimeoutStopSec(padrão 90s), depois SIGKILL.
A lição: seu timeout de graceful shutdown deve ser menor que o timeout do orquestrador. Se o Docker mata em 10s, seu graceful shutdown precisa completar em 8s.
# Dockerfile: aumenta o grace period do Docker
# STOPSIGNAL SIGTERM
# (Docker já usa SIGTERM por padrão)
# docker-compose.yml:
# services:
# worker:
# stop_grace_period: 30s
# Kubernetes pod spec:
# terminationGracePeriodSeconds: 30
Shutdown Hook: O Padrão Completo
Para aplicações mais complexas, um shutdown hook global permite que qualquer módulo registre sua rotina de limpeza:
class ShutdownRegistry:
"""Registry global de hooks de shutdown."""
_instance = None
def __init__(self):
self._hooks: List[tuple] = [] # (priority, name, callback)
self._shutdown = GracefulShutdown(timeout=25.0)
@classmethod
def get(cls):
if cls._instance is None:
cls._instance = cls()
return cls._instance
def register(self, name: str, callback: Callable, priority: int = 100):
"""Registra hook. Prioridade menor = executa primeiro."""
self._hooks.append((priority, name, callback))
self._hooks.sort(key=lambda x: x[0])
self._shutdown.on_cleanup(self._run_hooks_ordered)
def _run_hooks_ordered(self):
"""Executa hooks em ordem de prioridade."""
executados = set()
for priority, name, callback in self._hooks:
if name not in executados:
try:
logger.info(f"Executando shutdown hook: {name}")
callback()
executados.add(name)
except Exception as e:
logger.error(f"Hook '{name}' falhou: {e}")
# Uso em qualquer módulo:
# ShutdownRegistry.get().register("db", fechar_db, priority=10)
# ShutdownRegistry.get().register("cache", flushar_cache, priority=20)
# ShutdownRegistry.get().register("logs", fechar_logs, priority=90)
🧱 O Perrengue do Olivetto
Tinha um worker que processava pagamentos. Cada payment levava ~3 segundos. Quando fizemos deploy, o Docker mandou SIGTERM. O worker morreu no meio de um requests.post() pro gateway de pagamento.
Resultado: o gateway processou o pagamento, mas o worker nunca registrou a confirmação no banco. O cliente foi cobrado, o pedido ficou como “pendente”, e o suporte levou 2 dias pra entender o que aconteceu.
Depois disso, todo worker nosso tem graceful shutdown com três regras: (1) não pegar job novo depois do SIGTERM, (2) terminar o job atual com timeout de 10s, (3) se não der, marcar como pendente para retry. Zero pagamentos perdidos desde então.
Checklist: Seu Processo Está Pronto pra Morrer?
- Sinal capturado: SIGTERM e SIGINT registrados?
- Loop principal responde: verifica
should_stopa cada iteração? - Jobs em andamento completam: tem lógica pra terminar o trabalho atual?
- Timeout configurado: menor que o timeout do orquestrador?
- Recursos liberados: conexões, arquivos, locks — tudo tem callback de cleanup?
- Estado persistido: se morrer no meio, o próximo processo sabe de onde continuar?
E Agora?
Seu worker atual tem graceful shutdown ou morre na hora que recebe SIGTERM? Já perdeu dados por causa de deploy sem cuidado? Conta aí nos comentários — quanto mais perrengues a gente compartilha, menos madrugadas apagando incêndio.
