لماذا لم يُعد systemd تشغيل خدمتك؟
تعرّف إلى سبب تجاهل systemd للعملية الابنة المتوقفة، وكيف يحدد Type= وRestart= العملية الرئيسية، ومتى تمنع حدود إعادة التشغيل المحاولة مجدداً.
الإجابة المختصرة: تراقب سياسات إعادة تشغيل systemd عملية واحدة
تراقب سياسات إعادة تشغيل systemd عملية واحدة لكل وحدة: العملية الرئيسية. يقرأ Restart= حالة خروج هذه العملية وحدها، ولا يقرأ أي شيء آخر. قد تضم مجموعة التحكم الخاصة بالوحدة 20 عملية، وقد تتوقف إحداها، بينما تبقى الوحدة active (running) لأن العملية الرئيسية ما زالت تعمل. لا يعتبر systemd أن شيئاً قد فشل، لذلك لا يعيد تشغيل أي شيء.
يعرف systemd العمليات الأخرى. فهو ينهيها عند إيقاف الوحدة، ويحتسب استهلاكها للذاكرة ضمن حدود الوحدة، ويطبّق عليها حصة الوحدة من وحدة المعالجة المركزية، ويعرضها في systemctl status. لكنه لا يقرأ حالة خروجها مطلقاً. منطق إعادة التشغيل ومجموعة cgroup شيئان مختلفان، ويتناول معظم هذا الدليل الفجوة بينهما.
ما الذي يحتفظ به cgroup، وما الذي تقرؤه آلية إعادة التشغيل
cgroup (مجموعة التحكم) هو كائن في النواة يملك مجموعة من العمليات. تحصل كل وحدة خدمة على cgroup خاص بها، وتتم تسميته باسم الوحدة. لا يمكن للعملية مغادرته. ترث العمليات الابنة cgroup الخاص بعملية الوالد، ولا يمكن لعملية غير ذات امتيازات نقل نفسها إلى مكان آخر. لذلك يستطيع systemd تنظيف daemon ينشئ عمليات فرعية مرتين، وهو ما لم تكن نصوص init القديمة تستطيع تنفيذه بشكل موثوق.
انظر إلى الحقيقتين جنباً إلى جنب:
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Restart -p RestartUSec myapp.serviceيسرد systemd-cgls كل عملية في الوحدة. أما MainPID فهو الرقم الوحيد الذي تقرؤه سياسة إعادة التشغيل. عندما يختلف هذان الرقمان عن تصورك، يكون هذا الاختلاف هو الخطأ. ويكون MainPID=0 أسوأ من وجود PID خاطئ: فهذا يعني أن systemd لا يتتبع أي شيء على الإطلاق، ولذلك لا يمكن لأي قيمة Restart= أن تؤدي إلى تشغيل الإجراء.
يوجد استثناء حقيقي واحد لقاعدة العملية الرئيسية. إذا أنهى قاتل العمليات بسبب نفاد الذاكرة أي عملية داخل cgroup الخاص بالوحدة، فإن systemd يكتشف ذلك لأنه يراقب ملف memory.events الخاص بـcgroup. تحدد OOMPolicy= ما يحدث بعد ذلك، وقيمتها الافتراضية هي stop: تتوقف الوحدة بالكامل، وتُسجَّل النتيجة على أنها oom-kill، ويُعد ذلك فشلاً، ولذلك تُشغَّل Restart=on-failure. يوضح journal ذلك صراحةً.
myapp.service: A process of this unit has been killed by the OOM killer.
myapp.service: Failed with result 'oom-kill'.لذلك، يؤدي إنهاء عملية ابنة بسبب الذاكرة إلى إيقاف الوحدة، بينما لا يؤدي موت العملية الابنة نفسها بسبب خطأ segmentation fault إلى ذلك. إذا عيّنت حدوداً للذاكرة على وحدة، فاقرأ كيفية تطبيق MemoryMax وCPUQuota على cgroup الخاص بالوحدة قبل ضبط سياسة إعادة التشغيل، لأن هاتين الميزتين تلتقيان هنا ولا تلتقيان في أي موضع آخر.
كيفية يحدّد Type= العملية الرئيسية
لا يقتصر Type= في قسم [Service] على ترتيب بدء التشغيل. فهو القاعدة التي تحدد أي PID (معرّف العملية) يصبح MainPID، أي تحدد أيضاً ما يستطيع Restart= رؤيته.
Type=simpleهو الإعداد الافتراضي. العملية التي ينشئ لها systemd تفرعاً منExecStart=هي العملية الرئيسية. يضع systemd الوحدة في حالة البدء فوراً، قبل أن يعرف ما إذا كانexecقد نجح أصلاً. يؤدي الخطأ المطبعي في مسار الملف التنفيذي إلى نجاح مهمة البدء، ثم إلىMain process exited, code=exited, status=203/EXECبعد لحظة.Type=execيعمل مثلsimple، لكن مهمة البدء تنتظر حتى ينجحexec. يحوّل ذلك الخطأ المطبعي السابق إلى فشل صريح في البدء. ويتطلب systemd 240 أو إصداراً أحدث، وهو متوفر في كل توزيعة مدعومة. استخدمه بدلاً منsimple.Type=forkingيتوقع أن تنشئ العملية الناتجة منExecStart=خدمة daemon في الخلفية ثم تنتهي. ينتظر systemd انتهاء العملية الأصلية، ثم يبحث عن خدمة daemon الفعلية. امنحهPIDFile=. من دونه، يعملGuessMainPID=، المفعّل افتراضياً، فقط عندما تبقى عملية واحدة بالضبط في cgroup. إذا أبقيت عمليتين، فسيبقىMainPIDفي الحالة0.Type=notifyيعني أن الخدمة تستدعيsd_notify(3)وترسلREADY=1عندما تصبح قادرة على معالجة network traffic. ويمكنها أيضاً إرسالMAINPID=لتسليم systemd عملية مختلفة لمتابعتها. تكون قيمةNotifyAccess=الافتراضية هيmain، لذلك يُتجاهل الإشعار الذي ترسله عملية فرعية، ويذكر journal PID الذي أرسل الإشعار.Type=oneshotلا يملك عملية رئيسية مستمرة. تصبح الوحدة غير نشطة بمجرد انتهاءExecStart=، ما لم تضبطRemainAfterExit=yes. يُرفضRestart=alwaysوRestart=on-successهنا، مع الرسالةService has Restart= set to either always or on-success, which isn't allowed for Type=oneshot services. Refusing.. وتُقبل القيم الأخرى، بما فيهاon-failure.
يجدر حفظ خطأين من أخطاء Type=forking، لأن كل واحد منهما يترك وحدة تبدو معطلة من دون سبب ظاهر:
myapp.service: Can't open PID file /run/myapp.pid (yet?) after start: No such file or directory
myapp.service: New main PID 4711 does not belong to service, and PID file is not owned by root. Refusing.يعني الخطأ الأول أن الخدمة daemon تكتب ملف PID في مكان آخر، أو تكتبه بعد الوقت الذي يبحث فيه systemd عنه. ويعني الخطأ الثاني أن ملف PID يحدد عملية خارج cgroup الخاصة بالوحدة، وهو ما يرفض systemd تبنّيه، لأن ملف PID القابل للكتابة سيصبح بخلاف ذلك وسيلة لجعل systemd يرسل إشارات إلى أي عملية على الخادم.
لماذا يخفي البرنامج المغلِّف انتهاء عملياته الابنة
هذا هو النمط الذي يطرح السؤال الوارد في العنوان.
#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
waitالوحدة هي Type=simple، ولذلك تكون العملية الرئيسية هي الصدفة. يعيد wait، عند تشغيله دون وسيطات، النتيجة فقط بعد انتهاء جميع العمليات الابنة. أوقف عملية العامل، فتبقى الصدفة في انتظار عملية الويب. لذلك لا تنتهي الصدفة، ولا ينتهي MainPID، ولا يتم الرجوع إلى Restart=. أصبحت مجموعة التحكم تحتوي على عملية واحدة أقل، ويعرض systemctl status شجرة أقصر، بينما تظل الوحدة في الحالة active (running). لا يراقب systemd هذه الشجرة بحثاً عن تغييرات.
النسخة الثانية من الخطأ نفسه أقل وضوحاً:
ExecStart=/bin/sh -c 'export APP_ENV=production; /usr/local/bin/myapp'العملية الرئيسية هي الصدفة، وليست myapp. عند تنفيذ systemctl stop، يرسل systemd الإشارة SIGTERM إلى العملية الرئيسية. ولا تمرر الصدفة الإشارة عندما تكون في انتظار عملية ابنة تعمل في الواجهة الأمامية. لذلك يستغرق الإيقاف كامل مدة TimeoutStopSec، وهي 90 ثانية افتراضياً، وينتهي كما يلي:
myapp.service: State 'stop-sigterm' timed out. Killing.
myapp.service: Killing process 4711 (myapp) with signal SIGKILL.الحل هو exec. اكتب exec /usr/local/bin/myapp، فتُستبدل الصدفة بالبرنامج، وبذلك تصبح MainPID هي البرنامج وتصل الإشارات إليه. والأفضل من ذلك حذف الصدفة واستخدام Environment= أو EnvironmentFile= في الوحدة. لاحظ أن هذا الخطأ لا يظهر عندما تحتوي سلسلة -c على أمر واحد، لأن bash وdash يحسّنان هذه الحالة إلى exec مباشرة. أضف أمراً ثانياً إلى السلسلة، فتبقى الصدفة حية أمام برنامجك.
أعد إنتاج المشكلة على VPS اختباري خلال دقيقتين
احفظ البرنامج المغلِّف أعلاه باسم /usr/local/bin/two-children.sh، واجعله قابلاً للتنفيذ باستخدام chmod +x، واستبدل مساري البرنامجين بـsleep 3600. اربط وحدة به باستخدام Type=simple وRestart=on-failure، ثم نفّذ systemctl daemon-reload وابدأ تشغيلها. نفّذ systemd-cgls --unit two-children.service ولاحظ أرقام العمليات الثلاثة: الصدفة وعمليتاها الابنتان. أوقف إحدى العمليتين الابنتين باستخدام sudo kill <pid>. افحص الوحدة مرة أخرى. أصبحت الشجرة أقصر بعملية واحدة، وما زالت الحالة active (running)، ولم يضف السجل شيئاً جديداً. نفّذ الآن sudo kill -9 <shell pid> بدلاً من ذلك. تفشل الوحدة، وتُنظَّف العملية الابنة الباقية لأن KillMode=control-group هي القيمة الافتراضية، ويعرض السجل Scheduled restart job, restart counter is at 1.
المفردات الكاملة للخيار Restart=، ومتى يكون on-failure أفضل من always
Restart= يقبل إحدى سبع قيم. والتمييز بينها يعتمد على ما يُعد خروجاً سليماً. يعتبر systemd رمز الخروج 0، وأي رمز مدرج في SuccessExitStatus=، والإشارات SIGHUP وSIGINT وSIGTERM وSIGPIPE خروجاً سليماً. وكل ما عدا ذلك، بما في ذلك SIGKILL وSIGSEGV، يُعد خروجاً غير سليم.
noهي القيمة الافتراضية. لا تعيد الوحدة تشغيل نفسها أبداً. لذلك تتوقف الوحدة التي لا تحتوي على سطرRestart=عند أول انهيار وتبقى متوقفة.on-successتعيد التشغيل بعد الخروج السليم فقط.on-failureتعيد التشغيل عند رمز خروج غير صفري، أو إشارة غير سليمة، أو انتهاء مهلة البدء أو الإيقاف، أو انتهاء مهلة watchdog.on-abnormalتعيد التشغيل عند إشارة غير سليمة، أو انتهاء مهلة، أو انتهاء مهلة watchdog، لكنها لا تعيد التشغيل عند رمز خروج غير صفري عادي.on-abortتعيد التشغيل عند إشارة غير سليمة فقط، أي عند حدوث انهيار.on-watchdogتعيد التشغيل فقط عند انتهاءWatchdogSec=.alwaysتعيد التشغيل بعد كل حالة مما سبق، بما في ذلك الخروج السليم بحالة 0.
تُعد on-failure القيمة الافتراضية المناسبة لخدمة طويلة التشغيل. فهي تعيد الخدمة بعد الانهيار، ولا تتدخل عندما يحدث exit 0 مقصود. وتناسب always برنامجاً يخرج بسلاسة لأسباب خارجة عن سيطرته، مثل عميل نفق يعيد 0 عند انقطاع الطرف البعيد. لكن always قد تخفي الأخطاء: إذا بدأت الخدمة، وقرأت ملف إعداد تالفاً، وسجلت الخطأ، ثم خرجت بالحالة 0، فستدخل في حلقة إعادة تشغيل لا تنتهي. وستكون العلامة الوحيدة هي ارتفاع عداد إعادة التشغيل.
تغيّر SuccessExitStatus= الحد الفاصل بين الخروج السليم وغير السليم. يعيد Borg الرمز 1 للتحذيرات والرمز 2 للأخطاء، لذلك تُعلَّم وحدة النسخ الاحتياطي بالفشل في كل مرة تتخطى فيها ملفاً واحداً يتعذر قراءته إذا لم تتضمن SuccessExitStatus=1. وتدرج RestartPreventExitStatus= الرموز التي تمنع إعادة التشغيل حتى مع always. وهذه هي الطريقة السليمة ليطلب البرنامج عدم تشغيله مجدداً. أما RestartForceExitStatus= فتفعل العكس. يجب أن تعمل مهمة النسخ الاحتياطي ضمن وحدة Type=oneshot يشغّلها مؤقت، بدلاً من وضعها في حلقة إعادة تشغيل. ويمكنك نسخ بنية زوج الخدمة والمؤقت الذي يشغّل مهمة وفق جدول زمني.
تنبيه مهم بشأن الاختبار. يؤدي إيقاف خدمتك باستخدام kill <pid> وحده إلى إرسال SIGTERM، وهي إشارة مدرجة ضمن قائمة الخروج السليم. لذلك لا تفعل Restart=on-failure شيئاً، وقد تستنتج أن إعدادك معطّل. استخدم kill -9 <pid> أو systemctl kill -s SIGKILL myapp.service بدلاً من ذلك. تذكّر أيضاً أن أياً من قيم Restart= لا يعمل بعد systemctl stop، أو عندما تتوقف الوحدة لأن اعتماداً من نوع BindsTo= أو PartOf= لم يعد متاحاً. مهمة الإيقاف ليست فشلاً.
RestartSec ومدة 100 ميلي ثانية الافتراضية
RestartSec= هي مدة التوقف بين إيقاف الوحدة وإعادة تشغيلها بواسطة systemd. والقيمة الافتراضية هي 100 ميلي ثانية. تحقّق من القيمة التي حمّلها النظام فعلياً لوحدتك:
systemctl show -p RestartUSec -p StartLimitIntervalUSec -p StartLimitBurst myapp.serviceتطبع الوحدة التي لم تحدد هذه القيمة RestartUSec=100ms. هذه القيمة الافتراضية مناسبة لخدمة تتعطل مرة واحدة ثم تعود إلى العمل. لكنها غير مناسبة لخدمة لا يمكنها البدء إطلاقاً، لأن خمس محاولات إعادة تشغيل تحدث خلال أقل من نصف ثانية، وهذا بالضبط ما يؤدي إلى تفعيل حد المعدل الموضح لاحقاً. بالنسبة إلى أي خدمة تنتظر قاعدة بيانات أو mount أو مسار شبكة، اضبط RestartSec=5s أو قيمة أكبر.
اعتباراً من August 2026، يوفّر systemd 254 والإصدارات الأحدث منه أيضاً RestartSteps= وRestartMaxDelaySec=. وتزيد هذه الخيارات مدة التأخير من RestartSec= حتى تصل إلى حد أقصى على مدى ذلك العدد من المحاولات. يأتي Ubuntu 24.04 مع systemd 255 ويتضمن هذه الخيارات. أما Debian 12 فيأتي مع systemd 252 ولا يتضمنها. تكون مدد التأخير المتزايدة هي الخيار الصحيح عندما قد تظل التبعية متوقفة مدة طويلة.
ما الذي يعنيه فعلياً "start request repeated too quickly"
هذه هي الحالة التي يظن فيها القراء أن systemd يتوقف عشوائياً عن المحاولة. لكنها في الواقع عدّاد. القاعدة هي: إذا بدأ تشغيل الوحدة أكثر من StartLimitBurst= مرة خلال StartLimitIntervalSec=، يرفض systemd تشغيلها مجدداً ويضعها في حالة الفشل. القيم الافتراضية هي 5 محاولات تشغيل خلال 10 ثوانٍ.
يعرض السجل التسلسل التالي:
myapp.service: Scheduled restart job, restart counter is at 5.
myapp.service: Start request repeated too quickly.
myapp.service: Failed with result 'start-limit-hit'.
Failed to start myapp.service - My application.وتجيب systemctl start بالحل المكتوب مسبقاً:
Job for myapp.service failed because start of the service was attempted too often. See "systemctl status myapp.service" and "journalctl -xeu myapp.service" for details. To force a start use "systemctl reset-failed myapp.service" followed by "systemctl start myapp.service" again.تُصفّر systemctl reset-failed myapp.service العدّاد وحالة الفشل. لا يفعل أي شيء آخر ذلك، ولذلك يستمر رفض systemctl start العادي إلى أن تشغّله. تُحتسب عمليات التشغيل اليدوية أيضاً ضمن الحد، لذا قد تؤدي عدة عمليات systemctl restart متعجلة أثناء تعديل ملف إعداد إلى تجاوز الحد، من دون حدوث أي تعطل فعلي.
الجزء المضلل هو أن start-limit-hit لا يذكر سبب فشل الخدمة. فهو يذكر فقط أنها فشلت مراراً وبسرعة. أما السبب الفعلي فتجده في أسطر السجل السابقة.
ينبغي وضع الإعدادين في قسم [Unit]. ستجد أمثلة تضعهما في [Service]، وقد كان systemd يقبل ذلك في الإصدارات الأقدم، ومن هنا يبدأ الالتباس. اكتب الإعدادين في [Unit]، ثم اطلب من systemd عرض ما حمّله باستخدام systemctl show، لأن القيمة المحمّلة هي الوحيدة التي تُطبّق.
[Unit]
Description=My application
StartLimitIntervalSec=300
StartLimitBurst=5
[Service]
Type=exec
ExecStart=/usr/local/bin/myapp
Restart=on-failure
RestartSec=10sيمنح ذلك الوحدة خمس محاولات خلال نافذة مدتها خمس دقائق قبل أن تتوقف عن المحاولة. يعطّل StartLimitIntervalSec=0 الحد بالكامل. ويجب أن تعرف ما تختاره: الخدمة التي يتعذر تشغيلها ستعيد المحاولة بلا نهاية، وستكتب في السجل عند كل محاولة. توجد القيم الافتراضية على مستوى الجهاز في /etc/systemd/system.conf، وهما DefaultStartLimitIntervalSec= وDefaultStartLimitBurst=.
يوجد إعداد مجاور يستحق التحذير. يحدد StartLimitAction= ما يحدث عند بلوغ الحد، ويقبل قيماً تشمل reboot وreboot-force وpoweroff. القيمة الافتراضية هي none، وهي تفشل الوحدة وتترك الجهاز دون تغيير. على VPS بعيد، يعني poweroff بقاء الجهاز متوقفاً إلى أن تفتح وحدة التحكم لدى مزود الخدمة.
الحل الأول: عملية واحدة لكل وحدة
هذا هو الحل في معظم الحالات. إذا كان يجب تشغيل برنامجين، فاكتب وحدتين. سيكون لكل وحدة بعد ذلك عملية رئيسية فعلية، وحالة خروج فعلية، وسياسة إعادة تشغيل خاصة بها. وستحصل أيضاً على سجلات منفصلة، وحدود موارد منفصلة، وعدادات إعادة تشغيل منفصلة. هذا هو المطلوب في الساعة الثالثة صباحاً.
عبّر عن العلاقة بين الوحدات في ملفات الوحدات، وليس في برنامج shell النصي.
After=يرتّب بدء التشغيل فقط. ولا يحدد شيئاً بشأن حالات الفشل.Requires=يبدأ الوحدة الأخرى بالتوازي مع هذه الوحدة، ويوقف هذه الوحدة إذا أُوقفت الوحدة الأخرى صراحةً.BindsTo=هوRequires=مع إضافة الحالة المهمة: تتوقف هذه الوحدة عندما تتوقف الوحدة الأخرى لأي سبب، بما في ذلك تعطلها. اقرنه معAfter=، وإلا فسيكون ترتيب التشغيل غير محدد.PartOf=يمرّر أوامر الإيقاف وإعادة التشغيل إلى الوحدات التابعة، ولذلك يصلsystemctl restart myapp.targetإلى كل وحدة تكونPartOf=لها.Upholds=(في systemd 249 والإصدارات الأحدث، أي Ubuntu 22.04 والإصدارات الأحدث) يحافظ على تشغيل الوحدة المسماة: فإذا توقفت، يبدأها systemd مرة أخرى. ويخضع ذلك لحد معدل البدء نفسه الذي تخضع له جميع الوحدات الأخرى.
عامل يجب ألا يعمل مطلقاً من دون خادم API، ويحافظ systemd على تشغيله ما دام API قيد التشغيل:
# /etc/systemd/system/myapp-api.service
[Unit]
Description=myapp API server
Wants=network-online.target
After=network-online.target
Upholds=myapp-worker.service
[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp serve
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target# /etc/systemd/system/myapp-worker.service
[Unit]
Description=myapp background worker
BindsTo=myapp-api.service
After=myapp-api.service
StartLimitIntervalSec=120
StartLimitBurst=5
[Service]
Type=exec
User=myapp
ExecStart=/usr/local/bin/myapp worker
Restart=on-failure
RestartSec=5sلا يحتوي العامل على قسم [Install]، ولا يجري تفعيله يدوياً مطلقاً. تستدعيه وحدة API باستخدام Upholds=، ولذلك يكون systemctl enable --now myapp-api.service هو الأمر الوحيد الذي تشغّله. أعد التحميل وتحقق مما أنشأه systemd للوحدتين:
sudo systemctl daemon-reload
systemd-analyze verify /etc/systemd/system/myapp-worker.service
systemctl list-dependencies myapp-api.serviceيطبع systemd-analyze verify لا شيء إطلاقاً عندما يكون الملف سليماً. وأي خرج يعني وجود مشكلة، وعادةً تكون عبارة عن مفتاح لا يتعرف إليه systemd في القسم الذي كتبته فيه، أو تبعية لوحدة غير موجودة.
الإصلاح الثاني: Type=notify، كي يعرف systemd ما هو أبعد من PID
إذا كان البرنامج يتحدث بروتوكول الإشعار الخاص بـsystemd، فاستخدمه. مع Type=notify، تُخبر الخدمة systemd عندما تصبح جاهزة، ما يجعل ترتيب التشغيل فعلياً بدلاً من أن يكون مبنياً على التوقعات، ويمكنها إرسال MAINPID= لتوجيه systemd إلى العملية المهمة بدلاً من عملية التشغيل الأولية.
WatchdogSec= هو الجزء الذي يستحق الجهد. اضبطه، وعندها يجب أن ترسل الخدمة WATCHDOG=1 عبر sd_notify(3) بهذه الوتيرة على الأقل. عندما تتوقف الرسائل، ينهي systemd الخدمة باستخدام SIGABRT ويضع علامة فشل عليها، لذلك يعيد Restart=on-failure أو Restart=on-watchdog تشغيلها. هذه هي الطريقة المضمّنة الوحيدة لإعادة تشغيل عملية ما تزال حية لكنها عالقة؛ إذ لا تستطيع أي سياسة لحالة الخروج اكتشاف ذلك.
[Service]
Type=notify
NotifyAccess=main
ExecStart=/usr/local/bin/myapp serve
WatchdogSec=30s
Restart=on-failure
RestartSec=5sيظهر تجاوز مهلة المراقب في journal على شكل myapp.service: Watchdog timeout (limit 30s)!، ثم عملية القتل. أما إذا بقيت الوحدة في activating (start) حتى تنتهي TimeoutStartSec، فهذا يعني أن READY=1 لم تصل قط: إما أن البرنامج لا يتحدث البروتوكول، أو أن NotifyAccess=main ترفض إشعاراً صدر من عملية فرعية، وهو ما يسجله journal مع رقمي PID معاً.
بالنسبة إلى البرامج التي توفّر نقطة نهاية لفحص الصحة عبر HTTP، لكنها لا تدعم sd_notify، فالخياران الصريحان هما إنشاء وحدة timer صغيرة تفحص نقطة النهاية وتستدعي systemctl restart، أو ترك بيئة تشغيل الحاويات تنفّذ الفحص، وهذا هو الغرض من فحوصات صحة Compose وسلوك إعادة تشغيلها.
الإصلاح الثالث: مشرف داخل الوحدة، فقط عند عدم وجود خيار آخر
تُوزَّع بعض البرمجيات فعلياً على مجموعة من العمليات خلف مشغّل لا يمكنك فصل مكوّناته. عندها تشغّل مشرفاً داخل الوحدة وتقبل النتيجة: يراقب systemd المشرف، ويراقب المشرف جميع العناصر الأخرى، وتصبح سياسة إعادة التشغيل موزعة على ملفين.
الشكل الشائع لذلك هو بيئة تشغيل الحاويات. تتبع وحدة docker compose أو podman هذا النمط تماماً، إذ تُحدَّد سياسة إعادة التشغيل الخاصة بكل حاوية في ملف Compose، بينما تقتصر وحدة systemd على إبقاء بيئة التشغيل قيد العمل. إذا كان هذا هو وضعك، توضّح الوحدة التي تشغّل مكدس Compose عند الإقلاع النسخة العملية، بما في ذلك سبب صحة استخدام Type=oneshot مع RemainAfterExit=yes عادةً في هذه الحالة.
تظل cgroup تعمل لصالحك. فكل ما يشغّله المشرف يبقى داخل cgroup الخاصة بالوحدة، لذلك تغطي MemoryMax= وCPUQuota= وعملية التنظيف عند الإيقاف شجرة العمليات بأكملها. أما قرار إعادة التشغيل وحده فيُفوَّض إلى المشرف.
أيّاً كان المشرف الذي تختاره، لا تضبط Restart=always على الوحدة الخارجية وتضع داخلها سياسة إعادة تشغيل قوية من دون دراسة النتائج. تؤدي طبقتان من منطق إعادة التشغيل، لكل منهما آلية تراجع خاصة بها، إلى خدمة تتوقف وتُعاد محاولتها لدقائق، وإلى سجل journal لا يوضح السبب.
لا يعني ExitType=cgroup «إعادة التشغيل عند توقف أي عملية»
ExitType= (في systemd 250 والإصدارات الأحدث، ولذلك يتوفر في Ubuntu 24.04 وDebian 12) هو الإعداد الذي يجده المستخدمون عند البحث عن هذه المشكلة، لكنه يفعل عكس ما يوحي به اسمه. الإعداد الافتراضي، ExitType=main، يعني أن الخدمة تُعد متوقفة عند خروج العملية الرئيسية. أما ExitType=cgroup فيعني أن الخدمة تُعد قيد التشغيل حتى تخرج آخر عملية في cgroup.
لذلك يجعل ExitType=cgroup الوحدة أقل حساسية لتوقف عملية واحدة، وليس أكثر حساسية. وهو الإعداد المناسب لبرنامج ينشئ عملية العامل الفعلية ثم يخرج من العملية الأب من دون كتابة ملف PID، بحيث لا يستطيع Type=forking العثور على البرنامج الخفي. لكنه الإعداد الخاطئ للفشل الموضح هنا.
لا توجد قيمة Restart= تعني «إعادة تشغيل الوحدة عند توقف أي عملية في cgroup». إذا كنت تحتاج إلى هذا السلوك، فعليك تشغيل عملية واحدة لكل وحدة. وإذا تعذّر تقسيم البرنامج وكنت تتحكم في البرنامج النصي المغلِّف، فأقرب خيار هو wait -n، الذي يعيد التحكم فور خروج أول عملية فرعية:
#!/bin/bash
/usr/local/bin/myapp-web &
/usr/local/bin/myapp-worker &
wait -n
exit 1يؤدي توقف أي عملية فرعية الآن إلى إيقاف البرنامج المغلِّف بحالة خروج غير صفرية، ولذلك يعمل Restart=on-failure. لكن هذا حل توفيقي وليس إصلاحاً. سيبقى لديك عداد إعادة تشغيل واحد لبرنامجين، وتدفق سجلات واحد، ولن تتمكن من إعادة تشغيل الجزء المتعطل بشكل مستقل.
كيفية فحص ما حدث فعلياً
أربعة أوامر، بهذا الترتيب.
systemctl status myapp.service
systemd-cgls --unit myapp.service
systemctl show -p MainPID -p NRestarts -p Result -p ExecMainStatus myapp.service
journalctl -u myapp.service -b -o short-preciseيعرض systemctl status الحالة وPID الرئيسي وشجرة cgroup في شاشة واحدة. تظهر الوحدة السليمة بالقيمة Active: active (running)، مع سطر Main PID: يذكر العملية المتوقعة. إذا كانت الشجرة في الأسفل تعرض عمليات لا تتعرف إليها، أو تفتقد عملية تعرفها، فقد حصلت على الإجابة بالفعل.
يعرض systemd-cgls --unit الشجرة نفسها من دون اقتطاع. ويصبح ذلك مهماً عندما تحتفظ الوحدة بأكثر من عدد قليل من العمليات.
يعرض systemctl show حقائق قابلة للقراءة آلياً. يمثّل NRestarts= عداد إعادة التشغيل، وهو أسرع طريقة لتمييز خدمة أُعيد تشغيلها أربعين مرة عن خدمة تعمل منذ الإقلاع. يحتوي Result= على سبب الفشل الأخير: exit-code أو signal أو timeout أو oom-kill أو watchdog أو start-limit-hit. وتمثّل ExecMainStatus= حالة الخروج الخام لآخر عملية رئيسية.
يحتوي journal على التسلسل الزمني. هذه هي الأسطر الثلاثة التي يجب البحث عنها:
myapp.service: Main process exited, code=exited, status=1/FAILURE
myapp.service: Failed with result 'exit-code'.
myapp.service: Scheduled restart job, restart counter is at 1.تعني code=exited, status=N أن البرنامج اختار إرجاع N، ولذلك يكون الخلل في البرنامج أو في إعداده. وتعني code=killed, signal=SEGV أنه تعطل. وتعني code=killed, signal=TERM عادةً أن جهة أخرى طلبت منه التوقف، وهذا ليس فشلاً ولن يؤدي إلى تشغيل Restart=on-failure. وتعني code=dumped أنه ترك ملف core، وسيعرضه لك coredumpctl list عند تثبيت systemd-coredump.
عند التعامل مع أكثر من جهاز، فإن NRestarts هو الرقم الجدير بجمعه وفق جدول زمني. فالوحدة التي يرتفع عدادها كل يوم تتعطل كل يوم، سواء لاحظ أحد ذلك أم لا. بعد تجاوز جهازين أو ثلاثة، يحوّل أسلوب متسق لتشغيل أمر واحد على كل خادم هذا الأمر من تخمين إلى تقرير.
FAQ
لماذا يقول systemctl إن خدمتي نشطة رغم أن العملية انتهت؟
يتتبّع systemd عملية واحدة لكل وحدة خدمة، وهي العملية الرئيسية، ويقرأ Restart= حالة خروج هذه العملية فقط. كل ما تبدأه الوحدة لاحقاً يبقى في cgroup نفسها، وسيُنهي systemd هذه العمليات عندما تتوقف الوحدة، لكنه لا يراقب خروجها. شغّل systemctl show -p MainPID myapp.service وقارن العدد مع systemd-cgls --unit myapp.service. إذا ظهرت العملية التي انتهت في الشجرة، لكنها ليست MainPID، فقد تصرّف systemd وفق التصميم تماماً. الحل هو تشغيل عملية واحدة لكل وحدة، مع كتابة العلاقة بين الوحدات باستخدام BindsTo= وUpholds=.
ماذا يعني "start request repeated too quickly"؟
يعني ذلك أن الوحدة بدأت أكثر من StartLimitBurst= مرات خلال StartLimitIntervalSec=، والإعداد الافتراضي هو 5 مرات خلال 10 ثوانٍ، لذلك توقف systemd عن المحاولة. هذا حدّ لمعدل إعادة التشغيل، ولا يوضح سبب فشل الخدمة، لذا اقرأ أسطر journal التي تسبقه. امسح الحالة باستخدام systemctl reset-failed myapp.service، ثم أصلح سبب الفشل الأساسي. إذا كانت الخدمة تنتظر مورداً بطيئاً حتى يصبح جاهزاً، فارفع قيمة RestartSec=، لأن الفاصل الافتراضي البالغ 100 مللي ثانية يستهلك المحاولات الخمس كلها خلال أقل من ثانية.
هل أستخدم Restart=always أم Restart=on-failure؟
استخدم on-failure في معظم الحالات. فهو يعيد تشغيل الخدمة بعد crash أو خروج بقيمة غير صفرية أو timeout أو تفعيل watchdog، ولا يتدخل عند exit 0 مقصود. استخدم always فقط عندما يخرج البرنامج بنجاح لأسباب خارجة عن سيطرته، مثل عميل يعيد 0 عند انقطاع الطرف المقابل. تكمن مشكلة always في أن خدمة تقرأ إعداداً تالفاً، وتسجل خطأ واحداً، ثم تخرج بالقيمة 0، ستدخل في حلقة لا نهائية، وسيكون العَرَض الوحيد المرئي هو ارتفاع قيمة NRestarts في systemctl show.
لماذا لا يؤدي إنهاء عمليتي يدوياً إلى إعادة تشغيلها؟
لأن systemd يتعامل مع SIGHUP وSIGINT وSIGTERM وSIGPIPE على أنها عمليات خروج سليمة، ولأن kill <pid> العادي يرسل SIGTERM. عند استخدام Restart=on-failure لا يُعد الخروج السليم فشلاً، لذلك لا يُعاد التشغيل، وقد تبدو الإعدادات معطّلة رغم أنها ليست كذلك. اختبر باستخدام kill -9 <pid> أو systemctl kill -s SIGKILL myapp.service، إذ يؤدي ذلك إلى إنهاء غير سليم ويُفعّل السياسة. وتفسر القاعدة نفسها سبب أن systemctl stop لا يتعارض أبداً مع سياسة إعادة التشغيل.
أين أضع StartLimitIntervalSec وStartLimitBurst؟
ضعهما في قسم [Unit]. تضعهما المواد القديمة وإصدارات systemd القديمة في [Service]، لذلك تختلف الأمثلة المنسوخة عن بعضها. لا تفترض أي قسم تعتمد عليه نسختك. بعد systemctl daemon-reload، اطلب من systemd عرض ما حمّله باستخدام systemctl show -p StartLimitBurst -p StartLimitIntervalUSec myapp.service، وتعامل مع هذه القيم على أنها المرجع الفعلي. يفحص systemd-analyze verify /etc/systemd/system/myapp.service المفاتيح التي لا يتعرّف عليها systemd إطلاقاً، ولا يطبع شيئاً عندما يكون الملف سليماً.