← All posts

O webhook de admissão que disse sim e ainda assim nos derrubou

Authagonal·July 27, 2026

Operamos um serviço de autenticação, então a pergunta "o contêiner que acabou de subir é mesmo o contêiner que construímos?" não é acadêmica. Nossa resposta foram as assinaturas: assinar cada imagem na CI, verificá-la antes do deploy e depois, como segunda camada, colocar um webhook no cluster para que nem mesmo um kubectl digitado à mão pudesse rodar algo sem assinatura. Isso entrou numa terça-feira à tarde. Ao anoitecer, nosso Deployment de autenticação no cluster de dev tinha acumulado 2.242 ReplicaSets, criava um novo a cada três segundos aproximadamente, reportava Available=False e não servia nada.

A hipótese óbvia é que o novo webhook estava rejeitando nossas imagens. Não estava. Seus logs, para cada requisição, diziam allowed: true. Ele admitiu tudo o que lhe enviamos, a noite toda, enquanto o Deployment que ele admitia se desfazia.

A camada que estávamos adicionando

A pipeline já assinava e verificava. Cada imagem recebe uma assinatura sem chave no momento do build, atrelada à identidade OIDC do GitHub Actions, e o job de deploy roda uma verificação contra essa identidade antes que qualquer coisa chegue ao cluster. Esse portão é fail-closed e é o controle de verdade.

A camada do lado do cluster é defesa em profundidade: o policy-controller do sigstore, rodando como webhook de admissão e mantendo duas policies. Uma diz que imagens que batem com o nosso próprio caminho de registro precisam carregar uma assinatura sem chave da nossa identidade de workflow. A outra é um curinga que deixa passar todo o resto, porque "nenhuma policy correspondeu" significa negar, e sem o curinga o cluster perde seus agentes Vault, seus drivers CSI e todo sidecar que ele não construiu. Instalar, rotular o namespace, pronto. O rótulo é o interruptor: sem rótulo, sem aplicação.

A primeira queda, que foi chata

Ligá-lo com failurePolicy: Fail e o timeout padrão de dez segundos do chart derrubou o dev quase de imediato, do jeito que todo mundo espera que um webhook de admissão derrube você. Um controller frio precisa alcançar o Fulcio e o Rekor para verificar uma assinatura que ainda não viu. A frio, esse trabalho não termina em dez segundos. Fail-closed mais um prazo estourado significa que a criação de pod é recusada, o que significa que o rollout não consegue colocar pods, o que significa que o serviço não tem réplicas.

Esse modo de falha é bem documentado e a correção é a documentada: elevar o timeout do webhook para trinta segundos e definir failurePolicy: Ignore. Ignore soa como desistir, e num design de camada única seria. No nosso, o portão da pipeline é a barreira fail-closed principal, e a camada do cluster existe para pegar o que nunca passou pela pipeline. Um webhook que nunca pode derrubar o cluster vale mais para nós do que um webhook que pega o último um por cento, porque esse último um por cento já foi pego lá em cima.

Isso entregamos às 17h40. Corrigiu a primeira queda por completo. Também criou a segunda, e essa é a parte que vale a pena ler.

Mutating, não apenas validating

Todo mundo pensa num webhook de admissão como um segurança de porta: ele inspeciona o objeto e responde sim ou não. O policy-controller não é só isso. É um webhook mutating, e o que ele muta é justamente aquilo que acabou de verificar. Quando admite uma spec de pod que referencia uma imagem por tag, ele reescreve essa referência para incluir o digest que resolveu e conferiu. myregistry.io/authagonal-auth:abc123 entra. myregistry.io/authagonal-auth:abc123@sha256:... sai.

É uma ideia genuinamente boa. Uma tag é um ponteiro mutável, então verificar uma tag e depois deixar o kubelet resolvê-la de novo mais tarde deixa uma janela em que os dois poderiam divergir. Fixar o digest no momento da admissão fecha essa janela. O objeto que roda é o objeto que foi verificado.

Agora ponha isso ao lado de como um Deployment funciona. O controller de Deployment calcula o hash do seu template de pod, e esse hash é o que identifica o ReplicaSet dono dos pods. Ele calcula o hash do template que você declarou. O webhook reescreve o template no ReplicaSet que admite. Então se o template do seu Deployment diz :abc123, o próprio ReplicaSet filho dele diz :abc123@sha256:..., e os dois não batem mais.

