MÓDULO 08 · CONCEITO 03 DE 14

Bulkhead e Isolation Patterns

Contendo falhas em compartimentos — a diferença entre sistema que afunda e sistema que sobrevive a um dano

Tempo de leitura ~21 min Pré-requisito 02 · Error budgets · Módulo 05 conceito 09 (circuit breaker) Próximo 04 · Graceful degradation

O nome "bulkhead" vem da arquitetura naval. Navios são divididos internamente em compartimentos estanques — as anteparas (bulkheads) — que impedem que água que entrou em um compartimento inunde todo o casco. Se o casco é perfurado na proa, as anteparas garantem que só a proa afunda: o resto do navio permanece flutuante. Michael Nygard, em Release It! (Pragmatic Programmers, 2007, segunda edição 2018), formalizou essa metáfora para sistemas distribuídos: se uma dependência externa começa a travar, o bulkhead garante que só o pool de recursos dedicado àquela dependência é comprometido — o restante do sistema continua operando.

A falha em cascata é o inimigo que o bulkhead combate. Em sistemas sem isolamento de recursos, um serviço dependente lento pode consumir todos os threads ou conexões disponíveis aguardando resposta — e isso priva todas as outras operações do sistema dos mesmos recursos. O resultado é que a degradação de uma dependência secundária (um serviço de recomendações, um provedor de email, um sistema de analytics) derruba funcionalidades críticas (o checkout, a autenticação, o core do produto) que não têm relação direta com a dependência problemática.

O bulkhead opera na camada de alocação de recursos: em vez de um pool compartilhado de threads ou conexões que qualquer dependência pode consumir, cada dependência tem seu próprio pool com tamanho máximo definido. Quando a dependência de email trava e esgota seu pool de 20 threads, o checkout tem seus próprios 50 threads intactos. O email degrada; o checkout não sabe.

Este conceito explora as duas formas principais de bulkhead em aplicações — isolamento por thread pool e isolamento por semáforo — e estende o padrão para o contexto de multi-tenancy: isolamento por tenant como bulkhead contra o problema do "noisy neighbor".

Bulkhead por thread pool

A implementação mais clássica de bulkhead é dedicar um thread pool separado para cada dependência externa. Quando uma chamada a um serviço downstream é feita, ela é executada em um thread do pool daquela dependência — não no thread pool geral da aplicação. Se todos os threads do pool estão ocupados aguardando respostas lentas, novas chamadas à mesma dependência recebem um rejection imediato (que pode ser tratado com fallback ou erro rápido) em vez de bloquear um thread geral.

Netflix Hystrix, desenvolvido internamente e open-sourceado em 2012, popularizou esse padrão. A documentação do Hystrix descrevia o problema que motivou o design: em 2011, uma dependência de serviço de usuário estava com latência elevada. Sem isolamento, todos os threads do serviço de API do Netflix estavam bloqueados aguardando respostas. O sistema todo ficou indisponível por conta de um único serviço lento. Com thread pool isolation por serviço, a degradação do serviço de usuário seria confinada ao pool dedicado a ele.

heurística do sênior

Bulkhead por thread pool tem custo: cada pool tem overhead de criação e manutenção de threads, e o número de pools se multiplica com o número de dependências. O padrão é justificado quando você tem dependências com latência variável e imprevisível — chamadas a terceiros, serviços de parceiros, APIs públicas. Para dependências internas com latência previsível e baixa, o overhead raramente se justifica.

Calibrando o tamanho do pool

A pergunta mais comum ao implementar bulkhead é: qual tamanho para cada pool? Nygard e a documentação do Hystrix convergem em uma heurística prática: calcule a concorrência máxima esperada para aquela dependência com base no throughput e latência P99.

Fórmula (Lei de Little):
pool_size = throughput × latency_P99

Exemplo:
- Serviço de pagamento: 100 req/s, P99 = 500ms
- pool_size = 100 × 0.5 = 50 threads
- Adicionar 20-30% de margem: pool_size = 65

