SSD Nodes Learn 🎉 VPS من $5.50/شهر
الأدلة Matt Connorبقلم Matt Connor

لماذا تفشل وحدة systemd؟ اقرأ رمز الخروج

اقرأ systemctl status أولاً: تعرّف إلى معنى 203/EXEC و226/NAMESPACE، وافهم لماذا قد تبدأ الوحدة بنجاح ثم تتوقف بعد ثانية واحدة.

لماذا لا تبدأ وحدة systemd

توضح لك وحدة systemd التي لا تبدأ السبب في حقل واحد. شغّل systemctl status <unit> وابحث عن code= وstatus= في السطر الذي يبلّغ عن الفشل. يعني رقم الحالة في نطاق 200 أن systemd لم يصل إلى برنامجك مطلقاً؛ بل فشل أثناء إعداد البيئة التي طلبها ملف الوحدة. أما رقم الحالة الأقل من 200 فيعني أن برنامجك عمل فعلاً ثم أنهى تشغيله من تلقاء نفسه، ولذلك يُرجَّح أن يكون ملف الوحدة صحيحاً وأن تكون المشكلة في التطبيق.

هذا التقسيم هو مسار اتخاذ القرار. وكل ما يلي مبني عليه، وفق الترتيب الذي تظهر به الأرقام.

ما الأوامر الثلاثة التي تجيب عن السؤال، بالترتيب

systemctl status myapp.service
journalctl -u myapp.service -b --no-pager
systemd-analyze verify /etc/systemd/system/myapp.service

يعطي systemctl status النتيجة الحاسمة. اقرأ سطر Loaded: أولاً، لأنه يذكر الملف الذي حلّله systemd فعلياً، ويبيّن ما إذا كانت الوحدة مفعّلة أو مقنّعة أو غير موجودة أصلاً. ثم اقرأ سطر Active: والزوج code= وstatus= أسفله.

يعطي journalctl -u myapp.service -b --no-pager التفاصيل. يقتصر -u على تلك الوحدة، ويقيّد -b المخرجات بعملية الإقلاع الحالية حتى لا تقرأ فشلاً حدث الأسبوع الماضي، ويطبع --no-pager مباشرةً إلى الطرفية، بحيث يمكنك تمريره إلى grep. يعرض status آخر بضعة أسطر من السجل فقط، ويختصر الأسطر الطويلة. يعرض journal كل ما طبعه البرنامج قبل توقفه، وهذا هو الخطأ الفعلي عادةً. أضف -n 100 لعرض سجل أقدم، أو شغّله مع -f في طرفية ثانية أثناء إعادة تشغيل الوحدة.

يحمّل systemd-analyze verify ملف الوحدة من دون تشغيلها. ويحذّر من الأقسام والتوجيهات غير المعروفة، ويشير إلى الأوامر الموجودة في ExecStart= التي لا يستطيع تنفيذها. يكشف ذلك فئتين شائعتين من الأخطاء الصامتة: مفتاحاً مكتوباً خطأً، يتجاهله systemd عند التحميل مع إصدار تحذير لا يقرأه معظم الأشخاص، ومساراً غير موجود.

بعد تعديل أي ملف وحدة، شغّل sudo systemctl daemon-reload. إلى أن تفعل ذلك، يواصل systemd استخدام النسخة التي حمّلها سابقاً، ويضيف systemctl status تحذيراً يفيد بأن الملف الموجود على القرص تغيّر. غالباً ما يكون الإصلاح الذي «لم يغيّر شيئاً» إصلاحاً لم يقرأه systemd بعد.

هناك أمران إضافيان يستحقان الاستخدام. يطبع systemctl cat myapp.service الوحدة الفعلية، أي الملف الرئيسي مع كل ملفات drop-in الموجودة ضمن /etc/systemd/system/myapp.service.d/. ويطبع systemctl show myapp.service -p ExecStart -p User -p WorkingDirectory تلك القيم وفق الطريقة التي حلّلها بها systemd، وهي القيم التي ستُنفّذ فعلياً.

ماذا يعني status=203/EXEC؟

