الأدلة التقنية

تمارين Kata في تشخيص الأعطال بثلاثة مستويات مع الحلول

حوادث صنعناها في المختبر بأعراض حقيقية، تقرر فيها ما تفحصه أولاً ولماذا، ثم تقارن مسارك بمسار التحقيق الكامل.

تمارين Kata في تشخيص الأعطال بثلاثة مستويات مع الحلول

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

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

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

  • تجهيز المختبر Lab: تطبيق Python صغير خلف Nginx في Docker، نغير سلوكه بمتغيرات البيئة لنصنع كل حادثة.
  • المستوى الأول: 502 بعد النشر مرتين بسببين مختلفين، والقرص الممتلئ بالسجلات، وال Container الذي يخرج بالرمز 137، والشهادة المنتهية، و504 على صفحة تقرير واحدة.
  • المستوى الثاني: الموقع بطيء للجميع بسبب فهرس ناقص، وقرص فيه مساحة ويرفض الكتابة، و«too many clients»، و502 والتطبيق يعمل، وطلب يفشل من كل اثنين.
  • المستوى الثالث: تغيير DNS لا يراه الزوار، والمعالج 100% بسبب مدخل واحد، وتحديثات معلقة والمعالج خامل، و502 بعد كل إعادة نشر.
  • جدول متابعة تنسخه لتسجل تقدمك، وروابط المقالات التي تشرح كل موضوع بالتفصيل.

والصورة التالية تبين الحوادث الخمس عشرة موزعة على المستويات الثلاثة:

ثلاثة أعمدة ملونة: في اليمين المستوى الأول Beginner بالأخضر من 10 إلى 15 دقيقة، وفيه الحوادث 1 إلى 6: 502 بعد النشر لأن التطبيق توقف، و502 بعد النشر بسبب منفذ خاطئ، وNo space left on device، وContainer يخرج بالرمز 137، وشهادة منتهية، و504 على صفحة تقرير. وفي الوسط المستوى الثاني Intermediate بالأصفر من 15 إلى 20 دقيقة، وفيه الحوادث 7 إلى 11: بطء للجميع، وقرص فيه مساحة والكتابة تفشل، وtoo many clients، و502 والتطبيق يعمل، وفشل طلب من كل اثنين. وفي اليسار المستوى الثالث Advanced بالأحمر من 20 إلى 30 دقيقة، وفيه الحوادث 12 إلى 15: تغيير DNS لا يظهر، ومعالج 100% مع مدخل واحد، وتحديثات معلقة والمعالج خامل، و502 بعد كل إعادة نشر
ابدأ من المستوى الأول، فأغلب الحوادث الحقيقية من هذا النوع، وسرعة حلها هي ما يفرق بين دقيقتين وساعة

تجهيز المختبر Lab

المختبر تطبيق Python من مكتبة Python القياسية فقط، يرد بالنص ok from <container> v<version>، ونتحكم فيه بمتغيرات البيئة: APP_VERSION للنسخة، وPORT وBIND للمنفذ والعنوان الذي يستمع عليه، وREPORT_SECONDS لزمن صفحة التقرير، وBROKEN=1 ليرد بالخطأ 500، ومن النسخة 2.0 يحتاج المتغير DATABASE_URL. احفظ هذا الملف باسم app.py:

import os, socket, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

VERSION = os.environ.get("APP_VERSION", "1.0")
if VERSION >= "2.0":
    DATABASE_URL = os.environ["DATABASE_URL"]      # new in 2.0: required setting
BIND = os.environ.get("BIND", "0.0.0.0")
PORT = int(os.environ.get("PORT", "8000"))
BROKEN = os.environ.get("BROKEN") == "1"

class H(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    def do_GET(self):
        if self.path.startswith("/report"):
            time.sleep(float(os.environ.get("REPORT_SECONDS", "0")))
        code, body = (500, "internal error\n") if BROKEN else (200, f"ok from {socket.gethostname()} v{VERSION}\n")
        data = body.encode()
        self.send_response(code)
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

print(f"listening on {BIND}:{PORT} (v{VERSION})", flush=True)
ThreadingHTTPServer((BIND, PORT), H).serve_forever()

وهذا إعداد Nginx في الملف default.conf، ثم أوامر تشغيل الشبكة والتطبيق وNginx على المنفذ 127.0.0.1:21415:

server {
    listen 80;
    location / {
        proxy_pass http://lab-kata-ops-ts-app:8000;
    }
}
docker network create lab-kata-ops-ts
docker run -d --name lab-kata-ops-ts-app --network lab-kata-ops-ts --restart unless-stopped \
  -v "$PWD/app.py:/app.py:ro" python:3.13-slim python -u /app.py
docker run -d --name lab-kata-ops-ts-nginx --network lab-kata-ops-ts -p 127.0.0.1:21415:80 \
  -v "$PWD/default.conf:/etc/nginx/conf.d/default.conf:ro" nginx:1.29-alpine
curl -s http://127.0.0.1:21415/

والمخرج سوف يكون كما يلي:

ok from aa191895c441 v1.0

و«النشر» في كل تمرين يعني أن تحذف ال Container الخاص بالتطبيق وتشغله من جديد بنفس الأمر مع متغيرات التمرين، مثل -e APP_VERSION=2.0، وبعض التمارين تحتاج Containers إضافية مثل PostgreSQL نذكر أوامرها في التمرين نفسه. وكل الأسماء تبدأ ب lab-kata-ops-ts- حتى تحذفها بالاسم في النهاية. ولاحظ أن عناوين IP والأرقام والأوقات في المخرجات تختلف عندك، والمهم هو شكل الدليل وليس قيمته.

المستوى الأول: مبتدئ Beginner

في هذا المستوى الدليل واضح لمن يعرف أين ينظر، والمطلوب أن تصل إليه بأقل عدد من الأوامر، وأن تميز بين الحالات التي تتشابه أعراضها.

التمرين 1: 502 بعد نشر النسخة 2.0

نشر الفريق النسخة 2.0 من التطبيق قبل دقائق، والآن كل طلب يعطي هذا:

$ curl -i http://127.0.0.1:21415/
HTTP/1.1 502 Bad Gateway
Server: nginx/1.29.8
Date: Wed, 07 Oct 2026 08:28:23 GMT

واقترح أحدهم أن تعيد تشغيل Nginx. ما الأمر التالي الذي تشغله، ولماذا؟ وأكمل حتى السبب والإصلاح.

الوقت المقترح: 10 دقائق.

تلميح Hint

502 تعني أن ال Proxy يعمل لكنه لم يحصل على رد صحيح ممن خلفه، فالسؤال الأول ليس عن Nginx وإنما عن التطبيق. وسجل أخطاء Nginx يخبرك أي عنوان حاول وماذا حدث، ثم انظر إلى حالة ال Container وعدد مرات إعادة تشغيله.

الحل Answer

الأمر التالي هو سجل Nginx، لأنه الطرف الذي أعطى 502 ويعرف لماذا، والمخرج سوف يكون كما يلي:

$ docker logs --tail 2 lab-kata-ops-ts-nginx
2026/10/07 08:28:23 [error] 22#22: *3 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.96.1, server: , request: "GET / HTTP/1.1", upstream: "http://192.168.96.2:8000/", host: "127.0.0.1:21415"
192.168.96.1 - - [07/Oct/2026:08:28:23 +0000] "GET / HTTP/1.1" 502 157 "-" "curl/8.12.1" "-"

الرسالة Connection refused تعني أن العنوان موجود ولا يوجد شيء يستمع على المنفذ، فالخطوة التالية حالة التطبيق:

$ docker ps -a --filter name=lab-kata-ops-ts --format 'table {{.Names}}\t{{.Status}}'
NAMES                   STATUS
lab-kata-ops-ts-app     Up 1 second
lab-kata-ops-ts-nginx   Up 52 seconds
$ docker inspect lab-kata-ops-ts-app --format 'RestartCount={{.RestartCount}} ExitCode={{.State.ExitCode}}'
RestartCount=7 ExitCode=1
$ docker logs --tail 6 lab-kata-ops-ts-app
Traceback (most recent call last):
  File "/app.py", line 6, in <module>
    DATABASE_URL = os.environ["DATABASE_URL"]      # new in 2.0: required setting
                   ~~~~~~~~~~^^^^^^^^^^^^^^^^
  File "<frozen os>", line 717, in __getitem__
KeyError: 'DATABASE_URL'

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

  • Up 1 second يبدو حالة سليمة، وهذا فخ، فال Container يعمل لأن سياسة --restart unless-stopped تعيده كل مرة، والدليل الحقيقي هو RestartCount=7 وExitCode=1.
  • السبب الجذري Root Cause أن النسخة 2.0 تحتاج إعداداً جديداً DATABASE_URL لم يضف إلى بيئة الإنتاج، والإصلاح السريع إما أن تضيف المتغير وتعيد النشر، أو تتراجع إلى 1.0 إذا لم تعرف القيمة الصحيحة الآن.
  • إعادة تشغيل Nginx لم تكن لتغير شيئاً، والأسوأ أنها تمسح من ذهنك أن المشكلة بدأت مع النشر.
  • للمستقبل: أي إعداد جديد يذكر في ملاحظات النسخة، والتطبيق يفحص إعداداته عند التشغيل ويطبع رسالة واضحة، وفحص صحة Healthcheck في خط النشر يمنع اعتبار النشر ناجحاً.

للتفصيل: حالة 502 و504 في دليل استكشاف الأخطاء، وتجربة 502 في دليل تتبع الطلب البطيء.

التمرين 2: 502 مرة أخرى، والتطبيق لا يتوقف هذه المرة

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

$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:21415/
502
$ docker ps --filter name=lab-kata-ops-ts-app --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}'
NAMES                 STATUS         PORTS
lab-kata-ops-ts-app   Up 9 seconds

