← All posts

Admission webhook đã nói có nhưng vẫn đánh sập chúng tôi

Authagonal·July 27, 2026

Chúng tôi vận hành một dịch vụ xác thực, nên câu hỏi "cái container vừa khởi động có thật sự là cái container chúng tôi đã build không?" không hề mang tính lý thuyết suông. Câu trả lời của chúng tôi là chữ ký: ký từng image trong CI, xác minh trước khi deploy, rồi, như một lớp thứ hai, đặt một webhook vào cụm để cho dù một lệnh kubectl gõ tay cũng không thể chạy thứ gì chưa ký. Việc đó được đưa vào một chiều thứ Ba. Đến tối, Deployment xác thực của chúng tôi trên cụm dev đã tích lũy 2.242 ReplicaSet, cứ khoảng ba giây lại đúc thêm một cái, báo Available=False, và không phục vụ gì cả.

Giả thuyết hiển nhiên là webhook mới đang từ chối các image của chúng tôi. Không phải vậy. Log của nó, với mọi yêu cầu, đều ghi allowed: true. Nó chấp nhận mọi thứ chúng tôi gửi, suốt cả buổi tối, trong khi chính cái Deployment mà nó chấp nhận đang tan rã.

Lớp mà chúng tôi đang thêm vào

Pipeline vốn đã ký và xác minh rồi. Mỗi image nhận được một chữ ký keyless tại thời điểm build, gắn với danh tính OIDC của GitHub Actions, và job deploy chạy một bước xác minh dựa trên danh tính đó trước khi bất cứ thứ gì chạm tới cụm. Cổng đó là fail-closed và nó mới là kiểm soát thật sự.

Lớp phía cụm là phòng thủ theo chiều sâu: policy-controller của sigstore, chạy như một admission webhook, nắm hai policy. Một cái nói rằng các image khớp với đường dẫn registry của chính chúng tôi phải mang một chữ ký keyless từ danh tính workflow của chúng tôi. Cái còn lại là một quy tắc bắt-tất-cả cho mọi thứ khác đi qua, vì "không policy nào khớp" nghĩa là từ chối, và không có quy tắc bắt-tất-cả này thì cụm mất các agent Vault, các driver CSI và mọi sidecar mà nó không tự build. Cài đặt, gắn nhãn cho namespace, xong. Cái nhãn chính là công tắc: không nhãn, không thực thi.

Sự cố đầu tiên, vốn nhàm chán

Bật nó lên với failurePolicy: Fail và timeout mặc định mười giây của chart đã đánh sập dev gần như ngay lập tức, đúng theo cái cách ai cũng ngờ rằng một admission webhook sẽ đánh sập bạn. Một controller ở trạng thái nguội phải liên lạc tới Fulcio và Rekor để xác minh một chữ ký nó chưa từng thấy. Khi nguội, việc đó không xong trong mười giây. Fail-closed cộng với hạn chót bị vượt nghĩa là việc tạo pod bị từ chối, nghĩa là rollout không thể đặt pod, nghĩa là dịch vụ không có replica nào.

Kiểu lỗi này được ghi chép đầy đủ và cách khắc phục cũng là cách đã được ghi chép: nâng timeout của webhook lên ba mươi giây và đặt failurePolicy: Ignore. Ignore nghe như đầu hàng, và trong một thiết kế một lớp thì đúng là vậy. Trong thiết kế của chúng tôi, cổng pipeline mới là hàng phòng thủ fail-closed chính, còn lớp cụm tồn tại để bắt những gì hoàn toàn chưa hề đi qua pipeline. Một webhook không bao giờ có thể đánh sập cụm đáng giá với chúng tôi hơn một webhook bắt được một phần trăm cuối cùng, vì một phần trăm cuối cùng đó đã được bắt từ thượng nguồn rồi.

Chúng tôi phát hành cái đó lúc 17:40. Nó khắc phục hoàn toàn sự cố đầu tiên. Nó cũng tạo ra sự cố thứ hai, và đây mới là phần đáng đọc.

Mutating, không chỉ validating

Ai cũng hình dung admission webhook như một người gác cửa: nó xét đối tượng rồi trả về có hoặc không. Policy-controller không chỉ như vậy. Nó là một webhook mutating, và cái nó biến đổi lại chính là thứ nó vừa xác minh. Khi nó chấp nhận một pod spec tham chiếu image bằng tag, nó viết lại tham chiếu đó để thêm vào digest mà nó đã phân giải và kiểm tra. myregistry.io/authagonal-auth:abc123 đi vào. myregistry.io/authagonal-auth:abc123@sha256:... đi ra.

Đó thật sự là một ý tưởng hay. Tag là một con trỏ có thể thay đổi, nên xác minh một tag rồi để kubelet phân giải lại nó sau đó để hở một khoảng mà hai bên có thể khác nhau. Ghim digest ngay tại thời điểm admission sẽ đóng khoảng hở đó. Đối tượng chạy chính là đối tượng đã được xác minh.

Bây giờ đặt điều đó cạnh cách một Deployment hoạt động. Deployment controller băm pod template của bạn, và cái hash đó là thứ định danh ReplicaSet sở hữu các pod. Nó băm template mà bạn đã khai báo. Webhook viết lại template trên ReplicaSet mà nó chấp nhận. Vậy nên nếu template của Deployment của bạn ghi :abc123, thì ReplicaSet con của chính nó ghi :abc123@sha256:..., và hai cái không còn khớp nhau nữa.