يشير 203/EXEC إلى أن systemd أنهى الإعداد، واستدعى execve()، لكن النواة رفضت الطلب. لم ينفّذ برنامجك أي سطر من شفرته. تغطي أربعة أسباب معظم الحالات.

  1. المسار الموجود في ExecStart= خاطئ أو ليس مساراً مطلقاً. تحقّق منه باستخدام ls -l وقارنه بالسلسلة النصية المطابقة تماماً في ملف الوحدة.
  2. لا يحتوي الملف على بت التنفيذ. يصلح sudo chmod +x /opt/myapp/run.sh ذلك. غالباً ما يفقد الملف هذا البت عند فكّه من أرشيف أو نسخه من جهاز آخر.
  3. سطر shebang تالف. تقرأ النواة السطر الأول من البرنامج النصي وتشغّل المفسّر المحدد فيه. لذلك يفشل #!/usr/bin/env python3 عندما لا يحتوي PATH الخاص بالخدمة على python3. كما أن الملف المحفوظ بنهايات أسطر Windows يطلب مفسّراً اسمه /bin/bash\r، وهو غير موجود.
  4. الملف ليس من النوع الذي يستطيع هذا الجهاز تشغيله: إما أن بنيته المعمارية خاطئة، أو أنه ملف نصي لا يحتوي على shebang.

أعد إنتاج المشكلة يدوياً، بصفة مستخدم الخدمة، قبل أن تغيّر أي شيء.

sudo -u appuser /opt/myapp/run.sh
file /opt/myapp/run.sh
head -1 /opt/myapp/run.sh | cat -A

يحدّد file البنية المعمارية، ويعرض العبارة "with CRLF line terminators" عندما تكون المشكلة في نهايات الأسطر. يعرض cat -A النتيجة نفسها على شكل ^M في النهاية. أزل هذه المحارف باستخدام sed -i 's/\r$//' /opt/myapp/run.sh.

هناك ملاحظة مهمة بشأن هذا النطاق: الأرقام من 200 فما فوق اصطلاح وليست ضماناً. يمكن لبرنامجك نفسه الخروج بالرمز 203، ولا يستطيع systemd التمييز بين الحالتين. يطبع systemd-analyze exit-status 203 اسم أي رمز وتصنيفه، ما يساعدك على قراءة الجدول. لكن إذا كان تطبيقك يستخدم رموز خروج أكبر من 199، فغيّرها.

لماذا يظهر الخطأ 217/USER أو 216/GROUP؟

يعني 217/USER أن الحساب المذكور في User= غير موجود عند بدء الخدمة. وينطبق الأمر نفسه على 216/GROUP بالنسبة إلى Group= أو SupplementaryGroups=. أكّد ذلك باستخدام أمر واحد لكل حالة.

getent passwd appuser
getent group appgroup

يطبع كل أمر سطراً واحداً، أو لا يطبع شيئاً ويعيد قيمة غير صفرية. تعني النتيجة الفارغة أن الاسم غير معروف للنظام، ولذلك لا يستطيع systemd التبديل إليه ويتوقف قبل تنفيذ العملية. الحل هو إنشاء الحساب، وليس ضبط User=root. إن تشغيل كل خدمة باستخدام حساب نظام مخصص بأقل صلاحيات هو الغرض الأساسي من هذا التوجيه.

sudo useradd --system --no-create-home --shell /usr/sbin/nologin appuser

يتجاوز DynamicUser=yes المشكلة بجعل systemd ينشئ حساباً مؤقتاً عند كل بدء. يناسب ذلك خدمة لا تحتفظ بأي حالة. أما أي خدمة تكتب ملفات فتحتاج إلى StateDirectory= معها، لأن معرّف المستخدم يتغير بين مرات البدء، وتصبح الملفات الموجودة في مسار عادي مملوكة لحساب لم يعد موجوداً.

ما هو 226/NAMESPACE؟

ينتج 226/NAMESPACE عن توجيهات العزل. عندما تضبط الوحدة ProtectSystem= أو ProtectHome= أو PrivateTmp= أو ReadWritePaths= أو أي توجيه مشابه، ينشئ systemd مساحة أسماء mount خاصة بالخدمة قبل أن ينفّذ البرنامج. مساحة الأسماء هنا هي عرض خاص لنظام الملفات لعملية واحدة. إذا فشل أي mount ضمن هذه الخطة، يفشل التشغيل مع الرمز 226، ولا يعمل برنامجك مطلقاً.