- Serviço de email: 10 req/s, P99 = 200ms
- pool_size = 10 × 0.2 = 2 threads
- Mínimo razoável: pool_size = 5

O tamanho mínimo razoável de pool é geralmente 5 — pools menores aumentam probabilidade de rejection desnecessário sob variância normal de latência. O tamanho máximo deve ser calibrado pela memória disponível: cada thread consome memória de stack (256KB a 1MB por thread em Java/.NET típico).

Bulkhead por semáforo

Uma alternativa mais leve ao isolamento por thread pool é o isolamento por semáforo. Em vez de executar a chamada em um thread separado, o semáforo controla quantas chamadas concorrentes podem estar em voo para aquela dependência. Se o limite é 20 e já há 20 chamadas em andamento, a 21ª recebe rejection imediato.

Semáforo tem custo menor que thread pool: não cria threads adicionais, funciona no thread atual. A troca é que se a chamada bloqueia (timeout longo), o thread que a fez fica bloqueado mesmo assim — o semáforo apenas limita a concorrência, não isola o thread. Para chamadas síncronas com I/O bloqueante, thread pool é mais seguro. Para código assíncrono (async/await, goroutines, asyncio), semáforo é geralmente suficiente e muito mais leve.

C# — Bulkhead com Polly v8
using Polly;
using Polly.Bulkhead;

// Thread pool isolation via Polly v8 ResiliencePipeline
var paymentPipeline = new ResiliencePipelineBuilder()
    .AddBulkhead(new BulkheadStrategyOptions
    {
        MaxConcurrentCalls = 50,    // máximo paralelo
        MaxQueuedCalls = 10,        // fila de espera
        OnRejected = args =>
        {
            logger.LogWarning("Bulkhead payment rejeitou chamada");
            return ValueTask.CompletedTask;
        }
    })
    .AddTimeout(TimeSpan.FromMilliseconds(500))
    .Build();

var emailPipeline = new ResiliencePipelineBuilder()
    .AddBulkhead(new BulkheadStrategyOptions
    {
        MaxConcurrentCalls = 5,
        MaxQueuedCalls = 0,  // sem fila: rejeição imediata
    })
    .AddTimeout(TimeSpan.FromMilliseconds(200))
    .Build();

// Registro no DI
services.AddKeyedSingleton("payment", paymentPipeline);
services.AddKeyedSingleton("email", emailPipeline);

// Uso — email não impacta payment
try {
    await emailPipeline.ExecuteAsync(ct =>
        emailClient.SendAsync(notification, ct), cancellationToken);
} catch (BulkheadRejectedException) {
    // fallback: enfileirar para retry assíncrono
    await outbox.EnqueueAsync(notification);
}

Polly v8 reescreveu completamente a API em relação ao v7. Use ResiliencePipelineBuilder e BulkheadStrategyOptions. O semáforo é o default — para thread pool verdadeiro, use ExecuteAsync com Task.Run explícito.

Python — Bulkhead com asyncio.Semaphore
import asyncio
import functools
from typing import Callable, TypeVar

T = TypeVar("T")


class Bulkhead:
    """Isolamento por semáforo para dependências externas."""

    def __init__(self, name: str, max_concurrent: int):
        self.name = name
        self._sem = asyncio.Semaphore(max_concurrent)
        self._rejected = 0
        self._active = 0

    async def execute(self, coro_func: Callable, *args, **kwargs):
        if self._sem._value == 0:
            self._rejected += 1
            raise BulkheadRejectedError(
                f"Bulkhead {self.name!r} cheio "
                f"({self._rejected} rejeições totais)"
            )
        async with self._sem:
            self._active += 1
            try:
                return await coro_func(*args, **kwargs)
            finally:
                self._active -= 1

    @property
    def stats(self) -> dict:
        return {"active": self._active, "rejected": self._rejected}


class BulkheadRejectedError(Exception):
    pass


