Linux من الصفر إلى السيرفر الأدلة التقنية

استكشاف أعطال السيرفر Troubleshooting: خمس حوادث حقيقية

نعطل خدمة على سيرفر Linux خمس مرات بأسباب تحدث كل يوم، ونتبع في كل مرة نفس الأسئلة الستة حتى نصل إلى السبب برقمه، ونرى أين تسكت الأدوات أو تخدعك.

استكشاف أعطال السيرفر Troubleshooting: خمس حوادث حقيقية

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

وفي هذا الدليل سوف نأخذ نفس واجهة الطلبات التي بنيناها خدمة systemd في مقال إدارة الخدمات والسجلات مع systemd، ونعطلها خمس مرات بخمسة أسباب حقيقية تحدث على أي سيرفر: صلاحيات خاطئة بعد نشر جديد، ومنفذ يأخذه برنامج آخر، وقرص بيانات ممتلئ، وتسرب في الذاكرة، وخيط يستهلك المعالج. وفي كل مرة نسير على نفس الأسئلة الستة بالترتيب، ونقرأ كل مخرج سطراً سطراً، ونرى أين تكذب الأدوات أو تسكت. ولاحظ الفرق بين هذا المقال ومقال تحليل أداء سيرفر Linux في 60 ثانية: ذاك يبدأ من سيرفر بطيء ويبحث عن المورد المستهلك، أما هنا فنبدأ من خدمة معطلة أو تعطي أخطاء، ونبحث عن السبب.

وسوف نناقش في هذا المقال ما يلي:

  • لماذا تحتاج طريقة Method وليس حدساً، وما الأسئلة الستة التي تسير عليها في كل عطل.
  • أين يمكن أن يتعطل الطلب بين المتصفح والقرص، وكيف تقسم المسار لتجد المكان بسرعة.
  • خمس حوادث حقيقية في المختبر: الصلاحيات Permissions، والمنفذ المشغول Port Conflict، والقرص الممتلئ Disk Full، وتسرب الذاكرة Memory Leak، والمعالج المشغول CPU.
  • ثلاث مشاكل واجهتنا في الأدوات نفسها: أمر لم يعرض صاحب المنفذ، وسجل لم يعرض الخطأ، وحد ذاكرة لم يقتل أحداً.
  • رموز الخطأ Errno ورموز الخروج التي تقودك إلى السبب مباشرة.
  • الإصلاح المؤقت والإصلاح الدائم والمراجعة بعد الحادثة، ثم الأخطاء الشائعة وقائمة الدقائق الخمس الأولى.

لماذا تحتاج طريقة Method وليس حدساً؟

الحدس مفيد عندما تعرف النظام جيداً، ولكنه يخذلك في العطل الأول من نوعه، وهو غالباً العطل الذي يوقظك في الليل. والطريقة البسيطة التي يتبعها كثيرون هي تجربة الحلول المعروفة واحداً بعد الآخر: إعادة تشغيل الخدمة، ثم السيرفر، ثم التراجع عن آخر نشر، ثم زيادة الذاكرة. وهذه الطريقة تنجح أحياناً، ولكنها تفشل بثلاث طرق: فهي تمسح الدليل، لأن إعادة التشغيل تمسح حالة العمليات والذاكرة، وتخفي السبب، لأن العطل يعود بعد ساعة كما في القصة، وتضيع الوقت في حلول لا علاقة لها بالمشكلة. ويصف كتاب Google عن هندسة الاعتمادية في فصل التشخيص الفعال التشخيص بأنه دورة: تلاحظ، ثم تضع فرضية عن السبب، ثم تختبرها بأمر يمكن أن يثبتها أو ينفيها، ثم تعدل الفرضية، ويذكر أن أكثر الجلسات الفاشلة تتعثر في الفرز والفحص والتشخيص بسبب نقص الفهم العميق للنظام.

والترجمة العملية لهذه الدورة على سيرفر Linux ستة أسئلة بالترتيب، تبدأ من الخدمة نفسها ثم تتسع إلى ما حولها، ولكل سؤال أمر يجيب عنه في ثوان:

ستة أسئلة بالترتيب: هل تعمل الخدمة مع systemctl status، هل تستمع على المنفذ مع ss -ltnp، هل ترد من السيرفر نفسه مع curl، ماذا يقول السجل مع journalctl، هل نفدت الموارد مع df وfree وtop، ماذا تغير مع find /etc -newermt، وتحتها تنبيه: قبل أي إصلاح انسخ ما رأيته واكتب الوقت
لا تنتقل إلى السؤال التالي قبل أن تعرف إجابة الذي قبله
  1. هل تعمل الخدمة؟ الأمر systemctl status يقول هل العملية حية، ومنذ متى، وكم مرة أعيد تشغيلها.
  2. هل تستمع على المنفذ؟ الأمر ss -ltnp يقول هل يوجد من يستمع، وعلى أي عنوان، ومن هو.
  3. هل ترد من السيرفر نفسه؟ الأمر curl إلى localhost يتجاوز الشبكة والـ Proxy والجدار الناري، فإذا ردت هنا فالمشكلة قبلها.
  4. ماذا يقول السجل؟ الأمر journalctl للخدمة في آخر ربع ساعة، وابحث عن آخر خطأ ورقمه.
  5. هل نفدت الموارد؟ القرص بالمساحة والـ Inodes، والذاكرة، والمعالج.
  6. ماذا تغير؟ أغلب الأعطال تأتي بعد تغيير: نشر جديد، أو إعداد، أو تحديث، أو إعادة إقلاع.

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

الفرز Triage: هل تعيد الخدمة أولاً أم تبحث عن السبب؟

قد يتساءل البعض: إذا كان العملاء لا يستطيعون الشراء الآن، أليس الأهم أن تعود الخدمة بأي طريقة، ثم نبحث عن السبب؟ والإجابة نعم في أغلب الحالات، فإعادة الخدمة للعملاء تأتي قبل فهم السبب، وهذا ما يسميه فصل التشخيص في كتاب Google الفرز Triage: أن تقدر حجم الضرر وتوقف النزيف قبل أن تبحث عن الجرح. ولكن بين «أعد الخدمة» و«امسح الدليل» دقيقة واحدة تستحق أن تأخذها، وهي أن تحفظ مخرجات الأسئلة الستة في ملف قبل أي إصلاح. فالأمر systemctl status وآخر مئتي سطر من السجل ومخرج ss وdf وfree وtop تأخذ ثوان، ثم تعيد التشغيل وأنت تعرف ماذا كانت الحالة قبله. وخيارات الإصلاح المؤقت ليست إعادة التشغيل وحدها، فقد يكون التراجع عن آخر نشر Rollback أسرع وأسلم إذا بدأ العطل بعده مباشرة، وقد يكون تحويل الحركة إلى سيرفر آخر، أو صفحة صيانة تمنع العملاء من إدخال طلبات تضيع. واختر الإصلاح الذي يمكن الرجوع عنه، لأنك في هذه اللحظة لا تعرف السبب بعد، وأي خطوة لا رجوع عنها، مثل حذف ملفات لتفريغ القرص، قد تكون هي الخطأ الثاني في نفس الليلة.