السبب المعتاد هو وجود مسار في ReadWritePaths= غير موجود. يجعل ProtectSystem=strict نظام الملفات بالكامل للقراءة فقط، ثم يعيد ReadWritePaths= فتح المسارات المحددة للكتابة. لا يستطيع systemd إعادة فتح دليل غير موجود. يوجد حلان مناسبان. دع systemd ينشئ الدليل باستخدام StateDirectory=، إذ ينشئ /var/lib/<name> عند كل تشغيل ويمنحه إلى مستخدم الخدمة. أو أضف - إلى بداية المسار، لإخبار systemd بتجاهل هذا الإدخال عندما يكون المصدر مفقوداً. أما الحل السيئ فهو حذف إعدادات التقوية الأمنية، لأن ذلك يستبدل مشكلة تستغرق خمس دقائق بمشكلة دائمة.

[Service]
ProtectSystem=strict
ProtectHome=yes
StateDirectory=myapp
ReadWritePaths=-/srv/uploads

عندما لا تستطيع تحديد السطر المسؤول، أزل كتلة التقوية الأمنية بالكامل، ثم أعد التحميل وابدأ الخدمة. إذا بدأت الخدمة، فأعد الأسطر واحداً تلو الآخر وأعد التشغيل بعد كل سطر. يوجد توجيهان قريبان من هذه المجموعة، هما 233/RUNTIME_DIRECTORY و238/STATE_DIRECTORY. ويعنيان أن systemd لم يتمكن من إنشاء الدليل المحدد في RuntimeDirectory= أو StateDirectory=، أو لم يتمكن من تغيير مالكه، ويحدث ذلك عادةً لأن المسار موجود مسبقاً ويملكه مستخدم آخر.

لماذا يظهر 200/CHDIR عندما يبدو أن WorkingDirectory= مضبوط بشكل صحيح؟

200/CHDIR يعني أن chdir() إلى WorkingDirectory= فشل. الدليل غير موجود، أو أن مستخدم الخدمة لا يستطيع دخوله. يتطلب الدخول إلى دليل صلاحية التنفيذ على ذلك الدليل وعلى كل دليل أب أعلى منه. لذلك يتعذر الوصول إلى /home/deploy/app المقروء بشكل صحيح عندما تكون صلاحيات /home/deploy هي 700 وتعمل الخدمة باسم appuser.

sudo -u appuser test -x /srv/myapp && echo ok
namei -l /srv/myapp

يطبع namei -l المالك والصلاحيات لكل مكوّن من مكوّنات المسار. وهذه أسرع طريقة للعثور على الدليل الذي يمنع الوصول إلى بقية المسار. يجعل ضبط WorkingDirectory=-/srv/myapp الدليل المفقود غير قاتل للخدمة. وهذا مناسب لبرنامج لا يهتم بمكان بدء تشغيله، وغير مناسب لبرنامج يفتح الملفات باستخدام مسارات نسبية.

لماذا تبدأ الخدمة ثم تتوقف بعد ثانية واحدة؟

لا يظهر هنا أي رمز من سلسلة 200، وغالباً لا يظهر نص خطأ على الإطلاق. تعرض الوحدة inactive (dead) مباشرة بعد بدء التشغيل، أو تتكرر حالتها بين activating (auto-restart). أنشأ systemd البيئة بصورة صحيحة. المشكلة هي عدم تطابق سلوك البرنامج مع ما وعدت به Type=.

تقول Type=simple، وهي القيمة الافتراضية، إن البرنامج سيبقى في الواجهة الأمامية. إذا شغّلت daemon يتفرع إلى الخلفية ثم ينتهي، فسيرى systemd أن العملية الرئيسية انتهت، ويعتبر الخدمة مكتملة. تتضمن معظم daemons راية للبقاء في الواجهة الأمامية، مثل nginx -g 'daemon off;'.

تقول Type=forking إن العملية الأولى تنتهي بعد أن تصبح العملية الابنة جاهزة. إذا شغّلت برنامجاً يعمل في الواجهة الأمامية، فستنتظر مهمة البدء حتى ينتهي TimeoutStartSec=، ومدته الافتراضية 90 ثانية، ثم يقتله systemd ويسجل انتهاء المهلة.

تقول Type=notify إن البرنامج يستدعي sd_notify() للإعلان عن جاهزيته. لا يعلن البرنامج الذي لا يدعم ذلك شيئاً، لذلك تنتهي مهلة البدء، وتسجل journal النتيجة على أنها فشل في البروتوكول.

اختر النوع وفقاً لما يفعله البرنامج فعلياً. يوضّح الفرق بين الأنواع simple وforking وoneshot وnotify القرار الوحيد الذي يحسم هذه الفئة كاملةً من حالات الفشل.

