Pular para o conteúdo

Microserviços

O que muda quando a chamada atravessa a rede: prazo, retry seguro, disjuntor, descoberta e propagação do rastro.

Uma chamada de função ou funciona, ou levanta. Uma chamada de rede tem um terceiro estado: não se sabe. Ela pode ter chegado e a resposta se perdido; pode estar a caminho; pode ter sido processada duas vezes.

Quase todo bug de microserviço vem de tratar o terceiro estado como um dos dois primeiros:

O que se fazO que acontece
tentar de novo, sem cuidadoo pedido é processado duas vezes
esperar sem prazouma thread presa por serviço, para sempre
insistir num serviço caídoele nunca se recupera, porque nunca para de receber
propagar só o dadoo rastro se perde na primeira fronteira

Arcane.Malha trata os quatro.

Uma chamada#

dataforge
adopt Arcane.Malha as Malha

cliente := Malha.cliente("https://estoque.interno", {
    "prazo": 3.0,
    "tentativas": 3,
    "disjuntor": {"falhas": 5, "espera": 30}
})

r := cliente.get("/estoque/CAF-500")

given r["ok"]:
    out r["body"]["quantidade"]
otherwise:
    out $"nao deu: {r["erro"]} (status {r["status"]})"

A resposta é um vault com ok, status, body, headers, tentativas, ms e — quando algo deu errado — erro.

`status: 0` é o terceiro estado#

text
status  500  →  chegou, o servidor quebrou
status  404  →  chegou, o pedido está errado
status    0  →  NÃO CHEGOU (ou a resposta se perdeu)

A diferença importa: um 500 foi processado, e um 0 talvez não. Para uma operação que cobra dinheiro, o 0 é o caso que exige a chave de idempotência.

Retry: só o que é seguro#

SituaçãoRepete?
GET, HEAD, OPTIONS, PUT, DELETEsim — não mudam estado, ou são idempotentes
POST ou PATCH sem chavenão — um pagamento repetido cobra duas vezes
POST com Idempotency-Keysim — é o servidor que garante o efeito único
5xx, 429, 408, 425sim — o problema é do outro lado
outro 4xxnão — o pedido está errado; repetir dá o mesmo erro com mais latência
status: 0sim — não chegou, vale tentar
dataforge
// repetido sem risco
cliente.get("/estoque/CAF-500")

// NÃO é repetido
cliente.post("/pagamentos", {"valor": 100.0})

// repetido, porque o servidor garante
cliente.post("/pagamentos", {"valor": 100.0}, chave := uuid())

Recuo exponencial, com tremor#

text
tentativa 1  →  espera até 0,2 s
tentativa 2  →  espera até 0,4 s
tentativa 3  →  espera até 0,8 s
tentativa 4  →  espera até 1,6 s      (teto: 10 s)

O tremor não é refinamento. Sem ele, cem clientes que falharam junto tentam de novo junto — e a rajada derruba o serviço que estava se recuperando. É a diferença entre uma recuperação e um laço de queda.

Disjuntor#

Sem ele, um serviço que cai leva os que dependem dele: cada pedido espera o prazo inteiro antes de falhar, as threads acabam, e o que estava de pé cai também. E o serviço caído nunca se recupera, porque nunca para de receber.

text
    fechado   ────falhas demais────▶  aberto
       ▲                                 │
       │                            espera passou
    sucesso                              │
       │                                 ▼
       └──────────────────────────  entreaberto
                                   (deixa UM passar)
dataforge
cliente := Malha.cliente(base, {
    "disjuntor": {"falhas": 5, "espera": 30}
})

// depois de 5 falhas, a chamada NÃO SAI:
r := cliente.get("/x")
// {"ok": no, "status": 0, "erro": "disjuntor aberto para 'estoque'",
//  "tentativas": 0, "espera_restante": 27.4}

O entreaberto é o que evita a avalanche na volta: com cem threads esperando, abrir tudo de uma vez derruba o serviço no instante em que ele volta. Uma passa; se der certo, fecha; se falhar, abre de novo e o relógio reinicia — sem reiniciar, uma fila de threads sondaria em rajada.

dataforge
Malha.disjuntor(falhas := 3, espera := 10)   // um, avulso
cliente.disjuntor.estado                     // "fechado" | "aberto" | "entreaberto"
cliente.disjuntor.resumo()

Descoberta#

Não cravar http://localhost:8080 no código, sem precisar de um registro distribuído:

dataforge
Malha.registrar("estoque", "http://estoque:8080", {"prazo": 2.0})
Malha.registrar("pagamentos", "http://pagamentos:8080")

cliente := Malha.de("estoque")      // o MESMO cliente, sempre

Sem registro explícito, ele procura a variável de ambiente — ESTOQUE_URL ou ESTOQUE_HOST —, que é a convenção que o Docker Compose e o Kubernetes já produzem:

yaml
services:
  pedidos:
    environment:
      ESTOQUE_URL: http://estoque:8080

Quando o serviço não é conhecido, a mensagem diz o que fazer:

text
erro: nao sei onde esta o servico 'estoque'.
  nota: procurei no registro e na variavel ESTOQUE_URL
  dica: Malha.registrar("estoque", "http://estoque:8080"),
        ou defina ESTOQUE_URL

O rastro do pedido#

Sem um identificador que viaja com o pedido, investigar um incidente em cinco serviços é cruzar horário de log — o que é impossível quando dois pedidos acontecem no mesmo segundo.

dataforge
adopt Kiln
adopt Arcane.Malha as Malha

server pedidos on 8080:
    // Na borda: continua o rastro que chegou, ou começa um.
    middleware lambda req: Malha.propagar(req, "pedidos")

    route POST "/pedidos":
        // A chamada de saída leva o rastro sozinha.
        r := Malha.de("estoque").get($"/estoque/{req["body"]["sku"]}")
        given not r["ok"]:
            respond 503 json {"erro": "estoque indisponível"}
        respond 201 json criar(req["body"])

A propagação é automática na saída: um cliente que obrigasse a passar o id em cada chamada o perderia na primeira vez que alguém esquecesse — e esquecer é o caso normal.

ChamadaFaz
Malha.propagar(req, origem)continua o rastro que chegou, ou começa um
Malha.rastro()o id de agora — para pôr no log
Malha.contexto()o vault inteiro: rastro, origem, extra
Malha.comecar_contexto(id, origem, extra)à mão, fora de uma rota
Malha.cabecalhos_de_contexto()o que vai nas chamadas de saída
dataforge
// o 'extra' também viaja — o inquilino, a versão do cliente
Malha.comecar_contexto(void, "web", {"tenant": "acme"})
// vira X-Ctx-Tenant: acme em toda chamada de saída

O contexto vive numa threading.local: duas requisições ao mesmo tempo não misturam rastro. E o rastro também entra em req["state"]["rastro"], para o log do Kiln o alcançar sem passar pelo contexto.

Observar#

dataforge
cliente.resumo()
// {"servico": "estoque", "chamadas": 1204, "erros": 7,
//  "retentativas": 12, "recusadas": 0, "media_ms": 34.2,
//  "disjuntor": {"estado": "fechado", "falhas": 0, …}}

Malha.saude()      // o resumo de TODOS os clientes ativos

Malha.saude() é o que se põe numa rota de diagnóstico: ela responde quais dependências deste serviço estão de pé, que é a primeira pergunta num incidente.

dataforge
route GET "/saude/dependencias":
    respond json {"servicos": Malha.saude()}

Um sistema em três serviços#

O caminho completo, com tudo junto:

dataforge
// ── pedidos/src/main.df ──
adopt Kiln
adopt Arcane.Malha as Malha
adopt Arcane.Database as Banco

db := Banco.connect("pedidos.db")

// As opções valem para todo cliente criado daqui para frente.
Malha.padrao({"prazo": 3.0, "tentativas": 3,
              "disjuntor": {"falhas": 5, "espera": 30}})

server pedidos on 8080:
    middleware lambda req: Malha.propagar(req, "pedidos")
    middleware Kiln.logger()

    route POST "/pedidos":
        item := req["body"]

        // 1. o estoque tem?
        e := Malha.de("estoque").get($"/estoque/{item["sku"]}")
        given not e["ok"]:
            respond 503 json {
                "erro": "não deu para confirmar o estoque",
                "rastro": Malha.rastro()
            }
        given e["body"]["quantidade"] smaller item["quantidade"]:
            respond 409 json {"erro": "estoque insuficiente"}

        // 2. grava o pedido e reserva, numa transação
        action corpo():
            id := Banco.insert(db, "pedidos", {
                "sku": item["sku"],
                "quantidade": item["quantidade"],
                "status": "reservando"
            })
            // A chave de idempotência é o id do pedido: se a resposta
            // se perder e o cliente repetir, o estoque não baixa duas
            // vezes.
            r := Malha.de("estoque").post("/reservas", {
                "sku": item["sku"], "quantidade": item["quantidade"]
            }, chave := $"pedido-{id}")
            given not r["ok"]:
                trigger $"reserva recusada: {r["erro"] ?? r["status"]}"
            Banco.update(db, "pedidos", {"status": "reservado"}, {"id": id})
            yield id

        monitor:
            id := Banco.transacao(db, corpo)
            respond 201 json {"id": id, "rastro": Malha.rastro()}
        handle Error as erro:
            respond 502 json {"erro": erro.message,
                              "rastro": Malha.rastro()}

    route GET "/saude":
        respond json {"estado": "ok"}

    route GET "/saude/dependencias":
        respond json {"servicos": Malha.saude()}

ignite pedidos on 8080 at "0.0.0.0"

Quatro coisas acontecem aí que não aconteceriam com um cliente HTTP cru:

O quê
1o estoque fora do ar devolve 503, e não uma exceção que vira 500 — a diferença muda o que o balanceador faz
2a reserva leva chave := "pedido-N": se a resposta se perder e o cliente repetir, o estoque não baixa duas vezes
3o pedido só fica reservado se a reserva confirmou — a transação desfaz o resto
4o rastro volta na resposta de erro: quem recebeu o 502 pode dizer qual foi, e o log dos três serviços é pesquisável por ele