تجهيز المختبر Lab Setup: خمس طرق لتعطيل نفس الخدمة

نستخدم نفس الطريقة التي شرحناها في مقال systemd: Container من Debian 13 يعمل فيه systemd كالعملية رقم 1، ونفس الـ Image. والإضافة هنا قرص بيانات صغير بحجم 30 MiB نمثله بنظام ملفات في الذاكرة tmpfs مركب على /var/lib/orders، حتى نملأه في ثوان:

#!/bin/sh
# systemd as PID 1, with a 30 MiB tmpfs as the small data disk of the orders service
set -e
docker run -d --name sbl-ts --tmpfs /run --tmpfs /run/lock --tmpfs /tmp \
  --tmpfs /var/lib/orders:size=30m,mode=0755 \
  -v /sys/fs/cgroup:/sys/fs/cgroup:rw --cgroupns=host -p 19361:8080 sbl-systemd:1
sleep 3
docker exec sbl-ts mkdir -p /opt/orders
docker cp app.py sbl-ts:/opt/orders/app.py
docker cp orders.service sbl-ts:/etc/systemd/system/orders.service
docker exec sbl-ts useradd --system --no-create-home --shell /usr/sbin/nologin orders
docker exec sbl-ts systemctl daemon-reload
docker exec sbl-ts systemctl enable --now orders

وملف الخدمة يضيف إلى ما رأيناه سابقاً حداً للذاكرة MemoryMax=80M، ومكان البيانات في متغير:

[Unit]
Description=Orders API
Wants=network-online.target
After=network-online.target

[Service]
User=orders
Group=orders
Environment=PYTHONUNBUFFERED=1
Environment=DATA_DIR=/var/lib/orders
ExecStart=/usr/bin/python3 /opt/orders/app.py
Restart=on-failure
RestartSec=2
MemoryMax=80M

[Install]
WantedBy=multi-user.target

والتطبيق واجهة صغيرة فيها مسار /order يحفظ فاتورة بحجم 1 MiB في مجلد البيانات، ومسار /leak يضيف 10 MiB إلى ذاكرة مؤقتة لا تفرغ أبداً كما يفعل أي Cache بلا حد، ومسار /spin يبدأ خيطاً خلفياً لا ينتهي كما يفعل تقرير فيه حلقة خاطئة. وكل خطأ يكتبه التطبيق برقمه من النواة:

        if self.path == "/order":
            counter += 1
            path = os.path.join(DATA_DIR, f"order-{counter:05d}.bin")
            try:
                with open(path, "wb") as f:
                    f.write(os.urandom(1 << 20))      # 1 MiB invoice
                print(f"<6>saved {path}")
                return self.reply(201, "saved")
            except OSError as e:
                print(f"<3>cannot save {path}: [Errno {e.errno}] {e.strerror}")
                return self.reply(500, "error")

والملف كاملاً في مجلد المختبر lab/app.py. ولاحظ أن التطبيق يكتب رقم الخطأ e.errno وليس رسالة عامة مثل «حدث خطأ»، وهذا السطر وحده سوف يختصر نصف الوقت في الحوادث القادمة، وقد شرحنا لماذا في مقال سجلات تساعدك فعلاً.

أين يمكن أن يتعطل الطلب Request؟ من المتصفح إلى القرص

عبارة «الموقع لا يعمل» تصف ما يراه العميل، ولا تقول أين العطل. فالطلب يمر من المتصفح إلى DNS الذي يحول الاسم إلى عنوان، ثم إلى الجدار الناري Firewall، ثم إلى الـ Reverse Proxy مثل nginx، ثم إلى الخدمة، ثم إلى القرص والذاكرة تحتها، وكل محطة يمكن أن تكون السبب. وأسرع طريقة أن تقسم المسار إلى نصفين، كما تفعل في البحث الثنائي: اسأل الخدمة من داخل السيرفر نفسه، فإذا ردت بشكل صحيح فالمشكلة في النصف الخارجي، وإذا لم ترد فهي في الخدمة أو تحتها:

مسار الطلب من العميل إلى DNS إلى الجدار الناري إلى الـ Reverse Proxy إلى orders.service إلى القرص والذاكرة، مقسوماً إلى نصفين: من داخل السيرفر بالأمر curl localhost:8080 ومن الخارج بالأمر curl https://example.com، وتحته أرقام المختبر: 200 في 0.0007 ثانية من الداخل و200 في 0.52 ثانية عبر المنفذ المنشور، ومنفذ لا يستمع عليه أحد أعطى curl exit 7
اسأل الخدمة من داخل السيرفر أولاً، فتعرف في أي نصف تبحث

وفي المختبر جربنا النصفين بعد أن انتهينا من الحوادث، من داخل الـ Container ومن الجهاز عبر المنفذ المنشور 19361، ثم إلى منفذ لا يستمع عليه أحد:

+ curl from the host through the published port 19361
orders ok pid=357
200 in 0.519023s
+ a port nothing listens on
curl exit 7

والطلب من الداخل أخذ أقل من ملي ثانية واحدة، بينما أخذ نفس الطلب من الخارج نصف ثانية، والفرق كله في طريق Docker Desktop بين Windows والجهاز الافتراضي، وهذا مثال على أن زمن الاستجابة Latency الذي يراه العميل قد لا تكون للخدمة علاقة به. أما الرمز 7 من curl فمعناه في قائمة أخطاء curl أنه لم يستطع الاتصال، أي لا أحد يستمع، وهذا نفس ما يراه nginx عندما يكتب للعميل خطأ 502 Bad Gateway، فإذا رأيت 502 فابدأ من السؤال الأول: هل تعمل الخدمة خلف الـ Proxy؟

إذا ردت الخدمة من الداخل: DNS والجدار الناري والـ Proxy