عندما تنتهي الخدمة مراراً، يتوقف systemd عن المحاولة ويبلغ بأن طلب البدء تكرر بسرعة كبيرة. تبقى الوحدة في حالة فشل حتى تنقضي نافذة تحديد المعدل، أو تشغّل sudo systemctl reset-failed myapp.service. لا تؤدي زيادة الحد إلا إلى إخفاء العَرَض. اقرأ journal بدءاً من أول فشل، لا من آخر فشل، وراجع ما الذي يعيد Restart=on-failure محاولة تشغيله فعلياً قبل تغييره.

لماذا تكون الوحدة غير نشطة من دون أي خطأ؟

قد يتخطى النظام الوحدة بدلاً من تشغيلها. صُممت توجيهات Condition* لتعمل بصمت: عندما يفشل الفحص، يضع systemd المهمة في حالة نجاح ولا يفعل شيئاً. ولن تبدأ الوحدة التي تحتوي على ConditionPathExists=/etc/myapp/config.yml ما دام ذلك الملف مفقوداً، كما أنها لن تبلغ عن أي خطأ.

systemctl show myapp.service -p ConditionResult -p ConditionTimestamp
journalctl -u myapp.service -b --no-pager | grep -i condition

يؤكد ConditionResult=no أن الوحدة تم تخطيها، وتذكر السجلات الفحص الذي لم يتحقق. استخدم توجيه Assert* بدلاً منه عندما يجب أن يؤدي غياب المتطلب الأساسي إلى فشل واضح. يوضح الشروط والتأكيدات وترتيب الوحدات موضع كل فحص مناسب.

توجد حالات صامتة أخرى ذات صلة. تعني رسالة «تعذر العثور عليه» عادةً أن الملف موجود في دليل خاطئ أو أنك لم تُعد تحميل الإعدادات: ضع ملفات الوحدات التي تكتبها في /etc/systemd/system/. ترفض الوحدة المقنّعة كل محاولة تشغيل إلى أن يزيل sudo systemctl unmask myapp.service القناع عنها. ويفشل systemctl enable مع الوحدة التي لا تحتوي على قسم [Install]، لذا أضف إليها WantedBy=multi-user.target.

ماذا لو أُنهيت العملية بدلاً من أن تفشل؟

code=killed تختلف عن code=exited. أنهى شيء ما العملية من خارجها. تشير status=9/KILL إلى قاتل نفاد الذاكرة (OOM)، وتذكر السجلات العملية التي اختارها. ويؤدي الحد الذي تحدده بنفسك إلى النتيجة نفسها داخل cgroup (مجموعة التحكم)، لذلك تحقّق من الذاكرة الحرة على المضيف باستخدام free -m، وتحقّق من الوحدة بحثاً عن MemoryMax=. يوضّح حدود MemoryMax وCPUQuota وحدود cgroup الأخرى أي حد ينهي العملية وأي حد يبطئها فقط.

تعني status=15/TERM مباشرةً بعد محاولة البدء عادةً أن systemd تجاوز مهلة البدء وأنهى العملية، وهذا يعيدك إلى Type=.

عادتان تمنعان معظم هذه الأعطال

استخدم المسارات المطلقة في كل مكان. لا يشغّل systemd صدفة تسجيل الدخول الخاصة بك، لذلك لا يوجد .bashrc ولا .profile ولا بيئة افتراضية مفعّلة. إن $PATH الخاصة بخدمة النظام هي قائمة مضمّنة قصيرة، ولن تتضمن /opt أو أدوات shim الخاصة بمدير إصدارات اللغة. اكتب /usr/bin/python3 أو /opt/myapp/venv/bin/python بالكامل. يطبع command -v myapp في صدفة الأوامر المسار المطلوب نسخه. وتنطبق القاعدة نفسها على WorkingDirectory= وEnvironmentFile= وكل مسار في ReadWritePaths=.

ExecStart= ليست صدفة أوامر. يقسّم systemd السطر إلى كلمات، ويستدعي execve() بنفسه. لا معنى للأنابيب، وإعادة التوجيه، وأنماط glob، و&&، والعلامات الخلفية، و~؛ إذ تصل إلى برنامجك كوسائط حرفية. يمرّر ExecStart=/usr/bin/myapp --flag > /tmp/out.log و> و/tmp/out.log إلى myapp، ثم يخرج الأخير بخطأ استخدام لا يشبه مشكلة في systemd. عندما تحتاج إلى ميزات الصدفة، اطلب تشغيل صدفة.