# Uso
payment_bh = Bulkhead("payment", max_concurrent=50)
email_bh = Bulkhead("email", max_concurrent=5)

async def process_order(order_id: str):
    # payment: crítico — deixa propagar o erro
    result = await payment_bh.execute(
        payment_client.charge, order_id
    )
    # email: opcional — trata rejeição como degradação
    try:
        await email_bh.execute(email_client.send_confirmation, order_id)
    except BulkheadRejectedError:
        await outbox.enqueue(order_id)  # retry async

Em Python assíncrono, asyncio.Semaphore é leve e eficiente. A checagem _sem._value == 0 antes de async with implementa rejeição imediata sem entrar na fila de espera do semáforo.

Go — Bulkhead com semaphore.Weighted
import "golang.org/x/sync/semaphore"

type Bulkhead struct {
    name     string
    sem      *semaphore.Weighted
    rejected atomic.Int64
}

func NewBulkhead(name string, maxConcurrent int64) *Bulkhead {
    return &Bulkhead{
        name: name,
        sem:  semaphore.NewWeighted(maxConcurrent),
    }
}

// Execute tenta adquirir semáforo; rejeita imediatamente se cheio.
func (b *Bulkhead) Execute(ctx context.Context, fn func(context.Context) error) error {
    if !b.sem.TryAcquire(1) {
        b.rejected.Add(1)
        return &BulkheadRejectedError{Bulkhead: b.name}
    }
    defer b.sem.Release(1)
    return fn(ctx)
}

type BulkheadRejectedError struct{ Bulkhead string }
func (e *BulkheadRejectedError) Error() string {
    return fmt.Sprintf("bulkhead %q: capacidade esgotada", e.Bulkhead)
}

// Uso com isolamento por dependência
var (
    paymentBH = NewBulkhead("payment", 50)
    emailBH   = NewBulkhead("email", 5)
)

func ProcessOrder(ctx context.Context, orderID string) error {
    if err := paymentBH.Execute(ctx, func(ctx context.Context) error {
        return paymentClient.Charge(ctx, orderID)
    }); err != nil {
        return fmt.Errorf("payment falhou: %w", err)
    }
    // email não crítico — degrada silenciosamente
    if err := emailBH.Execute(ctx, func(ctx context.Context) error {
        return emailClient.SendConfirmation(ctx, orderID)
    }); err != nil {
        _ = outbox.Enqueue(ctx, orderID)
    }
    return nil
}

semaphore.TryAcquire retorna false imediatamente se não há capacidade — comportamento de bulkhead sem fila de espera. Para Go, prefira o pacote golang.org/x/sync/semaphore ao canal buffered em código de produção.

Isolamento por tenant — o problema do noisy neighbor

Em sistemas multi-tenant, o bulkhead se aplica em outra dimensão: isolar tenants uns dos outros, não apenas dependências do sistema. O problema do "noisy neighbor" é quando um tenant de alto volume (ou simplesmente mal-comportado) consome uma fração desproporcional de recursos compartilhados — CPU, memória, conexões de banco, slots de fila — e degrada a experiência dos outros tenants.

O módulo 07 cobriu a arquitetura de multi-tenancy (silo, pool, hybrid). O foco aqui é a camada de isolamento de recursos em runtime, independentemente da arquitetura de dados escolhida. Mesmo um sistema pool (banco compartilhado) pode ter isolamento de recurso adequado se implementar bulkheads por tenant.

Estratégias de isolamento por tenant

Rate limiting por tenant: a estratégia mais simples e comum. Cada tenant tem um limite de requisições por segundo (ou por minuto) independente. Um tenant que ultrapassa seu limite recebe 429 — sem afetar os outros. Implementado tipicamente com token bucket ou leaky bucket por tenant, armazenado em Redis para ser compartilhado entre instâncias.

Thread/connection pool por tenant: mais caro, mas garante que um tenant com workload lento não esgota recursos para todos. Prático quando você tem um número pequeno de tenants tier enterprise que pagam por isolamento garantido, e um número maior de tenants tier free/básico em pool compartilhado.