عندما ترد الخدمة بشكل صحيح من داخل السيرفر ويستمر العملاء في رؤية الخطأ، فالبحث ينتقل إلى المحطات قبلها، وبنفس طريقة التقسيم: اسأل من السيرفر نفسه عبر الـ Proxy، ثم من جهاز آخر في نفس الشبكة، ثم من خارجها. وكل محطة لها علامة تعرفها بها. فإذا كان الاسم لا يتحول إلى العنوان الصحيح، أو يتحول إلى عنوان قديم بعد نقل السيرفر، فالمشكلة في DNS، والأمر dig example.com من جهازك ومن السيرفر يقول ذلك، وقد يختلف الجواب بينهما بسبب الذاكرة المؤقتة للأسماء. وإذا كان الاتصال يتوقف حتى ينتهي الوقت دون أي رد فهذا غالباً جدار ناري يسقط الحزم بصمت، بينما الرفض الفوري يعني أن الحزمة وصلت ولا أحد يستمع. وإذا رد الـ Proxy نفسه بخطأ فرقمه يقول الكثير: الرمز 502 Bad Gateway يعني أن nginx لم يستطع الاتصال بالخدمة أو انقطع الاتصال، وهذا ما يقابل curl exit 7 وcurl exit 52 في مختبرنا، أما الرمز 504 Gateway Timeout فيعني أن الاتصال نجح ولكن الخدمة لم ترسل شيئاً خلال المهلة، وهي المهلة التي يضبطها الخيار proxy_read_timeout كما في توثيق وحدة الـ Proxy في nginx. فالأول يوجهك إلى السؤال الأول، هل الخدمة حية، والثاني يوجهك إلى السؤال الخامس، هل الخدمة مشغولة أو تنتظر شيئاً. وفي الحالتين يكتب nginx التفاصيل في سجل الأخطاء الخاص به، ومعها العنوان الذي حاول الاتصال به، وهذا يكشف أيضاً الحالة الشائعة التي تستمع فيها الخدمة على 127.0.0.1 بينما يحاول الـ Proxy الاتصال بها من Container آخر أو من عنوان آخر.

الحادثة 1: Permission Denied بعد نشر جديد

بعد تشغيل الخدمة لأول مرة على القرص الجديد بدأ العملاء يرون رسالة خطأ عند كل طلب، بينما الصفحة الرئيسية تعمل. ونسير على الأسئلة:

systemctl is-active orders
ss -ltnp | grep 8080
curl -s -o /dev/null -w "%{http_code} in %{time_total}s\n" localhost:8080/order
journalctl -u orders -p warning --since "-5min" --no-pager -o short-iso
active
LISTEN 0      5            0.0.0.0:8080      0.0.0.0:*
500 in 0.001254s
2026-10-30T19:02:40+00:00 1175e57a71ba python3[96]: cannot save /var/lib/orders/order-00001.bin: [Errno 13] Permission denied
2026-10-30T19:02:54+00:00 1175e57a71ba python3[96]: cannot save /var/lib/orders/order-00002.bin: [Errno 13] Permission denied

والأسئلة الثلاثة الأولى أخذت ثانيتين: الخدمة تعمل، وتستمع على كل العناوين 0.0.0.0، وترد على الطلب من داخل السيرفر في ملي ثانية واحدة، ولكن الرد 500. فالمشكلة ليست في الشبكة ولا في الـ Proxy، وإنما في الخدمة نفسها، والسجل يقول السبب برقمه: Errno 13 وهو EACCES كما في صفحة errno(3)، أي أن النواة رفضت فتح الملف للكتابة. ولكن لماذا؟ الأداة namei تعرض صلاحيات كل مجلد في المسار، لأن المشكلة قد تكون في أي مستوى منه وليس في المجلد الأخير فقط:

namei -l /var/lib/orders/order-00002.bin
id orders
f: /var/lib/orders/order-00002.bin
drwxr-xr-x root root /
drwxr-xr-x root root var
drwxr-xr-x root root lib
drwxr-xr-x root root orders
                      order-00002.bin - No such file or directory
uid=996(orders) gid=996(orders) groups=996(orders)

المجلد orders يملكه root بصلاحيات rwxr-xr-x، أي أن غير root يستطيع القراءة والدخول ولا يستطيع الكتابة، والخدمة تعمل بالمستخدم orders رقم 996. والسطر الأخير No such file or directory طبيعي، لأن الملف لم ينشأ أصلاً. وهذا من أكثر الأعطال بعد النشر، فالمجلد أنشأه سكربت التثبيت أو القرص الجديد بالمالك root، والتطبيق لم يعد يعمل بـ root كما كان. والإصلاح:

chown orders:orders /var/lib/orders
curl -s -o /dev/null -w "%{http_code}\n" localhost:8080/order
201

والإصلاح الدائم ألا يعتمد المجلد على تذكر أحد، فالخيار StateDirectory=orders في ملف الخدمة يجعل systemd ينشئ /var/lib/orders بمالك الخدمة عند كل بدء، كما في صفحة systemd.exec(5). وتفاصيل الصلاحيات نفسها في مقال صلاحيات الملفات Permissions في Linux.

مشكلة واجهتنا: الأمر ss لم يعرض صاحب المنفذ

ارجع إلى مخرج ss -ltnp أعلاه، فالخيار -p يطلب اسم العملية التي تملك المنفذ، ومع ذلك جاء العمود الأخير فارغاً. والسبب أن ss يعرف صاحب كل اتصال بقراءة روابط الملفات المفتوحة في /proc/PID/fd، وصفحة proc_pid_fd(5) تقول إن قراءة هذه الروابط تخضع لفحص صلاحية من نوع ptrace. وroot يتجاوز هذا الفحص بالصلاحية CAP_SYS_PTRACE، ولكن Docker لا يعطيها للـ Container افتراضياً، وقد تأكدنا من ذلك بقراءة صلاحيات العملية:

ls /proc/166/fd
0
1
2
3
readlink /proc/166/fd/3
exit 1

القائمة نفسها ظهرت، ولكن قراءة الرابط فشلت، فلم يعرف ss أن الملف رقم 3 هو المقبس الذي يستمع على 8080. وفي الحادثة التالية سوف ترى أن نفس الأمر عرض صاحب المنفذ عندما كان يعمل بنفس المستخدم root. وعلى سيرفر عادي تعمل فيه كـ root لن تواجه هذا، ولكنك ستواجهه في كل Container وفي أي أداة مراقبة تعمل بصلاحيات محدودة، والبديل عندها systemctl status الذي يعطيك رقم العملية الرئيسية للخدمة دون أي صلاحية خاصة.

الحادثة 2: المنفذ مشغول Port Conflict

أثناء تجربة سريعة شغل أحد الزملاء سيرفر ملفات مؤقتاً بالأمر python3 -m http.server 8080 ونسيه، وبعد ساعة أعيد تشغيل الخدمة مع نشر جديد فلم تعد. والأسئلة:

systemctl status orders --no-pager -n 0
journalctl -u orders --no-pager -o short-iso -n 12
● orders.service - Orders API
     Loaded: loaded (/etc/systemd/system/orders.service; enabled; preset: enabled)
     Active: activating (auto-restart) (Result: exit-code) since Fri 2026-10-30 19:03:19 UTC; 254ms ago
 Invocation: f8910668af3748be93347e215a3c5ade
    Process: 138 ExecStart=/usr/bin/python3 /opt/orders/app.py (code=exited, status=1/FAILURE)
   Main PID: 138 (code=exited, status=1/FAILURE)
