← All posts

الـ admission webhook الذي قال نعم وأسقطَنا رغم ذلك

Authagonal·July 27, 2026

نُشغّل خدمة مصادقة، لذا فإن السؤال "هل الحاوية التي بدأت للتو هي فعلًا الحاوية التي بنيناها؟" ليس سؤالًا نظريًا. كان جوابنا هو التواقيع: وقِّع كل صورة في الـ CI، وتحقّق منها قبل النشر، ثم، كطبقة ثانية، ضع webhook داخل العنقود بحيث لا يستطيع حتى kubectl مكتوب باليد أن يُشغّل شيئًا غير موقَّع. دخل ذلك حيّز التنفيذ بعد ظهر أحد أيام الثلاثاء. وبحلول المساء، كان Deployment المصادقة لدينا على عنقود dev قد راكم 2242 من ReplicaSet، ويسكّ واحدًا جديدًا كل ثلاث ثوانٍ تقريبًا، ويُبلّغ Available=False، ولا يخدم شيئًا.

الفرضية البديهية هي أن الـ webhook الجديد كان يرفض صورنا. لم يكن كذلك. كانت سجلاته، لكل طلب، تقول allowed: true. لقد قبِل كل ما أرسلناه إليه، طوال المساء، بينما كان الـ Deployment الذي يقبله يتداعى.

الطبقة التي كنا نضيفها

كان خط الأنابيب يوقّع ويتحقّق أصلًا. تحصل كل صورة على توقيع بلا مفتاح وقت البناء، مرتبط بهوية OIDC الخاصة بـ GitHub Actions، وتُجري مهمة النشر تحقّقًا مقابل تلك الهوية قبل أن يصل أي شيء إلى العنقود. تلك البوابة fail-closed، وهي الضابط الحقيقي.

الطبقة على جانب العنقود هي دفاع في العمق: policy-controller الخاص بـ sigstore، يعمل بوصفه admission webhook، ويحمل سياستين. تقول إحداهما إن الصور التي تطابق مسار سجلّنا الخاص يجب أن تحمل توقيعًا بلا مفتاح من هوية سير عملنا. أما الأخرى فهي قاعدة شاملة تسمح لكل ما تبقّى بالمرور، لأن "لم تطابق أي سياسة" يعني الرفض، وبدون القاعدة الشاملة يفقد العنقود وكلاء Vault لديه، ومشغّلات CSI لديه، وكل sidecar لم يبنِه بنفسه. ثبّت، وضع تسمية على الـ namespace، وانتهى الأمر. التسمية هي المفتاح: لا تسمية، لا إنفاذ.

أول انقطاع، وكان مملًّا

تشغيله بـ failurePolicy: Fail ومهلة الانتظار الافتراضية في الـ chart البالغة عشر ثوانٍ أسقط dev على الفور تقريبًا، بالطريقة نفسها التي يتوقّع الجميع أن يُسقطك بها admission webhook. على controller بارد أن يصل إلى Fulcio وRekor للتحقق من توقيع لم يره من قبل. وهو بارد، لا يكتمل ذلك العمل في عشر ثوانٍ. fail-closed زائد مهلة متجاوَزة يعني أن إنشاء الـ pod يُرفض، ما يعني أن الـ rollout لا يستطيع وضع pods، ما يعني أن الخدمة بلا أي نسخة.

نمط الإخفاق هذا موثّق جيدًا، والإصلاح هو الموثّق: ارفع مهلة الـ webhook إلى ثلاثين ثانية واضبط failurePolicy: Ignore. تبدو Ignore كأنها استسلام، وفي تصميم أحادي الطبقة ستكون كذلك. أما في تصميمنا فبوابة خط الأنابيب هي الضابط الأساسي fail-closed، وطبقة العنقود موجودة لتلتقط ما لم يمر عبر خط الأنابيب إطلاقًا. webhook لا يمكنه أبدًا أن يُسقط العنقود أثمن لدينا من webhook يلتقط آخر واحد بالمئة، لأن ذلك الواحد بالمئة الأخير قد جرى التقاطه بالفعل في الأعلى.