Fila de trabalho por tenant: em sistemas de processamento assíncrono, uma fila dedicada por tenant (ou por tier de tenant) garante que o processamento de um tenant de alto volume não atrasa a fila de outros. Kafka com partições por tenant é uma implementação natural.

armadilha em produção

Rate limiting por tenant sem observabilidade por tenant é cego. Você sabe que está rejeitando requisições, mas não sabe de quem nem por quê. Sempre instrumentar: taxa de rejeição por tenant, qual tenant está mais próximo do limite, histórico de consumo por tenant. Sem esses dados, rate limiting vira uma caixa preta que clientes reclamam sem que você consiga diagnosticar.

Bulkhead e circuit breaker — papéis diferentes

Bulkhead e circuit breaker (coberto no módulo 05, conceito 09) são frequentemente confundidos ou tratados como alternativos, mas têm papéis complementares distintos.

O circuit breaker monitora a taxa de falha de uma dependência e, quando a taxa ultrapassa um threshold, "abre o circuito" — passa a rejeitar chamadas imediatamente em vez de tentar. Seu papel é evitar chamadas inúteis a uma dependência que provavelmente vai falhar de qualquer jeito, e dar tempo para ela se recuperar.

O bulkhead controla a concorrência de chamadas a uma dependência, independentemente de ela estar falhando ou não. Seu papel é garantir que uma dependência lenta (não necessariamente com falhas) não esgota os recursos do chamador.

Os dois juntos formam uma defesa em profundidade: o bulkhead limita quantas chamadas simultâneas podem estar em voo (protege de sobrecarga por latência); o circuit breaker fecha o circuito quando as chamadas em voo começam a falhar (protege de falha em cascata). Um sistema resiliente tipicamente tem os dois, configurados na mesma pipeline de resiliência.

Observabilidade do bulkhead

Um bulkhead sem métricas é de utilidade limitada. As métricas essenciais para monitorar:

Taxa de rejeição por bulkhead: quantas chamadas foram rejeitadas por segundo. Uma taxa de rejeição crescente indica que o bulkhead está sendo ativado frequentemente — pode ser sinal de que o pool está subdimensionado ou que a dependência está degradada.

Ocupação do pool: quantos slots do pool estão em uso no momento. Uma ocupação consistentemente acima de 80% é sinal de subdimensionamento ou de dependência lenta.

Latência do path com bulkhead: qual fração das chamadas é acelerada pelo fallback após rejeição versus o path normal. Isso mede o custo de experiência do usuário quando o bulkhead atua.

// Métricas OpenTelemetry para bulkhead
var bulkheadActive = meter.CreateObservableGauge(
    "bulkhead.active_calls",
    description: "Chamadas ativas no pool");

var bulkheadRejected = meter.CreateCounter(
    "bulkhead.rejected_total",
    description: "Total de rejeições por bulkhead");

// Tags: bulkhead_name, service_name, tenant_tier

Quando não usar bulkhead

Bulkhead por thread pool não é gratuito. Criar pools separados para cada dependência em um microserviço com 20 dependências significa gerenciar 20 pools de threads — overhead de operação não trivial. E pools mal calibrados podem rejeitar requisições legítimas sob carga normal.

O bulkhead por semáforo em código assíncrono é muito mais leve e pode ser aplicado mais liberalmente. Para código síncrono bloqueante, o thread pool é necessário. Para código assíncrono (a maioria dos sistemas modernos), o semáforo é suficiente e o overhead é mínimo.

A decisão de nível mais alto: bulkhead faz sentido quando você tem dependências com comportamento imprevisível e quando a falha de uma não deve afetar as outras. Para sistemas onde todas as dependências são internas, sob seu controle, com SLOs confiáveis — o overhead pode não se justificar.