ما الأمر التالي، ولماذا؟

الوقت المقترح: 10 دقائق.

تلميح Hint

ال Container لم يعد يتوقف، فاقرأ أول سطر طبعه التطبيق، ثم جرب الاتصال من داخل شبكة Docker نفسها، من Container Nginx، على المنفذ الذي يتوقعه Nginx والمنفذ الذي يقوله التطبيق.

الحل Answer

سجل Nginx يعطي نفس السطر connect() failed (111: Connection refused) إلى 192.168.96.2:8000، فالأمر التالي سجل التطبيق، ثم الاختبار من مكان Nginx:

$ docker logs lab-kata-ops-ts-app
listening on 0.0.0.0:8080 (v2.0)
$ docker exec lab-kata-ops-ts-nginx wget -qO- http://lab-kata-ops-ts-app:8000/
wget: can't connect to remote host (192.168.96.2): Connection refused
$ docker exec lab-kata-ops-ts-nginx wget -qO- http://lab-kata-ops-ts-app:8080/
ok from 6fcdef9de0fb v2.0
$ docker exec lab-kata-ops-ts-app cat /proc/net/tcp
  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode
   0: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 899805 1 000000009d9cf5fd 100 0 0 10 0
   1: 0B00007F:AA5B 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 901609 1 000000006df71daf 100 0 0 10 0

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

  • التطبيق يستمع على 8080 وNginx يرسل إلى 8000، وهذا هو السبب الجذري. والإصلاح أن تثبت المنفذ صراحة في إعداد التطبيق PORT=8000، ولا تعتمد على القيمة الافتراضية لمكتبة قد تتغير.
  • ال Docker Image الصغير لا يحتوي على ss أو netstat، فقرأنا الجدول الذي تقرأ منه هذه الأدوات: 1F90 بالست عشري هي 8080، والحالة 0A تعني LISTEN، و00000000 تعني كل العناوين، والسطر الثاني 0B00007F هو 127.0.0.11 أي ال DNS الداخلي في Docker.
  • الفرق بين هذا التمرين والذي قبله يظهر في الحالة وعدد مرات إعادة التشغيل، وليس في رسالة Nginx، فرسالة Nginx واحدة في الحالتين.

للتفصيل: حالة 502 في دليل شبكات Docker، وقسم ss وحالات الاتصال في دليل تحليل أداء Linux.

التمرين 3: الطلبات الجديدة لا تحفظ

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

$ echo "{\"id\": 1042}" > /srv/shop/data/order-1042.json
sh: write error: No space left on device

ما الأمر التالي، ولماذا؟ وأكمل حتى تجد ما يأخذ المساحة وتصلح المشكلة دون أن تحذف بيانات العملاء.

الوقت المقترح: 10 دقائق.

تلميح Hint

تأكد أولاً أن القرص ممتلئ فعلاً وأي قسم Partition بالضبط، ثم انزل في المجلدات من الأكبر إلى الأصغر بالأمر du مع الخيار -x الذي يبقيك في نفس القسم، أو ابحث مباشرة عن الملفات الكبيرة بالأمر find -size.

الحل Answer

صنعنا الحادثة في Container فيه قسم صغير بحجم 64 ميجابايت (--tmpfs /srv:size=64m) حتى لا نملأ قرص جهازك. والأمر التالي df -h على المسار نفسه، ثم du ثم find، والمخرج سوف يكون كما يلي:

$ df -h /srv
Filesystem                Size      Used Available Use% Mounted on
tmpfs                    64.0M     64.0M         0 100% /srv
$ du -xh -d2 /srv | sort -h | tail -4
3.8M	/srv/shop/uploads
60.2M	/srv/shop/logs
64.0M	/srv
64.0M	/srv/shop
$ find /srv -xdev -type f -size +10240k -exec ls -lh {} +
-rw-r--r--    1 root     root       60.2M Oct  7 08:44 /srv/shop/logs/app.log
$ truncate -s 0 /srv/shop/logs/app.log && df -h /srv
Filesystem                Size      Used Available Use% Mounted on
tmpfs                    64.0M      3.8M     60.2M   6% /srv
$ echo "{\"id\": 1042}" > /srv/shop/data/order-1042.json && cat /srv/shop/data/order-1042.json
{"id": 1042}

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

  • السبب الجذري ملف سجل واحد بلا تدوير Rotation، والإصلاح السريع truncate وليس rm، لأن التطبيق يبقي الملف مفتوحاً، والحذف لا يعيد المساحة حتى يغلقه، وهذه حالة مستقلة شرحناها في تمرين الملف المحذوف في تمارين Linux.
  • الإصلاح الدائم تدوير السجلات بالأداة logrotate، أو حدود حجم لسجلات Docker في daemon.json، وتنبيه عندما يتجاوز القرص 80% قبل أن يصل إلى 100%.
  • ابدأ دائماً من df على المسار الذي فشلت فيه الكتابة، لأن السيرفر قد يكون فيه عدة أقسام، وقد يكون القسم الممتلئ غير الذي تظنه.

للتفصيل: قسم القرص الممتلئ في دليل تحليل أداء Linux، وقسم logrotate في دليل السجلات.

التمرين 4: ال Worker يختفي كل بضع دقائق دون أي خطأ

خدمة معالجة في الخلفية Worker تخزن نتائج مؤقتة في الذاكرة، وتتوقف كل فترة، وسجلها ينتهي هكذا دون أي رسالة خطأ:

cached 40 MB
cached 48 MB
cached 56 MB

وحالتها في docker ps -a:

NAMES                 STATUS
lab-kata-ops-ts-oom   Exited (137) 2 seconds ago

ما الأمر التالي، ولماذا؟ وماذا يعني الرقم 137؟

