El webhook de admisión que dijo que sí y aun así nos tumbó
Operamos un servicio de autenticación, así que la pregunta "¿el contenedor que acaba de arrancar es realmente el contenedor que construimos?" no es académica. Nuestra respuesta fueron las firmas: firmar cada imagen en la CI, verificarla antes del despliegue y luego, como segunda capa, poner un webhook en el clúster para que ni siquiera un kubectl tecleado a mano pudiera ejecutar algo sin firmar. Eso entró un martes por la tarde. Al anochecer, nuestro Deployment de autenticación en el clúster de dev había acumulado 2.242 ReplicaSets, creaba uno nuevo aproximadamente cada tres segundos, informaba Available=False y no servía nada.
La hipótesis obvia es que el nuevo webhook estaba rechazando nuestras imágenes. No era así. Sus logs, para cada solicitud, decían allowed: true. Admitió todo lo que le enviamos, toda la noche, mientras el Deployment que admitía se venía abajo.
La capa que estábamos añadiendo
La pipeline ya firmaba y verificaba. Cada imagen recibe una firma sin clave en tiempo de build, ligada a la identidad OIDC de GitHub Actions, y el job de despliegue ejecuta una verificación contra esa identidad antes de que nada llegue al clúster. Ese control es fail-closed y es el control de verdad.
La capa del lado del clúster es defensa en profundidad: el policy-controller de sigstore, que corre como webhook de admisión y sostiene dos policies. Una dice que las imágenes que coinciden con nuestra propia ruta de registro deben llevar una firma sin clave de nuestra identidad de workflow. La otra es un comodín que deja pasar todo lo demás, porque "ninguna policy coincidió" significa denegar, y sin el comodín el clúster pierde sus agentes de Vault, sus drivers CSI y todo sidecar que no construyó. Instalar, etiquetar el namespace, listo. La etiqueta es el interruptor: sin etiqueta, no hay aplicación.
La primera caída, que fue aburrida
Encenderlo con failurePolicy: Fail y el timeout por defecto de diez segundos del chart tumbó dev casi de inmediato, de la manera en que todo el mundo espera que un webhook de admisión lo tumbe. Un controller en frío tiene que alcanzar a Fulcio y a Rekor para verificar una firma que no ha visto antes. En frío, ese trabajo no termina en diez segundos. Fail-closed más un plazo agotado significa que se rechaza la creación de pods, lo que significa que el rollout no puede colocar pods, lo que significa que el servicio no tiene réplicas.
Ese modo de fallo está bien documentado y el arreglo es el documentado: subir el timeout del webhook a treinta segundos y poner failurePolicy: Ignore. Ignore suena a rendirse, y en un diseño de una sola capa lo sería. En el nuestro, el control de la pipeline es la barrera fail-closed principal, y la capa del clúster existe para atrapar lo que nunca pasó por la pipeline. Un webhook que nunca puede tumbar el clúster vale más para nosotros que un webhook que atrapa el último uno por ciento, porque ese último uno por ciento ya se atrapó aguas arriba.
Eso lo lanzamos a las 17:40. Arregló por completo la primera caída. También creó la segunda, y esta es la parte que vale la pena leer.
Mutating, no solo validating
Todo el mundo piensa en un webhook de admisión como en un portero: inspecciona el objeto y devuelve sí o no. El policy-controller no es solo eso. Es un webhook mutating, y lo que muta es aquello que acaba de verificar. Cuando admite una spec de pod que referencia una imagen por tag, reescribe esa referencia para incluir el digest que resolvió y comprobó. myregistry.io/authagonal-auth:abc123 entra. myregistry.io/authagonal-auth:abc123@sha256:... sale.
Es una idea genuinamente buena. Un tag es un puntero mutable, así que verificar un tag y luego dejar que el kubelet lo resuelva de nuevo más tarde deja una ventana en la que ambos podrían diferir. Fijar el digest en el momento de la admisión la cierra. El objeto que se ejecuta es el objeto que se verificó.
Ahora pon eso junto a cómo funciona un Deployment. El controller de Deployment calcula el hash de tu template de pod, y ese hash es lo que identifica al ReplicaSet dueño de los pods. Calcula el hash del template que tú declaraste. El webhook reescribe el template en el ReplicaSet que admite. Así que si el template de tu Deployment dice :abc123, su propio ReplicaSet hijo dice :abc123@sha256:..., y los dos ya no coinciden.
El controller reconcilia, calcula el hash del template, busca un ReplicaSet con ese hash y encuentra uno cuyo template es distinto. En Kubernetes eso solo significa una cosa: una colisión de hash, dos templates distintos cayendo en el mismo hash. El controller maneja las colisiones como se supone que debe hacerlo. Incrementa collisionCount, lo que perturba el hash, y crea un nuevo ReplicaSet. Tres segundos después reconcilia de nuevo, y el nuevo ReplicaSet también ha sido mutado. El bucle no converge, porque la discrepancia que intenta resolver es recreada por el webhook cada vez que lo intenta.
Dos mil doscientos cuarenta y dos ReplicaSets es el aspecto que tiene un bucle infinito cuando lo pillas por la noche en lugar de por la mañana.
Por qué solo se rompió un Deployment
Cuatro workloads corren en ese namespace. Uno entró en espiral. Tres estaban perfectamente bien, que es el detalle que nos mantuvo mirando en el lugar equivocado, porque una mala configuración sistémica no debería ser selectiva.
failurePolicy: Ignore es el porqué. Cuando el webhook está lento o en frío y una solicitud agota su tiempo, Ignore significa que el objeto se admite sin mutar. Que un apply dado volviera con un digest incrustado o con el tag desnudo con el que entró dependía de si el webhook respondía a tiempo. Los tres Deployments sanos se habían aplicado mientras estaba caliente, así que sus templates ya llevaban digests, y la reescritura del webhook era un no-op que coincidía con lo que ya estaba ahí. El que entró en espiral se había aplicado mientras el webhook estaba en frío, conservó su tag desnudo en el template y luego vio cómo cada ReplicaSet hijo era mutado bajo sus pies.
Así que el detonante fue una carrera, decidida en cada apply, que cualquiera de los cuatro pudo haber perdido en cualquier despliegue. Ignore no causó el bug. Convirtió un bug determinista en uno intermitente y lo escondió detrás de tres workloads sanos.
El arreglo es un punto fijo
El instinto es impedir que el webhook mute. Mejor instinto: no le des nada que mutar. Si el template de pod que enviamos ya es exactamente lo que el webhook produciría, la reescritura no cambia nada, el hash se mantiene estable, y el bucle no puede arrancar.
Así que el job de despliegue ahora resuelve el digest por sí mismo antes de aplicar. Para cada imagen le pregunta al registro a qué apunta el tag en este momento, y luego escribe la referencia totalmente fijada en el overlay:
digest=$(az acr repository show --name "$acr" \
--image "authagonal-${img}:${tag}" --query digest -o tsv)
kustomize edit set image "${ref}=${ref}:${tag}@${digest}"
Lo que se despliega es :tag@sha256:..., tag y digest juntos. El tag se queda para la legibilidad humana, el digest es lo que de verdad se resuelve. El webhook lo verifica, no encuentra nada que reescribir y devuelve el objeto sin cambios. collisionCount ha estado plano desde entonces.
Hay una pequeña trampa en la cola de todo esto. Como es la CI la que escribe el digest, aplicar el overlay a mano desde un portátil envía en su lugar el tag de marcador de posición de los manifiestos base, y la policy ahora lo rechaza correctamente con "must be an image digest". El clúster te está diciendo la verdad: eso que acabas de teclear nunca fue verificado.
Un webhook mutating es un segundo escritor, no un punto de control
Un webhook de admisión mutating no es un punto de control. Es un escritor dentro de un bucle de control que ya está comparando lo que pediste con lo que existe. Dos escritores con opiniones distintas sobre el mismo campo no es una cuestión de policy, es una cuestión de sistemas distribuidos, y lo único que lo hace seguro es la idempotencia: el estado que declaras tiene que ser un punto fijo de la mutación, de modo que aplicar la mutación sobre él lo vuelva a producir.
Ese replanteo se generaliza más allá de sigstore. Cualquier cosa que reescriba tus specs en el momento de la admisión, ya inyecte sidecars, añada límites de recursos por defecto o normalice referencias de imagen, está en la misma posición, y aplica la misma pregunta. Si pasaras tu propio manifiesto por esta cosa, ¿recuperarías tu propio manifiesto? Si no, algo va a seguir notando la diferencia. Será paciente, y será mucho más rápido que tú.
Y la lección más pequeña, la que de verdad tuvimos que desaprender esa noche: allowed: true no significa que el webhook no sea el problema. Pasamos una hora tratando esos logs como una coartada. El webhook decía la verdad todo el tiempo. Le estábamos haciendo la pregunta equivocada, porque solo lo habíamos pensado como algo que dice no.
Si prefieres que tu proveedor de identidad venga con su cadena de suministro ya resuelta, Authagonal es un servicio de autenticación gestionado cuyas imágenes se firman en la CI, se verifican antes del despliegue y se fijan por digest en el punto de admisión, una frase que podemos escribir porque ya perdimos una noche ganándonosla.