أطلقنا ذلك في الساعة 17:40. أصلح الانقطاع الأول تمامًا. كما خلق الثاني، وهذا هو الجزء الذي يستحق القراءة.

Mutating، وليس مجرد validating

يتخيّل الجميع الـ admission webhook كحارس باب: يفحص الكائن ويعيد نعم أو لا. الـ policy-controller ليس هذا فحسب. إنه webhook mutating، وما يعدّله هو الشيء نفسه الذي تحقّق منه للتو. حين يقبل مواصفة pod تشير إلى صورة عبر tag، يعيد كتابة تلك الإشارة لتتضمّن الـ digest الذي حلّه وفحصه. يدخل myregistry.io/authagonal-auth:abc123. ويخرج myregistry.io/authagonal-auth:abc123@sha256:....

إنها فكرة جيدة حقًا. الـ tag مؤشّر قابل للتغيّر، لذا فإن التحقق من tag ثم ترك الـ kubelet يحلّه من جديد لاحقًا يترك نافذة قد يختلف فيها الاثنان. تثبيت الـ digest وقت القبول يُغلق تلك النافذة. الكائن الذي يعمل هو الكائن الذي جرى التحقق منه.

الآن ضع ذلك بجانب طريقة عمل الـ Deployment. يحسب controller الـ Deployment تجزئة قالب الـ pod الخاص بك، وتلك التجزئة هي ما يعرّف الـ ReplicaSet الذي يملك الـ pods. إنه يحسب تجزئة القالب الذي أعلنتَه أنت. أما الـ webhook فيعيد كتابة القالب على الـ ReplicaSet الذي يقبله. فإذا قال قالب Deployment لديك :abc123، قال ابنه من ReplicaSet :abc123@sha256:...، ولم يعُد الاثنان متطابقين.

يوفّق الـ controller، ويحسب تجزئة القالب، ويبحث عن ReplicaSet بتلك التجزئة، فيجد واحدًا قالبه مختلف. في Kubernetes لا يعني ذلك سوى شيء واحد: تصادم تجزئة، قالبان مختلفان يقعان على التجزئة نفسها. يعالج الـ controller التصادمات كما يُفترض به. يزيد collisionCount، ما يشوّش التجزئة، وينشئ ReplicaSet جديدًا. بعد ثلاث ثوانٍ يوفّق من جديد، وقد جرى تعديل الـ ReplicaSet الجديد أيضًا. لا تتقارب الحلقة، لأن الخلاف الذي تحاول حلّه يعيد الـ webhook خلقه في كل مرة تحاول فيها.

ألفان ومئتان واثنان وأربعون من ReplicaSet، هذا هو شكل حلقة لا نهائية حين تضبطها في المساء بدل الصباح.

لماذا انكسر Deployment واحد فقط

أربعة أعباء عمل تعمل في ذلك الـ namespace. واحد دخل في دوّامة. ثلاثة كانت على ما يرام تمامًا، وهذه هي التفصيلة التي أبقتنا نبحث في المكان الخطأ، لأن خطأ إعداد منهجيًا لا ينبغي أن يكون انتقائيًا.

failurePolicy: Ignore هو السبب. حين يكون الـ webhook بطيئًا أو باردًا وينتهي وقت طلب ما، تعني Ignore أن الكائن يُقبل دون تعديل. أما إن كانت عملية apply معيّنة تعود ومعها digest مطبوع فيها أو مع الـ tag العاري الذي دخلت به، فقد توقّف على ما إذا كان الـ webhook قد أجاب في الوقت. الـ Deployments الثلاثة السليمة جرى تطبيقها بينما كان دافئًا، فكانت قوالبها تحمل digests أصلًا، وكانت إعادة كتابة الـ webhook مجرد no-op يطابق ما كان موجودًا سلفًا. أما الذي دخل الدوّامة فقد جرى تطبيقه بينما كان الـ webhook باردًا، فأبقى الـ tag العاري في قالبه، ثم جرى تعديل كل ReplicaSet ابن من تحته.