الوقت المقترح: 10 دقائق.

تلميح Hint

رمز الخروج الأكبر من 128 يعني أن العملية قتلت بإشارة Signal، والرقم ناقص 128 هو رقم الإشارة. والعملية التي تقتل بالإشارة 9 لا تستطيع أن تطبع شيئاً. ابحث في docker inspect عن حالة اسمها OOMKilled وعن حد الذاكرة.

الحل Answer

137 = 128 + 9، أي أن العملية قتلت بالإشارة SIGKILL، ولهذا لا يوجد خطأ في السجل. والأمر التالي docker inspect لمعرفة من قتلها:

$ docker inspect lab-kata-ops-ts-oom --format 'ExitCode={{.State.ExitCode}} OOMKilled={{.State.OOMKilled}} Memory={{.HostConfig.Memory}}'
ExitCode=137 OOMKilled=true Memory=67108864

والحادثة صنعناها بهذا الأمر، وفيه حد ذاكرة 64 ميجابايت وكود يضيف 8 ميجابايت إلى الذاكرة كل 0.2 ثانية دون أي حد:

docker run --name lab-kata-ops-ts-oom -m 64m --memory-swap 64m python:3.13-slim python -c "
import time
cache = []
for i in range(1, 200):
    cache.append(bytearray(8 * 1024 * 1024))
    print(f'cached {i * 8} MB', flush=True)
    time.sleep(0.2)
"; echo "exit=$?"

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

  • OOMKilled=true تعني أن النواة قتلت العملية لأنها تجاوزت حد الذاكرة في ال Container (67108864 بايت = 64 ميجابايت)، وآخر رقم في السجل 56 ميجابايت لأن المحاولة التالية تجاوزت الحد مع ذاكرة Python نفسها.
  • السبب الجذري في الكود وليس في الحد: ذاكرة مؤقتة Cache بلا حد أعلى ولا وقت انتهاء، فرفع الحد إلى 512 ميجابايت يؤخر الحادثة فقط. والإصلاح أن تحدد حجم ال Cache، وبعدها تضبط الحد حسب ما تقيسه بالأمر docker stats.
  • 137 قد تأتي أيضاً من docker kill أو من انتهاء مهلة docker stop، ولذلك لا تحكم من الرقم وحده، فالحقل OOMKilled هو الذي يفرق.

للتفصيل: قسم OOM في دليل تحليل أداء Linux، وحدود الموارد في توثيق Docker.

التمرين 5: المتصفح يرفض الموقع منذ صباح اليوم

منذ صباح 7 أكتوبر يرى الزوار صفحة تحذير أمني بدل الموقع، وأمس كان كل شيء يعمل، ولم ينشر أحد شيئاً. وهذا ما يعطيه curl من سيرفر Linux:

$ curl -sS --cacert /certs/ca.crt https://shop.example.test:21416/
curl: (60) SSL certificate OpenSSL verify result: certificate has expired (10)
More details here: https://curl.se/docs/sslcerts.html

ما الأمر التالي الذي يعطيك التاريخ بالضبط، ولماذا لا تكتفي برسالة curl؟ وماذا تفعل بعد تجديد الشهادة حتى لا تتكرر الحادثة؟

الوقت المقترح: 10 دقائق.

تلميح Hint

اطلب الشهادة التي يرسلها السيرفر فعلاً بالأمر openssl s_client، ومرر الناتج إلى openssl x509 -noout لتقرأ الاسم وتاريخ الانتهاء. وقارن التاريخ بساعة السيرفر نفسه.

الحل Answer

صنعنا شهادة من جهة إصدار CA خاصة بالمختبر، صلاحيتها من 1 يوليو حتى 5 أكتوبر 2026، وشغلناها على Nginx في المنفذ 21416. والأمر التالي يقرأ الشهادة التي يرسلها السيرفر الآن، لأن الملف على القرص قد يكون غير الذي حمله السيرفر في الذاكرة:

echo | openssl s_client -connect shop.example.test:21416 -servername shop.example.test -CAfile /certs/ca.crt 2>/dev/null | grep -E "Verify return code|Verification error"
echo | openssl s_client -connect shop.example.test:21416 -servername shop.example.test 2>/dev/null | openssl x509 -noout -subject -enddate
date -u

والمخرج سوف يكون كما يلي:

Verification error: certificate has expired
Verify return code: 10 (certificate has expired)
subject=CN=shop.example.test
notAfter=Oct  5 00:00:00 2026 GMT
Wed Oct  7 08:48:27 UTC 2026

وبعد إصدار شهادة جديدة لمدة 90 يوماً وإعادة تحميل Nginx بالأمر nginx -s reload:

notAfter=Jan  5 08:49:05 2027 GMT
$ curl -sS --cacert /certs/ca.crt https://shop.example.test:21416/
shop ok

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

  • الخيار -servername يرسل اسم الموقع SNI، وبدونه قد يرد السيرفر بشهادة موقع آخر على نفس العنوان، فتشخص الشهادة الخطأ.
  • تحقق من date -u على السيرفر وعلى جهاز العميل، فالساعة الخاطئة تعطي نفس الرسالة لشهادة سليمة.
  • السبب الجذري ليس الشهادة وإنما أن التجديد التلقائي لم يعمل ولم ينبه أحداً، فبعد التجديد افحص لماذا فشل (مهمة cron متوقفة، أو منفذ 80 مغلق أمام التحقق، أو Reload لم يحدث)، وأضف مراقبة تنبه قبل الانتهاء بأسبوعين على الأقل.

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

التمرين 6: صفحة التقرير السنوي وحدها تعطي 504

كل صفحات الموقع سريعة، وصفحة التقرير السنوي تعطي بعد خمس ثوان تقريباً خطأ 504:

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" http://127.0.0.1:21415/
200 0.077030s
$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" "http://127.0.0.1:21415/report?year=2026"
504 5.147367s

ما الأمر التالي، ولماذا؟ وما الفرق بين هذه الحالة وحالة 502؟ ولماذا قد يكون رفع المهلة Timeout هو الإصلاح الخاطئ؟

الوقت المقترح: 15 دقيقة.

تلميح Hint

الزمن 5.1 ثانية رقم مريب، فهو قريب جداً من رقم مستدير، وهذا يشير إلى مهلة في الإعداد. اقرأ سطر الخطأ في سجل Nginx، ثم قس زمن التطبيق وحده بطلب مباشر من داخل الشبكة دون Nginx.

الحل Answer

شغلنا التطبيق بالمتغير REPORT_SECONDS=8، ووضعنا في إعداد Nginx السطر proxy_read_timeout 5s;. والأمر التالي سجل Nginx، ثم قياس التطبيق مباشرة:

$ docker logs --tail 2 lab-kata-ops-ts-nginx
2026/10/07 08:35:40 [error] 60#60: *15 upstream timed out (110: Operation timed out) while reading response header from upstream, client: 192.168.96.1, server: , request: "GET /report?year=2026 HTTP/1.1", upstream: "http://192.168.96.2:8000/report?year=2026", host: "127.0.0.1:21415"
192.168.96.1 - - [07/Oct/2026:08:35:40 +0000] "GET /report?year=2026 HTTP/1.1" 504 167 "-" "curl/8.12.1" "-"
$ docker exec lab-kata-ops-ts-nginx sh -c 'time wget -qO- http://lab-kata-ops-ts-app:8000/report?year=2026'
ok from 1d9c8ff8381e v1.0
real	0m 8.00s
user	0m 0.00s
sys	0m 0.00s

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

  • 502 تعني أن الاتصال فشل أو جاء رد غير صالح، و504 تعني أن الاتصال نجح والتطبيق لم يرد في المهلة، والرسالة upstream timed out ... while reading response header تقول ذلك حرفياً.
  • التطبيق يأخذ 8 ثوان والمهلة 5، فالإصلاح السهل رفع المهلة، لكنه يخفي السؤال الحقيقي: لماذا يأخذ التقرير 8 ثوان؟ وكل طلب معلق يحجز اتصالاً وخيطاً Thread في التطبيق، ومع عشرين زائراً يطلبون التقرير تتعطل بقية الصفحات.
  • الإصلاح الصحيح للتقارير الثقيلة أن تبنى في الخلفية وترسل عند جهوزها، أو أن تحفظ نتيجتها مسبقاً، وأن تقيس الاستعلام الذي خلفها كما في التمرين 7. والقيمة الافتراضية للمهلة في Nginx هي 60 ثانية، وأي قيمة أخرى قرار يكتب سببه بجانبه.

