استضافة تطبيقك في بيئة الإنتاج: النشر الذري، والإشراف على العمليات، وأخطاء الخادم التي لا تذكرها الدروس
دليل متقدم للمطوّر الذي يدير خادمه بنفسه: النشر دون توقف عبر مجلدات الإصدارات، وترحيلات قاعدة البيانات الآمنة، والإشراف على العمّال، وحساب ذاكرة PHP-FPM، وتسلسل المهلات، وصلاحيات الملفات، ونسخ احتياطية جُرّبت استعادتها.
تنتهي أغلب الدروس عند اللحظة التي يفتح فيها التطبيق في المتصفح على الخادم. لكن ما يُسقط التطبيقات في الإنتاج نادرًا ما يكون الكود نفسه؛ إنه ما يحيط به: نشر يترك الموقع نصف محدَّث لثوانٍ، وعامل طوابير ما زال يشغّل الكود القديم من ذاكرته، وأمر نُفّذ بصلاحيات root فترك ملفات لا يستطيع خادم الويب قراءتها، وقرص امتلأ بالسجلات، ونسخة احتياطية لم يحاول أحد استعادتها قط.
هذا المقال للمطوّر الذي يملك خادمًا افتراضيًا (VPS) وتطبيقًا يعمل، ويريد أن يبقى يعمل. اختيار نوع الاستضافة ناقشناه في مقال المقارنة بين الاستضافة المشتركة والافتراضية والمخصصة، وإيصال الكود إلى الخادم في مقال CI/CD. هنا نتناول ما يحدث على الخادم نفسه. الأمثلة من بيئة Linux وNginx وPHP-FPM وMySQL لأنها الأكثر شيوعًا، لكن المبادئ تنطبق على Node وPython وغيرهما.
النشر: لماذا لا يكفي git pull
النشر بتنفيذ git pull داخل مجلد الموقع الحي يعمل حتى اليوم الذي لا يعمل فيه. خلال تحديث الملفات وتثبيت الحزم وبناء الواجهة، يخدم الموقع مزيجًا من ملفات قديمة وجديدة؛ وإذا فشل composer install في منتصفه بقي الموقع معطّلًا حتى تصلحه يدويًا؛ ولا توجد نسخة سابقة سليمة ترجع إليها. وأسوأ من ذلك: ما يُبنى على الخادم هو شجرة العمل كما هي، فأي تعديل غير مُودَع (uncommitted) تُرك في المجلد يُنشر مع البناء دون أن يظهر في أي سجل.
| الأسلوب | كيف يعمل | التراجع | متى يناسب |
|---|---|---|---|
| التحديث في المكان | git pull وتثبيت الحزم داخل المجلد الحي | يدوي وبطيء، وغالبًا تحت الضغط | بيئة تجريبية فقط |
| مجلدات إصدارات ورابط رمزي | كل نشر في مجلد جديد يُجهَّز بالكامل، ثم يُحوَّل الرابط current إليه دفعة واحدة | إعادة الرابط إلى الإصدار السابق: ثانية واحدة | أغلب التطبيقات على خادم واحد أو بضعة خوادم |
| صورة حاوية (Container) | صورة مبنية ومختبرة في CI تُشغَّل كما هي، وتُستبدل الحاوية بعد نجاح فحص الصحة | تشغيل وسم الصورة السابقة | عند وجود عدة خدمات أو خوادم، أو فريق يعتمد الحاويات أصلًا |
بنية مجلدات الإصدارات بسيطة: مجلد releases/ فيه مجلد لكل نشر باسم زمني، ومجلد shared/ للأشياء التي يجب أن تبقى بين الإصدارات (ملف .env، ومجلد التخزين والملفات المرفوعة) تُربط داخل كل إصدار، ورابط current يشير إلى الإصدار الفعّال وإليه يشير خادم الويب. بعد تجهيز الإصدار الجديد بالكامل (الحزم، والبناء، وتوليد ملفات التخزين المؤقت) يأتي التحويل.
تفصيلتان تفوتان كثيرين. الأولى: ln -sfn ليس عملية ذرية، فهو يحذف الرابط ثم ينشئه، وبينهما لحظة لا يوجد فيها current. الطريقة الذرية إنشاء رابط مؤقت ثم إعادة تسميته فوق القديم: ln -s releases/20261002 current_tmp && mv -Tf current_tmp current. الثانية: يحتفظ OPcache وذاكرة realpath في PHP بالمسار القديم، فيستمر بعض العمال في خدمة الإصدار السابق. الحل أن يمرّر Nginx المسار الحقيقي بعد حلّ الرابط عبر $realpath_root في SCRIPT_FILENAME وDOCUMENT_ROOT، ثم إعادة تحميل PHP-FPM بهدوء (reload لا restart) لتفريغ الذاكرة دون قطع الطلبات الجارية.
وأخيرًا: لا تعتبر النشر ناجحًا لأن الأوامر انتهت دون خطأ. اطلب بعد التحويل نقطة فحص صحة تلمس قاعدة البيانات والتخزين المؤقت فعلًا، وأعد الرابط تلقائيًا إلى الإصدار السابق إن فشلت. واحتفظ بآخر خمسة إصدارات تقريبًا واحذف الأقدم، وإلا امتلأ القرص بها بعد بضعة أشهر.
ترحيلات قاعدة البيانات: إصداران يعملان في الوقت نفسه
النشر دون توقف له ثمن لا يُذكر كثيرًا: لفترة قصيرة يعمل الكود القديم والجديد معًا على قاعدة بيانات واحدة، فالطلبات الجارية والعمال الذين لم يُعَد تشغيلهم بعد ما زالوا على الإصدار السابق. والتراجع بإعادة الرابط لا يعيد قاعدة البيانات. لذلك يجب أن يكون كل ترحيل متوافقًا مع الإصدار الذي قبله، وأي تغيير هادم يُقسَّم على عدة عمليات نشر (نمط التوسيع ثم التقليص):
| النشر | مثال: إعادة تسمية العمود phone إلى mobile |
|---|---|
| الأول: التوسيع | أضف العمود mobile قابلًا للفراغ، واجعل الكود يكتب في العمودين ويقرأ من القديم |
| بين النشرين | انسخ البيانات القديمة إلى العمود الجديد على دفعات صغيرة، لا باستعلام واحد يقفل الجدول |
| الثاني: التحويل | اجعل الكود يقرأ من mobile ويكتب فيه وحده |
| الثالث: التقليص | احذف العمود phone بعد التأكد من أن لا شيء يستخدمه، بما في ذلك التقارير والمهام المجدولة |
وانتبه إلى زمن الترحيل نفسه: تعديل جدول فيه ملايين الصفوف قد يقفله دقائق. في MySQL 8 تُضاف أغلب الأعمدة فورًا عبر ALGORITHM=INSTANT، أما التعديلات الأثقل على الجداول الكبيرة فتحتاج أدوات تغيير المخطط دون قفل مثل gh-ost أو pt-online-schema-change، وجرّب الترحيل أولًا على نسخة من بيانات الإنتاج لتعرف كم يستغرق.
التطبيق ليس عملية واحدة
التطبيق الحديث مجموعة عمليات، ولكل منها طريقة فشل مختلفة. والتي تُشغَّل يدويًا داخل جلسة طرفية تموت بصمت عند إغلاقها أو عند إعادة تشغيل الخادم:
| العملية | كيف تُشغَّل | ما يجب الانتباه إليه |
|---|---|---|
| خادم التطبيق (PHP-FPM، أو Node، أو Gunicorn) | خدمة systemd أو مجمّع العمّال الخاص به | عدد العمال محسوب على الذاكرة، لا على التخمين (انظر القسم التالي) |
| عمّال الطوابير | Supervisor أو خدمة systemd مع Restart=always | العامل يحمل الكود في ذاكرته، فيجب إعادة تشغيله بعد كل نشر (في Laravel: queue:restart)، وإلا نفّذ وظائف جديدة بكود قديم |
| المهام المجدولة | سطر cron واحد يستدعي المجدول كل دقيقة | تُشغَّل بمستخدم التطبيق لا root، وتُمنع من التداخل إذا تجاوزت مدتها الفاصل بين تشغيلين |
| WebSockets والعمليات طويلة الأمد | خدمة مستقلة خلف الوكيل العكسي | إعادة تشغيل متدرجة كي لا ينقطع كل المتصلين في اللحظة نفسها |
عند الإيقاف يرسل systemd أو Supervisor إشارة SIGTERM ثم ينتظر مدة محددة قبل القتل القسري. اجعل هذه المدة (TimeoutStopSec أو stopwaitsecs) أطول من أطول وظيفة يُتوقع أن ينفذها العامل، وإلا قُطعت الوظيفة في منتصفها عند كل نشر. وضع حدًا لذاكرة كل خدمة (MemoryMax) كي يُعاد تشغيل عامل يتسرّب من الذاكرة بدل أن يدفع النظام إلى قتل قاعدة البيانات.
حساب الذاكرة قبل أن يحسبها النظام عنك
أكثر انقطاع غامض على الخوادم الصغيرة سببه أن مجموع ما قد تستهلكه العمليات أكبر من الذاكرة المتاحة. حين تنفد يتدخل OOM Killer في Linux ويقتل العملية الأكبر، وهي في الغالب MySQL، فيبدو الأمر عطلًا في قاعدة البيانات وهو في الحقيقة إعداد خاطئ في PHP-FPM.
القاعدة: pm.max_children = الذاكرة المتبقية للتطبيق ÷ متوسط استهلاك العامل الواحد. قِس الاستهلاك الفعلي تحت الحمل بأمر مثل ps -o rss -C php-fpm8.4 بدل الاعتماد على رقم من مقال. مثال على خادم بذاكرة 4 غيغابايت: نحو 0.5 غيغابايت للنظام، و1 غيغابايت لـ MySQL، و0.3 لـ Redis والعمّال، فيبقى نحو 2.2 غيغابايت؛ وإذا كان العامل يستهلك 70 ميغابايت فالحد الآمن قرابة 30 عاملًا، لا 100.
| الإعداد | القيمة المعقولة | الخطأ الشائع |
|---|---|---|
pm.max_children | محسوب من الذاكرة كما في المثال | رفعه عند البطء، فيتحول البطء إلى انهيار |
pm | dynamic لتطبيق واحد نشط، وondemand لعدة مواقع قليلة الزيارة على خادم واحد | تجمّع static كبير لكل موقع يحجز الذاكرة وإن لم يزره أحد |
pm.max_requests | بضع مئات إلى ألف، كي تُعاد دورة العامل ويُستعاد ما تسرّب من ذاكرة | صفر (بلا حد) مع مكتبات تتسرب منها الذاكرة |
innodb_buffer_pool_size | 70–80% من الذاكرة على خادم مخصص لقاعدة البيانات فقط، وأقل بكثير حين تشاركه التطبيق | نسخ نصيحة «80%» إلى خادم يشغّل كل شيء |
| ذاكرة التبديل (Swap) | قدر صغير يمتص الذروات العابرة ويمنح وقتًا للتنبيه | الاعتماد عليه بدل الذاكرة، فيصبح الخادم بطيئًا جدًا بدل أن يتعطل |
تسلسل المهلات: من يستسلم أولًا
الطلب الطويل يمر بعدة طبقات لكل منها مهلة: شبكة توزيع المحتوى (Cloudflare مثلًا تقطع بعد نحو 100 ثانية)، ثم Nginx (fastcgi_read_timeout أو proxy_read_timeout، وافتراضيها 60 ثانية)، ثم PHP-FPM (request_terminate_timeout)، ثم PHP نفسه (max_execution_time). يجب أن تكون المهلات متصاعدة من الداخل إلى الخارج: الطبقة الأعمق تستسلم أولًا، فيسجّل التطبيق خطأً واضحًا بالسطر والسبب. إن حدث العكس رأى المستخدم 504 من الوكيل بينما يستمر PHP في العمل وحده، ولا يبقى في سجلات التطبيق أثر.
وفي Linux لا يحسب max_execution_time الوقت الذي يقضيه السكربت في انتظار قاعدة البيانات أو الشبكة، فاستعلام عالق لدقائق لا يوقفه. الضمانة الحقيقية لزمن الطلب الفعلي هي request_terminate_timeout في FPM. والعمل الذي يحتاج أكثر من بضع ثوانٍ مكانه أصلًا طابور خلفي، لا طلب HTTP ينتظره المستخدم.
إعدادات الوكيل العكسي التي تُنسى
| العَرَض | السبب | الإصلاح |
|---|---|---|
| ملفات JavaScript وCSS تُرسل بحجمها الكامل رغم تفعيل الضغط | gzip on وحده لا يضغط إلا text/html | أضف gzip_types بأنواع CSS وJavaScript وJSON وSVG، أو فعّل Brotli إن كان متاحًا |
| تقييد معدل الطلبات يحظر الجميع معًا، والسجلات كلها بعنوان IP واحد | الموقع خلف شبكة توزيع محتوى، فيرى الخادم عنوانها لا عنوان الزائر | set_real_ip_from بنطاقات الشبكة المنشورة رسميًا مع real_ip_header، وضبط الوكلاء الموثوقين في إطار العمل |
| رفع ملف كبير يفشل بخطأ 413 أو يصل فارغًا | حدود متعددة: client_max_body_size في Nginx (1 ميغابايت افتراضيًا) وupload_max_filesize وpost_max_size في PHP | اضبط الحدود الثلاثة معًا على القيمة نفسها التي يعدها التطبيق |
| روابط تُولَّد بـ http رغم أن الموقع على https | التطبيق لا يرى أن الاتصال الأصلي مشفّر لأن الوكيل ينهي TLS | تمرير X-Forwarded-Proto والوثوق بالوكيل في إعدادات التطبيق |
الصلاحيات والإعدادات المخزّنة: عطل الساعة الثالثة فجرًا
قاعدة واحدة تمنع فئة كاملة من الأعطال: الكود يملكه مستخدم النشر، والتطبيق يعمل بمستخدم خادم الويب (www-data مثلًا)، ولا يُنفَّذ أي أمر للتطبيق بصلاحيات root. حين تشغّل أمرًا من أوامر الإطار بـ root ينشئ ملفات تخزين مؤقت أو سجلات يملكها root، فلا يستطيع التطبيق الكتابة فيها ويظهر خطأ 500 بعد ساعات حين يحاول تحديثها. نفّذ الأوامر بمستخدم التطبيق: sudo -u www-data php artisan …. والأمر نفسه عند تعديل .env بمحرر يعمل بـ root: بعض المحررات تكتب ملفًا جديدًا وتغيّر مالكه، فيصبح غير مقروء للتطبيق. اجعل صلاحياته 640، وتحقق من المالك بعد كل تعديل.
أما الإعدادات المخزّنة مؤقتًا (config:cache وroute:cache وما يقابلهما في الأطر الأخرى) فتسرّع التطبيق، لكن لها أثرين جانبيين. الأول: أي تعديل على .env أو ملفات المسارات لا يُفعَّل حتى يُعاد بناء الذاكرة المؤقتة، فيبدو التعديل كأنه لم يحدث. الثاني وهو الأخطر: الإعدادات المخزّنة تتجاوز متغيرات البيئة التي يضعها إطار الاختبار، فتشغيل الاختبارات على خادم الإنتاج قد يتصل بقاعدة البيانات الحقيقية، واختبار يستخدم إعادة تهيئة قاعدة البيانات يمسح جداولها. لا تشغّل الاختبارات على خادم الإنتاج، وأضف حارسًا في كود الاختبارات يرفض العمل إن لم يكن الاتصال بقاعدة اختبار.
عدة مواقع على خادم واحد
استضافة عدة مشاريع على خادم واحد اقتصادية، بشرط ألا يُسقط موقع واحد البقية أو يقرأ ملفاتها:
| العزل | كيف |
|---|---|
| مستخدم نظام لكل موقع | تجمّع PHP-FPM مستقل لكل موقع يعمل بمستخدمه الخاص وله مقبس (socket) خاص، فثغرة في موقع لا تكشف ملفات غيره |
| حدود موارد لكل موقع | pm.max_children منفصل لكل تجمّع، فموقع يتعرض لزيارات مفاجئة لا يستهلك كل العمال |
| مستخدم قاعدة بيانات لكل موقع | صلاحيات على قاعدته فقط، وليس على كل القواعد |
| إصدارات PHP مختلفة | كل تجمّع يمكن أن يعمل بإصدار مختلف؛ تأكد أن أوامر composer وCLI تستخدم الإصدار نفسه الذي يشغّل الموقع، لا الإصدار الافتراضي للنظام |
النسخ الاحتياطي: لا يُعدّ موجودًا حتى تستعيده
ملف نسخة احتياطية لم تُجرَّب استعادته هو أمل وليس نسخة احتياطية. والنسخة المحفوظة على الخادم نفسه تضيع معه حين يتعطل القرص أو يُخترق الخادم أو يُحذف عند المزوّد.
| المكوّن | الحد الأدنى المعقول |
|---|---|
| قاعدة البيانات | تفريغ يومي تلقائي بـ mysqldump --single-transaction لجداول InnoDB (نسخة متسقة دون قفل الجداول)، مع تفعيل السجل الثنائي (binlog) إن كنت تحتاج الاستعادة إلى لحظة محددة |
| الملفات المرفوعة | مزامنة تزايدية إلى مكان آخر، فهي لا تُعاد إنشاؤها من الكود كما تُعاد بقية الملفات |
| الموقع | خارج الخادم وخارج حساب المزوّد نفسه إن أمكن، بصلاحيات كتابة لا تسمح للخادم بحذف النسخ القديمة |
| الاحتفاظ | نسخ يومية لأسبوعين، وأسبوعية لشهرين على الأقل؛ بعض الأخطاء لا تُكتشف إلا بعد أيام |
| التجربة | استعادة كاملة على خادم منفصل مرة كل بضعة أشهر، مع قياس الوقت الذي استغرقته |
والسبب الأكثر شيوعًا لتعطل خادم صغير ليس الاختراق ولا الحمل، بل امتلاء القرص: سجلات بلا تدوير، وإصدارات قديمة لم تُحذف، ونسخ احتياطية محلية تتراكم. فعّل logrotate لسجلات التطبيق، وضع تنبيهًا عند تجاوز 80% من مساحة القرص.
قائمة فحص قبل أن تعتبر الخادم جاهزًا
| المجال | السؤال |
|---|---|
| النشر | هل يمكنني التراجع إلى الإصدار السابق في أقل من دقيقة، دون أن أبني شيئًا؟ |
| الترحيلات | هل يعمل الإصدار السابق مع قاعدة البيانات بعد ترحيلات هذا النشر؟ |
| العمليات | إذا أعدت تشغيل الخادم الآن، هل يعود كل شيء، بما فيه العمّال والمجدول، دون تدخل؟ |
| الذاكرة | هل مجموع الحد الأقصى لكل العمليات أقل من الذاكرة الفعلية؟ |
| المهلات | هل تستسلم الطبقة الأعمق قبل الخارجية، فيبقى للخطأ أثر في سجلات التطبيق؟ |
| الصلاحيات | هل يعمل كل شيء دون أي أمر بصلاحيات root بعد التثبيت الأول؟ |
| النسخ الاحتياطي | متى كانت آخر مرة استعدت فيها نسخة كاملة، وكم استغرقت؟ |
| المراقبة | هل سأعرف بامتلاء القرص أو توقف العمّال قبل أن يخبرني عميل؟ |
الخادم المُدار جيدًا ممل: النشر فيه حدث عادي في منتصف يوم العمل، وإعادة التشغيل لا تحتاج أحدًا، والأعطال تظهر في التنبيهات قبل أن تظهر في رسائل العملاء. وإن لم يكن لدى فريقك وقت لبناء ذلك كله، فهذا بالضبط ما تدفع مقابله في الاستضافة المُدارة.