تحليل حادثة أمان Cloudflare Workers AI: دليل عملي لتسربات ذاكرة التخزين المؤقت KV بين المستأجرين
باختصار
اكتشف كيف تكتشف تسربات ذاكرة التخزين المؤقت KV عبر المستأجرين في خوادم الألعاب وتمنعها من خلال دليل عملي مستوحى من حادثة Cloudflare Workers AI.
ميزة ذكاء اصطناعي مواجهة للاعبين أعادت استجابة تحتوي على نص برمجي من استوديو آخر. لم يكن انهيارًا. ولا خطأ HTTP 500. بل إجابة متماسكة وذات صلة بالموضوع وصلت برمز HTTP 200 وعدد رموز مطابق لطلب المُرسِل.
هذا هو أسوأ شكل يمكن أن يتخذه خطأ متعدد المستأجرين: المخرجات تبدو منطقية، فلا يفتح أحد تذكرة دعم. أصابت Cloudflare هذه المشكلة بالضبط في 27 أغسطس 2026، عندما احتوت 155 استجابة من نموذج GLM-5.3 Workers AI على بيانات من جلسات عملاء آخرين. كان كل طلب متأثر أطول من 681,280 رمزًا. تطلّب الإصلاح تغييرين — أحدهما في الإعدادات والآخر في تصحيح محرك — وقصة الاكتشاف أكثر إفادة من الإصلاح نفسه.
إليك الدليل العملي: ما الذي تعطّل، وكيف تكتشف نفس فئة الخطأ في بنيتك، وكيف تعالجها، وكيف تمنع تكرارها.
ما الذي تعطّل بالفعل
تداخلت مشكلتان مستقلتان. أي واحدة منهما بمفردها كانت ستكون محتملة.
المشكلة الأولى: عدم تطابق في السعة لم يتحقق منه أحد
نشرت Cloudflare نموذج GLM-5.3 بنافذة سياق تبلغ 1,048,576 رمزًا. بينما كان تجمّع ذاكرة التخزين المؤقت KV على وحدات معالجة الرسوم المخصصة للملء المسبق (prefill) يحمل 681,280 رمزًا.
هذه الفجوة مهمة لأن حجم التجمّع ليس قيمة إعدادات. يحدده SGLang عند الإقلاع بناءً على ما تبقى من ذاكرة GPU بعد تحميل أوزان النموذج. طول السياق المُعلَن هو قيمة إعدادات منفصلة. لا يوجد شيء في النظام يُجبر القيمتين على التطابق، ولا شيء يحذرك عندما تتباعدان.
اجتازت الاختبارات ما قبل الإطلاق. كانت رموز حالة HTTP كلها 200، ولم تحتوِ أجسام البث على إطارات خطأ، وبدا الإنتاجية طبيعيًا. اختبرت الاختبارات النقل، وليس المحتوى.
المشكلة الثانية: طلب مرفوض ما زال يبلّغ عن نفسه بأنه حي
عندما تجاوزت مطالبة حجم التجمّع، رفضها مُجدول SGLang. سجّل مسار الرفض الخطأ للعميل واستبدل المطالبة بعنصر نائب من رمز واحد حتى يتمكن الطلب من التدفق عبر آلية الدفعات دون تخصيص ذاكرة. على عقدة واحدة، عمل ذلك بشكل جيد: نُفّذ العنصر النائب، وانتهت الدفعة، وحصل العميل على خطأ.
في وضع الملء المسبق/فك الترميز المنفصل — حيث تبني وحدات GPU للملء المسبق ذاكرة التخزين المؤقت KV وتنقلها عبر RDMA إلى وحدات GPU لفك الترميز التي تولّد الرموز — كان التسلسل على النحو التالي:
- الإقلاع (Bootstrap). أخبر الموجّه (router) عقدة فك الترميز أن المطالبة طولها 8 صفحات. حجزت عقدة فك الترميز 8 صفحات KV.
- قبول الملء المسبق. قبلت قائمة الانتظار أي طلب حيث يُرجع
req.finished()القيمة False. يخزّن الطلب المرفوض رفضه فيto_finishوليس فيfinished_reason، لذلك ما زالfinished()يُرجع False. تم قبول الطلب المرفوض. - تمرير أمامي للملء المسبق. كانت المطالبة قد استُبدلت بالفعل بعنصر نائب من رمز واحد. نفّذ الملء المسبق تمريرًا أماميًا بسيطًا على ذلك الرمز الواحد.
- نقل KV. أرسل الملء المسبق صفحة واحدة من KV إلى فك الترميز وأشار إلى "اكتمل النقل".
- قبول فك الترميز. استخدمت قائمة انتظار فك الترميز نفس فحص
finished()وقبلت التسليم. - انتباه فك الترميز. ولّد فك الترميز رموزًا مع الانتباه على جميع الصفحات الثماني المحجوزة. كانت الصفحة 0 فقط قد كُتبت بواسطة هذا الطلب. الصفحات من 1 إلى 7 ما زالت تحتفظ بمطالبات مستأجرين آخرين وتوليدات جزئية.
- الاستجابة. حصل العميل على HTTP 200 وعدد
prompt_tokensمطابقًا وإجابة متماسكة مُجمّعة من صفحات ذاكرة تخزين مؤقت قديمة.
تصرف كل طلب يتجاوز عتبة 681,280 رمزًا بهذه الطريقة. كان الخطأ قد سُجّل في المستودع العلوي كقضية #30233 في 6 يوليو 2026، ولم يكن النشر المتأثر قد التقط الإصلاح بعد.
اكتشفها قبل أن يكتشفها لاعبوك
لا يمكنك اختبار طريقك إلى العزل عبر رموز الحالة. تحتاج إلى ثلاث إشارات.
الإشارة 1: حجم التجمّع مقابل طول السياق، مقروء من المحرك قيد التشغيل
تحقق من ذلك عند الإقلاع وفي CI، وليس من ملف إعدادات. التجمّع مشتق من الذاكرة المتبقية، لذا فإن أي تغيير في التكميم (quantization) أو التوازي الموتر (tensor parallelism) أو تعديل حجم رسوميات CUDA يحركه بصمت.
# deploy_gate.py - run before promoting any disaggregated inference config
import json, os, sys, urllib.request
CONTEXT_LEN = 1_048_576 # the model's advertised window
HEADROOM = 1.25 # pool must exceed context, not merely match it
def server_info(base_url: str) -> dict:
with urllib.request.urlopen(f"{base_url}/get_server_info", timeout=10) as r:
return json.load(r)
def kv_pool_tokens(info: dict) -> int:
# SGLang reports the pool it sized at startup; field names vary by version
for key in ("max_total_num_tokens", "kv_pool_tokens", "max_total_tokens"):
if key in info:
return int(info[key])
raise RuntimeError("KV pool size not exposed by this build - fail closed")
def main(prefill_url: str) -> None:
pool = kv_pool_tokens(server_info(prefill_url))
required = int(CONTEXT_LEN * HEADROOM)
print(f"kv_pool={pool} required={required} context={CONTEXT_LEN}")
if pool < required:
print("FAIL: pool smaller than context window; over-length prompts reach the rejection path")
sys.exit(1)
print("OK: pool covers the full context window")
if __name__ == "__main__":
main(os.environ.get("PREFILL_URL", "http://localhost:30000"))
شغّله على كل عقدة ملء مسبق قبل أن يصلها حركة المرور من موازن التحميل. أي خروج برمز غير صفري يمنع النشر.
الإشارة 2: علامات الكناري في ذاكرة التخزين المؤقت KV
ضع سلسلة مراقبة فريدة في مطالبة طويلة، واترك الطلب يكتمل، ثم أرسل مطالبة طويلة ثانية يجب ألا تحتويها أبدًا. افحص النص المُعاد، وليس رمز الحالة.
import uuid, requests
def canary_check(base_url: str, token: str, long_prompt_tokens: int) -> None:
sentinel = f"CANARY-{uuid.uuid4()}"
filler = "lorem ipsum " * (long_prompt_tokens // 2)
requests.post(base_url, json={
"prompt": f"{sentinel} {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
# A fresh, unrelated request must never surface the previous sentinel
r = requests.post(base_url, json={
"prompt": f"Summarize this: {filler}",
"max_tokens": 16,
}, headers={"Authorization": f"Bearer {token}"}, timeout=300)
assert sentinel not in r.text, f"KV cache leak: {sentinel} surfaced in a fresh response"
شغّل هذا ضد أطول مطالبة تنوي قبولها، عند الحد الفاصل تمامًا، وعند الحد الفاصل + رمز واحد. حالة الحد الفاصل هي حيث يعمل مسار الرفض.
الإشارة 3: زمن استجابة جيد جدًا لدرجة الريبة
يستغرق الملء المسبق بالطول الكامل وقتًا قابلًا للقياس. إذا عادت مطالبة من 8 صفحات في الوقت الذي يجب أن تستغرقه مطالبة من صفحة واحدة، فأنت تقرأ ذاكرة تخزين مؤقت لشخص آخر. سجّل مدة الملء المسبق مفهرسة حسب طول المطالبة ونبّه على النسبة المئوية الخامسة (p5) لمجموعة السياق الطويل. المطالبة الطويلة السريعة بشكل شاذ هي توقيع تسرب، وليس مكسبًا.
الإصلاح، بالترتيب
1. اجعل التجمّع يحمل كامل السياق
غيّرت Cloudflare إعدادات GLM-5.3 لتطابق GLM-5.2، مما حرّر ذاكرة GPU كافية بحيث يحمل تجمّع الملء المسبق الآن حوالي 2.04 مليون رمز — أي ضعف سياق 1,048,576 رمزًا تقريبًا. المطالبة التي تناسب نافذة السياق تناسب الآن دائمًا التجمّع، لذلك لا يُصل إلى مسار الرفض أبدًا لطلب صالح.
2. اجعل مسار الرفض آمنًا على أي حال
حجم التجمّع وحده هشّ. أي تغيير في التكميم أو التوازي الموتر أو أحجام رسوميات CUDA أو إصدار النموذج يحرك التجمّع بطريقة يسهل تفويتها.
يصرف تصحيح المحرك أي طلب يحمل رفضًا معلقًا عند قائمة انتظار قبول الفصل، مع خطئه الأصلي، قبل أي إقلاع أو تخصيص أو نقل KV. تتحقق عقدة فك الترميز أيضًا من أن عدد الرموز المنقولة يطابق ما حجزته. وصل ذلك إلى المستودع العلوي كـ PR #38704، مع متابعات في #38935، وصدر في SGLang v0.5.20 حتى v0.5.22.
إذا كنت تشغّل SGLang في أي مكان، ثبّت الإصدار v0.5.22 أو أحدث. هذه ليست ترقية "جدولها في السباق القادم".
3. تحقق باستخدام سيناريو إعادة الإنتاج، لا بالتخمين
تحققت Cloudflare من كلا التغييرين باستخدام سيناريو إعادة الإنتاج الخاص بالباحث واختبارات علامات الكناري الخاصة بها قبل استعادة النموذج. اختباران مستقلان، أحدهما عدائي والآخر داخلي. هذا هو المعيار الصحيح قبل إعادة تفعيل نموذج أو أسطول.
لماذا "أعادت 200" ليس اختبارًا
كان فشل الاختبار الجذري ضيقًا وعاديًا تمامًا: تحقق الفريق من رموز استجابة HTTP وأداء المحرك عند أطوال سياق طويلة، دون التحقق من مخرجات النموذج وتماسكه. نجحت المقاييس لأنها تسأل "هل استجاب؟" وليس "هل استجاب بشكل صحيح؟"
بالنسبة لميزة ذكاء اصطناعي مستضافة، فحص الصحة رخيص. بالنسبة لخادم لعبتك، الفجوة المكافئة عادةً أوسع. إليك القاعدة القابلة للنقل: أي تجمّع موارد مشترك يحتاج إلى تأكيد يربط حجم التجمّع بحجم أكبر طلب سيقبله النظام، ويجب أن يعمل هذا التأكيد ضد العملية الحية، وليس ملف إعدادات.
نفس فئة الخطأ تعيش في خوادم الألعاب
استبدل "صفحات ذاكرة التخزين المؤقت KV" بـ "فتحات الجلسة" وستتكرر الحادثة سطرًا بسطر تقريبًا.
تخصص تجمّعًا ثابتًا من فتحات المباريات أو فتحات حالة اللاعب أو مخازن الممثلين. يطلب ما يحجز 8 فتحات. يفحص فحص القبول state != Finished بدلاً من state == Valid، لذلك يتم قبول الحجز المرفوض على أي حال. تُسلَّم الفتحة إلى اللوبي التالي مع تهيئة الفتحة 0 فقط. ما زالت الفتحات من 1 إلى 7 تحتفظ بمعرّفات اللاعبين ومخزونهم وبيانات مواقعهم من المباراة السابقة.
هذا هو نفس نمط الفشل الذي غطيناه في هندسة خوادم الألعاب لتحمّل الاختراقات: التسرب لا يحتاج إلى هجوم متطور، بل يحتاج فقط إلى نموذج ملكية لا يفرضه الكود فعليًا.
إليك النمط المعطوب والإصلاح، بلغة C#:
public sealed class SessionSlotPool
{
private readonly SessionSlot[] _slots;
private readonly Queue<int> _free = new();
// BUG: release without invalidation leaves stale state for the next tenant
public void ReleaseUnsafe(int[] indices)
{
foreach (var i in indices) _free.Enqueue(i);
}
// FIX: invalidate on release, stamp a generation, verify on read
public void Release(int[] indices)
{
foreach (var i in indices)
{
_slots[i].Reset(); // zero player list, inventory, match id
_slots[i].OwnerId = null;
_slots[i].Generation++; // stale handles now fail the check
_free.Enqueue(i);
}
}
public bool TryRead(int index, long expectedGeneration, out SessionSlot slot)
{
slot = _slots[index];
return slot.Generation == expectedGeneration && slot.OwnerId != null;
}
}
عداد الأجيال هو الجزء الرخيص. يحوّل "نأمل أن الفتحة استُبدلت" إلى رفض صارم قابل للاختبار. أي قراءة لمقبض قديم تفشل بدلاً من إرجاع بيانات لاعب آخر بصمت.
قائمة التحقق من العزل
- أكّد حجم التجمّع مقابل طول السياق عند الإقلاع، من العملية قيد التشغيل. ليس من الإعدادات. أي خروج برمز غير صفري يمنع النشر.
- تعامل مع "مرفوض" كحالة نهائية منفصلة عن "منتهٍ". أوقف التنفيذ عند القبول، قبل أي تخصيص أو تسليم، وأعد الخطأ الأصلي.
- تحقق من أعداد النقل على الجانبين. إذا حجز فك الترميز 8 صفحات، يجب أن يؤكد وصول 8 صفحات. نقل صفحة واحدة يبلّغ عن "اكتمل" هو الخطأ بالضبط.
- اختبر مسار الفشل بتأكيدات المحتوى. علامات الكناري، سلاسل المراقبة، مطالبات الحد الفاصل + 1. افحص جسم الاستجابة، ولا تفحص رمز الحالة أبدًا.
- أعد تشغيل البوابة عند كل تغيير في السعة. التكميم والتوازي الموتر وحجم الدفعة وإصدار النموذج كلها تحرّك التجمّع. تعامل مع كل منها كتغيير ذي صلة بالعزل.
البند 5 هو ما تتجاهله الفرق. كل تغيير في السعة هو أيضًا تغيير أمني، لأن التجمّع مشتق من الذاكرة المتبقية بدلاً من أن يكون معلنًا. ينطبق نفس التفكير الصفري على أي تجمّع موارد تحدّد حجمه يدويًا — حسابات المفاضلة في تحليل السبات الصفري للخوادم هي نفس الحسابات.
ما تكلفه هذه المشكلة
العزل ليس مجانيًا، والمفاضلة صريحة.
مضاعفة تجمّع KV من 681,280 إلى حوالي 2.04 مليون رمز تستهلك ذاكرة GPU كانت ستستوعب طلبات متزامنة أكثر. عدد أقل من الطلبات المتزامنة يعني تكلفة أعلى لكل رمز مولّد. في أسطول الملء المسبق، هذه المساحة الإضافية سعة لا يمكنك بيعها.
هناك بديل أرخص: أبقِ التجمّع صغيرًا وارفض المطالبات التي تتجاوز الطول برمز HTTP 400 صريح عند الحافة، قبل وصولها إلى المُجدول. يحافظ ذلك على الإنتاجية لكنه يفشل في طلبات السياق الطويل المشروعة — والتي، بالنسبة لنموذج برمجة وكيل، هي معظم المنتج.
اختارت Cloudflare الصحة على حساب السعة. بالنسبة لميزة مواجهة للاعبين، هذا صحيح دائمًا تقريبًا. البديل — استجابة سريعة ورخيصة ومعقولة تحتوي على بيانات لاعب آخر — ليس مفاضلة يمكنك الدفاع عنها في تحليل ما بعد الحادثة.
أين يقع عبء العزل فعليًا
كل نظام متعدد المستأجرين يتخذ هذا القرار: أي الموارد المشتركة تجمّعها وتعيد استخدامها، ومن يملك الإبطال. الإجابة لصفحات KV على GPU هي "محرك الاستدلال، بشكل سيئ، حتى v0.5.22."
بالنسبة لحالة لاعب لعبتك، الإجابة عادةً هي "قاعدة بياناتك، والمسؤولية عليك." إذا كنت تفضل ألا تملك دلالات التجمّع من الأساس، فإن خدمات الحالة المرتبطة بالحساب تنقل السطح: horizOn يوفّر حفظًا سحابيًا مرتبطًا بالحساب مع قراءات وكتابات واعية بالمراجعة، حيث يحل العميل التعارضات بدلاً من أن تخمّن خوادمك أي فتحة قديمة.
لا يلغي ذلك عمل العزل — لا شيء يفعله. إنه يضيّقه إلى الحدود التي تتحكم فيها فعليًا.
ما الذي تفعله هذا الأسبوع
دقّق في كل تجمّع موارد تملكه واسأل ثلاثة أسئلة. هل يغطي حجم التجمّع بشكل قابل للإثبات أكبر طلب يقبله النظام؟ هل ينهي الطلب المرفوض قبل أن يخصص أي شيء؟ هل يتحقق الجانب المستقبِل من أن ما وصل يطابق ما حجزه؟
إذا كانت أي إجابة "نفترض ذلك"، فلديك نفس الخطأ بانتظار طلب طويل بما يكفي. ابدأ بالتجمّع الذي ستكون محتوياته الأكثر ضررًا إذا ظهرت في استجابة لاعب آخر.
إذا كنت مستعدًا للتوقف عن صيانة ذلك التجمّع لحالة اللاعب، جرّب horizOn مجانًا — مصادقة، حفظ سحابي مرتبط بالحساب، لوحات صدارة، إعدادات عن بُعد، وتقارير أعطال، دون إقامة قاعدة بيانات ونموذج جلسات من الصفر. تعرض وثائق API سطح التكامل في حوالي عشر دقائق.
المصدر: كشف تعرض ذاكرة التخزين المؤقت KV عبر الطلبات في نموذج GLM-5.3 Workers AI، ومعالجتنا مع SGLang