للتفصيل: حالة 504 في دليل تطبيقك خلف Reverse Proxy، وقسم رموز الحالة في دليل تتبع الطلب البطيء.

المستوى الثاني: متوسط Intermediate

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

التمرين 7: الموقع بطيء للجميع منذ ازدياد العملاء

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

الوقت المقترح: 20 دقيقة.

تلميح Hint

اسأل القاعدة ماذا تفعل الآن بالعرض pg_stat_activity، فإذا تكرر نفس الاستعلام فاطلب خطته بالأمر EXPLAIN (ANALYZE, BUFFERS)، وابحث عن Seq Scan وعن عدد الصفوف المستبعدة Rows Removed by Filter.

الحل Answer

صنعنا الحادثة في PostgreSQL 18 بجدول customers فيه مليونا صف بلا فهرس على البريد، وحمل من عشرة اتصالات بالأداة pgbench:

docker run -d --name lab-kata-ops-ts-pg -e POSTGRES_PASSWORD=example postgres:18-alpine -c max_connections=20
docker exec -i lab-kata-ops-ts-pg psql -U postgres -c "create table customers(id bigserial primary key, email text not null, name text, created_at timestamptz default now()); insert into customers(email,name) select 'user'||g||'@example.com', 'Customer '||g from generate_series(1,2000000) g; analyze customers;"

والأمر التالي pg_stat_activity أثناء الحمل، لأنه يقول ماذا تفعل القاعدة الآن بدل أن تخمن، والمخرج سوف يكون كما يلي:

$ psql -c "select state, left(query, 70) as query, count(*) from pg_stat_activity where backend_type='client backend' and pid <> pg_backend_pid() group by 1,2 order by 3 desc;"
 state  |                                 query                                  | count
--------+------------------------------------------------------------------------+-------
 active | select id, name from customers where email = 'user' || 1083796 || '@ex |     1
 active | select id, name from customers where email = 'user' || 1807715 || '@ex |     1
 active | select id, name from customers where email = 'user' || 1845771 || '@ex |     1
 ...
(10 rows)
$ docker stats --no-stream lab-kata-ops-ts-pg
NAME                 CPU %     MEM USAGE / LIMIT
lab-kata-ops-ts-pg   847.75%   383.8MiB / 31.29GiB

كل الاتصالات العشرة تنفذ نفس الاستعلام، فالخطوة التالية خطته:

$ psql -c "explain (analyze, buffers) select id, name from customers where email = '[email protected]';"
 Gather  (cost=1000.00..33089.77 rows=1 width=24) (actual time=255.492..256.657 rows=1.00 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=13365 read=8308 written=166
   ->  Parallel Seq Scan on customers  (cost=0.00..32089.67 rows=1 width=24) (actual time=242.874..242.875 rows=0.33 loops=3)
         Filter: (email = '[email protected]'::text)
         Rows Removed by Filter: 666666
         Buffers: shared hit=13365 read=8308 written=166
 Execution Time: 256.745 ms

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

$ psql -c '\timing on' -c "create index customers_email_idx on customers (email);"
Timing is on.
CREATE INDEX
Time: 27497.086 ms (00:27.497)
$ psql -c "explain (analyze) select id, name from customers where email = '[email protected]';"
 Index Scan using customers_email_idx on customers  (cost=0.43..8.45 rows=1 width=24) (actual time=0.063..0.064 rows=1.00 loops=1)
   Index Cond: (email = '[email protected]'::text)
 Execution Time: 0.150 ms

ونتيجة pgbench بعشرة اتصالات لمدة 20 ثانية قبل الفهرس وبعده على نفس الجهاز (أرقام توضيحية من جهاز المختبر، والمهم النسبة بينها):

$ pgbench -U postgres -n -c 10 -j 2 -T 20 -f /tmp/lookup.sql postgres     # before the index
number of clients: 10
number of transactions actually processed: 181
number of failed transactions: 0 (0.000%)
latency average = 1134.632 ms
tps = 8.813429 (without initial connection time)
$ pgbench -U postgres -n -c 10 -j 2 -T 20 -f /tmp/lookup.sql postgres     # after the index
number of transactions actually processed: 142679
number of failed transactions: 0 (0.000%)
latency average = 1.400 ms
tps = 7143.857459 (without initial connection time)

والملف lookup.sql يختار عميلاً عشوائياً في كل طلب:

\set n random(1, 2000000)
select id, name from customers where email = 'user' || :n || '@example.com';

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

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

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

التمرين 8: القرص فيه مساحة والكتابة تفشل

نفس الخطأ الذي رأيته في التمرين 3، لكن هذه المرة:

$ echo "{\"id\": 1043}" > /srv/shop/data/order-1043.json
sh: can't create /srv/shop/data/order-1043.json: No space left on device
$ df -h /srv
Filesystem                Size      Used Available Use% Mounted on
tmpfs                    64.0M      7.8M     56.2M  12% /srv

القرص مستخدم بنسبة 12% فقط. ما الأمر التالي، ولماذا؟

الوقت المقترح: 15 دقيقة.

تلميح Hint

كل ملف يحتاج شيئين: مساحة للبيانات، وسجلاً يصفه اسمه Inode، وعدد ال Inodes في نظام الملفات محدود ويحدد عند إنشائه. والأمر df له خيار يعرض ال Inodes بدل المساحة.

الحل Answer

صنعنا الحادثة بقسم فيه 2000 Inode فقط (--tmpfs /srv:size=64m,nr_inodes=2000) وتطبيق ينشئ ملف جلسة صغيراً لكل زائر. والأمر التالي df -i، ثم البحث عن المجلد الذي فيه أكبر عدد من الملفات، والمخرج سوف يكون كما يلي:

$ df -i /srv
Filesystem              Inodes      Used Available Use% Mounted on
tmpfs                     2000      2000         0 100% /srv
$ for d in /srv/shop/*; do echo "$(find $d | wc -l) $d"; done | sort -n
1 /srv/shop/data
1997 /srv/shop/sessions
$ ls /srv/shop/sessions | head -3
sess_0
sess_1
sess_10
$ find /srv/shop/sessions -type f -delete; df -i /srv
Filesystem              Inodes      Used Available Use% Mounted on
tmpfs                     2000         4      1996   0% /srv

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

  • رسالة النواة واحدة No space left on device لنفاد المساحة ولنفاد ال Inodes، والذي يفرق بينهما هو df -h مقابل df -i، فاجعل الأمرين معاً عادة.
  • ال du لا يفيد هنا لأن الملفات صغيرة، والمفيد هو عد الملفات في كل مجلد.
  • في الإنتاج لا تحذف كل الجلسات كما فعلنا في المختبر، وإنما القديمة فقط مثل find /srv/shop/sessions -type f -mmin +1440 -delete، والإصلاح الدائم أن ينظف التطبيق جلساته المنتهية، أو أن تنقل الجلسات إلى Redis أو قاعدة البيانات.

للتفصيل: قسم ال Inodes في دليل تحليل أداء Linux، وصفحة df في دليل Linux.

التمرين 9: too many clients بعد زيادة عدد النسخ

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

psql: error: connection to server at "127.0.0.1", port 5432 failed: FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute

ما الأمر التالي، ولماذا؟ واحسب لماذا يحدث الخطأ مع ثلاث نسخ بالضبط.

الوقت المقترح: 15 دقيقة.

تلميح Hint

المستخدم superuser له مقاعد محجوزة، فادخل به واسأل عن ثلاثة أشياء: max_connections وsuperuser_reserved_connections، ومن يملك الاتصالات الحالية وما حالتها في pg_stat_activity.

الحل Answer

صنعنا الحادثة في نفس قاعدة التمرين السابق (max_connections=20) بمستخدم عادي اسمه shop، و17 اتصالاً مفتوحاً منه لا تفعل شيئاً. والأمر التالي الدخول بالمستخدم postgres الذي له المقاعد المحجوزة:

$ psql -U postgres -c "show max_connections;" -c "show superuser_reserved_connections;"
 max_connections
-----------------
 20
(1 row)

 superuser_reserved_connections
--------------------------------
 3
(1 row)

$ psql -U postgres -c "select usename, application_name, state, count(*), max(now() - state_change) as longest_in_state from pg_stat_activity where backend_type = 'client backend' group by 1,2,3 order by 4 desc;"
 usename  | application_name | state  | count | longest_in_state
----------+------------------+--------+-------+------------------
 shop     | shop-web         | idle   |    17 | 00:00:10.844264
 postgres | psql             | active |     1 | -00:00:00.000001
(2 rows)

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

  • المتاح للمستخدمين العاديين هو 20 ناقص 3 = 17 اتصالاً، ونسختان بستة اتصالات تأخذان 12، أما ثلاث نسخ فتطلب 18، فيرفض الاتصال الثامن عشر، وهذا حساب تجريه قبل أن تزيد النسخ وليس بعدها.
  • الاتصالات كلها idle، أي أن القاعدة لا تعمل بجهد، وإنما ال Pools تحجز اتصالات لا تستخدمها، فرفع max_connections إلى 500 ليس الحل، لأن كل اتصال في PostgreSQL عملية كاملة تأخذ ذاكرة.
  • الإصلاح أن تحسب حجم ال Pool من حد القاعدة مقسوماً على عدد النسخ مع هامش للصيانة، أو أن تضع PgBouncer بين التطبيق والقاعدة فتشترك كل النسخ في عدد صغير من الاتصالات الحقيقية.

للتفصيل: قسم حجم ال Pool في دليل تجميع الاتصالات، وقسم النسخ المتعددة في نفس الدليل، وإعدادات الاتصال في توثيق PostgreSQL.

التمرين 10: 502 والتطبيق يعمل ويرد على نفسه

نسخة جديدة من التطبيق، و502 من Nginx، لكن هذه المرة سجل التطبيق سليم، والمطور يقسم أن التطبيق يعمل لأنه جربه من داخل ال Container:

$ docker logs lab-kata-ops-ts-app
listening on 127.0.0.1:8000 (v1.0)
$ docker exec lab-kata-ops-ts-app python -c "import urllib.request;print(urllib.request.urlopen('http://127.0.0.1:8000/').read().decode().strip())"
ok from bb58b8d9689e v1.0

المنفذ صحيح هذه المرة. ما الأمر التالي، ولماذا؟

الوقت المقترح: 15 دقيقة.

تلميح Hint

جرب من المكان الذي يأتي منه الطلب الحقيقي، أي من Container Nginx. واقرأ أول سطر في السجل مرة أخرى: على أي عنوان يستمع التطبيق؟ ومن يستطيع الوصول إلى هذا العنوان؟

الحل Answer

الأمر التالي هو نفس الطلب من Container Nginx، ثم جدول المنافذ في التطبيق:

$ docker exec lab-kata-ops-ts-nginx wget -qO- http://lab-kata-ops-ts-app:8000/
wget: can't connect to remote host (192.168.96.2): Connection refused
$ docker exec lab-kata-ops-ts-app sh -c "awk 'NR==1 || \$4==\"0A\" {print \$2, \$4}' /proc/net/tcp"
local_address st
0100007F:1F40 0A
0B00007F:8235 0A

وبعد إعادة التشغيل دون BIND=127.0.0.1، أي على 0.0.0.0:

local_address st
0B00007F:ADB5 0A
00000000:1F40 0A

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

  • 0100007F:1F40 تعني 127.0.0.1:8000 (العنوان مقلوب البايتات، و1F40 هي 8000)، أي أن التطبيق يقبل الاتصالات من داخل ال Container فقط، وكل Container له 127.0.0.1 خاص به، فطلب Nginx يصل إلى عنوان الشبكة 192.168.96.2 حيث لا يستمع أحد.
  • بعد الإصلاح أصبح السطر 00000000:1F40 أي كل العناوين، وهذا صحيح داخل Container لأن العزل يأتي من الشبكة ومن عدم نشر المنفذ، وليس من عنوان الاستماع.
  • كثير من أطر العمل تستمع على 127.0.0.1 افتراضياً في وضع التطوير، مثل خادم التطوير في Flask وVite، فعند نقلها إلى Container تحتاج خياراً مثل --host 0.0.0.0.

للتفصيل: قسم التطبيق داخل Container في قائمة تحقق المطور.

التمرين 11: نصف الطلبات تفشل ولا أحد يستطيع إعادة الخطأ

يقول الدعم الفني إن بعض العملاء يرون «internal error» وبعضهم لا، والمطور لا يرى الخطأ عندما يجرب. التطبيق يعمل بنسختين خلف Nginx بهذا الإعداد:

upstream shop {
    server lab-kata-ops-ts-app1:8000;
    server lab-kata-ops-ts-app2:8000;
}

وعشرة طلبات متتالية تعطي:

$ for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " http://127.0.0.1:21415/; done
500 200 500 200 500 200 500 200 500 200

ما الذي يجب أن يكون في السجل حتى تعرف السبب من أول نظرة؟ وما الأمر التالي؟

الوقت المقترح: 15 دقيقة.

تلميح Hint

النمط المتناوب تماماً يشير إلى توزيع الحمل Round Robin بين نسختين. وسجل Nginx الافتراضي لا يذكر النسخة التي ردت، فابحث عن المتغيرين $upstream_addr و$upstream_status والتعليمة log_format.

الحل Answer

السجل يجب أن يذكر النسخة التي ردت على كل طلب، فأضفنا صيغة سجل جديدة:

log_format upstreamlog '$remote_addr "$request" $status upstream=$upstream_addr '
                       'upstream_status=$upstream_status rt=$request_time';

server {
    listen 80;
    access_log /var/log/nginx/upstream.log upstreamlog;
    location / {
        proxy_pass http://shop;
    }
}

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

$ docker exec lab-kata-ops-ts-nginx tail -4 /var/log/nginx/upstream.log
192.168.96.1 "GET / HTTP/1.1" 500 upstream=192.168.96.4:8000 upstream_status=500 rt=0.001
192.168.96.1 "GET / HTTP/1.1" 200 upstream=192.168.96.2:8000 upstream_status=200 rt=0.001
192.168.96.1 "GET / HTTP/1.1" 500 upstream=192.168.96.4:8000 upstream_status=500 rt=0.001
192.168.96.1 "GET / HTTP/1.1" 200 upstream=192.168.96.2:8000 upstream_status=200 rt=0.001
$ docker exec lab-kata-ops-ts-nginx sh -c "awk '{print \$5, \$6}' /var/log/nginx/upstream.log | sort | uniq -c"
      5 200 upstream=192.168.96.2:8000
      5 500 upstream=192.168.96.4:8000
$ docker exec lab-kata-ops-ts-nginx wget -S -qO- http://lab-kata-ops-ts-app1:8000/
  HTTP/1.1 200 OK
$ docker exec lab-kata-ops-ts-nginx wget -S -qO- http://lab-kata-ops-ts-app2:8000/
  HTTP/1.1 500 Internal Server Error
$ docker inspect lab-kata-ops-ts-app1 lab-kata-ops-ts-app2 --format '{{.Name}} {{.Config.Env}}'
/lab-kata-ops-ts-app1 [APP_VERSION=1.4 PYTHON_VERSION=3.13.16 ...]
/lab-kata-ops-ts-app2 [APP_VERSION=1.4 BROKEN=1 PYTHON_VERSION=3.13.16 ...]

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

  • كل الأخطاء من عنوان واحد، فالسبب في النسخة الثانية وحدها، ومقارنة إعداد النسختين تكشف الفرق، وهو في المختبر متغير BROKEN=1، وفي الواقع يكون إعداداً مختلفاً أو نسخة لم تحدث أو قرصاً ممتلئاً على سيرفر واحد.
  • الإصلاح السريع أن تخرج النسخة المعطوبة من التوزيع بإضافة down بجانبها أو بإيقافها، ثم تصلحها بهدوء.
  • نسخة Nginx المفتوحة المصدر لا تعتبر الرد 500 فشلاً في النسخة افتراضياً، فهو يعد أخطاء الاتصال والمهلة فقط حسب proxy_next_upstream، ولذلك استمر يرسل نصف الطلبات إلى النسخة المعطوبة، وهذا سبب وجود فحص الصحة Health Check في موزع الحمل.

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

المستوى الثالث: متقدم Advanced

في هذا المستوى السبب لا يظهر في أي سجل بشكل مباشر، فإما أنه في ذاكرة مؤقتة Cache لا تملكها، أو في سلوك خوارزمية، أو في قفل Lock لا يطبع شيئاً، فتحتاج أن تعرف كيف يعمل الشيء من الداخل حتى تعرف أين تنظر.

التمرين 12: نقلنا الموقع إلى سيرفر جديد ونصف الزوار ما زالوا على القديم

غير الفريق سجل A للنطاق shop.example.test من السيرفر القديم 203.0.113.10 إلى الجديد 203.0.113.20، وأطفأ السيرفر القديم بعد خمس دقائق لأن «لوحة DNS تظهر العنوان الجديد». والآن جزء من الزوار لا يصلون. وهذا ما يظهر من شبكة أحد الزوار:

$ dig @10.250.77.20 shop.example.test +noall +answer
shop.example.test.	255	IN	A	203.0.113.10

ما الأمر التالي، ولماذا؟ واشرح الرقم 255، وماذا كان يجب أن يفعل الفريق قبل التغيير بيوم.

الوقت المقترح: 20 دقيقة.

تلميح Hint

هناك نوعان من سيرفرات DNS: السيرفر الموثوق Authoritative الذي يملك السجل، والسيرفر الذي يسأل نيابة عن الزوار Resolver ويحفظ الجواب. اسأل الاثنين مباشرة وقارن. والرقم في العمود الثاني هو مدة البقاء TTL بالثواني.

الحل Answer

صنعنا الحادثة بسيرفري dnsmasq على شبكة Docker: سيرفر موثوق على 10.250.77.10 يعطي السجل بمدة بقاء 300 ثانية، وResolver على 10.250.77.20 يحفظ الأجوبة، ثم غيرنا السجل في الموثوق. والأمر التالي أن تسأل السيرفر الموثوق مباشرة، ثم ال Resolver، والمخرج سوف يكون كما يلي:

$ dig @10.250.77.20 shop.example.test +noall +answer     # before the change
shop.example.test.	300	IN	A	203.0.113.10
$ dig @10.250.77.10 shop.example.test +noall +answer     # authoritative, after the change
shop.example.test.	300	IN	A	203.0.113.20
$ dig @10.250.77.20 shop.example.test +noall +answer     # visitor's resolver, after the change
shop.example.test.	255	IN	A	203.0.113.10

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

  • السيرفر الموثوق يعطي العنوان الجديد، فالتغيير صحيح، وال Resolver يعطي العنوان القديم ومعه 255 أي أنه سوف يبقيه 255 ثانية أخرى قبل أن يسأل من جديد، والرقم ينقص كل ثانية حتى يصل إلى الصفر.
  • لوحة DNS تسأل السيرفر الموثوق، والزوار يسألون Resolvers كثيرة (مزود الإنترنت، و1.1.1.1، و8.8.8.8، وشبكات الشركات)، وكل واحد منها حفظ الجواب في وقت مختلف، فلا يوجد «انتشار» لحظي، وإنما ذواكر مؤقتة تنتهي واحدة بعد الأخرى.
  • الطريقة الصحيحة أن تخفض TTL إلى 60 أو 300 ثانية قبل التغيير بمدة لا تقل عن TTL القديم (إذا كان يوماً كاملاً فقبل يوم)، ثم تغير السجل، وتبقي السيرفر القديم يعمل أو يحول الطلبات إلى الجديد حتى تنتهي أطول مدة بقاء، ثم ترفع TTL من جديد.
  • لا تستطيع أن تمسح ذاكرة Resolver لا تملكه، فإطفاء السيرفر القديم مبكراً هو الخطأ الذي حول تغييراً عادياً إلى حادثة.

للتفصيل: قسم TTL والانتشار في شرح DNS، وقسم الذاكرة المؤقتة في نفس الشرح.

التمرين 13: المعالج 100% بعد تسجيل مستخدم واحد

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

GET /signup len=11 valid=True regex_ms=0.0
GET /signup len=21 valid=False regex_ms=452.0
GET /signup len=23 valid=False regex_ms=2118.6
GET /signup len=25 valid=False regex_ms=8403.9
GET /signup len=27 valid=False regex_ms=31888.7

والتعبير في الكود:

NAME = re.compile(r"^([a-zA-Z0-9]+\s?)*$")   # "letters, digits and single spaces"

ما الذي تستنتجه من الأرقام قبل أن تقرأ التعبير؟ وما الأمر التالي؟ وأعد كتابة التعبير بطريقة لا يتضاعف فيها الزمن.

الوقت المقترح: 25 دقيقة.

تلميح Hint

كل حرفين إضافيين يضربان الزمن في قرابة أربعة، وهذا نمو أسي. وكل المدخلات البطيئة غير صالحة. ابحث عن التراجع الكارثي Catastrophic Backtracking، وانظر إلى المحدد + داخل مجموعة عليها *.

الحل Answer

الأرقام تقول إن الزمن يتضاعف مع طول المدخل، وإن المدخلات البطيئة كلها مرفوضة، وهذه علامة التراجع الكارثي. وأرسلنا المدخل aaaa…a! بأطوال مختلفة إلى التطبيق على المنفذ 21417، ثم طلباً بطول 30 حرفاً، وقسنا أثناءه:

$ curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" "http://127.0.0.1:21417/signup?name=Ahmad%20Salem"
200 0.231114s
len=21 422 0.510917s
len=23 422 2.232191s
len=25 422 8.472407s
len=27 422 31.968550s
$ docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}' lab-kata-ops-ts-regex
NAME                    CPU %
lab-kata-ops-ts-regex   104.98%

والسبب في التعبير ([a-zA-Z0-9]+\s?)*: المسافة اختيارية، فالنص aaaa يمكن تقسيمه إلى مجموعات بطرق كثيرة جداً (aaaa أو a|aaa أو aa|aa أو a|a|a|a...)، وعندما تفشل المطابقة عند ! في النهاية يجرب المحرك كل التقسيمات قبل أن يقول «لا»، وعددها يتضاعف مع كل حرف. والإصلاح أن تجعل لكل نص طريقة تقسيم واحدة، فالمسافة إلزامية بين الكلمات:

SAFE = re.compile(r'^[a-zA-Z0-9]+(?: [a-zA-Z0-9]+)*\Z')

len=11 valid=True regex_ms=0.007
len=27 valid=False regex_ms=0.005
len=10001 valid=False regex_ms=1.068
len=12 valid=False regex_ms=0.007

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

  • طلب واحد بطول 30 حرفاً يأخذ دقائق ويحجز نواة معالج كاملة، فعشرة طلبات من مخترق تكفي لتعطيل السيرفر، وهذا هجوم معروف باسم ReDoS، وزيادة عدد الطلبات في المراقبة لم تظهر لأنه لا يحتاج عدداً كبيراً.
  • المسار الصحيح للتحقيق هنا: المعالج مرتفع ← أي عملية (docker stats أو top) ← أي طلب (السجل مع الزمن والطول) ← أي سطر في الكود، وأداة تحليل مثل py-spy تعطيك السطر مباشرة لكنها تحتاج صلاحيات إضافية في ال Container.
  • التعبير الجديد يرفض المسافتين المتتاليتين (len=12 valid=False)، وهذا تغيير في السلوك يجب أن تتأكد أنه مقصود، وأضف حداً لطول المدخل قبل التعبير، فلا يوجد اسم مستخدم بعشرة آلاف حرف.
  • بعض المحركات مثل RE2 وRust تضمن زمناً خطياً ولا تتراجع، و.NET يدعم مهلة للتعبير، وكل ذلك في دليل التعبيرات النمطية.

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

التمرين 14: تحديث المخزون معلق والمعالج خامل

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

$ time psql -c "set statement_timeout = 5000" -c "update stock set qty = qty - 1 where product_id = 1"
SET
ERROR:  canceling statement due to statement timeout
CONTEXT:  while updating tuple (0,1) in relation "stock"
real	0m 5.03s
$ psql -c "update stock set qty = qty - 1 where product_id = 2"
UPDATE 1

ما الأمر التالي، ولماذا؟ وأكمل حتى تحرر التحديث، ثم اذكر الإعداد الذي يمنع تكرار الحادثة.

الوقت المقترح: 25 دقيقة.

تلميح Hint

التحديث لا يعمل وإنما ينتظر، والقاعدة تعرف من ينتظر ومن يمنعه: الأعمدة wait_event_type وstate في pg_stat_activity، والدالة pg_blocking_pids(). وابحث عن الحالة idle in transaction.

الحل Answer

صنعنا الحادثة بجلسة فتحت معاملة Transaction وحدثت المنتج 1 ثم توقفت دون commit، كما يحدث عندما يفتح الكود معاملة ثم ينتظر خدمة خارجية أو يرمي استثناء لا يغلقها. والأمر التالي pg_stat_activity مع الدالة pg_blocking_pids بينما تحديث آخر ينتظر:

$ psql -c "select pid, state, wait_event_type, wait_event, pg_blocking_pids(pid) as blocked_by, now() - xact_start as xact_age, left(query, 50) as query from pg_stat_activity where backend_type = 'client backend' and pid <> pg_backend_pid() order by xact_start;"
 pid |        state        | wait_event_type |  wait_event   | blocked_by |    xact_age     |                       query
-----+---------------------+-----------------+---------------+------------+-----------------+----------------------------------------------------
 136 | idle in transaction | Client          | ClientRead    | {}         | 00:00:33.108682 | update stock set qty = qty - 1 where product_id =
 160 | active              | Lock            | transactionid | {136}      | 00:00:07.554467 | update stock set qty = qty - 1 where product_id =
(2 rows)

الجلسة 160 تنتظر قفلاً تملكه الجلسة 136، والجلسة 136 لا تفعل شيئاً منذ 33 ثانية وهي داخل معاملة. والإصلاح الفوري إنهاؤها، ثم إعداد يمنع التكرار:

$ psql -c "select pg_terminate_backend(136);"
 pg_terminate_backend
----------------------
 t
(1 row)

$ psql -c "select * from stock order by 1;"
 product_id | qty
------------+-----
          1 |  49
          2 |   9
(2 rows)

$ psql -c "alter system set idle_in_transaction_session_timeout = '60s';" -c "select pg_reload_conf();"
$ psql -c "show idle_in_transaction_session_timeout;"
 idle_in_transaction_session_timeout
-------------------------------------
 1min
(1 row)

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

  • الكمية أصبحت 49 وليس 48: تحديث الجلسة 136 ألغي Rollback عند إنهائها، والتحديث المنتظر 160 أكمل فوراً، وهذا ما تريده، لكن تأكد دائماً ماذا كانت الجلسة التي تنهيها تفعل.
  • المعالج خامل لأن الانتظار على قفل لا يستهلك شيئاً، ولهذا لا تظهر المشكلة في مراقبة الموارد، وتظهر فقط في الزمن وفي wait_event_type = Lock.
  • الإعداد idle_in_transaction_session_timeout ينهي أي جلسة تبقى خاملة داخل معاملة أكثر من دقيقة، والإصلاح الحقيقي في الكود: لا تنتظر شيئاً خارجياً داخل معاملة، وأغلق المعاملة في finally أو بأداة اللغة التي تغلقها تلقائياً.

للتفصيل: تجربة القفل في دليل تتبع الطلب البطيء، والدالة pg_blocking_pids في توثيق PostgreSQL.

التمرين 15: 502 بعد كل إعادة نشر حتى يعاد تشغيل Nginx

نمط غريب يتكرر: كل نشر للتطبيق ينتهي ب 502 لكل الزوار، والحل الذي تعلمه الفريق هو nginx -s reload بعد كل نشر. ونسي أحدهم هذه الخطوة اليوم:

--- before deploy
ok from 1c7d5285fecd v1.0
app IP 192.168.96.2
--- after deploy
502 502 502
$ docker logs --tail 2 lab-kata-ops-ts-nginx
2026/10/07 08:40:27 [error] 22#22: *7 connect() failed (111: Connection refused) while connecting to upstream, client: 192.168.96.1, server: , request: "GET / HTTP/1.1", upstream: "http://192.168.96.2:8000/", host: "127.0.0.1:21415"

ال Container الجديد يعمل وسجله سليم. ما الأمر التالي الذي يكشف السبب، ولماذا يصلحه ال Reload؟ وأصلح الإعداد حتى لا يحتاج النشر إلى Reload.

الوقت المقترح: 30 دقيقة.

تلميح Hint

قارن العنوان الذي يذكره Nginx في السطر upstream: بالعنوان الحالي لاسم التطبيق في DNS الداخلي لشبكة Docker. ومتى يحول Nginx الاسم المكتوب في proxy_pass إلى عنوان؟ ابحث في توثيق التعليمة resolver.

الحل Answer

الأمر التالي أن تسأل DNS الشبكة عن عنوان التطبيق الآن، وتقارنه بما يستخدمه Nginx:

$ docker exec lab-kata-ops-ts-nginx getent hosts lab-kata-ops-ts-app
192.168.96.4      lab-kata-ops-ts-app  lab-kata-ops-ts-app
$ docker inspect lab-kata-ops-ts-worker --format '{{(index .NetworkSettings.Networks "lab-kata-ops-ts").IPAddress}}'
192.168.96.2

التطبيق الجديد على 192.168.96.4، وNginx ما زال يرسل إلى 192.168.96.2، وهذا العنوان أخذه Container آخر (Worker) بدأ أثناء النشر، فالرفض يأتي منه. والسبب أن Nginx يحول الاسم المكتوب في proxy_pass إلى عنوان مرة واحدة عند بدء التشغيل أو ال Reload، ولا يسأل بعد ذلك، ولهذا يصلحه ال Reload. وفي المختبر صنعنا الحادثة بحذف التطبيق وتشغيل Container آخر قبله حتى يأخذ العنوان القديم، ثم تشغيل التطبيق من جديد. والإصلاح أن تجعل Nginx يسأل DNS الخاص بDocker على 127.0.0.11 وأن تضع الاسم في متغير، لأن Nginx لا يستخدم ال resolver إلا عندما يكون العنوان في proxy_pass متغيراً:

server {
    listen 80;
    resolver 127.0.0.11 valid=10s ipv6=off;

    location / {
        set $app http://lab-kata-ops-ts-app:8000;
        proxy_pass $app;
    }
}

وبعد Reload واحد لتحميل هذا الإعداد، نشرنا نسخة جديدة بنفس الطريقة (Container آخر يأخذ العنوان القديم، والتطبيق على عنوان جديد)، دون أي Reload بعدها:

new app IP 192.168.96.5
200 200 200 200 200 200
ok from 3eb29d8bbdde v1.6

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

  • العطل لا يحدث في كل نشر في الواقع، فإذا أخذ التطبيق الجديد نفس العنوان القديم يعمل كل شيء، ولذلك يبدو عشوائياً، وهذا أصعب نوع من الأعطال لأن الفريق يتعلم «حلاً» دون أن يعرف السبب.
  • valid=10s يجعل Nginx يحفظ الجواب عشر ثوان فقط، ف DNS الخاص بDocker لا يعطي مدة بقاء طويلة، وبعض الإعدادات تضع أقل من ذلك.
  • مع المتغير في proxy_pass يتغير سلوك المسار: يرسل Nginx المسار كما جاء ما لم تكتب URI، فانتبه إذا كان الإعداد القديم يعتمد على استبدال المسار كما في تمرين الشرطة في تمارين DevOps.
  • Nginx من النسخة 1.27.3 يدعم أيضاً في النسخة المفتوحة المصدر الخيار resolve لكل server داخل upstream مع التعليمة zone، وهو الطريق الأفضل عندما يكون لديك أكثر من نسخة.

للتفصيل: التعليمة resolver في توثيق Nginx، وDNS الداخلي في توثيق شبكات Docker، وقسم استكشاف الأخطاء في دليل شبكات Docker.

جدول المتابعة

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

| #  | المستوى  | الحادثة                                  | التاريخ | الوقت | عدد الأوامر | تلميح؟ |
|----|----------|------------------------------------------|---------|-------|-------------|--------|
| 1  | مبتدئ    | 502 والتطبيق يتوقف                       |         |       |             |        |
| 2  | مبتدئ    | 502 ومنفذ خاطئ                           |         |       |             |        |
| 3  | مبتدئ    | القرص ممتلئ بالسجلات                     |         |       |             |        |
| 4  | مبتدئ    | الخروج بالرمز 137                        |         |       |             |        |
| 5  | مبتدئ    | شهادة منتهية                             |         |       |             |        |
| 6  | مبتدئ    | 504 على صفحة التقرير                     |         |       |             |        |
| 7  | متوسط    | بطء للجميع وفهرس ناقص                    |         |       |             |        |
| 8  | متوسط    | نفاد ال Inodes                           |         |       |             |        |
| 9  | متوسط    | too many clients                         |         |       |             |        |
| 10 | متوسط    | 502 والتطبيق على 127.0.0.1               |         |       |             |        |
| 11 | متوسط    | نسخة معطوبة من اثنتين                    |         |       |             |        |
| 12 | متقدم    | TTL وتغيير DNS                           |         |       |             |        |
| 13 | متقدم    | Regex والمعالج 100%                      |         |       |             |        |
| 14 | متقدم    | قفل من جلسة idle in transaction          |         |       |             |        |
| 15 | متقدم    | Nginx يحفظ العنوان القديم                |         |       |             |        |

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

docker rm -f $(docker ps -aq --filter name=^lab-kata-ops-ts-)
docker network rm lab-kata-ops-ts lab-kata-ops-dnsnet
docker rmi lab-kata-ops-dns

الخلاصة

وصلنا لنهاية تمارين تشخيص الأعطال، وأهم ما تخرج به منها:

  • ابدأ من الطرف الذي أعطى الخطأ: 502 و504 يكتب Nginx سببهما في سجل الأخطاء، وConnection refused تعني لا أحد يستمع، وtimed out تعني أن أحداً يستمع ولا يرد.
  • الحالة Up لا تعني أن التطبيق سليم، فانظر إلى عدد مرات إعادة التشغيل ورمز الخروج والحقل OOMKilled، وجرب الاتصال من المكان الذي يأتي منه الطلب الحقيقي، وليس من داخل التطبيق.
  • «No space left on device» لها سببان: المساحة و ال Inodes، فشغل df -h وdf -i معاً، وأفرغ السجل المفتوح بالأمر truncate وليس rm.
  • عندما تكون القاعدة هي المشكلة فاسألها ماذا تفعل: pg_stat_activity للاستعلامات والاتصالات والأقفال، وEXPLAIN ANALYZE للخطة، والسيرفر الأكبر لا يصلح فهرساً ناقصاً ولا قفلاً معلقاً.
  • بعض الأسباب في ذاكرة مؤقتة لا تراها: ال Resolver يحفظ سجل DNS حتى ينتهي TTL، وNginx يحفظ عنوان التطبيق حتى ال Reload، فاعرف من يحفظ ماذا ولكم من الوقت.

وهذه المقالات التي تشرح كل موضوع في التمارين بالتفصيل:

دليلاستكشاف الأخطاء وتحليل الأداء للمبتدئين: من العرض إلى السببعندما يبطئ الموقع أو يعطي 502، هذه طريقة منظمة توصلك من العرض إلى السبب قبل أن تخمن أو تشتري سيرفراً أكبر.دليلتحليل أداء سيرفر Linux في 60 ثانية: ماذا تكتب أولاً؟ماذا تكتب أول ما تدخل إلى سيرفر بطيء، وكيف تعرف خلال دقيقة هل المشكلة في المعالج أم الذاكرة أم القرص أم الشبكة.دليلتتبع الطلب البطيء من المتصفح إلى قاعدة البياناتكيف تأخذ طلباً واحداً بطيئاً أو فاشلاً وتعرف في أي طبقة ضاع وقته، بقياسات حقيقية في كل طبقة بدل التخمين.دليلConnection Pooling: لماذا الاتصال بقاعدة البيانات مكلفلماذا تظهر رسالة too many clients عندما تزيد نسخ تطبيقك، وكيف يحل مجمع الاتصالات وPgBouncer هذه المشكلة.دليلالسجلات Logging للمطورين: ماذا تسجل وماذا لا تسجلكيف تكتب سجلات Logs تساعدك على فهم ما حدث في الإنتاج، وما الذي لا يدخل السجل أبداً، وكيف تجمعها في مكان واحد.دليلشرح DNS وسجلاته للمبتدئين بمثال حقيقيكيف يتحول اسم نطاقك إلى عنوان سيرفرك، وما وظيفة كل سجل تضيفه في لوحة النطاق، والأخطاء التي تضيع فيها الساعات عادة.

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

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

  • أكتوبر 2026: كتابة الحوادث الخمس عشرة، وإعادة إنتاج كل حادثة على Docker Engine 29.8.0 (Docker Desktop) باستخدام python:3.13-slim (Python 3.13.16) وnginx:1.29-alpine (Nginx 1.29.8) وpostgres:18-alpine وalpine:3.22 مع OpenSSL 3.5.9 وdnsmasq وbind-tools من حزم Alpine، وcurl 8.17.0 من nginx:1.29-alpine. أرقام pgbench توضيحية من جهاز المختبر، والمهم فيها النسبة بين القياسين على نفس الجهاز.
نشرة عرب رووت | ArabRoot

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

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

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

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