Controller đối soát, băm template, tìm một ReplicaSet có hash đó, và tìm thấy một cái có template khác đi. Trong Kubernetes điều đó chỉ có đúng một nghĩa: một va chạm hash, hai template khác nhau rơi vào cùng một hash. Controller xử lý va chạm đúng như nó phải làm. Nó tăng collisionCount, việc này làm nhiễu cái hash, rồi tạo một ReplicaSet mới. Ba giây sau nó đối soát lại, và ReplicaSet mới cũng đã bị biến đổi. Vòng lặp không hội tụ, vì sự bất đồng mà nó đang cố giải quyết lại bị webhook tái tạo mỗi lần nó thử.

Hai nghìn hai trăm bốn mươi hai ReplicaSet là hình hài của một vòng lặp vô hạn khi bạn bắt gặp nó vào buổi tối thay vì buổi sáng.

Vì sao chỉ một Deployment hỏng

Có bốn workload chạy trong namespace đó. Một cái xoáy vòng. Ba cái hoàn toàn ổn, và chính chi tiết đó khiến chúng tôi cứ tìm nhầm chỗ, vì một cấu hình sai mang tính hệ thống thì không nên có chọn lọc.

failurePolicy: Ignore là lý do. Khi webhook chậm hoặc nguội và một yêu cầu bị timeout, Ignore nghĩa là đối tượng được chấp nhận mà không bị biến đổi. Việc một apply cụ thể quay lại với digest đã được nung sẵn hay với tag trần mà nó đi vào phụ thuộc vào chuyện webhook có trả lời kịp hay không. Ba Deployment khỏe mạnh đã được apply khi nó còn ấm, nên template của chúng vốn đã mang digest, và việc viết lại của webhook chỉ là một no-op khớp với những gì đã có sẵn. Cái xoáy vòng thì đã được apply khi webhook còn nguội, giữ nguyên tag trần trong template, rồi bị biến đổi từng ReplicaSet con ngay dưới chân mình.

Vậy nên tác nhân kích hoạt là một cuộc đua, được phân định theo từng apply, mà bất kỳ cái nào trong bốn cũng có thể thua ở bất kỳ lần deploy nào. Ignore không gây ra lỗi. Nó biến một lỗi tất định thành một lỗi chập chờn và giấu nó sau ba workload khỏe mạnh.

Bản vá là một điểm bất động

Bản năng là ngăn webhook biến đổi. Bản năng tốt hơn: đừng cho nó thứ gì để biến đổi. Nếu pod template chúng tôi nộp lên đã đúng y hệt cái mà webhook sẽ tạo ra, thì việc viết lại chẳng thay đổi gì, hash giữ nguyên ổn định, và vòng lặp không thể khởi động.

Vậy nên job deploy giờ tự phân giải digest trước khi apply. Với mỗi image, nó hỏi registry xem tag hiện đang trỏ tới đâu, rồi ghi tham chiếu đã ghim đầy đủ vào overlay:

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

Cái được phát hành là :tag@sha256:..., tag và digest đi cùng nhau. Tag ở lại vì con người dễ đọc, còn digest mới là thứ thật sự được phân giải. Webhook xác minh nó, không tìm thấy gì để viết lại, và trả về đối tượng nguyên vẹn. collisionCount phẳng lì kể từ đó.

Có một cái bẫy nhỏ ở khúc đuôi chuyện này. Vì chính CI mới là bên ghi digest, việc apply overlay bằng tay từ một laptop lại gửi đi cái tag placeholder của các manifest gốc, và policy giờ từ chối nó đúng đắn với "must be an image digest". Cụm đang nói cho bạn sự thật: cái thứ bạn vừa gõ chưa bao giờ được xác minh.

Một mutating webhook là người ghi thứ hai, không phải một chốt kiểm

Một mutating admission webhook không phải một chốt kiểm. Nó là một người ghi bên trong một vòng điều khiển vốn đã đang so sánh cái bạn yêu cầu với cái đang tồn tại. Hai người ghi có ý kiến khác nhau về cùng một trường không phải là chuyện policy, mà là chuyện hệ phân tán, và thứ duy nhất khiến nó an toàn là tính idempotent: trạng thái bạn khai báo phải là một điểm bất động của phép biến đổi, sao cho áp phép biến đổi lên nó sẽ tạo ra chính nó một lần nữa.

Cách nhìn lại đó khái quát ra ngoài sigstore. Bất cứ thứ gì viết lại spec của bạn tại thời điểm admission, dù là chèn sidecar, thêm giới hạn tài nguyên mặc định, hay chuẩn hóa tham chiếu image, đều ở cùng vị thế, và cùng một câu hỏi đó áp dụng. Nếu bạn cho chính manifest của mình chạy qua cái thứ này, bạn có nhận lại đúng manifest của mình không? Nếu không, sẽ có thứ gì đó cứ liên tục nhận ra sự khác biệt. Nó sẽ kiên nhẫn, và nó sẽ nhanh hơn bạn rất nhiều.

Và bài học nhỏ hơn, cái mà đêm hôm đó chúng tôi thật sự phải học lại từ đầu: allowed: true không có nghĩa là webhook không phải thủ phạm. Chúng tôi mất một tiếng đồng hồ coi mấy cái log đó như một chứng cứ ngoại phạm. Webhook đã nói thật suốt từ đầu. Chúng tôi đã hỏi nó sai câu, vì chúng tôi chỉ từng nghĩ về nó như một thứ chuyên nói không.

Nếu bạn muốn nhà cung cấp danh tính của mình đến với chuỗi cung ứng đã được luận giải sẵn, Authagonal là một dịch vụ xác thực được lưu trữ sẵn mà image được ký trong CI, được xác minh trước khi deploy, và được ghim bằng digest ngay tại điểm admission, một câu chúng tôi viết được vì đã đánh đổi cả một buổi tối để giành lấy nó.