2026-10-30T19:03:19+00:00 1175e57a71ba python3[138]:   File "/usr/lib/python3.13/socketserver.py", line 478, in server_bind
2026-10-30T19:03:19+00:00 1175e57a71ba python3[138]:     self.socket.bind(self.server_address)
2026-10-30T19:03:19+00:00 1175e57a71ba python3[138]:     ~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^
2026-10-30T19:03:19+00:00 1175e57a71ba python3[138]: OSError: [Errno 98] Address already in use
2026-10-30T19:03:19+00:00 1175e57a71ba systemd[1]: orders.service: Main process exited, code=exited, status=1/FAILURE
2026-10-30T19:03:19+00:00 1175e57a71ba systemd[1]: orders.service: Failed with result 'exit-code'.

هذه المرة الإجابة على السؤال الأول «لا»: الحالة activating (auto-restart) والعملية خرجت برمز 1، أي أن الخدمة تبدأ وتموت وsystemd يعيدها كل ثانيتين. وآخر سطر من تتبع الخطأ Traceback يقول السبب: Errno 98 وهو EADDRINUSE، أي أن المنفذ مأخوذ. ومن يأخذه؟

ss -ltnp "sport = :8080"
ps -o pid,user,lstart,cmd -p 130
State  Recv-Q Send-Q Local Address:Port Peer Address:PortProcess
LISTEN 0      5            0.0.0.0:8080      0.0.0.0:*    users:(("python3",pid=130,fd=3))
  PID USER                      STARTED CMD
  130 root     Fri Oct 30 19:03:12 2026 python3 -m http.server 8080

هنا عرض ss صاحب المنفذ لأن العملية تعمل بالمستخدم root مثلنا: العملية 130، وهي http.server، بدأت قبل الخدمة بسبع ثوان. والإصلاح أن نوقفها، ولم نحتج إلى تشغيل الخدمة بأنفسنا لأن systemd كان ما زال يحاول:

+ kill 130
+ systemctl is-active orders
active
+ systemctl show orders -p NRestarts -p MainPID
MainPID=166
NRestarts=10

والعداد NRestarts=10 يقول إن الخدمة حاولت عشر مرات قبل أن نصل. وقد شرحنا في مقال systemd لماذا لا يتوقف systemd عن المحاولة مع RestartSec=2، وهنا كان ذلك مفيداً، فبمجرد أن تحرر المنفذ عادت الخدمة وحدها:

سيرفر تجربة python3 -m http.server يعمل بالمستخدم root برقم 130 يملك المنفذ 8080، والخدمة orders.service لا تستطيع الوصول إليه فتخرج بالخطأ Errno 98 Address already in use ويعيدها systemd، وبعد إيقاف العملية 130 بدأت الخدمة وحدها وكان العداد NRestarts=10
رقم الخطأ 98 يقول إن المنفذ مأخوذ، والأمر ss يقول من أخذه

مشكلة واجهتنا: journalctl -p err لم يعرض الخطأ

في نهاية المختبر طلبنا كل الأخطاء منذ الإقلاع بالأمر journalctl -p err -b، لنكتب ملخص الليلة:

2026-10-30T19:02:40+00:00 1175e57a71ba python3[96]: cannot save /var/lib/orders/order-00001.bin: [Errno 13] Permission denied
2026-10-30T19:02:54+00:00 1175e57a71ba python3[96]: cannot save /var/lib/orders/order-00002.bin: [Errno 13] Permission denied
2026-10-30T19:04:10+00:00 1175e57a71ba python3[166]: cannot save /var/lib/orders/order-00031.bin: [Errno 28] No space left on device
2026-10-30T19:04:10+00:00 1175e57a71ba python3[166]: cannot save /var/lib/orders/order-00032.bin: [Errno 28] No space left on device

ظهرت الأخطاء التي كتبها التطبيق بنفسه بالبادئة <3>، أما Address already in use، وهو الخطأ الذي أوقف الخدمة تماماً، فلم يظهر إطلاقاً. والسبب أن Python كتب تتبع الخطأ على مخرج الأخطاء stderr دون أي مستوى، وjournald يعطي كل سطر بلا مستوى المستوى 6 وهو info، فلا يظهر مع -p err. فالخطأ الأخطر في الليلة كان أقل السطور أهمية في نظر السجل. ولذلك لا تعتمد على -p err وحده في التشخيص، واقرأ سجل الخدمة كاملاً في نافذة الحادثة، وفي تطبيقك اجعل معالج الأخطاء العام يكتب الأخطاء غير المتوقعة بمستوى err قبل أن يخرج.

الحادثة 3: القرص الممتلئ Disk Full

بعد ساعات من العمل الطبيعي بدأت الطلبات تفشل مرة أخرى. والسكربت أرسل 32 طلب حفظ متتالياً، ثم سار على الأسئلة:

201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 201 500 500
+ systemctl is-active orders
active
+ journalctl -u orders -p err --since -1min --no-pager -o short-iso
2026-10-30T19:04:10+00:00 1175e57a71ba python3[166]: cannot save /var/lib/orders/order-00031.bin: [Errno 28] No space left on device
2026-10-30T19:04:10+00:00 1175e57a71ba python3[166]: cannot save /var/lib/orders/order-00032.bin: [Errno 28] No space left on device
+ df -h /var/lib/orders
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            30M   30M     0 100% /var/lib/orders
+ df -i /var/lib/orders
Filesystem      Inodes IUsed   IFree IUse% Mounted on
tmpfs          4100609    33 4100576    1% /var/lib/orders
+ du -sh /var/lib/orders
30M	/var/lib/orders

في المخرج أعلاه لاحظ التالي:

  • ثلاثون طلباً نجحت ثم فشل كل ما بعدها، والخدمة active طوال الوقت، فلم يكن أي شيء في systemd سينبهك.
  • Errno 28 وهو ENOSPC، ومعناه أن نظام الملفات لا يستطيع الكتابة، والسبب إما المساحة أو الـ Inodes، ولهذا سألنا df عن الاثنين.
  • المساحة 100% والـ Inodes 1%، فالمشكلة مساحة، وdu يتفق مع df على 30M، أي لا توجد ملفات مخفية ولا ملفات محذوفة مفتوحة. ولو اختلفا لكان ذلك دليلاً على إحدى الحالتين اللتين شرحناهما في مقال الأقراص في Linux.

وقبل أن نحذف شيئاً نظرنا إلى الملفات الأخيرة، فوجدنا شيئاً لم نتوقعه:

-rw-r--r-- 1 orders orders 1048576 Oct 30 19:04 /var/lib/orders/order-00030.bin
-rw-r--r-- 1 orders orders       0 Oct 30 19:04 /var/lib/orders/order-00031.bin
-rw-r--r-- 1 orders orders       0 Oct 30 19:04 /var/lib/orders/order-00032.bin

الطلبان الفاشلان تركا ملفين بحجم صفر، لأن فتح الملف نجح والكتابة هي التي فشلت، والكود لم يحذف الملف بعد الفشل. وهذه فواتير «موجودة» في نظر أي برنامج يبحث عن الاسم، وفارغة في الحقيقة، وهي مشكلة أخطر من القرص الممتلئ نفسه لأنها تبقى بعد إصلاحه. ولهذا يكتب التطبيق الجيد إلى ملف مؤقت ثم يعيد تسميته بعد نجاح الكتابة، كما شرحنا في قسم الاستبدال الآمن من مقال نظام الملفات من الداخل. والإصلاح المؤقت أن ننقل الفواتير القديمة إلى أرشيف ونحذف الملفين الفارغين:

+ mv /var/lib/orders/order-00001.bin ... /var/lib/orders/order-00019.bin /root/archive/
+ rm -f /var/lib/orders/order-00031.bin /var/lib/orders/order-00032.bin
+ df -h /var/lib/orders
Filesystem      Size  Used Avail Use% Mounted on
tmpfs            30M   11M   19M  37% /var/lib/orders
+ curl -s -w ' %{http_code}\n' localhost:8080/order
saved
 201

والإصلاح الدائم ثلاثة أشياء: تنبيه على المساحة قبل أن تصل إلى 100% وليس بعدها، وسياسة واضحة لما يبقى على القرص وما ينقل إلى التخزين الدائم مثل Object Storage، وقرص أكبر إذا كانت هذه الأرقام نمواً طبيعياً. ولاحظ هنا ما تبينه الصورة التالية، فالحادثتان الأولى والثالثة متشابهتان من الخارج:

ما يقوله systemd: active running، والمنفذ 8080 يستمع، والصفحة الرئيسية ترد بـ 200، بينما في الحادثة الأولى أعاد طلب الحفظ 500 بالخطأ Errno 13 Permission denied، وفي الحادثة الثالثة 500 بالخطأ Errno 28 No space left on device، وإلى اليسار ما يجب مراقبته: طلب حقيقي من البداية للنهاية ونسبة الأخطاء 5xx والمساحة والذاكرة قبل 100%
الحالة تقول إن العملية حية، والطلب الحقيقي يقول هل تعمل

ففي الحالتين كانت الخدمة حية وتستمع وترد على الصفحة الرئيسية، وأي مراقبة تسأل «هل العملية تعمل؟» أو «هل الصفحة ترد بـ 200؟» كانت ستقول إن كل شيء بخير. والمراقبة التي تكتشف هذه الأعطال تقيس ما يهم العميل: نسبة الطلبات التي تنتهي بخطأ، وطلب اختبار يمر بنفس طريق الطلب الحقيقي، وقد شرحنا هذا الفرق في مقال مفاهيم المراقبة Observability.

لماذا لم ينقذنا systemd هنا؟

في الحادثة الثانية أعاد systemd الخدمة وحده بمجرد أن تحرر المنفذ، أما هنا فلم يفعل شيئاً، وهذا متوقع: الخيار Restart=on-failure يعمل عندما تخرج العملية، والعملية هنا لم تخرج، وإنما بقيت حية تعيد 500 لكل طلب حفظ. ولو أعادها لما تغير شيء، فالقرص ما زال ممتلئاً. وهذا يفسر لماذا لا تكفي إعادة التشغيل التلقائي وحدها لحماية أي خدمة، فهي تعالج نوعاً واحداً من الأعطال، وهو موت العملية، بينما أغلب الأعطال التي يراها العملاء تحدث والعملية حية. والأداة التي تغطي هذا النوع في systemd اسمها المراقبة الدورية Watchdog، حيث يجب على البرنامج أن يرسل إشارة حياة كل فترة بالخيار WatchdogSec، فإذا توقف عن إرسالها اعتبره systemd معطلاً، ولكنها تحتاج دعماً في كود التطبيق نفسه، وتكشف التجمد لا الأخطاء. ولذلك يبقى الحل الأعم أن تراقب نتيجة الطلبات نفسها من الخارج.

الحادثة 4: تسرب الذاكرة Memory Leak وحد MemoryMax

أضاف أحد المطورين ذاكرة مؤقتة Cache لتسريع الطلبات، ونسي أن يضع لها حداً، فصارت تكبر مع كل طلب. والخدمة عندها حد MemoryMax=80M، فتوقعنا أن يقتلها النظام عند تجاوزه. أرسلنا تسعة طلبات، كل منها يضيف 10 MiB:

MemoryCurrent=22470656
MemoryMax=83886080
cache 10 MiB
cache 20 MiB
...
cache 80 MiB
cache 90 MiB
● orders.service - Orders API
     Active: active (running) since Fri 2026-10-30 19:03:37 UTC; 1min 6s ago
   Main PID: 166 (python3)
      Tasks: 1 (limit: 14999)
     Memory: 79.8M (max: 80M, available: 160K)
Result=success
NRestarts=10

الذاكرة المؤقتة وصلت إلى 90 MiB، والحد 80M، والخدمة ما زالت تعمل، وsystemd يقول إنها تستخدم 79.8M وبقي 160K، ولا يوجد أي خطأ في السجل. وهذه المشكلة الثالثة التي واجهتنا، فأين ذهبت العشرة الزائدة؟

grep -E "VmRSS|VmSwap" /proc/166/status
curl -s -o /dev/null -w "GET / %{http_code} in %{time_total}s\n" localhost:8080/
cat $CG/memory.limit_in_bytes $CG/memory.max_usage_in_bytes $CG/memory.failcnt $CG/memory.swappiness
grep -E "^(rss|swap|cache) " $CG/memory.stat
VmRSS:	   61424 kB
VmSwap:	   58972 kB
GET / 200 in 0.015610s
83886080
83886080
3310
60
cache 0
rss 53186560
swap 42708992

والإجابة في سطري /proc/PID/status: العملية تشغل 61 MB في الذاكرة الحقيقية و59 MB في مساحة التبديل Swap. فالجهاز يعمل بمجموعات التحكم من الإصدار الأول cgroup v1، وفيها الحد memory.limit_in_bytes يحد الذاكرة الحقيقية فقط، فعندما وصلت الخدمة إليه نقلت النواة صفحاتها القديمة إلى Swap بدلاً من قتلها، كما يشرح توثيق مجموعة الذاكرة في cgroup v1. والعداد failcnt بالقيمة 3310 يقول إن الخدمة اصطدمت بالحد 3310 مرة، وكل اصطدام انتهى بنقل صفحات إلى القرص. والخدمة ما زالت ترد، ولكن الطلب أخذ 15 ملي ثانية بدلاً من أقل من ملي ثانية، وهذا هو الشكل الحقيقي لتسرب الذاكرة على سيرفر فيه Swap: لا يموت شيء، وإنما يبطئ كل شيء ببطء حتى ينفد Swap نفسه بعد أيام.

وماذا لو لم يكن Swap متاحاً؟ لتجربة ذلك أعدنا تشغيل الخدمة، ثم منعنا مجموعتها من Swap بكتابة 0 في memory.swappiness الخاص بها، وكررنا الطلبات:

swappiness for the service cgroup: 0
cache 10 MiB
...
cache 60 MiB
request 7 failed: curl exit 52
request 8 failed: curl exit 7
request 9 failed: curl exit 7
2026-10-30T19:05:21+00:00 1175e57a71ba systemd[1]: orders.service: Main process exited, code=killed, status=9/KILL
2026-10-30T19:05:21+00:00 1175e57a71ba systemd[1]: orders.service: Failed with result 'signal'.
2026-10-30T19:05:23+00:00 1175e57a71ba systemd[1]: orders.service: Scheduled restart job, restart counter is at 1.
2026-10-30T19:05:23+00:00 1175e57a71ba systemd[1]: Started orders.service - Orders API.

هذه المرة مات الطلب السابع في منتصفه: الرمز 52 من curl يعني أن الاتصال قبل ثم انقطع دون رد، لأن العملية قتلت وهي تعالجه، ثم فشل الطلبان التاليان بالرمز 7 لأن لا أحد يستمع خلال الثانيتين قبل إعادة التشغيل. وsystemd كتب status=9/KILL وFailed with result 'signal' فقط، دون كلمة عن الذاكرة. والسبب الحقيقي في سجل النواة، الذي لا تستطيع قراءته من داخل Container عادي، فقرأناه من Container بصلاحيات كاملة:

[147716.774113] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=1175e57a71ba...,mems_allowed=0,oom_memcg=/docker/1175e57a71ba.../system.slice/orders.service,task_memcg=/docker/1175e57a71ba.../system.slice/orders.service,task=python3,pid=48324,uid=996
[147716.778213] Memory cgroup out of memory: Killed process 48324 (python3) total-vm:109960kB, anon-rss:81580kB, file-rss:9540kB, shmem-rss:0kB, UID:996 pgtables:244kB oom_score_adj:0

والسطران يقولان كل شيء: CONSTRAINT_MEMCG أي أن القتل بسبب حد مجموعة تحكم وليس بسبب امتلاء ذاكرة الجهاز كله، وoom_memcg يحمل اسم المجموعة وهي خدمتنا، وanon-rss:81580kB هي الذاكرة التي كانت تشغلها العملية لحظة قتلها، أي فوق الحد بقليل. ورقم العملية 48324 مختلف عن رقمها داخل الـ Container لأن النواة تكتب الرقم كما تراه هي، لا كما يراه الـ Container. وعلى سيرفر حقيقي تقرأ هذا السطر بالأمر journalctl -k أو dmesg، وقد شرحنا قراءة سجل قاتل العمليات بالتفصيل في مقال تحليل الأداء:

مع swappiness=60 لم تمت الخدمة: VmRSS 61 MB وVmSwap 59 MB، والذاكرة 79.8M من الحد 80M، وfailcnt 3310، وResult=success دون أي خطأ في السجل لأن الحد في cgroup v1 لا يشمل Swap. وبدون Swap: انقطع الطلب عند 60 MiB برمز curl 52، وخرجت العملية بالإشارة 9 ونتيجة signal ثم أعيد تشغيلها، وسجل النواة يقول Memory cgroup out of memory: Killed process python3 anon-rss:81580kB
نفس التسرب ونفس الحد: بطء صامت مع Swap، وقتل وإعادة بدونه

وعلى الأنظمة الحديثة التي تعمل بمجموعات التحكم من الإصدار الثاني cgroup v2، وهي الافتراضية في Debian 13 وUbuntu الحديثة، يختلف السلوك في نقطتين: يمكنك منع الخدمة من Swap صراحة بالخيار MemorySwapMax=0 الذي تقول صفحة systemd.resource-control(5) إنه يعمل في التسلسل الموحد Unified Hierarchy فقط، وsystemd يعرف عندها أن النواة قتلت العملية بسبب الذاكرة فيكتب النتيجة oom-kill بدلاً من signal. والإصلاح الدائم في الحالتين واحد: حد لحجم الـ Cache في الكود نفسه، ومراقبة لذاكرة الخدمة وعدد مرات إعادة تشغيلها، فالخدمة التي يعيدها systemd كل ساعة بسبب الذاكرة تبدو سليمة في أي لحظة تنظر إليها.

الحادثة 5: خيط يستهلك المعالج CPU

في المساء أضيف تقرير يعمل في خيط خلفي داخل نفس الخدمة، وفيه حلقة لا تنتهي بسبب خطأ في شرط التوقف. لم يتوقف شيء، ولكن العملاء بدأوا يشتكون من البطء. والسؤال الخامس، الموارد، هو الذي يجيب هنا:

+ uptime
 19:06:03 up 1 day, 17:02,  0 users,  load average: 0.74, 0.30, 0.31
+ top -b -n 1 -o %CPU
top - 19:06:03 up 1 day, 17:02,  0 users,  load average: 0.74, 0.30, 0.31
Tasks:   7 total,   1 running,   6 sleeping,   0 stopped,   0 zombie
%Cpu(s):  8.9 us,  4.4 sy,  0.0 ni, 83.3 id,  0.0 wa,  0.0 hi,  3.3 si,  0.0 st
MiB Mem :  32036.0 total,  28452.6 free,   2138.7 used,   1936.1 buff/cache
MiB Swap:   8192.0 total,   7650.6 free,    541.4 used.  29897.3 avail Mem

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  314 orders    20   0  746176  89584   9620 S 100.0   0.3   0:06.35 python3

العملية 314 تستخدم 100% من نواة معالج كاملة، بينما المعالج كله مشغول بنسبة 17% فقط لأن في الجهاز 8 أنوية، ومتوسط الضغط Load Average بدأ يرتفع من 0.30 إلى 0.74. ولاحظ أن حالة العملية S أي نائمة، رغم أنها تستهلك نواة كاملة، والسبب أن top يعرض حالة الخيط الرئيسي، والخيط الرئيسي نائم ينتظر الطلبات، أما الذي يعمل فخيط آخر. ولكي ترى الخيوط نفسها استخدمنا الخيار -H في top:

  PID USER      PR  NI    VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  332 orders    20   0 2455892  86808   9620 R  90.9   0.3   0:24.70 python3
  314 orders    20   0 2455892  86808   9620 S   0.0   0.3   0:00.21 python3

الخيط 332 في الحالة R ويأخذ 90.9%، والخيط الرئيسي 314 لا يأخذ شيئاً. ولكن لماذا أبطأ خيط واحد الطلبات إذا كان في الجهاز سبع أنوية أخرى فارغة؟ قسنا زمن خمسة طلبات قبل إعادة التشغيل وبعدها:

0.197278 0.026315 0.018246 0.023870 0.028209
0.000731 0.000668 0.000898 0.000708 0.000689

الطلبات كانت أبطأ من 25 إلى 270 مرة من الطبيعي. والسبب قفل المفسر العام GIL في Python، فخيط واحد فقط يستطيع تنفيذ كود Python في كل لحظة، والخيط المشغول في الحلقة ينافس خيط الطلبات على هذا القفل وليس على المعالج، وقد شرحنا ذلك بالأرقام في قسم Python من مقال التزامن Concurrency والخيوط. وفي لغات أخرى مثل Java وGo لن يبطئ خيط مشغول الطلبات بنفس الشكل ما دامت توجد أنوية فارغة، ولكنه يستهلك ما تدفع ثمنه، ويأخذ من حصة الـ Container إذا كان لها حد.

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