Como praticar

  1. Mapear as dependências externas. Inventarie todas as chamadas externas que seu serviço faz: bancos de dados, outros serviços, APIs de terceiros, sistemas de mensageria. Para cada uma, estime throughput e latência P99. Calcule qual seria o pool size adequado usando a Lei de Little. Identifique quais dependências são críticas (falha = falha do core) e quais são opcionais (falha = degradação aceitável).
  2. Implementar bulkhead em uma dependência opcional. Escolha uma dependência opcional (notificações, recomendações, analytics) e envolva todas as chamadas a ela com um bulkhead com pool pequeno (5-10 slots). Implemente um fallback: quando o bulkhead rejeitar, o sistema degrada graciosamente em vez de falhar. Monitore a taxa de rejeição por uma semana.
  3. Simular o noisy neighbor. Em ambiente de staging com dados de dois tenants sintéticos, faça um tenant enviar tráfego 10× acima do normal sem rate limiting. Observe o impacto no outro tenant. Depois aplique rate limiting por tenant e repita o experimento. Documentar a diferença de latência P99 do segundo tenant antes e depois do isolamento.

Referências para aprofundar

  1. livro Release It! — Design and Deploy Production-Ready Software — Michael Nygard (Pragmatic Programmers, 2ª ed. 2018). O livro que formalizou o padrão bulkhead para software. Capítulo 5 ("Stability Patterns") é leitura obrigatória para qualquer sênior que opera sistemas em produção.
  2. docs Polly — Bulkhead and Rate Limiter — App-vNext/Polly GitHub. github.com/App-vNext/Polly. Documentação do Polly v8 com exemplos de BulkheadStrategy e RateLimiterStrategy para .NET.
  3. artigo Netflix Hystrix — How it Works — Netflix Tech Blog, 2012. netflixtechblog.com. O artigo original que descreveu o problema do thread starvation no Netflix e a solução via thread pool isolation. Histórico importante.
  4. docs Resilience4j — Bulkhead — Resilience4j.github.io. Documentação do sucessor do Hystrix em Java/Kotlin. Exemplos de thread pool bulkhead vs semaphore bulkhead com análise de quando usar cada um.
  5. artigo Bulkhead Pattern — Azure Architecture Center — Microsoft, 2023. learn.microsoft.com/azure/architecture/patterns/bulkhead. Explicação clara com diagramas e exemplos de implementação em múltiplas linguagens.
  6. livro Building Microservices — Sam Newman (O'Reilly, 2ª ed. 2021). Capítulo sobre resiliência de serviços trata bulkhead no contexto de arquitetura de microsserviços — integração com service mesh (Istio, Linkerd).
  7. artigo golang.org/x/sync/semaphore — Package Documentation. pkg.go.dev/golang.org/x/sync/semaphore. Documentação oficial do semáforo weighted em Go — inclui TryAcquire para pattern de rejeição imediata.
  8. artigo Rate Limiting Patterns — Stripe Engineering Blog, 2015. stripe.com/blog/rate-limiters. Como a Stripe implementou rate limiting por API key com token bucket e leaky bucket. Referência para rate limiting por tenant.
  9. vídeo Patterns of Resilience — the Bulkhead Pattern — Uwe Friedrichsen (GOTO, 2019). YouTube. Palestra de 40 minutos sobre padrões de resiliência incluindo bulkhead — com análise de trade-offs e antipadrões comuns.
  10. paper Overload Control for Scaling WeChat Microservices — Zhou et al. (SOSP, 2018). Apresenta a arquitetura de controle de sobrecarga do WeChat — inclui isolamento de recursos por serviço em escala de bilhões de requisições.
  11. docs Envoy Proxy — Circuit Breaking — envoyproxy.io. Documentação do circuit breaking e connection limits no Envoy — inclui max_connections, max_pending_requests, max_requests que implementam bulkhead a nível de proxy.
  12. artigo Service Mesh and the Sidecar Pattern — Istio.io. Como service meshes (Istio, Linkerd) implementam bulkhead transparentemente via sidecar, sem modificar o código da aplicação.