إذًا كان المُشغّل سباقًا، يُحسم في كل apply، كان بإمكان أي من الأربعة أن يخسره في أي عملية نشر. لم تُسبّب Ignore العلة. بل حوّلت علة حتمية إلى علة متقطّعة وأخفتها خلف ثلاثة أعباء عمل سليمة.

الإصلاح هو نقطة ثابتة

الغريزة هي منع الـ webhook من التعديل. الغريزة الأفضل: لا تعطه شيئًا ليعدّله. إذا كان قالب الـ pod الذي نقدّمه هو بالضبط ما سينتجه الـ webhook، فإن إعادة الكتابة لا تغيّر شيئًا، وتبقى التجزئة مستقرة، ولا يمكن للحلقة أن تبدأ.

لذا تحلّ مهمة النشر الآن الـ digest بنفسها قبل التطبيق. لكل صورة تسأل السجل عمّا يشير إليه الـ tag حاليًا، ثم تكتب الإشارة المثبّتة بالكامل في الـ overlay:

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

ما يُطلَق هو :tag@sha256:...، الـ tag والـ digest معًا. يبقى الـ tag من أجل قابلية القراءة البشرية، والـ digest هو ما يُحلّ فعليًا. يتحقق منه الـ webhook، فلا يجد شيئًا ليعيد كتابته، ويعيد الكائن دون تغيير. ظلّ collisionCount مستويًا منذ ذلك الحين.

هناك فخ صغير في ذيل كل هذا. بما أن الـ CI هي التي تكتب الـ digest، فإن تطبيق الـ overlay يدويًا من حاسوب محمول يرسل بدلًا من ذلك tag العنصر النائب الخاص بالـ manifests الأساسية، وترفضه الـ policy الآن بحق بـ "must be an image digest". العنقود يقول لك الحقيقة: ذلك الشيء الذي كتبته للتو لم يجرِ التحقق منه قط.

الـ mutating webhook كاتب ثانٍ، لا نقطة تفتيش

الـ mutating admission webhook ليس نقطة تفتيش. إنه كاتب داخل حلقة تحكّم تقارن أصلًا بين ما طلبتَه وبين ما هو موجود. كاتبان لهما رأيان مختلفان في الحقل نفسه ليست مسألة policy، بل مسألة أنظمة موزّعة، والشيء الوحيد الذي يجعلها آمنة هو الجَعْل المتكرِّر (idempotence): يجب أن تكون الحالة التي تعلنها نقطة ثابتة للتعديل، بحيث إن طبّقتَ التعديل عليها أنتجها من جديد.

تتجاوز إعادة التأطير هذه sigstore. أي شيء يعيد كتابة مواصفاتك وقت القبول، سواء حقن sidecars، أو أضاف حدود موارد افتراضية، أو طبّع إشارات الصور، فهو في الموضع نفسه، وينطبق السؤال نفسه. لو مرّرتَ manifest الخاص بك عبر هذا الشيء، فهل ستستعيد manifest الخاص بك؟ إن لم يكن كذلك، فسيظل شيء ما يلاحظ الفرق مرة تلو الأخرى. سيكون صبورًا، وسيكون أسرع منك بكثير.

أما الدرس الأصغر، ذاك الذي اضطُررنا فعلًا إلى نسيان ما تعلّمناه عنه في تلك الليلة: allowed: true لا يعني أن الـ webhook ليس هو المشكلة. أمضينا ساعة نعامل تلك السجلات كأنها حجّة غياب. كان الـ webhook يقول الحقيقة طوال الوقت. كنا نسأله السؤال الخطأ، لأننا لم نفكّر فيه قط إلا بوصفه شيئًا يقول لا.

إن كنت تفضّل أن يأتيك مزوّد الهوية وقد جرى بالفعل حسم سلسلة توريده، فإن Authagonal خدمة مصادقة مُستضافة تُوقَّع صورها في الـ CI، ويُتحقَّق منها قبل النشر، وتُثبَّت بالـ digest عند لحظة القبول، وهي جملة نستطيع كتابتها لأننا خسرنا مساءً كاملًا في سبيل استحقاقها.