Saga: escritas que precisam acontecer juntas#

Não existe transação que atravesse a rede. BEGIN no serviço de estoque não alcança o de cobrança, e nem deveria: um BEGIN distribuído pediria que cada serviço segurasse uma transação aberta esperando os outros, o que transforma a queda de um na queda de todos.

A resposta correta é compensar — cada passo declara como se desfaz, e uma falha no meio desfaz em ordem inversa o que já aconteceu.

text
reservar ──▶ cobrar ──▶ despachar
                            │
                          falhou
                            │
            ◀─── estornar ◀─┘
   liberar ◀───
dataforge
adopt Arcane.Malha as Malha

action reservar(estado, chave):
    r := Malha.de("estoque").post("/reservas", {
        "sku": estado["sku"], "quantidade": estado["quantidade"]
    }, chave := chave)
    given not r["ok"]:
        trigger $"reserva recusada: {r["erro"] ?? r["status"]}"
    yield {"reserva": r["body"]["id"]}

action liberar(estado, chave):
    Malha.de("estoque").post($"/reservas/{estado["reserva"]}/liberar",
                             void, chave := chave)

action cobrar(estado, chave):
    r := Malha.de("cobranca").post("/cobrancas", {
        "valor": estado["valor"]
    }, chave := chave)
    given not r["ok"]:
        trigger $"cobranca recusada: {r["status"]}"
    yield {"cobranca": r["body"]["id"]}

action estornar(estado, chave):
    Malha.de("cobranca").post($"/cobrancas/{estado["cobranca"]}/estorno",
                              void, chave := chave)

// O id da saga e o do pedido: e o que torna a chave de cada passo
// estavel entre execucoes.
s := Malha.saga("checkout", $"pedido-{id}")
s.passo("reservar", reservar, liberar)
s.passo("cobrar", cobrar, estornar)
s.passo("despachar", despachar, cancelar_despacho)

// ANTES de executar: nenhum passo que escreve sem compensacao.
given s.conferir() is not []:
    trigger $"passos sem compensacao: {s.conferir()}"

r := s.executar({"sku": "CAF-500", "quantidade": 2, "valor": 65.0})

given r["ok"]:
    out $"pedido fechado em {r["ms"]}ms"
otherwise:
    out $"parou em {r["falhou_em"]}: {r["erro"]}"
    out $"desfeitos: {r["desfeitos"]}"
    given r["orfas"] is not []:
        // Isto precisa chegar a um humano.
        out $"COMPENSACAO FALHOU: {r["orfas"]}"

Três coisas que a diferenciam de um `monitor` com `ensure`#

O quêPor quê
1a compensação roda em ordem inversaestornar a cobrança antes de liberar o estoque deixa uma janela em que o cliente não tem dinheiro nem produto
2uma compensação que falha não é engolidaum estorno que não passa deixa o sistema inconsistente, e isso precisa chegar a um humano — ok continua no mesmo que o resto tenha desfeito
3cada passo tem chave de idempotência estávelderivada do id da saga mais o nome do passo: reprocessar uma fila não cobra duas vezes

O passo que falhou não é compensado. Ele não concluiu, e desfazer o que não aconteceu é o outro lado do mesmo bug — um estorno de cobrança que nunca existiu devolve dinheiro que nunca foi cobrado.

`conferir()` roda antes de executar#

Um passo que escreve e não declara compensação não falha em teste feliz. Ele aparece no dia em que o passo seguinte falha — e aí já escreveu.

dataforge
s := Malha.saga("risco")
s.passo("consultar", consultar_score, escreve := no)
s.passo("marcar", marcar_cliente)

assert s.conferir() is ["marcar"]

escreve := no é a declaração de que o passo não precisa de compensação: uma leitura, um log, uma consulta de score.

O diário, e por que a cada passo#

dataforge
// O terceiro argumento e onde escrever cada linha.
s := Malha.saga("checkout", $"pedido-{id}",
                lambda linha: Banco.insert(db, "saga_log", linha))

Gravado a cada passo, e não no fim: uma queda do processo no meio da saga perderia o que já foi feito, e ninguém saberia o que compensar. Cada linha leva o rastro do contexto, o que liga o diário da saga ao log dos serviços que ela chamou.

Um diário que não grava não derruba a saga — ela está no meio de escritas reais em serviços reais, e falhar por causa do log seria trocar um problema pequeno por um grande.

O que este módulo NÃO é#

Ele não é um service mesh. Não há sidecar, não há plano de controle, não há mTLS nem roteamento por peso. Isso é trabalho de infraestrutura — Istio, Linkerd, ou o balanceador do seu provedor — e reimplementá-lo em Python daria um subconjunto pior amarrado à linguagem.

O que ele é: o que uma chamada entre serviços precisa para não mentir, escrito sobre o urllib da biblioteca padrão, sem dependência.

Onde continuar#