السياق: صورة Docker مبنية على php:8.3-fpm-alpine، عدد العمال 12، وخدمة تُنشر 3–4 مرات يومياً على Kubernetes. بعد كل نشر، زمن p95 للطلب يقفز من 35ms إلى 400ms لدقيقتين تقريباً ثم يعود. عدد الملفات في التطبيق حوالي 4200 ملف PHP. هذا ليس حمل زائد، بل شيء يتعلق بكيفية تعامل OPcache مع الحاوية الجديدة.
الخطوة الأولى كانت قياس نسبة الإصابة لا تخمينها:
kubectl exec deploy/app -- php -r 'var_dump(opcache_get_status(false));'
النتيجة بعد النشر مباشرة: opcache_hit_rate حوالي 0.42، وcache_available_memory منخفض، وnum_cached_scripts لا يزال أقل من 2000. أي أن كل عامل يبني ذاكرته من الصفر بينما الطلبات تصل. في 8.3 لا يوجد شيء اسمه cache مشترك بين العمال بشكل افتراضي، لذلك 12 عاملاً يقرأون 4200 ملفاً كل واحد على حدة.
ما فعلته أولاً هو تثبيت حجم الذاكرة والملفات بشكل صريح بدل الاعتماد على القيم الافتراضية:
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=10000
opcache.validate_timestamps=0
opcache.save_comments=0
opcache.jit=disable
max_accelerated_files يجب أن يكون أكبر من عدد الملفات فعلياً، وليس مساوياً له، لأن هيكل التجزئة يعمل بكفاءة أقل عند الامتلاء. validate_timestamps=0 هنا مقبول لأن النشر يتم عبر صورة جديدة وليس عبر تعديل ملفات داخل الحاوية. أما save_comments=0 فوفّر حوالي 18% من الذاكرة المشغولة عندنا، لكنه يكسر أي كود يعتمد على annotations، وهذا ما سأعود إليه.
ثانياً، الأهم: تسخين الذاكرة قبل فتح الخدمة للترافيك. أضفت أمراً في entrypoint يمر على الملفات التي نعرف أنها تُنفَّذ فعلياً:
php -d opcache.enable_cli=1 -d opcache.file_cache=/tmp/opcache \
-r 'foreach (glob("/app/src/*.php") as $f) { opcache_compile_file($f); }'
لكن هذا وحده لا يكفي. opcache_compile_file يخزن الكود لكنه لا يحل مشكلة أن كل عامل له ذاكرته. لذلك وضعت readiness probe يتأكد أن opcache_get_status()["opcache_statistics"]["num_cached_scripts"] تجاوز حداً معيناً قبل استقبال الطلبات. النتيجة: p95 بعد النشر نزل من 400ms إلى 90ms، وما زال أعلى من 35ms لكنه مقبول.
لماذا هذا الحل وليس غيره؟ لأن المشكلة ليست في OPcache نفسه بل في أن عمر الذاكرة أقصر من عمر الترافيك. البديل هو opcache.file_cache على volume مشترك بين النسخ، لكن ذلك يفتح باباً لمشاكل صلاحيات وملفات قديمة إذا لم يُمسح بشكل صحيح. اخترت التسخين لأنه أبسط في التراجع: إذا فشل، تعود ببساطة لسلوكك القديم.
أين يفشل هذا؟ أولاً، إذا كان التطبيق يعتمد على Doctrine annotations أو غيرها، فإن save_comments=0 يجعله يفشل بصمت في بعض الحالات وبرسائل غامضة في أخرى. ثانياً، إذا كان هناك ملفات تُنشأ وقت التشغيل (compiled templates مثلاً)، فإن validate_timestamps=0 يعني أن التعديلات لن تُلتقط أبداً حتى إعادة تشغيل العمال. ثالثاً، التسخين عبر glob لا يغطي الملفات التي تُحمّل عبر autoload متأخر؛ عندنا بقي حوالي 600 ملف لا يُسخَّن، وهذا مقصود لأن تسخينها كان يبطئ بدء العامل أكثر من الفائدة. وأخيراً، في بيئة بها حد ذاكرة للحاوية أقل من 512MB، رفع memory_consumption إلى 256 قد يسبب OOM قتل للعامل قبل أن يستفيد.
السؤال الذي أواجهه الآن: هل تقومون بتسخين OPcache داخل كل عامل، أم تعتمدون على file_cache مشترك بين النسخ، وما الذي كسر عندكم أول مرة جربتم فيها validate_timestamps=0؟


0 条评论
还没有评论,写下第一条。