03 - Bulkhead
video: https://youtu.be/... no frontmatter da nota.Bulkhead
Bulkhead é o padrão de isolar os recursos de execução de um sistema em compartimentos independentes, de modo que o esgotamento de um compartimento não contamine os demais. O nome vem dos compartimentos estanques de cascos de navio: uma perfuração em um deles inunda apenas aquele espaço, e o casco permanece flutuando porque as divisas impedem a água de se espalhar. Em software, os compartimentos são pools de threads, pools de conexão, workers de fila, memória e cota de concorrência, e a água é a carga excessiva que uma dependência lenta provoca. O padrão é central em produção porque a maioria das quedas totais começa como queda parcial: uma integração externa degrada, os recursos compartilhados se esgotam e o serviço inteiro morre junto, inclusive as funcionalidades que nada tinham a ver com a falha original.
Conceito e metáfora do navio
Navios antigos afundavam com uma única perfuração no casco; a engenharia naval respondeu dividindo o interior em compartimentos estanques, aceitando a perda parcial como preço da sobrevivência total. O trade-off é claro e ensina a essência do padrão: as divisas ocupam espaço e complicam a construção, mas transformam desastre total em incidente local.
A tradução para software segue a mesma troca. Sem bulkhead, todo recurso é compartilhado, o que maximiza a utilização média e minimiza o custo em operação saudável; um pool único de trinta conexões serve tudo com menos memória que três pools de dez. Com bulkhead, parte da capacidade fica ociosa em cada compartimento, e o preço é pago exatamente para que o pior dia tenha forma de incidente local e não de indisponibilidade total.
O padrão opera em duas dimensões que costumam ser confundidas: o compartimento por dependência, que separa o consumo de recursos entre integrações distintas, e o compartimento por tenant ou por tipo de requisição, que separa classes de tráfego diferentes, como leitura e escrita, ou cliente gratuito e cliente pagante, dentro da mesma integração.
O problema que resolve: esgotamento global de recursos
O mecanismo do desastre é sempre o mesmo: um recurso compartilhado finito, threads de execução, conexões de banco, conexões HTTP, memória, se esgota porque uma classe de trabalho segura os recursos por muito mais tempo que o previsto. Um serviço de recomendação que passa a responder em dez segundos não derruba apenas recomendações; se ele compartilha o pool de threads com autenticação e com checkout, cada thread presa na espera é uma thread roubada das operações saudáveis.
O exemplo clássico é o servidor de aplicação com duzentas threads de execução servindo todas as rotas. Uma integração externa lenta prende as duzentas threads em chamadas pendentes; a partir daí, nem a verificação de saúde responde, e o orquestrador reinicia pods que reiniciam doentes. Nada está tecnicamente quebrado, exceto o desenho da divisão de recursos, que não existia.
Fila sofre a mesma doença em outra roupa. Um único worker consumindo uma fila mista, com e-mails e cobranças, para de processar tudo quando o provedor de e-mail fica lento, e as cobranças, sensíveis a prazo, ficam atrás dos e-mails travados. A resposta do bulkhead é separar as filas e os workers, para que o atraso de um tipo de trabalho apenas encolha o throughput dele próprio.
Funcionamento interno: particionamento de recursos
A implementação concreta é limitar a concorrência por compartimento, com três ingredientes: um semáforo ou pool que impõe o teto de trabalhos simultâneos, uma fila de espera com limite próprio que absorve picos breves e uma política de rejeição que responde rápido quando fila e teto estão cheios. O limite transforma lentidão infinita em fila limitada, e a fila cheia transforma espera silenciosa em erro explícito e rápido.
O particionamento vale para qualquer recurso finito, e a escolha da unidade define a granularidade da contenção. Nas aplicações web, a unidade mais comum é o pool por dependência remota; em processamento de eventos, é o conjunto de workers por fila ou por tópico; em sistemas multi-tenant, é a cota de concorrência por tenant, que impede um cliente anômalo de consumir a capacidade dos outros.
Bulkhead não decide quando parar de chamar, isso é papel do circuit breaker, nem quanto esperar, papel do timeout. Ele decide quanto da capacidade própria cada trabalho pode consumir ao mesmo tempo, e essa delimitação é o que dá sentido aos outros padrões: com teto de concorrência, uma dependência lenta degrada a funcionalidade dela, e o resto do sistema nem percebe.
O particionamento também existe fora do código da aplicação, e reconhecê-lo evita duplicar esforço: pools de conexão do banco, limites de conexão no balanceador e filas com consumidores dedicados são bulkheads no nível de infraestrutura. A arquitetura completa empilha esses níveis, com a aplicação impondo o teto fino por dependência e a infraestrutura impondo o teto grosseiro por serviço.
Dimensões de bulkhead na prática
| Dimensão | Unidade do compartimento | Sinal de saturação | Alívio típico |
|---|---|---|---|
| Threads de execução | Pool de threads por dependência | Fila do pool crescendo | Reduzir timeout, revisar tamanho do pool |
| Conexões HTTP | Pool de conexões por host remoto | Espera por conexão livre | Mais conexões ou concorrência limitada |
| Conexões de banco | Pool por classe de consulta | Checkout de conexão lento | Pool de leitura separado do de escrita |
| Workers de fila | Consumidores por fila | Backlog crescente | Escalar consumidores, separar filas |
| Memória | Cota por processo ou por tenant | GC frequente, OOM kills | Limites por tenant, admissão controlada |
Cada linha da tabela é um compartimento possível, e sistemas maduros adotam vários deles ao mesmo tempo, porque os recursos esgotam por camadas diferentes. A regra de prioridade é observar onde o incidente real aconteceu: não há valor em particionar memória se o histórico de produção mostra esgotamento de threads.
Aplicação prática: pools separados por dependência
O caso de uso mais frequente é a separação de pools por dependência remota, em que cada integração recebe cota própria de threads e de conexões, com rejeição rápida quando a cota esgota.
from concurrent.futures import ThreadPoolExecutor
# Bulkhead por dependência: cada pool tem cota própria de concorrência
POOLS = {
"pagamentos": ThreadPoolExecutor(max_workers=8, thread_name_prefix="pg"),
"notificacoes": ThreadPoolExecutor(max_workers=4, thread_name_prefix="nt"),
"consultas": ThreadPoolExecutor(max_workers=16, thread_name_prefix="qy"),
}
def chamar_com_bulkhead(nome_pool, operacao, *args, timeout_s=3, fila_max=10):
pool = POOLS[nome_pool]
if pool._work_queue.qsize() >= fila_max:
# fila cheia: falha rápida em vez de esperar em pé
raise CompartimentoSaturado(f"bulkhead {nome_pool} com fila cheia")
try:
futuro = pool.submit(operacao, *args)
return futuro.result(timeout=timeout_s) # timeout por chamada
except TimeoutError:
raise DependenciaLenta(f"{nome_pool} excedeu {timeout_s}s")
Com esse desenho, a lentidão do provedor de e-mails satura o compartimento de quatro threads de notificações, rejeita novos envios com erro explícito e não rouba uma única thread de pagamentos ou de consultas. O dano fica confinado à funcionalidade que depende da dependência doente, que é exatamente o comportamento dos compartimentos do navio.
Em ambientes JVM, os mesmos contornos aparecem como semáforos do Resilience4j em vez de pools físicos, o que reduz custo de troca de contexto; a semântica de teto, fila e rejeição é idêntica. Em malha de serviço, limites de conexão e de requisições pendentes por upstream cumprem o papel no nível de transporte.
A configuração declarativa torna o compartimento visível e auditável, como no exemplo abaixo.
# Bulkhead de semáforo por dependência no Resilience4j
resilience4j:
bulkhead:
instances:
pagamentos:
max-concurrent-calls: 8 # teto de concorrência do compartimento
max-wait-duration: 0ms # sem espera: rejeição imediata ao saturar
notificacoes:
max-concurrent-calls: 4 # cota isolada da dependência lenta
max-wait-duration: 100ms # espera curta antes de rejeitar
Sizing de bulkheads
Dimensionar compartimento é aplicar o teorema de Little ao tráfego esperado: a concorrência necessária é o throughput alvo multiplicado pela latência esperada por trabalho.
# Dimensionamento pelo teorema de Little
# concorrencia = throughput alvo x latencia esperada (p99)
# exemplo: 200 req/s x 0,25 s = 50 trabalhos simultâneos no pico
# bulkhead = concorrencia x fator de seguranca (1,2 a 1,5) + folga de sondagem
Três ajustes refinam a conta inicial. O primeiro é usar a latência p99 e não a média, porque o compartimento precisa acomodar o dia ruim; usar a média dimensiona para o dia bom que nunca aparece. O segundo é reservar folga para sondagem e recuperação, pois um compartimento que só tem vaga para a carga normal não consegue executar as chamadas de verificação do circuit breaker ao despertar. O terceiro é revisar depois de medir: o teto certo aparece na observação de produção, em que fila nunca espera muito e rejeição quase nunca dispara.
Compartimento pequeno demais é tão nocivo quanto compartimento único grande. Um pool de duas threads para uma dependência que atinge dez requisições por segundo fabrica rejeição artificial, e o sintoma de engenharia reversa é taxa de erro que não aparece na dependência, apenas no chamador. A calibragem entre saturar e superprovisionar é contínua, e por isso o valor pertence a configuração, não a código.
Bulkhead versus outros padrões de resiliência
| Padrão | Pergunta que responde | Escopo de atuação | Limitação própria |
|---|---|---|---|
| Bulkhead | Quanto do recurso próprio uma dependência pode consumir | Concorrência e capacidade local | Não detecta recuperação nem evita chamada inútil |
| Circuit Breaker | Vale a pena continuar chamando o destino | Estado de saúde da dependência | Não protege recursos próprios da concorrência |
| Timeout | Quanto tempo esperar cada resposta | Chamada individual | Não limita quantas chamadas coexistem |
| Rate limiting | Quantas requisições aceitar por intervalo | Tráfego total ou por cliente | Não isola classes de tráfego internamente |
| Retry com backoff | Quando repetir uma falha transiente | Tentativa individual | Amplifica carga se sem orçamento |
A tabela evidencia a complementaridade: timeout limita o tempo, bulkhead limita o simultaneismo, circuit breaker limita a insistência e rate limiting limita a entrada. Nenhum substitui outro, e incidentes reais quase sempre envolvem falha de mais de um deles ao mesmo tempo; a pergunta de arquitetura é quais recursos são finitos e quem pode esgotá-los, e a resposta desenha os bulkheads.
Erros comuns
- Pool único compartilhado por todas as dependências, que reproduz o esgotamento global exatamente no cenário que o padrão existe para evitar.
- Particionar por dependência e esquecer as filas, deixando workers mistos onde um trabalho lento trava os demais; a fila também é bulkhead.
- Teto dimensionado pela média de latência, que deixa o compartimento subdimensionado nos picos em que ele seria mais necessário.
- Rejeição silenciosa, com erro genérico sem identificação do compartimento; sem nomes e métricas por bulkhead, a triagem vira adivinhação.
- Cotas iguais para dependências de peso muito diferente, pois consultas em cache e geração de PDF não têm a mesma unidade de custo e merecem compartimentos distintos.
- Semáforo sem fila e sem política de rejeição definida, o que apenas empilha espera infinita em outro lugar da pilha.
- Bulkhead por tenant sem limites por requisição, permitindo que um único tenant com requisições pesadas sature a própria cota e gere falsa indisponibilidade percebida.
Boas práticas
- Começar pelos recursos finitos que já causaram incidentes, particionando pools de threads e conexões por dependência remota como primeiro passo.
- Dar nome a cada compartimento e publicar métricas de ocupação, tamanho de fila e rejeições por bulkhead, porque contenção invisível não se governa.
- Dimensionar com p99 e revisar com dados de produção, tratando o tamanho como configuração viva e não como decisão de uma vez.
- Definir falha rápida explícita para compartimento saturado, com exceção própria e resposta de fallback adequada ao caso.
- Combinar com timeout por chamada e circuit breaker por dependência, formando o conjunto que limita tempo, simultaneismo e insistência.
- Separar filas e workers por classe de trabalho no processamento assíncrono, evitando que trabalho em lento bloqueie trabalho sensível a prazo.
- Revisar os limites a cada mudança relevante de tráfego ou de dependência, pois compartimento dimensionado para o tráfego antigo vira gargalo novo.
- Testar a contenção em ambiente, saturando deliberadamente um compartimento e verificando que os demais continuam respondendo dentro do SLO.