السؤال السادس: ماذا تغير Changes؟

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

systemctl list-units --failed --no-pager
journalctl -p err -b --no-pager -o short-iso | tail -4
find /etc -newermt "-1 hour" -type f 2>/dev/null | head
ls -lt --time-style=+%H:%M /etc/systemd/system | head -5
  UNIT LOAD ACTIVE SUB DESCRIPTION

0 loaded units listed.
...
/etc/resolv.conf
/etc/systemd/system/orders.service
/etc/shadow
/etc/group
/etc/gshadow
/etc/hostname
/etc/ld.so.cache
/etc/passwd
/etc/hosts
total 20
drwxr-xr-x 1 root root 4096 19:02 multi-user.target.wants
-rwxr-xr-x 1 root root  320 19:02 orders.service

والأمر find بالخيار -newermt يعرض الملفات التي تغيرت في آخر ساعة، وهنا ظهر ملف الخدمة وملفات المستخدمين لأننا أنشأنا المستخدم orders في هذه الساعة، وهذه بالضبط الأدلة التي تبحث عنها. ولاحظ أن قائمة الخدمات الفاشلة فارغة رغم كل ما حدث، لأن كل خدمة فشلت ثم عادت لم تعد فاشلة. وعلى سيرفر حقيقي أضف إلى ذلك سجل مدير الحزم في /var/log/apt/history.log لمعرفة آخر تحديث، وjournalctl --list-boots لمعرفة آخر إعادة إقلاع، وسجل النشر في أداة CI إذا كانت لديك.

الخط الزمني Timeline: اجمع الأحداث بتوقيت واحد

السؤال «ماذا تغير؟» يحتاج أن تضع الأحداث على خط زمني واحد: متى بدأت الشكاوى، ومتى نشر الكود، ومتى ظهر أول خطأ في السجل، ومتى أعيد التشغيل. وهنا فخ صغير يضيع وقتاً كثيراً: لاحظ أن كل أوقات السجل في هذا المقال تنتهي بـ +00:00، أي أنها بالتوقيت العالمي UTC، لأن السيرفر مضبوط عليه، بينما وصلت رسالة القصة في الثانية فجراً بتوقيت السعودية، وهي الحادية عشرة مساءً بالتوقيت العالمي. فإذا بحثت في السجل عن «الساعة الثانية» وجدت أحداثاً لا علاقة لها بالعطل. ولذلك اعرف منطقة السيرفر الزمنية أولاً بالأمر timedatectl، واستخدم في البحث صيغة صريحة مثل journalctl --since "2026-10-30 22:50" --until "23:20" --utc، أو اطلب الأوقات بصيغة -o short-iso التي تكتب المنطقة مع كل سطر كما فعلنا هنا. ويمكنك أيضاً أن تطلب سجل أكثر من خدمة في نفس النافذة بتكرار الخيار -u، مثل الخدمة والـ Proxy معاً، فترى في قائمة واحدة مرتبة بالوقت ما حدث في الطبقتين، وهذه غالباً أسرع طريقة لترى السبب والنتيجة متجاورين.

رموز الخطأ Errno ورموز الخروج Exit Codes

إذا راجعت الحوادث الخمس ستجد أن كل واحدة منها كتبت سببها في رقم، والرقم نفسه يأتي من النواة، فهو واحد سواءً كان تطبيقك بلغة Python أو Java أو Node.js أو Go، وإن اختلفت الكلمات حوله. والجدول التالي يجمع ما ظهر في المختبر وأين تنظر بعد كل رقم:

جدول: Errno 13 EACCES يعني أن الصلاحيات لا تسمح فانظر بعده في namei -l وid، وErrno 28 ENOSPC يعني نفاد المساحة أو الـ Inodes فانظر في df -h وdf -i ثم du، وErrno 98 EADDRINUSE يعني أن عملية أخرى تملك المنفذ فانظر في ss -ltnp، وstatus=9/KILL أو الرمز 137 يعني إشارة من الخارج غالباً قاتل العمليات فانظر في journalctl -k أو dmesg، وcurl exit 7 يعني لا أحد يستمع، وcurl exit 52 يعني أن الاتصال قبل ثم انقطع
الرقم يأتي من النواة، فهو نفسه في كل لغة

وأسماء الأخطاء ومعانيها الكاملة في صفحة errno(3)، وأرقام الإشارات في signal(7)، وتذكر القاعدة التي رأيناها في أكثر من مقال: رمز خروج أكبر من 128 يعني أن العملية انتهت بإشارة رقمها هو الفرق، فالرمز 137 هو الإشارة 9 والرمز 143 هو الإشارة 15.

بعد الحادثة: الإصلاح المؤقت والدائم والمراجعة Postmortem

في كل حادثة من الخمس كان عندنا إصلاحان: مؤقت يعيد الخدمة الآن، مثل chown ونقل الفواتير وإعادة التشغيل، ودائم يمنع تكرارها، مثل StateDirectory وتنبيه المساحة وحد الـ Cache. والخطأ الشائع أن يتوقف الفريق عند الأول لأن الخدمة عادت والجميع متعب، فتعود الحادثة نفسها بعد أسبوع. والطريقة التي تمنع ذلك مراجعة مكتوبة بعد كل حادثة مهمة، يسميها الناس Postmortem، وفيها الوقت الذي بدأت فيه، وكيف اكتشفت، وما الذي فعلناه بالترتيب، والسبب، والإصلاحات الدائمة مع اسم المسؤول عن كل واحد. ويؤكد فصل ثقافة المراجعة بعد الحوادث في كتاب Google أن المراجعة تكون بلا لوم، فالسؤال «ماذا في النظام سمح بهذا الخطأ؟» وليس «من أخطأ؟»، والسبب أن الناس يخفون أخطاءهم عندما يلامون، فتضيع الدروس. وفي قصتنا كانت المراجعة ستقول إن المطور المناوب لم يكن عنده دليل مكتوب، وإن المراقبة كانت تسأل «هل الخدمة حية؟» فقط، وهذان إصلاحان أهم من أي سطر كود.

دليل التشغيل Runbook: ما كان ينقص المطور المناوب

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

  • اسم الخدمة في systemd، والمستخدم الذي تعمل به، والمنفذ، ومكان البيانات، فلا يبحث المناوب عنها.
  • الطلب الذي يثبت أن الخدمة تعمل فعلاً، وهو هنا طلب حفظ تجريبي وليس الصفحة الرئيسية.
  • أوامر الأسئلة الستة مكتوبة بأسماء هذه الخدمة بالضبط، جاهزة للنسخ.
  • الأعطال المعروفة برقم خطئها وإصلاحها: 13 يعني المالك على /var/lib/orders، و28 يعني القرص، و98 يعني من يملك المنفذ.
  • متى تعيد التشغيل ومتى لا، ومتى تتراجع عن آخر نشر وكيف.
  • من تتصل به إذا لم ينفع شيء خلال نصف ساعة، ومن يملك قرار صفحة الصيانة.

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