ExecStart=/bin/sh -c '/usr/bin/myapp --flag | /usr/bin/tee -a /var/log/myapp.log'

لا تحتاج إلى ذلك عند التعامل مع المخرجات فقط. تُرسل مخرجات الخدمة إلى journal افتراضياً، ويكتب StandardOutput=append:/var/log/myapp.log إلى ملف من دون استخدام صدفة.

ويقتصر توسيع المتغيرات بالطريقة نفسها. يُستبدل $MYVAR و${MYVAR} بالقيم من Environment= وEnvironmentFile=، ولا يُوسَّع أي شيء آخر. لا يكون $HOME مضبوطاً لخدمة النظام ما لم تضبطه أنت. كما أن EnvironmentFile= ليس نصاً برمجياً لصدفة الأوامر: لا ينتمي export إليه، وتختلف قواعد الاقتباس فيه عن bash، ويؤدي غياب الملف إلى فشل نهائي ما لم تسبق المسار بـ-.

العمل على خادم قيد التشغيل

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

FAQ

ماذا يعني status=203/EXEC في systemctl status؟

أعدّ systemd كل ما طلبته الوحدة، ثم فشل استدعاء execve()، لذلك لم يبدأ برنامجك. تحقّق من أربعة أمور بالترتيب: أن المسار في ExecStart= موجود ومطلق، وأن الملف يحمل بت التنفيذ، وأن shebang يحدد مفسراً موجوداً في PATH الخاص بالخدمة، وأن الملف يستخدم نهايات أسطر Unix. يعرض file العبارة "with CRLF line terminators" للحالة الأخيرة، ما يحوّل اسم المفسر إلى /bin/bash\r ويجعل kernel يرفضه.

لماذا تبدأ خدمتي ثم تتوقف فوراً؟

يعد ملف الوحدة بسلوك لا يطبقه البرنامج. عند استخدام Type=simple، يتوقع systemd أن يبقى البرنامج في المقدمة، لذلك يبدو daemon الذي ينشئ عملية في الخلفية منتهياً فور إنشائه لها. عند استخدام Type=forking، ينتظر systemd انتهاء العملية الأولى، لذلك يجعل البرنامج الذي يعمل في المقدمة مهمة البدء معلّقة إلى أن تنتهي مهلة TimeoutStartSec=. طابق Type= مع البرنامج، وعندما يوفّر البرنامج خياراً للعمل في المقدمة، استخدم هذا الخيار مع القيمة الافتراضية Type=simple.

كيف أرى الخطأ الفعلي بدلاً من ناتج الحالة المختصر؟

يعرض systemctl status أسطر journal القليلة الأخيرة فقط، ويختصر الأسطر الطويلة. شغّل journalctl -u myapp.service -b --no-pager لعرض كل ما سجلته الوحدة أثناء عملية الإقلاع الحالية، وأضف -n 200 لعرض نافذة أكبر، أو مرّر الناتج إلى grep. إذا كان التطبيق يكتب ملف سجل خاصاً به، فاقرأه أيضاً، لأن systemd يلتقط فقط ما يرسله البرنامج إلى standard output وstandard error.

لماذا تكون وحدتي غير نشطة من دون رسالة خطأ؟

في أغلب الحالات، تخطّاها توجيه Condition*. تكون هذه الفحوصات صامتة؛ إذ يضع فشل الشرط مهمة البدء في حالة نجاح. شغّل systemctl show myapp.service -p ConditionResult وابحث عن ConditionResult=no، ثم اقرأ سطر journal الذي يذكر الفحص. والسبب الشائع الآخر هو أن الوحدة محجوبة، فهي ترفض كل محاولة بدء إلى أن يزيل sudo systemctl unmask الحجب عنها.

هل أحتاج إلى daemon-reload بعد كل تغيير في ملف الوحدة؟

نعم، عند تعديل ملف وحدة أو drop-in. يجعل sudo systemctl daemon-reload systemd يعيد قراءة الملفات من القرص، ثم يطبّق sudo systemctl restart myapp.service التغييرات على الخدمة قيد التشغيل. لا تحتاج إليه بعد systemctl edit، لأن هذا الأمر يعيد التحميل نيابةً عنك، ولا تحتاج إليه بعد تغيير ملف إعداد يخص التطبيق لا systemd.

#systemd#troubleshooting#journalctl#exit-codes#linux-fundamentals