O controller reconcilia, calcula o hash do template, procura um ReplicaSet com esse hash e encontra um cujo template é diferente. No Kubernetes isso só significa uma coisa: uma colisão de hash, dois templates diferentes caindo no mesmo hash. O controller lida com colisões como deveria. Ele incrementa collisionCount, o que perturba o hash, e cria um novo ReplicaSet. Três segundos depois reconcilia de novo, e o novo ReplicaSet também foi mutado. O laço não converge, porque a divergência que ele tenta resolver é recriada pelo webhook a cada tentativa.

Dois mil duzentos e quarenta e dois ReplicaSets é a cara de um laço infinito quando você o flagra à noite em vez de de manhã.

Por que só um Deployment quebrou

Quatro workloads rodam nesse namespace. Um entrou em espiral. Três estavam perfeitamente bem, que é o detalhe que nos manteve olhando no lugar errado, porque uma má configuração sistêmica não deveria ser seletiva.

failurePolicy: Ignore é o motivo. Quando o webhook está lento ou frio e uma requisição estoura o tempo, Ignore significa que o objeto é admitido sem mutação. Se um apply específico voltava com um digest cravado ou com a tag nua com que entrou dependia de o webhook responder a tempo. Os três Deployments saudáveis tinham sido aplicados enquanto ele estava quente, então seus templates já carregavam digests, e a reescrita do webhook era um no-op que batia com o que já estava lá. O que entrou em espiral tinha sido aplicado enquanto o webhook estava frio, manteve a tag nua no template e depois viu cada ReplicaSet filho ser mutado por baixo dos panos.

Então o gatilho foi uma corrida, decidida a cada apply, que qualquer um dos quatro poderia ter perdido em qualquer deploy. Ignore não causou o bug. Transformou um bug determinístico num intermitente e o escondeu atrás de três workloads saudáveis.

A correção é um ponto fixo

O instinto é impedir o webhook de mutar. Melhor instinto: não dê a ele nada para mutar. Se o template de pod que enviamos já é exatamente o que o webhook produziria, a reescrita não muda nada, o hash permanece estável, e o laço não pode começar.

Então o job de deploy agora resolve o digest sozinho antes de aplicar. Para cada imagem ele pergunta ao registro para onde a tag aponta neste momento, e então escreve a referência totalmente fixada no overlay:

digest=$(az acr repository show --name "$acr" \
  --image "authagonal-${img}:${tag}" --query digest -o tsv)
kustomize edit set image "${ref}=${ref}:${tag}@${digest}"

O que é entregue é :tag@sha256:..., tag e digest juntos. A tag fica para a legibilidade humana, o digest é o que de fato resolve. O webhook o verifica, não encontra nada para reescrever e devolve o objeto inalterado. collisionCount ficou plano desde então.

Há uma pequena armadilha no rabo disso tudo. Como é a CI que escreve o digest, aplicar o overlay à mão a partir de um laptop envia em vez disso a tag de placeholder dos manifestos base, e a policy agora a recusa corretamente com "must be an image digest". O cluster está lhe dizendo a verdade: aquilo que você acabou de digitar nunca foi verificado.

Um webhook mutating é um segundo escritor, não um ponto de checagem

Um webhook de admissão mutating não é um ponto de checagem. É um escritor dentro de um laço de controle que já está comparando o que você pediu com o que existe. Dois escritores com opiniões diferentes sobre o mesmo campo não é uma questão de policy, é uma questão de sistemas distribuídos, e a única coisa que torna isso seguro é a idempotência: o estado que você declara tem que ser um ponto fixo da mutação, de modo que aplicar a mutação sobre ele o produza de novo.

Esse reenquadramento se generaliza para além do sigstore. Qualquer coisa que reescreva suas specs no momento da admissão, seja injetando sidecars, adicionando limites de recursos padrão ou normalizando referências de imagem, está na mesma posição, e a mesma pergunta se aplica. Se você passasse seu próprio manifesto por essa coisa, receberia seu próprio manifesto de volta? Se não, algo vai continuar notando a diferença. Vai ser paciente, e vai ser muito mais rápido que você.

E a lição menor, aquela que de fato tivemos que desaprender naquela noite: allowed: true não significa que o webhook não é o problema. Passamos uma hora tratando esses logs como um álibi. O webhook dizia a verdade o tempo todo. Estávamos fazendo a pergunta errada, porque só o tínhamos pensado como algo que diz não.

Se você preferir que seu provedor de identidade já venha com a cadeia de suprimentos resolvida, Authagonal é um serviço de autenticação hospedado cujas imagens são assinadas na CI, verificadas antes do deploy e fixadas por digest no ponto de admissão, uma frase que podemos escrever porque já perdemos uma noite merecendo-a.