أخطاء شائعة Common Mistakes

  • إعادة التشغيل أولاً: تمسح حالة العمليات والذاكرة والاتصالات، ويعود العطل دون أن تعرف سببه. سجل ما تراه أولاً، ثم أعد التشغيل إذا كان هو الإصلاح المؤقت المناسب.
  • الوثوق بالحالة active: في حادثتين كانت الخدمة حية وكل طلب حفظ يفشل.
  • البحث من الخارج إلى الداخل دون تقسيم: ابدأ بطلب من السيرفر نفسه، فيعرفك في ثانية في أي نصف من المسار تبحث.
  • قراءة journalctl -p err وحده: تتبع الخطأ الذي أوقف الخدمة في الحادثة الثانية لم يظهر فيه.
  • تجاهل رقم الخطأ: Errno 13 و28 و98 تقول السبب مباشرة، والرسائل العامة مثل «حدث خطأ» تخفيه.
  • افتراض أن MemoryMax يقتل الخدمة عند تجاوزه: على cgroup v1 مع Swap تحولت الخدمة إلى خدمة بطيئة دون أي خطأ.
  • البحث عن سبب القتل في سجل الخدمة: systemd كتب signal فقط، والسبب في سجل النواة.
  • الحكم من حالة العملية في top دون -H: العملية كانت S وخيط فيها يأخذ نواة كاملة.
  • الحذف قبل الفهم: حذف ملفات لتفريغ القرص دون أن تعرف ما هي قد يحذف الدليل، أو بيانات لا يمكن استعادتها.
  • التوقف عند الإصلاح المؤقت دون مراجعة: نفس الحادثة تعود.

قائمة الدقائق الخمس الأولى Checklist

  • اكتب الوقت الذي وصلت فيه الشكوى، والوقت الذي بدأت فيه أنت.
  • systemctl status <service>: هل هي active، ومنذ متى، وكم NRestarts؟
  • ss -ltnp: هل يوجد من يستمع على المنفذ، وعلى أي عنوان، ومن هو؟
  • curl -w "%{http_code} %{time_total}" إلى localhost بطلب حقيقي، لا الصفحة الرئيسية فقط.
  • journalctl -u <service> --since "-15min" كاملاً، وابحث عن آخر رقم خطأ.
  • df -h وdf -i وfree -m وtop ثم top -H -p PID إذا كانت عملية واحدة مشغولة.
  • journalctl -k --since "-1h" لرسائل النواة، وخصوصاً قاتل العمليات.
  • ماذا تغير: نشر، إعداد، تحديث، إعادة إقلاع، وملفات /etc الجديدة.
  • احفظ المخرجات في ملف قبل أي إصلاح، ثم الإصلاح المؤقت، ثم التحقق بنفس الطلب الحقيقي.
  • افتح مراجعة للحادثة إذا تأثر العملاء، ولو بخمسة أسطر.

حذف المختبر Cleanup

docker rm -f sbl-ts
docker rmi sbl-systemd:1

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • التشخيص طريقة لا حدس: ستة أسئلة بالترتيب من الخدمة إلى ما حولها، وتسجيل ما تراه قبل أي إصلاح.
  • طلب من داخل السيرفر يقسم المسار إلى نصفين: من الداخل أخذ الطلب أقل من ملي ثانية، ومن الخارج نصف ثانية بسبب الطريق وحده.
  • في حادثتي الصلاحيات والقرص الممتلئ كانت الخدمة active وكل طلب حفظ يعيد 500، والسبب مكتوب في رقم الخطأ 13 أو 28.
  • القرص الممتلئ ترك فواتير بحجم صفر، وهي أخطر من الامتلاء نفسه لأنها تبقى بعد إصلاحه.
  • المنفذ المشغول ظهر كخدمة تعيد التشغيل عشر مرات، وتتبع الخطأ الذي أوقفها لم يظهر مع -p err.
  • تسرب الذاكرة مع MemoryMax=80M على cgroup v1 لم يقتل أحداً، وإنما نقل 59 MB إلى Swap وأبطأ الطلبات، وبدون Swap قتلت النواة الخدمة وكتبت السبب في سجلها هي.
  • كل أوقات السجل كانت بالتوقيت العالمي UTC، فاعرف منطقة السيرفر الزمنية قبل أن تبحث عن «الساعة الثانية» في السجل، واجمع سجل الخدمة والـ Proxy في نافذة واحدة.
  • خيط واحد مشغول جعل الطلبات أبطأ حتى 270 مرة بسبب GIL، ورآه top -H بينما بدت العملية نائمة.

وهذه مقالات مرتبطة تكمل الصورة:

دليلإدارة الخدمات Services والسجلات Logs مع systemdننقل واجهة برمجية من nohup إلى خدمة systemd تعود وحدها بعد التوقف والإقلاع، ونقرأ سجلاتها مع journalctl، ونرى ثلاث مشاكل حقيقية واجهتنا في الطريق.دليلالأقراص في Linux: التركيب Mount والملف fstab وSwapنضيف قرص بيانات إلى سيرفر Linux من التقسيم إلى التركيب الدائم، ونرى بأعيننا لماذا تختفي الملفات ويتوقف الإقلاع ويمتلئ القرص، ثم نضيف Swap ونوسع القرص دون إيقاف.دليلتحليل أداء سيرفر Linux في 60 ثانية: ماذا تكتب أولاً؟ماذا تكتب أول ما تدخل إلى سيرفر بطيء، وكيف تعرف خلال دقيقة هل المشكلة في المعالج أم الذاكرة أم القرص أم الشبكة.دليلمفاهيم المراقبة Observability: من الإشارة إلى التنبيهما الذي تجيب عنه المقاييس والسجلات والتتبع، وكيف تبني تنبيهاً على ما يشعر به المستخدم وعلى ميزانية الأخطاء بدل كل رقم.دليلنظام التشغيل Operating System: ماذا يفعل بين برامجك والجهاز؟خريطة للمبتدئين تريك بأوامر حقيقية كيف يدير نظام التشغيل العمليات والذاكرة والملفات، ولماذا لا يسقط جهازك كله عندما يسقط برنامج واحد.

سجل التحديثات

  • أكتوبر 2026: كتابة الدليل، وتنفيذ الحوادث الخمس على systemd 257.13 داخل Container من Debian 13 (trixie) مع Python 3.13.5 وprocps-ng 4.0.4 وiproute2 6.15.0 وutil-linux 2.41.5، على نواة Linux 5.15 (WSL2، cgroup v1) وDocker Engine 29.8.0.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.