> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# تمارين Kata في DevOps بثلاثة مستويات مع الحلول
- URL: https://arabroot.io/articles/تمارين-kata-في-devops/
- Published: 2026-10-06T14:30:00.000Z
- Updated: 2026-10-09T09:44:13.000Z
- Description: تمارين DevOps تحلها بيدك دون ذكاء اصطناعي، من Dockerfile البطيء إلى التراجع عن نشر فاشل، وكل حل مجرب في Docker.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, أساسيات الحاسب والبرمجة, DevOps وCI/CD, Docker والحاويات, أساسيات, الاستضافة الذاتية

في مقال [لماذا تتمرن دون ذكاء اصطناعي؟](https://arabroot.io/articles/%D8%AA%D9%85%D8%A7%D8%B1%D9%8A%D9%86-kata-%D8%AD%D8%A7%D9%81%D8%B8-%D8%B9%D9%84%D9%89-%D9%85%D9%87%D8%A7%D8%B1%D8%AA%D9%83-%D8%AF%D9%88%D9%86-%D8%B0%D9%83%D8%A7%D8%A1-%D8%A7%D8%B5%D8%B7%D9%86%D8%A7%D8%B9%D9%8A/) شرحنا لماذا تضعف المهارة التي لا تستخدمها، وكيف تتمرن بطريقة Kata وحدك أو مع فريقك، وهذا المقال يطبق الطريقة على مجال DevOps: ملف Dockerfile الذي يبني في عشرين ثانية بدل ثانية واحدة، وملف Compose الذي لا يصل فيه التطبيق إلى قاعدة البيانات، وإعداد Nginx الذي يأكل جزءاً من الرابط، وأوامر Git التي تحتاجها في اللحظة التي ضاع فيها عمل يومين، وملف CI يطبع التوكن Token في السجل. والسبب في اختيار هذه المواضيع أن الخطأ فيها لا يظهر عادة كرسالة واضحة، وإنما كنشر بطيء أو 502 أو سر مكشوف، والذي يحل المشكلة بسرعة هو من يعرف ماذا يفعل كل سطر، وليس من ينسخ إعداداً جاهزاً.

وطريقة الاستخدام قصيرة: أغلق أي مساعد ذكاء اصطناعي، واقرأ التمرين، واضبط مؤقتاً على الوقت المقترح، ثم اكتب إجابتك كاملة في ملف قبل أن تفتح أي شيء، سواء كانت الإجابة سطراً في Dockerfile أو أمر Git أو جملة تشرح فيها السبب. المسموح لك هو التوثيق الرسمي و`--help`، وإذا علقت فافتح التلميح Hint ثم حاول مرة أخرى، وبعدها فقط افتح الحل Answer وقارنه بإجابتك، وفي النهاية سجل الوقت في [جدول المتابعة](#tracking) وارجع إلى التمرين بعد أسبوع.

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

- تجهيز المختبر Lab: Docker وحده يكفي، و Docker Image صغير فيه Git لتمارين Git.
- المستوى الأول: ترتيب طبقات Dockerfile، وتشغيل التطبيق بمستخدم غير root، و`localhost` داخل Compose، والتراجع عن Commit منشور، وحل تعارض Merge، وترويسات ال Reverse Proxy.
- المستوى الثاني: سر بقي في طبقة، والبناء متعدد المراحل Multi-stage Build، وترتيب التشغيل بفحص الصحة Healthcheck، وصلاحيات ال Volume، والشرطة المائلة في `proxy_pass`، وWebSocket، والسكربت المتكرر Idempotent.
- المستوى الثالث: استعادة Commit ضائع بالأمر `reflog`، ونقل إصلاح بالأمر `cherry-pick`، والبحث عن Commit المعطوب بالأمر `bisect run`، ومراجعة ملف CI، وترقية نفس ال Image بين البيئات، واختيار طريقة التراجع Rollback.
- جدول متابعة تنسخه لتسجل تقدمك، وروابط المقالات التي تشرح كل موضوع بالتفصيل.

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

![ثلاثة أعمدة ملونة: في اليمين المستوى الأول Beginner بالأخضر من 10 إلى 15 دقيقة، وفيه التمارين 1 إلى 6: ترتيب طبقات Dockerfile، وContainer يعمل بالمستخدم root، وlocalhost مقابل اسم الخدمة، والتراجع عن Commit منشور بالأمر revert، وحل تعارض Merge، وترويسات ال Proxy الناقصة. وفي الوسط المستوى الثاني Intermediate بالأصفر من 15 إلى 20 دقيقة، وفيه التمارين 7 إلى 13: سر بقي في طبقة، وتصغير Image من 1.38 GB إلى 15 MB، وdepends_on مع healthcheck، ورفض صلاحية ال Volume، والشرطة في proxy_pass، وترقية WebSocket، وسكربت إعداد يتكرر بأمان. وفي اليسار المستوى الثالث Advanced بالأحمر من 20 إلى 30 دقيقة، وفيه التمارين 14 إلى 19: Commit ضائع وreflog، وإصلاح بالأمر cherry-pick -x، والبحث بالأمر bisect run، ومراجعة ملف CI، والبناء مرة واحدة ثم الترقية، والتراجع مع ترحيلات قاعدة البيانات](https://arabroot.io/content/images/2026/10/katas-devops-01-levels-map.webp)

كل مستوى يبني على الذي قبله، فابدأ من الأول حتى لو بدا لك سهلاً

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

كل التمارين تعمل على جهازك بشرط وجود Docker Engine أو Docker Desktop، فتمارين Dockerfile وCompose وNginx تبني وتشغل Containers حقيقية، وتمارين Git تعمل داخل Container صغير من Alpine فيه Git، حتى لا تختلط بمستودعاتك ولا تظهر لك تحذيرات نهايات الأسطر في Windows. والأسماء كلها تبدأ بالبادئة `lab-kata-ops-` حتى تحذفها بالاسم في النهاية، والمنافذ على `127.0.0.1` فقط من 21410 إلى 21412\. جهز Docker Image الخاص بتمارين Git أولاً:

```bash
mkdir -p kata-ops/gitimg && cd kata-ops/gitimg
cat > Dockerfile <<'EOF'
FROM alpine:3.22
RUN apk add --no-cache git
RUN git config --global user.name "Lab" && git config --global user.email lab@example.test \
 && git config --global init.defaultBranch main && git config --global advice.detachedHead false
WORKDIR /work
EOF
docker build -q -t lab-kata-ops-git . && docker run --rm lab-kata-ops-git git --version

```

والمخرج سوف يكون كما يلي (الرقم الأول هو معرف ال Image ويختلف عندك):

```text
sha256:e6995ad347fe380658f845131c455d924387e426a6c3737faa9d6c70f1e40a81
git version 2.49.1

```

وكل تمرين Git يبدأ مستودعاً جديداً داخل Container يحذف نفسه بعد الخروج (`--rm`)، فتكتب الأوامر في سكربت وتمرره هكذا: `docker run --rm -i lab-kata-ops-git sh -s < kata.sh`، أو تفتح طرفية تفاعلية بالأمر `docker run --rm -it lab-kata-ops-git sh` وتكتب فيها بنفسك، وهذا أفضل للتمرن. وأرقام ال Commits في المخرجات تختلف عندك في كل مرة لأنها هاش Hash يدخل فيه وقت الإنشاء.

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

تمارين هذا المستوى أخطاء تراها في أول أسبوع لك مع Docker وGit وNginx، والمطلوب فيها أن تعرف السبب من قراءة الملف قبل أن تشغله.

### التمرين 1: كل تعديل صغير يعيد تثبيت المكتبات

لديك تطبيق Python صغير بثلاثة ملفات: `app.py` و`requirements.txt` فيه سطر واحد `requests==2.32.5`، وهذا ال Dockerfile:

```dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "app.py"]

```

يشتكي الفريق أن كل تعديل في سطر واحد من `app.py` يجعل البناء يأخذ قرابة عشرين ثانية، وأن خط CI يحمل نفس المكتبات من الإنترنت في كل مرة. اشرح لماذا، وأعد ترتيب الملف حتى يأخذ البناء بعد تعديل الكود ثانية واحدة تقريباً، ثم اذكر الملف الإضافي الذي يقلل ما يرسل إلى البناء.

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

تلميح Hint

كل سطر في Dockerfile طبقة Layer، وDocker يعيد استخدام الطبقة من ذاكرة البناء Build Cache إذا لم يتغير السطر ولم تتغير الملفات التي ينسخها، وإذا تغيرت طبقة فكل ما بعدها يبنى من جديد. فاسأل نفسك: ما الذي يتغير كثيراً، وما الذي يتغير نادراً؟

الحل Answer 

السطر `COPY . .` ينسخ كل المشروع قبل تثبيت المكتبات، فأي تعديل في `app.py` يغير هذه الطبقة، وبالتالي يبطل كل ما بعدها ومنه `pip install`. وهذا ما رأيناه عند البناء بعد تعديل سطر واحد في الكود (اختصرنا المخرج إلى الخطوات):

```text
#6 [2/4] WORKDIR /app
#6 CACHED
#7 [3/4] COPY . .
#7 DONE 0.6s
#8 [4/4] RUN pip install --no-cache-dir -r requirements.txt
#8 18.98 Successfully installed certifi-2026.7.22 charset_normalizer-3.5.2 idna-3.20 requests-2.32.5 urllib3-2.8.0
#8 DONE 19.9s

```

والحل أن تنسخ ملف المكتبات وحده أولاً وتثبتها، ثم تنسخ بقية الكود، لأن `requirements.txt` يتغير نادراً والكود يتغير في كل Commit:

```dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]

```

وبعد تعديل الكود مرة أخرى وإعادة البناء، والمخرج سوف يكون كما يلي:

```text
#6 [2/5] WORKDIR /app
#6 CACHED
#7 [3/5] COPY requirements.txt .
#7 CACHED
#8 [4/5] RUN pip install --no-cache-dir -r requirements.txt
#8 CACHED
#9 [5/5] COPY . .
#9 DONE 1.0s
$ docker run --rm lab-kata-ops-cache:good
app v3, requests 2.32.5

```

في الإعداد أعلاه لاحظ التالي:

- الطبقات الثلاث الأولى أصبحت `CACHED`، والخطوة الوحيدة التي تنفذ هي نسخ الكود، وهذه القاعدة نفسها في Node.js (`package.json` و`package-lock.json` ثم `npm ci`) وفي .NET (ملف `.csproj` ثم `dotnet restore`).
- الملف الإضافي هو `.dockerignore`، تضع فيه `.git` و`node_modules` و`.env` والسجلات، فلا ترسل إلى البناء ولا تبطل الطبقة `COPY . .` عندما تتغير، ولا تدخل في ال Image أسرار من ملف `.env`.

للتفصيل: [توثيق Docker عن ترتيب الطبقات وذاكرة البناء](https://docs.docker.com/build/cache/optimize/?ref=arabroot.io)، و[قسم نظافة ال Image في قائمة تحقق المطور](https://arabroot.io/articles/%D9%82%D8%A7%D8%A6%D9%85%D8%A9-%D8%AA%D8%AD%D9%82%D9%82-%D8%A7%D9%84%D9%85%D8%B7%D9%88%D8%B1-%D9%82%D8%A8%D9%84-%D9%86%D8%B4%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%87/#image-hygiene).

### التمرين 2: التطبيق يعمل بالمستخدم root داخل ال Container

بعد إصلاح التمرين السابق شغل أحد الزملاء الأمر `docker run --rm lab-kata-ops-cache:good id`، وظهر له السطر `uid=0(root) gid=0(root) groups=0(root)`، وقال إن هذا لا يهم لأن التطبيق داخل Container. هل كلامه صحيح؟ وعدل ال Dockerfile حتى يعمل التطبيق بمستخدم عادي رقمه 10001، مع بقاء الملفات قابلة للقراءة له، ثم أثبت بأمرين أن التعديل نجح.

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

تلميح Hint

المستخدم root داخل ال Container هو نفس الرقم 0 الذي تعرفه النواة Kernel، وأي ثغرة في التطبيق أو في ال Runtime تبدأ من صلاحياته. وتحتاج سطرين: سطر ينشئ المستخدم قبل نسخ الكود، والتعليمة `USER`، ولاحظ أن `COPY` يكتب الملفات بمالك root ما لم تطلب غير ذلك.

الحل Answer 

كلامه غير صحيح، فال Container يعزل العمليات عن بعضها لكنه يشترك مع السيرفر في نفس النواة، والعملية التي تعمل بالرقم 0 تملك داخل ال Container كل شيء، فإذا استغل المخترق ثغرة في التطبيق فهو root في نظام ملفات ال Container، وأي خطأ في الإعداد مثل ربط مجلد من السيرفر أو ملف `docker.sock` يصبح طريقاً إلى السيرفر نفسه. والحل أن تنشئ مستخدماً عادياً وتنتقل إليه:

```dockerfile
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
RUN useradd --system --uid 10001 --no-create-home app
COPY --chown=app:app . .
USER app
CMD ["python", "app.py"]

```

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

```text
$ docker run --rm lab-kata-ops-user id
uid=10001(app) gid=999(app) groups=999(app)
$ docker run --rm lab-kata-ops-user
app v3, requests 2.32.5
$ docker image inspect lab-kata-ops-user --format '{{.Config.User}}'
app
$ docker run --rm lab-kata-ops-user sh -c 'touch /etc/x'
touch: cannot touch '/etc/x': Permission denied

```

في الإعداد أعلاه لاحظ التالي:

- التثبيت بالأمر `pip install` يبقى قبل `USER` لأنه يكتب في مجلدات النظام، وكل ما بعد `USER` يعمل بالمستخدم الجديد، ومنه الأمر `CMD`.
- `--chown=app:app` يجعل ملفات الكود ملكاً للمستخدم، وإذا كان التطبيق يقرأ فقط فيمكنك أن تتركها ملكاً ل root وهذا أفضل، لأن التطبيق عندها لا يستطيع تعديل كوده إذا اخترق.
- رقم المجموعة 999 جاء من الخيار `--system` الذي يختار رقماً من نطاق حسابات النظام، وإذا أردت رقماً ثابتاً فأنشئ المجموعة أولاً بالأمر `groupadd --gid`.

للتفصيل: [التعليمة USER في مرجع Dockerfile](https://docs.docker.com/reference/dockerfile/?ref=arabroot.io#user)، و[قسم المستخدم والصلاحيات في قائمة تحقق المطور](https://arabroot.io/articles/%D9%82%D8%A7%D8%A6%D9%85%D8%A9-%D8%AA%D8%AD%D9%82%D9%82-%D8%A7%D9%84%D9%85%D8%B7%D9%88%D8%B1-%D9%82%D8%A8%D9%84-%D9%86%D8%B4%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%87/#volumes-permissions).

### التمرين 3: التطبيق لا يصل إلى قاعدة البيانات في Compose

هذا ملف `compose.yaml` فيه قاعدة PostgreSQL، و«تطبيق» نمثله هنا ب Docker Image نفسه لأنه يحتوي على `pg_isready` و`psql`، والتطبيق يقرأ عنوان القاعدة من المتغير `DB_HOST`:

```yaml
services:
  db:
    image: postgres:18-alpine
    container_name: lab-kata-ops-db
    environment:
      POSTGRES_PASSWORD: example
  app:
    image: postgres:18-alpine
    container_name: lab-kata-ops-app
    depends_on: [db]
    environment:
      DB_HOST: localhost
    command: sh -c 'sleep 5; pg_isready -h "$$DB_HOST" -p 5432; psql "postgresql://postgres:example@$$DB_HOST:5432/postgres" -c "select 1 as ok"'

```

عند التشغيل بالأمر `docker compose -p lab-kata-ops-d3 up --abort-on-container-exit --exit-code-from app` ظهر هذا المخرج من التطبيق:

```text
lab-kata-ops-app  | localhost:5432 - no response
lab-kata-ops-app  | psql: error: connection to server at "localhost" (::1), port 5432 failed: Connection refused
lab-kata-ops-app  | 	Is the server running on that host and accepting TCP/IP connections?
lab-kata-ops-app  | connection to server at "localhost" (127.0.0.1), port 5432 failed: Connection refused
lab-kata-ops-app  | 	Is the server running on that host and accepting TCP/IP connections?
lab-kata-ops-app exited with code 2

```

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

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

تلميح Hint

لكل Container شبكة خاصة به وعنوان `localhost` خاص به. فمن هو `localhost` عندما يسأل عنه ال Container المسمى `app`؟ وكيف يعرف Compose الخدمات على الشبكة التي ينشئها؟

الحل Answer 

`localhost` داخل ال Container يعني ال Container نفسه، وليس السيرفر ولا ال Container الآخر، ولذلك جاء الرفض Connection refused من داخل `app` لأنه لا يوجد شيء يستمع فيه على المنفذ 5432\. وCompose ينشئ شبكة افتراضية للمشروع ويسجل فيها كل خدمة باسمها، فالعنوان الصحيح هو اسم الخدمة `db`:

```yaml
    environment:
      DB_HOST: db

```

وعند التشغيل مرة أخرى، والمخرج سوف يكون كما يلي:

```text
lab-kata-ops-app  | db:5432 - accepting connections
lab-kata-ops-app  |  ok
lab-kata-ops-app  | ----
lab-kata-ops-app  |   1
lab-kata-ops-app  | (1 row)

```

ولاحظ أننا رفعنا الانتظار في هذا التشغيل إلى `sleep 20`، لأن المحاولة الأولى مع `db` و`sleep 5` أعطت `db:5432 - no response` ثم `connection to server at "db" (192.168.112.2), port 5432 failed: Connection refused`، أي أن الاسم صحيح والقاعدة لم تجهز بعد، وهذه مشكلة مختلفة تماماً نحلها بالطريقة الصحيحة في [التمرين 9](#kata-devops-9)، لأن الانتظار بعدد ثوان ثابت ليس حلاً. وطريقة التفريق بين المشكلتين في رسالة الخطأ نفسها: مع `localhost` ترى العنوان `127.0.0.1` و`::1`، ومع اسم الخدمة ترى عنوان ال Container الآخر في الشبكة.

للتفصيل: [الشبكات في توثيق Compose](https://docs.docker.com/compose/how-tos/networking/?ref=arabroot.io)، و[قسم شبكات Compose في دليل شبكات Docker](https://arabroot.io/articles/%D8%B4%D8%A8%D9%83%D8%A7%D8%AA-docker-%D9%88%D8%A3%D9%81%D8%B6%D9%84-%D8%A7%D9%84%D9%85%D9%85%D8%A7%D8%B1%D8%B3%D8%A7%D8%AA/#compose-networks).

### التمرين 4: التراجع عن Commit وصل إلى الفرع الرئيسي

هذا تاريخ الفرع `main`، وقد دفعته Push إلى المستودع المشترك قبل ساعة، وسحبه Pull اثنان من زملائك:

```text
085e333 Set discount to 90 for testing
7a6404b Add discount setting
8677838 Add pricing config

```

ال Commit الأخير جعل الخصم 90% وكان للتجربة فقط. اكتب الأوامر التي تلغي أثره في الفرع المشترك دون أن تكسر نسخ زملائك، واشرح ماذا يحدث لو استخدمت `git reset --hard HEAD~1` ثم `git push`.

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

تلميح Hint

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

الحل Answer 

الطريقة الصحيحة هي `git revert`، فهو ينشئ Commit جديداً عكس ال Commit المحدد، والتاريخ القديم يبقى كما هو، فيسحبه زملاؤك كأي تغيير عادي. والمخرج الكامل للتجربة، بما فيها الطريقة الخاطئة أولاً، سوف يكون كما يلي:

```text
$ git reset --hard HEAD~1 && git push origin main
To /work/origin.git
 ! [rejected]        main -> main (non-fast-forward)
error: failed to push some refs to '/work/origin.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
$ git revert --no-edit HEAD
[main 3e23d1b] Revert "Set discount to 90 for testing"
 Date: Wed Oct 7 08:14:03 2026 +0000
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git push origin main
To /work/origin.git
   085e333..3e23d1b  main -> main
$ git log --oneline
3e23d1b Revert "Set discount to 90 for testing"
085e333 Set discount to 90 for testing
7a6404b Add discount setting
8677838 Add pricing config
$ cat pricing.conf
price = 100
discount = 0

```

في الإعداد أعلاه لاحظ التالي:

- بعد `reset` أصبح فرعك المحلي خلف الفرع البعيد، فرفض الخادم ال Push لأنه ليس تقدماً مباشراً Fast-forward، والطريق الوحيد لإجباره هو `--force`، وهذا يعيد كتابة تاريخ سحبه زملاؤك، فيعود ال Commit المحذوف مع أول Push من أحدهم أو تضيع أعمالهم.
- `git revert` لا يحذف شيئاً، ولذلك يبقى ال Commit الخاطئ في التاريخ ويبقى معه سبب التراجع، وهذا ما تريده في الفرع المشترك.
- إذا كان ال Commit المراد عكسه هو Merge فتحتاج الخيار `-m 1` لتحدد الطرف الذي تبقى عليه.

للتفصيل: [قسم revert في دليل Git للمبتدئين](https://arabroot.io/articles/%D8%A7%D8%AF%D8%A7%D8%B1%D8%A9-%D8%A7%D9%84%D9%86%D8%B3%D8%AE-%D9%88-git-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#revert)، و[توثيق git revert](https://git-scm.com/docs/git-revert?ref=arabroot.io).

### التمرين 5: تعارض في ملف الإعداد عند الدمج

في الفرع `main` رفع أحد الزملاء قيمة `timeout` في الملف `app.conf` من 30 إلى 45 لتقارير المبيعات، وفي الفرع `feature/longer-timeout` رفعها زميل آخر إلى 60\. عند الدمج ظهر ما يلي:

```text
$ git merge feature/longer-timeout
Auto-merging app.conf
CONFLICT (content): Merge conflict in app.conf
Automatic merge failed; fix conflicts and then commit the result.
$ git status --short
UU app.conf
$ cat app.conf
server_port = 8080
<<<<<<< HEAD
timeout = 45
=======
timeout = 60
>>>>>>> feature/longer-timeout
log_level = info

```

اشرح ماذا يعني كل جزء بين العلامات، ثم اكتب الأوامر التي تنهي الدمج بالقيمة 60 بعد أن اتفقت مع الزميلين عليها، واذكر الأمر الذي يلغي الدمج كله لو أردت أن تبدأ من جديد.

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

تلميح Hint

`HEAD` هو الفرع الذي تقف عليه، والاسم بعد العلامات الأخيرة هو الفرع الذي تدمجه. والحل ليس أمراً سحرياً، وإنما أن تكتب الملف كما يجب أن يكون، ثم تخبر Git أنك انتهيت منه.

الحل Answer 

الجزء بين `<<<<<<< HEAD` و`=======` هو نسخة الفرع الحالي `main`، والجزء بين `=======` و`>>>>>>> feature/longer-timeout` هو نسخة الفرع المدموج، والأسطر خارج العلامات اتفق عليها الطرفان فدمجها Git وحده. و`UU` في `git status` تعني أن الملف معدل في الطرفين ولم يحل بعد. والحل أن تكتب الملف بالشكل النهائي دون علامات، ثم `git add` ثم `git commit`:

```bash
printf 'server_port = 8080\ntimeout = 60\nlog_level = info\n' > app.conf
git add app.conf
git commit --no-edit
git log --oneline --graph

```

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

```text
*   34a1333 Merge branch 'feature/longer-timeout'
|\
| * c8aa02a Raise timeout to 60
* | 20f4850 Raise timeout to 45 for reports
|/
* 94a8de1 Initial config

```

في الإعداد أعلاه لاحظ التالي:

- القرار هنا قرار عمل وليس قرار Git، فلا تختر أحد الطرفين بالأمر `git checkout --theirs` أو `--ours` قبل أن تفهم لماذا غير كل طرف القيمة.
- لإلغاء الدمج كله والعودة إلى ما قبله استخدم `git merge --abort`.
- قبل ال Commit ابحث عن علامات نسيتها في كل المشروع بالأمر `git diff --check` أو `grep -rn '^<<<<<<<'`، فالعلامة المنسية في ملف إعداد تكسر التطبيق عند التشغيل وليس عند البناء.

للتفصيل: [قسم التعارضات في دليل merge وrebase](https://arabroot.io/articles/git-merge-%D9%88-rebase-%D9%88-cherry-pick/#conflicts)، و[توثيق git merge](https://git-scm.com/docs/git-merge?ref=arabroot.io).

### التمرين 6: التطبيق خلف Nginx لا يعرف اسم الموقع ولا عنوان الزائر

تطبيق يسجل لكل طلب الرابط واسم الموقع Host وعنوان الزائر، ويعمل خلف Nginx بهذا الإعداد:

```nginx
server {
    listen 80;
    server_name shop.example.test;

    location / {
        proxy_pass http://lab-kata-ops-backend:8000;
    }
}

```

وعند الطلب بالأمر `curl -H "Host: shop.example.test" http://127.0.0.1:21411/orders?page=2` طبع التطبيق ما وصله:

```text
path=/orders?page=2
host=lab-kata-ops-backend:8000
client_ip=192.168.128.3
x-real-ip=None
x-forwarded-for=None
x-forwarded-proto=None

```

الروابط التي يبنيها التطبيق في رسائل البريد تبدأ ب `http://lab-kata-ops-backend:8000`، وكل الزوار في السجل لهم نفس العنوان. ما الأسطر الناقصة في الإعداد، وماذا يفعل كل سطر؟

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

تلميح Hint

Nginx يفتح اتصالاً جديداً إلى التطبيق، فالتطبيق يرى Nginx وليس الزائر، والترويسة Host يضعها Nginx افتراضياً من العنوان المكتوب في `proxy_pass`. ابحث عن التعليمة التي تضيف ترويسة إلى الطلب المرسل إلى التطبيق، والمتغيرات `$host` و`$remote_addr` و`$scheme`.

الحل Answer 

تحتاج أربعة أسطر `proxy_set_header`، تعيد اسم الموقع الأصلي وتخبر التطبيق بعنوان الزائر وبالبروتوكول الذي وصل به:

```nginx
server {
    listen 80;
    server_name shop.example.test;

    location / {
        proxy_pass http://lab-kata-ops-backend:8000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

```

وبعد `nginx -t` و`nginx -s reload` ونفس الطلب، والمخرج سوف يكون كما يلي:

```text
path=/orders?page=2
host=shop.example.test
client_ip=192.168.128.3
x-real-ip=192.168.128.1
x-forwarded-for=192.168.128.1
x-forwarded-proto=http

```

في الإعداد أعلاه لاحظ التالي:

- `client_ip` بقي عنوان Nginx، وهذا صحيح، لأن الاتصال فعلاً من Nginx، والعنوان الحقيقي يأتي في الترويستين، وعلى التطبيق أن يثق بهما فقط عندما يأتي الطلب من عنوان ال Proxy المعروف، وإلا فأي زائر يستطيع أن يكتب لنفسه X-Forwarded-For.
- العنوان `192.168.128.1` هو بوابة شبكة Docker، لأن الطلب في مختبرنا يمر عبر Docker Desktop، وعلى سيرفر Linux ترى عنوان الزائر الحقيقي.
- `$proxy_add_x_forwarded_for` يضيف عنوان الزائر إلى القائمة الموجودة بدل أن يستبدلها، وهذا مهم عندما يكون أمام Nginx طبقة أخرى مثل Cloudflare.

للتفصيل: [قسم الترويسات في دليل تطبيقك خلف Reverse Proxy](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#headers)، وقسم [انتحال العنوان Spoofing](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#spoofing) في نفس الدليل.

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

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

### التمرين 7: حذفنا ملف التوكن، فهل اختفى من ال Image؟

يحتاج البناء إلى توكن لمستودع npm خاص، ومفتاح API يمرر كمتغير بناء. كتب أحد الزملاء هذا الملف، وحذف الملف `.npmrc` بعد استخدامه، وبنى بالأمر `docker build --build-arg API_KEY=sk_live_FAKE_9f8e7d -t lab-kata-ops-secret:bad .`:

```dockerfile
FROM alpine:3.22
ARG API_KEY
COPY .npmrc /root/.npmrc
RUN echo "installing with the token..." && rm /root/.npmrc
RUN echo "calling the API with $API_KEY" > /dev/null

```

والأمر `docker run --rm lab-kata-ops-secret:bad ls -la /root/` يظهر المجلد فارغاً. هل السران آمنان في ال Image الذي سوف يرفع إلى السجل Registry؟ أثبت إجابتك بأوامر، ثم أعد كتابة الملف بالطريقة الصحيحة.

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

تلميح Hint

ال Image مجموعة طبقات فوق بعضها، والحذف في طبقة لاحقة يضيف علامة حذف ولا يغير الطبقة السابقة. وجرب الأمرين `docker history --no-trunc` و`docker save`. وابحث في توثيق BuildKit عن `RUN --mount=type=secret`.

الحل Answer 

السران مكشوفان، والإثبات بأمرين. الأول `docker history` يظهر قيمة `ARG` في كل سطر `RUN` يأتي بعده:

```text
$ docker history --no-trunc --format '{{.CreatedBy}}' lab-kata-ops-secret:bad | head -3
RUN |1 API_KEY=sk_live_FAKE_9f8e7d /bin/sh -c echo "calling the API with $API_KEY" > /dev/null # buildkit
RUN |1 API_KEY=sk_live_FAKE_9f8e7d /bin/sh -c echo "installing with the token..." && rm /root/.npmrc # buildkit
COPY .npmrc /root/.npmrc # buildkit

```

والثاني أن تحفظ ال Image في ملف tar وتفتح طبقاته، فتجد الملف كاملاً في طبقة `COPY`، وفي الطبقة التالية علامة الحذف فقط:

```bash
docker save lab-kata-ops-secret:bad -o img.tar && tar -xf img.tar
for b in blobs/sha256/*; do
  if tar -tf $b 2>/dev/null | grep -q npmrc; then echo "layer: $b"; tar -tvf $b | grep npmrc; tar -xOf $b root/.npmrc; fi
done

```

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

```text
layer: blobs/sha256/055e4323d9b0d3f91c1004bd2b8aebcc09d072bd4a097e3f185caafc1d448fac
-rwxr-xr-x 0/0              58 2026-10-07 11:00 root/.npmrc
//registry.npmjs.org/:_authToken=npm_FAKE1234567890abcdef
layer: blobs/sha256/a7f9f0d94f6d16cfebfd8e1755cef7f4658950104f9b52d92877e5468e28ee96
---------- 0/0               0 1970-01-01 03:00 root/.wh..npmrc
tar: root/.npmrc: Not found in archive

```

والطريقة الصحيحة هي أسرار البناء Build Secrets في BuildKit، فالسر يركب في ملف أو متغير أثناء تنفيذ سطر `RUN` واحد فقط، ولا يكتب في أي طبقة ولا في التاريخ:

```dockerfile
# syntax=docker/dockerfile:1
FROM alpine:3.22
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    echo "token file inside the build: $(wc -c < /root/.npmrc) bytes"
RUN --mount=type=secret,id=api_key,env=API_KEY \
    echo "API key length: ${#API_KEY}"

```

والبناء يمرر السرين من ملف ومن متغير بيئة:

```bash
API_KEY=sk_live_FAKE_9f8e7d docker build --progress=plain --no-cache \
  --secret id=npmrc,src=.npmrc --secret id=api_key,env=API_KEY -t lab-kata-ops-secret:good .
docker history --no-trunc --format '{{.CreatedBy}}' lab-kata-ops-secret:good | head -2
docker save lab-kata-ops-secret:good -o img.tar && tar -xf img.tar && (grep -rl "npm_FAKE\|sk_live_FAKE" . || echo "no secret found in any layer")

```

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

```text
#9 3.296 token file inside the build: 58 bytes
#10 1.996 API key length: 19
RUN /bin/sh -c echo "API key length: ${#API_KEY}" # buildkit
RUN /bin/sh -c echo "token file inside the build: $(wc -c < /root/.npmrc) bytes" # buildkit
no secret found in any layer

```

في الإعداد أعلاه لاحظ التالي:

- البناء رأى الملف (58 بايت) ورأى المفتاح (19 حرفاً)، ولا يوجد أي أثر لهما في التاريخ أو في الطبقات.
- `ARG` و`ENV` ليسا مكاناً للأسرار إطلاقاً، فالأول يظهر في التاريخ كما رأينا، والثاني يبقى في إعداد ال Image ويظهر بالأمر `docker inspect`.
- إذا رفعت Image فيه سر إلى أي سجل فاعتبر السر مسرباً وقم بتغييره Rotate، ولا يكفي أن تحذف ال Image من السجل لأن غيرك قد يكون سحبه.

للتفصيل: [أسرار البناء في توثيق Docker](https://docs.docker.com/build/building/secrets/?ref=arabroot.io)، و[قسم الأسرار داخل ال Images في دليل اكتشاف الأسرار المسربة](https://arabroot.io/articles/%D8%A7%D9%83%D8%AA%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%A7%D8%B3%D8%B1%D8%A7%D8%B1-%D8%A7%D9%84%D9%85%D8%B3%D8%B1%D8%A8%D8%A9-%D9%85%D8%B9-gitleaks-%D9%88-trufflehog/#leak-image).

### التمرين 8: Docker Image حجمه 1.38 GB لبرنامج من عشرة أسطر

خدمة Go صغيرة ترد بالنص `hello from go` على المنفذ 8080، وهذا ال Dockerfile الخاص بها:

```dockerfile
FROM golang:1.27.1-bookworm
WORKDIR /src
COPY . .
RUN go build -o /usr/local/bin/hello .
EXPOSE 8080
CMD ["hello"]

```

والأمر `docker images` يعطي الحجم `1.38GB`. اشرح من أين جاء هذا الحجم، واكتب Dockerfile ينتج Image فيه البرنامج وحده تقريباً ويعمل بمستخدم غير root، ثم تحقق أنه يعمل على المنفذ `127.0.0.1:21410`.

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

تلميح Hint

ال Image النهائي يحتوي كل ما في ال Image الأساسي، والمترجم Compiler ومكتبات Debian لا يحتاجها البرنامج بعد بنائه. ابحث عن البناء متعدد المراحل Multi-stage Build والتعليمة `COPY --from`، وعن الخيار `CGO_ENABLED=0` الذي يجعل برنامج Go لا يعتمد على مكتبات النظام.

الحل Answer 

الحجم كله تقريباً من ال Image الأساسي `golang:1.27.1-bookworm`، ففيه نظام Debian والمترجم وأدوات البناء، وطبقة البناء نفسها أضافت 101MB من ذاكرة المترجم كما يظهر `docker history`:

```text
$ docker history lab-kata-ops-go:single --format '{{.Size}}\t{{.CreatedBy}}' | head -4
0B	CMD ["hello"]
0B	EXPOSE [8080/tcp]
101MB	RUN /bin/sh -c go build -o /usr/local/bin/he…
24.6kB	COPY . . # buildkit

```

والحل أن تبني في مرحلة أولى، ثم تنسخ الملف التنفيذي وحده إلى Image صغير لا يحتوي حتى على Shell:

```dockerfile
FROM golang:1.27.1-bookworm AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/hello .

FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/hello /hello
EXPOSE 8080
ENTRYPOINT ["/hello"]

```

والتحقق، والمخرج سوف يكون كما يلي:

```text
$ docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}' | grep -E "REPO|lab-kata-ops-go"
REPOSITORY:TAG                                        SIZE
lab-kata-ops-go:multi                                 15MB
lab-kata-ops-go:single                                1.38GB
$ docker run -d --name lab-kata-ops-go -p 127.0.0.1:21410:8080 lab-kata-ops-go:multi
$ curl -s http://127.0.0.1:21410/
hello from go
$ docker image inspect lab-kata-ops-go:multi --format 'user={{.Config.User}}'
user=65532
$ docker exec lab-kata-ops-go sh
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH

```

في الإعداد أعلاه لاحظ التالي:

- المرحلة الأولى `AS build` لا تصل إلى السيرفر أبداً، فالمترجم وذاكرته يبقيان على جهاز البناء.
- الوسم `nonroot` في distroless يجعل المستخدم 65532 دون أن تكتب `USER`، وعدم وجود Shell يعني أن المخترق الذي يجد ثغرة في البرنامج لا يجد أدوات يكمل بها، وأنت أيضاً لا تجدها، فاستخدم `docker debug` أو Container مؤقتاً للتشخيص.
- نفس الفكرة في Node.js وPython: مرحلة فيها أدوات البناء وتنزيل المكتبات، ومرحلة نهائية من Image بنسخة `slim` تنسخ إليها الناتج فقط.

للتفصيل: [البناء متعدد المراحل في توثيق Docker](https://docs.docker.com/build/building/multi-stage/?ref=arabroot.io)، و[قسم نظافة ال Images الأساسية في دليل أمن سلسلة التوريد](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#base-image-hygiene).

### التمرين 9: depends\_on موجود والتطبيق يفشل في أول تشغيل

بعد إصلاح التمرين 3 حذف أحد الزملاء الانتظار `sleep` لأنه «غير احترافي»، وأصبح الملف هكذا:

```yaml
services:
  db:
    image: postgres:18-alpine
    container_name: lab-kata-ops-db
    environment:
      POSTGRES_PASSWORD: example
  app:
    image: postgres:18-alpine
    container_name: lab-kata-ops-app
    depends_on: [db]
    command: psql "postgresql://postgres:example@db:5432/postgres" -c "select 1 as ok"

```

والنتيجة في كل تشغيل على سيرفر جديد:

```text
lab-kata-ops-app  | psql: error: connection to server at "db" (192.168.112.2), port 5432 failed: Connection refused
lab-kata-ops-app  | 	Is the server running on that host and accepting TCP/IP connections?
lab-kata-ops-app exited with code 2

```

لماذا لم يمنع `depends_on` هذا؟ وعدل الملف حتى ينتظر التطبيق القاعدة فعلاً دون أي `sleep`.

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

تلميح Hint

هناك فرق بين أن يبدأ ال Container وأن تصبح الخدمة داخله جاهزة. ابحث في توثيق Compose عن `healthcheck` وعن الصيغة الطويلة ل `depends_on` مع `condition`.

الحل Answer 

`depends_on` بالصيغة القصيرة ينتظر أن يبدأ ال Container الخاص بالقاعدة فقط، وPostgreSQL يحتاج ثواني بعد ذلك لينشئ ملفاته عند أول تشغيل ثم يعيد التشغيل ليقبل الاتصالات، والتطبيق يحاول في هذه الثواني فيفشل. والحل أن تعرف Compose متى تكون القاعدة جاهزة بفحص صحة Healthcheck، وتجعل التطبيق ينتظر الحالة `service_healthy`:

```yaml
services:
  db:
    image: postgres:18-alpine
    container_name: lab-kata-ops-db
    environment:
      POSTGRES_PASSWORD: example
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres -h 127.0.0.1"]
      interval: 2s
      timeout: 3s
      retries: 30
      start_period: 5s
  app:
    image: postgres:18-alpine
    container_name: lab-kata-ops-app
    depends_on:
      db:
        condition: service_healthy
        restart: true
    command: psql "postgresql://postgres:example@db:5432/postgres" -c "select 1 as ok"

```

والمخرج سوف يكون كما يلي (اختصرنا سجلات القاعدة):

```text
 Container lab-kata-ops-db Waiting
 Container lab-kata-ops-db Healthy
lab-kata-ops-app  |  ok
lab-kata-ops-app  | ----
lab-kata-ops-app  |   1
lab-kata-ops-app  | (1 row)
lab-kata-ops-app exited with code 0

```

في الإعداد أعلاه لاحظ التالي:

- الفحص يستخدم `-h 127.0.0.1` وليس الاتصال المحلي عبر ال Socket، والسبب أن PostgreSQL في أول تشغيل يشغل نسخة مؤقتة لإنشاء القاعدة تستمع على ال Socket فقط، فالفحص عبر ال Socket قد ينجح قبل أن تجهز النسخة الحقيقية.
- `restart: true` يجعل Compose يعيد تشغيل التطبيق إذا أعيد تشغيل القاعدة بأمر من Compose.
- هذا يحل ترتيب التشغيل فقط، والقاعدة قد تتوقف لاحقاً أثناء العمل، لذلك يجب أن يعيد التطبيق محاولة الاتصال بنفسه، وهذا ما يفعله أي Connection Pool جيد.

للتفصيل: [ترتيب التشغيل في توثيق Compose](https://docs.docker.com/compose/how-tos/startup-order/?ref=arabroot.io)، و[قسم ترتيب التشغيل في قائمة تحقق المطور](https://arabroot.io/articles/%D9%82%D8%A7%D8%A6%D9%85%D8%A9-%D8%AA%D8%AD%D9%82%D9%82-%D8%A7%D9%84%D9%85%D8%B7%D9%88%D8%B1-%D9%82%D8%A8%D9%84-%D9%86%D8%B4%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%87/#startup-order).

### التمرين 10: Permission denied عند الكتابة في ال Volume

بعد التمرين 2 أصبحت كل خدماتكم تعمل بمستخدم غير root، وهذه خدمة تكتب ملفاتها في `/data`:

```dockerfile
FROM alpine:3.22
RUN adduser -D -u 10001 app
USER app
CMD ["sh", "-c", "echo hello > /data/out.txt && ls -ln /data"]

```

وعند التشغيل مع Volume مسمى:

```text
$ docker run --rm --name lab-kata-ops-vol -v lab-kata-ops-data:/data lab-kata-ops-vol:bad
sh: can't create /data/out.txt: Permission denied

```

اقترح أحد الزملاء أن يرجع الخدمة إلى root. اكتشف من يملك المجلد، وأصلح المشكلة في ال Dockerfile دون root ودون `chmod 777`.

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

تلميح Hint

افحص المجلد من Container آخر بالأمر `ls -lnd /data`. وعندما يكون ال Volume فارغاً، فإن Docker ينسخ إليه محتوى المجلد الموجود في ال Image في نفس المسار، فماذا لو لم يكن المجلد موجوداً في ال Image أصلاً؟

الحل Answer 

المجلد `/data` غير موجود في ال Image، فأنشأه Docker عند التركيب بمالك root، والمستخدم 10001 لا يستطيع الكتابة فيه:

```text
$ docker run --rm -v lab-kata-ops-data:/data alpine:3.22 ls -lnd /data
drwxr-xr-x    2 0        0             4096 Oct  7 07:58 /data

```

والحل أن تنشئ المجلد في ال Image وتعطيه للمستخدم قبل `USER`، فينسخ Docker المجلد بمالكه إلى ال Volume الجديد عند أول تركيب:

```dockerfile
FROM alpine:3.22
RUN adduser -D -u 10001 app && mkdir /data && chown app:app /data
USER app
CMD ["sh", "-c", "echo hello > /data/out.txt && ls -ln /data"]

```

وبعد حذف ال Volume القديم بالأمر `docker volume rm lab-kata-ops-data` والتشغيل مرة أخرى، والمخرج سوف يكون كما يلي:

```text
$ docker run --rm --name lab-kata-ops-vol -v lab-kata-ops-data:/data lab-kata-ops-vol:good
total 4
-rw-r--r--    1 10001    10001            6 Oct  7 07:58 out.txt
$ docker run --rm -v lab-kata-ops-data:/data alpine:3.22 ls -lnd /data
drwxr-xr-x    2 10001    10001         4096 Oct  7 07:58 /data

```

في الإعداد أعلاه لاحظ التالي:

- النسخ من ال Image يحدث فقط عندما يكون ال Volume فارغاً، فإذا كان فيه بيانات بمالك root من تشغيل قديم فأصلح ملكيتها مرة واحدة بأمر `chown` من Container يعمل بالمستخدم root، ولا تغير الخدمة نفسها.
- ربط مجلد من السيرفر Bind Mount لا ينسخ شيئاً، والمجلد يبقى بمالكه على السيرفر، فهناك تعطي المجلد للرقم 10001 على السيرفر بالأمر `chown 10001:10001`.
- Linux يقارن الأرقام وليس الأسماء، ولذلك نستخدم `ls -n`، فالمستخدم `app` في ال Container قد يكون اسمه شيئاً آخر على السيرفر وله نفس الرقم.

للتفصيل: [ال Volumes في توثيق Docker](https://docs.docker.com/engine/storage/volumes/?ref=arabroot.io)، و[قسم ال Volumes والصلاحيات في قائمة تحقق المطور](https://arabroot.io/articles/%D9%82%D8%A7%D8%A6%D9%85%D8%A9-%D8%AA%D8%AD%D9%82%D9%82-%D8%A7%D9%84%D9%85%D8%B7%D9%88%D8%B1-%D9%82%D8%A8%D9%84-%D9%86%D8%B4%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%87/#volumes-permissions).

### التمرين 11: ال API يعطي 404 خلف Nginx فقط

ال API يعرف المسارات `/users/7` و`/orders`، وتريد أن يصل إليه الزوار عبر `/api/`، فكتبت:

```nginx
server {
    listen 80;

    location /api/ {
        proxy_pass http://lab-kata-ops-backend:8000;
    }
}

```

والطلب `curl http://127.0.0.1:21411/api/users/7` يعطي 404 من التطبيق. حدد المسار الذي يصل إلى التطبيق، وأصلح الإعداد بحرف واحد. ثم توقع ما يحدث لو كتبت `location /api` بدون الشرطة الأخيرة مع نفس الإصلاح، وجرب الطلب `/apiusers`.

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

تلميح Hint

توثيق `proxy_pass` يفرق بين حالتين: عنوان بدون URI (اسم السيرفر والمنفذ فقط)، وعنوان معه URI ولو كان شرطة واحدة. في الحالة الثانية يستبدل Nginx الجزء الذي طابق ال `location` بال URI المكتوب.

الحل Answer 

بدون URI يرسل Nginx المسار كما جاء من الزائر، فيصل إلى التطبيق `/api/users/7` وهو لا يعرفه. والإصلاح أن تضيف شرطة في آخر العنوان، فيستبدل Nginx الجزء `/api/` بالشرطة `/`. والتطبيق في المختبر يطبع سطر الطلب كما وصله حرفياً، والمخرج سوف يكون كما يلي:

```text
--- without slash
path=/api/users/7
--- with slash
path=/users/7
$ curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:21411/api
301

```

ومع `location /api` بدون شرطة و`proxy_pass` بشرطة يحدث أمران خاطئان، والمخرج سوف يكون كما يلي:

```text
$ curl -s http://127.0.0.1:21411/api/users/7 | head -1
path=//users/7
$ curl -s http://127.0.0.1:21411/apiusers | head -1
path=/users

```

في الإعداد أعلاه لاحظ التالي:

- الاستبدال نصي بحت: `/api` استبدلت ب `/` فأصبح المسار `//users/7`، وبعض أطر العمل تصلح الشرطتين بصمت وبعضها يعطي 404، ولذلك لا ترى المشكلة دائماً في كل التطبيقات.
- الأخطر أن `location /api` يطابق أيضاً `/apiusers` و`/api-admin`، فيصل إلى التطبيق مسار لم تقصد نشره، لذلك اجعل الشرطة في آخر ال `location` وفي آخر ال `proxy_pass` معاً.
- الطلب `/api` بدون شرطة أعطى 301 إلى `/api/`، وهذا سلوك Nginx المعروف عندما ينتهي ال `location` بشرطة ويمرر إلى `proxy_pass`.

للتفصيل: [التعليمة proxy\_pass في توثيق Nginx](https://nginx.org/en/docs/http/ngx%5Fhttp%5Fproxy%5Fmodule.html?ref=arabroot.io#proxy%5Fpass)، و[قسم التطبيق تحت مسار فرعي في دليل تطبيقك خلف Reverse Proxy](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#subpath).

### التمرين 12: المحادثة المباشرة تعمل محلياً وتفشل خلف Nginx

صفحة المحادثة تفتح اتصال WebSocket على `/ws/chat`. مباشرة إلى التطبيق يرد بالرمز 101، ومن خلال Nginx يرد التطبيق نفسه بالرمز 400\. هذا الإعداد، والطلب الذي يمثل ما يرسله المتصفح:

```nginx
server {
    listen 80;

    location /ws/ {
        proxy_pass http://lab-kata-ops-backend:8000;
    }
}

```

```text
$ curl -s -i --max-time 2 -H Connection:Upgrade -H Upgrade:websocket -H Sec-WebSocket-Version:13 \
    -H Sec-WebSocket-Key:dGhlIHNhbXBsZSBub25jZQ== http://127.0.0.1:21411/ws/chat
HTTP/1.1 400 Bad Request
Server: nginx/1.29.8
Date: Wed, 07 Oct 2026 08:11:25 GMT
Content-Length: 68
Connection: keep-alive

400: expected a WebSocket upgrade, got Upgrade=None Connection=None

```

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

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

تلميح Hint

Upgrade وConnection ترويستان من نوع Hop-by-hop، أي أنهما تخصان الاتصال بين طرفين متجاورين فقط، وال Proxy لا يمررهما. ابحث في توثيق Nginx عن صفحة WebSocket proxying، وعن التعليمة `map`.

الحل Answer 

Nginx لا يمرر الترويستين Upgrade وConnection لأنهما Hop-by-hop، فوصل الطلب إلى التطبيق كطلب HTTP عادي ورفضه. والحل أن تمررهما صراحة، مع `map` يضع `close` عندما لا يطلب الزائر ترقية:

```nginx
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 80;

    location /ws/ {
        proxy_pass http://lab-kata-ops-backend:8000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_read_timeout 1h;
    }
}

```

وبعد إعادة التحميل ونفس الطلب، والمخرج سوف يكون كما يلي:

```text
HTTP/1.1 101 Switching Protocols
Server: nginx/1.29.8
Date: Wed, 07 Oct 2026 08:11:28 GMT
Connection: upgrade
Upgrade: websocket
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

curl exit=28

```

في الإعداد أعلاه لاحظ التالي:

- الرمز `curl exit=28` هو انتهاء المهلة التي حددناها بالخيار `--max-time 2`، وهو هنا علامة نجاح، لأن الاتصال بعد 101 يبقى مفتوحاً للرسائل.
- القيمة `s3pPLMBiTxaQ9kYGzzhZRbK+xOo=` هي نفس المثال في [RFC 6455](https://www.rfc-editor.org/rfc/rfc6455?ref=arabroot.io) للمفتاح الذي أرسلناه، أي أن التطبيق أكمل المصافحة Handshake فعلاً.
- السطر `proxy_http_version 1.1` أصبح القيمة الافتراضية منذ Nginx 1.29.7 حسب التوثيق، ونتركه لأن كثيراً من السيرفرات تعمل بنسخ أقدم، وبدونه فيها ترسل الطلبات بالإصدار 1.0 الذي لا يعرف الترقية.
- بدون `proxy_read_timeout` يغلق Nginx الاتصال إذا لم يرسل التطبيق شيئاً خلال 60 ثانية، والبديل أن يرسل التطبيق رسائل Ping دورية.

للتفصيل: [صفحة WebSocket proxying في توثيق Nginx](https://nginx.org/en/docs/http/websocket.html?ref=arabroot.io)، و[قسم WebSocket في دليل تطبيقك خلف Reverse Proxy](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/#websocket).

### التمرين 13: سكربت الإعداد ينجح مرة واحدة فقط

هذا سكربت يجهز سيرفراً جديداً، ويشغله خط النشر في كل مرة:

```bash
adduser -D deploy
mkdir /srv/app
echo "net.core.somaxconn = 1024" >> /etc/sysctl.d/99-app.conf
echo "done"

```

توقع ما يحدث في التشغيل الثاني على نفس السيرفر، ثم أعد كتابته حتى تكون النتيجة واحدة مهما شغلته، وهذه الخاصية اسمها التكرار الآمن Idempotency. شغل السكربتين في Container من `alpine:3.22` مرتين على الأقل.

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

تلميح Hint

كل سطر يجب أن يسأل «هل الحالة المطلوبة موجودة؟» قبل أن يفعل شيئاً: `id` للمستخدم، و`mkdir -p` للمجلد، و`grep -qxF` للسطر. وانتبه إلى السطر الأخير الذي يطبع `done` مهما حدث.

الحل Answer 

في التشغيل الثاني يفشل سطران ويتكرر الثالث بصمت، ثم يطبع السكربت `done` كأن شيئاً لم يحدث:

```text
$ sh setup-bad.sh   # run 1
done
$ sh setup-bad.sh   # run 2
adduser: user 'deploy' in use
mkdir: can't create directory '/srv/app': File exists
done
$ cat /etc/sysctl.d/99-app.conf
net.core.somaxconn = 1024
net.core.somaxconn = 1024

```

والنسخة المتكررة بأمان:

```bash
set -e
id deploy >/dev/null 2>&1 || adduser -D deploy
mkdir -p /srv/app
LINE="net.core.somaxconn = 1024"
grep -qxF "$LINE" /etc/sysctl.d/99-app.conf 2>/dev/null || echo "$LINE" >> /etc/sysctl.d/99-app.conf
echo "done"

```

وفي Container جديد بعد ثلاث مرات، والمخرج سوف يكون كما يلي:

```text
$ sh setup-good.sh   # run 1
done
$ sh setup-good.sh   # run 2
done
$ sh setup-good.sh   # run 3
done
$ cat /etc/sysctl.d/99-app.conf
net.core.somaxconn = 1024
$ grep -c ^deploy: /etc/passwd
1

```

في الإعداد أعلاه لاحظ التالي:

- كل أمر يصف حالة نهائية وليس خطوة، وهذا هو الفرق بين الأسلوب الأمري Imperative والتصريحي Declarative الذي تبنى عليه أدوات مثل Ansible وTerraform.
- `set -e` يوقف السكربت عند أول أمر يفشل، فلا يطبع `done` وخط النشر يرى الفشل، والسطر المكرر في ملف الإعداد أخطر من الخطأ الظاهر لأن لا أحد يراه حتى يتعارض سطران بقيمتين مختلفتين.
- `grep -x` يطابق السطر كاملاً و`-F` يعامل النص كما هو، فلا تفسر النقاط في القيمة كتعبير نمطي.

للتفصيل: [قسم التكرار الآمن في دليل البنية كشيفرة IaC](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D8%B4%D9%8A%D9%81%D8%B1%D8%A9-iac-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#idempotency).

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

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

### التمرين 14: reset --hard أضاع عمل يومين

كان عندك ثلاثة Commits محلية لم تدفعها بعد، وأردت أن تلغي تعديلاً لم تحفظه فكتبت بالخطأ:

```text
$ git log --oneline
6d00124 Add feature B (two days of work)
69afd84 Add feature A
f5191e6 Initial version
$ git reset --hard HEAD~2
HEAD is now at f5191e6 Initial version
$ git log --oneline
f5191e6 Initial version

```

استرجع ال Commits الضائعة دون أن تعيد كتابة الكود، واذكر المدة التي يبقى فيها هذا الإنقاذ ممكناً.

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

تلميح Hint

Git لا يحذف ال Commit عندما تتحرك الإشارة عنه، وإنما يتركه بلا اسم يشير إليه. وهناك سجل محلي لكل مكان وقف فيه `HEAD`، وبعده تحتاج اسماً جديداً يشير إلى ال Commit.

الحل Answer 

السجل اسمه `reflog`، وفيه كل حركة ل `HEAD` على جهازك، ومنه تأخذ المكان الذي كنت فيه قبل ال `reset` وتعطيه فرعاً باسم، والمخرج سوف يكون كما يلي:

```text
$ git reflog
f5191e6 HEAD@{0}: reset: moving to HEAD~2
6d00124 HEAD@{1}: commit: Add feature B (two days of work)
69afd84 HEAD@{2}: commit: Add feature A
f5191e6 HEAD@{3}: commit (initial): Initial version
$ git branch rescue HEAD@{1}
$ git log --oneline rescue
6d00124 Add feature B (two days of work)
69afd84 Add feature A
f5191e6 Initial version
$ git reset --hard rescue
HEAD is now at 6d00124 Add feature B (two days of work)
$ cat app.txt
v1
feature A
feature B

```

في الإعداد أعلاه لاحظ التالي:

- أنشأنا الفرع `rescue` أولاً قبل أي أمر آخر، لأن الفرع يحمي ال Commits من التنظيف، ثم انتقلنا إليه.
- حسب توثيق Git فإن السجلات التي لا يصل إليها أي فرع تنتهي افتراضياً بعد 30 يوماً، والباقية بعد 90 يوماً، ثم يحذف `git gc` ال Commits التي لا يشير إليها شيء، فالإنقاذ ممكن لأيام وليس للأبد.
- ال reflog محلي فقط، فلا يوجد على الخادم ولا في نسخة زميلك، والتعديلات التي لم تدخل Commit أصلاً لا يحفظها شيء، لذلك اعمل Commit مبكراً ولو مؤقتاً.

للتفصيل: [قسم reflog في دليل merge وrebase](https://arabroot.io/articles/git-merge-%D9%88-rebase-%D9%88-cherry-pick/#reflog)، و[توثيق git reflog](https://git-scm.com/docs/git-reflog?ref=arabroot.io).

### التمرين 15: إصلاح عاجل لنسخة منشورة دون بقية التطوير

النسخة 1.4 منشورة من الفرع `release/1.4`، والفرع `main` فيه عمل على صفحة دفع جديدة لم تجهز بعد، وفي وسطه إصلاح لخطأ في حساب المجموع يجب أن يصل إلى 1.4 اليوم:

```text
$ git log --oneline main
34222ac Checkout page styling
bb9cca6 Fix: total ignores quantity
42e60f3 New checkout page (not ready for 1.4)
3081b8e Cart total
$ git log --oneline release/1.4
3081b8e Cart total

```

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

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

تلميح Hint

`merge` سوف يجلب كل ما في `main`. ابحث عن الأمر الذي يطبق Commit واحداً فوق فرع آخر، وعن خياره الذي يضيف سطر «cherry picked from» إلى الرسالة.

الحل Answer 

الأمر `git cherry-pick -x` يطبق تغيير Commit واحد فوق الفرع الحالي، ويضيف إلى الرسالة رقم ال Commit الأصلي:

```text
$ git switch release/1.4 && git cherry-pick -x bb9cca6
[release/1.4 855a304] Fix: total ignores quantity
 Date: Wed Oct 7 08:15:16 2026 +0000
 1 file changed, 1 insertion(+), 1 deletion(-)
$ git log --oneline release/1.4
855a304 Fix: total ignores quantity
3081b8e Cart total
$ git log -1 --format=%B
Fix: total ignores quantity

(cherry picked from commit bb9cca6a57171757b0fa40904e78e4a367e2c9b3)

$ ls
cart.py

```

في الإعداد أعلاه لاحظ التالي:

- ال Commit الجديد له رقم مختلف `855a304` لأنه بني فوق أب مختلف، والسطر الذي أضافه `-x` هو الرابط الوحيد بين النسختين، وتحتاجه عندما تسأل بعد شهر «هل الإصلاح موجود في 1.4؟».
- الأمر `ls` لا يظهر `checkout.py`، أي أن صفحة الدفع بقيت في `main`.
- الترتيب الأفضل في الفرق أن يدخل الإصلاح أولاً إلى `main` ثم ينقل إلى فروع النسخ، حتى لا يعود الخطأ في النسخة القادمة.

للتفصيل: [قسم cherry-pick في دليل merge وrebase](https://arabroot.io/articles/git-merge-%D9%88-rebase-%D9%88-cherry-pick/#cherry-pick)، و[توثيق git cherry-pick](https://git-scm.com/docs/git-cherry-pick?ref=arabroot.io).

### التمرين 16: أي Commit من أربعين كسر حساب الضريبة؟

في المستودع 40 Commit، والسكربت `price.sh` يجب أن يطبع السعر مع ضريبة القيمة المضافة 15%، فالأمر `sh price.sh 100` يجب أن يطبع 115، لكنه اليوم يطبع 100، وأول Commit في المستودع كان يعمل. اكتب اختباراً صغيراً، واستخدم Git ليجد ال Commit المسؤول وحده دون أن تقرأ الأربعين.

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

تلميح Hint

البحث الثنائي Binary Search يقسم 40 إلى نصفين في كل خطوة، فيحتاج قرابة ست خطوات. والأمر `git bisect run` يشغل سكربتاً في كل خطوة ويقرأ رمز خروجه: 0 جيد، ومن 1 إلى 127 معطوب ما عدا 125 الذي يعني «تجاوز هذا ال Commit».

الحل Answer 

الاختبار سطران، ونضعه خارج المستودع حتى لا يتغير مع كل Checkout:

```bash
cat > /work/test.sh <<'T'
#!/bin/sh
[ "$(sh price.sh 100)" = "115" ]
T
chmod +x /work/test.sh
git bisect start HEAD $(git rev-list --max-parents=0 HEAD)
git bisect run /work/test.sh
git bisect reset

```

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

```text
$ git bisect start HEAD 71f3f9d
Bisecting: 19 revisions left to test after this (roughly 4 steps)
[68503c0a10a1fdbbeb2a599d8a55876ce4391cd3] Update changelog 20
$ git bisect run /work/test.sh
running '/work/test.sh'
Bisecting: 9 revisions left to test after this (roughly 3 steps)
[239d86a3319d1c8776e6b504cb4be512698f11d8] Update changelog 30
running '/work/test.sh'
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[4dd794f374bcf489a8116c6e34422133ec6e2e99] Update changelog 25
running '/work/test.sh'
Bisecting: 2 revisions left to test after this (roughly 1 step)
[98ff2e4499f117ee2b4561695787ad53b8924866] Update changelog 22
running '/work/test.sh'
Bisecting: 0 revisions left to test after this (roughly 1 step)
[57ab4f6f5b82508892408e28a28f2c4376979763] Update changelog 24
running '/work/test.sh'
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[f4c458d346246cc674fc2b81ef40bd073bfcbc15] Read VAT from environment (refactor)
running '/work/test.sh'
f4c458d346246cc674fc2b81ef40bd073bfcbc15 is the first bad commit
commit f4c458d346246cc674fc2b81ef40bd073bfcbc15
Author: Lab <lab@example.test>
Date:   Wed Oct 7 08:15:42 2026 +0000

    Read VAT from environment (refactor)

 CHANGELOG | 1 +
 price.sh  | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)
bisect found first bad commit
$ git bisect reset
Previous HEAD position was f4c458d Read VAT from environment (refactor)
Switched to branch 'main'

```

في الإعداد أعلاه لاحظ التالي:

- ست تشغيلات للاختبار بدل أربعين، وال Commit المسؤول عنوانه «refactor» أي أن صاحبه لم يقصد تغيير السلوك، فقد غير السطر `VAT=15` إلى `VAT=${VAT_RATE:-0}`، والقيمة الافتراضية صفر.
- الأمر `git rev-list --max-parents=0 HEAD` يعطي أول Commit في المستودع، وفي العمل الحقيقي تعطي `bisect` آخر وسم Tag تعرف أنه سليم.
- `bisect` يعمل جيداً عندما تكون ال Commits صغيرة وكل واحد منها يبنى ويعمل، وهذا سبب آخر لصغر ال Commits.

للتفصيل: [قسم bisect في دليل merge وrebase](https://arabroot.io/articles/git-merge-%D9%88-rebase-%D9%88-cherry-pick/#bisect)، و[توثيق git bisect](https://git-scm.com/docs/git-bisect?ref=arabroot.io).

### التمرين 17: مراجعة ملف CI قبل دمجه

هذا ملف Workflow بصيغة GitHub Actions، وهي نفس الصيغة التي تفهمها Gitea Actions تقريباً، وصل في طلب دمج Pull Request وطلب صاحبه الموافقة:

```yaml
name: deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@main
      - uses: docker/login-action@v3
        with:
          registry: registry.example.com
          username: ci
          password: ${{ secrets.REGISTRY_TOKEN }}
      - name: Debug
        run: |
          echo "Deploying with token ${{ secrets.DEPLOY_TOKEN }}"
          echo "${{ secrets.DEPLOY_TOKEN }}" | base64
      - name: Deploy
        run: ./deploy.sh "${{ secrets.DEPLOY_TOKEN }}"

```

صاحب الطلب يقول إن السجلات تخفي الأسرار بالنجوم تلقائياً فلا خوف من `echo`. اكتب ملاحظات المراجعة Review، مرتبة من الأخطر، ثم النسخة المصححة.

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

تلميح Hint

فكر في ثلاثة أسئلة: ما الكود الذي سوف يعمل غداً عندما يشير `@main` و`@v3` إلى Commit آخر؟ وماذا يرى من يفتح السجل عندما يتحول السر إلى Base64؟ وما الصلاحيات التي يحصل عليها التوكن الافتراضي للمهمة إذا لم تحددها؟

الحل Answer 

ملاحظات المراجعة من الأخطر:

1. السطر `echo ... | base64` يطبع السر بشكل آخر، والإخفاء التلقائي يبحث عن القيمة كما هي فقط، وتوثيق GitHub نفسه يقول إن الإخفاء غير مضمون لأن السر قد يتحول بطرق كثيرة، فأي شخص يقرأ السجل يفك ال Base64 في ثانية. واحذف سطري Debug بالكامل، فلا يوجد سبب لطباعة سر.
2. `actions/checkout@main` و`docker/login-action@v3` مراجع متحركة، فالفرع والوسم يمكن نقلهما إلى كود آخر في أي لحظة، والكود الجديد يعمل بأسرارك. والحل هو التثبيت Pinning برقم ال Commit الكامل، وهو حسب توثيق GitHub الطريقة الوحيدة لاستخدام Action كإصدار لا يتغير، وهذا ما استغله المهاجمون في حادثة tj-actions/changed-files في مارس 2025 التي رويناها في [دليل Gitea Actions](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/)، وشرحنا التثبيت بالتفصيل في [دليل أمن سلسلة التوريد](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#pin-actions).
3. كتابة `${{ secrets.X }}` داخل `run` تضع القيمة في نص السكربت قبل تشغيله، فإذا احتوت على علامات تنصيص أو `$(...)` تنفذ كأوامر، والطريقة الصحيحة أن تمررها في `env` ويقرأها السكربت من متغير البيئة.
4. لا يوجد قسم `permissions`، فالتوكن الافتراضي للمهمة يأخذ صلاحيات المستودع الافتراضية، والصحيح أن تبدأ من `contents: read`.
5. `actions/checkout` يحفظ التوكن في إعداد Git داخل مجلد العمل افتراضياً، وأي خطوة لاحقة أو ملف يرفع كناتج Artifact قد يحمله، فاستخدم `persist-credentials: false`.
6. النشر إلى الإنتاج بلا `environment` يعني أن أسرار الإنتاج متاحة لأي مهمة في المستودع، وبلا `concurrency` قد يعمل نشران في نفس الوقت.

وللحصول على الأرقام الكاملة للوسوم استخدمنا `git ls-remote`، والمخرج سوف يكون كما يلي:

```text
$ git ls-remote https://github.com/actions/checkout "refs/tags/v7.0.1*"
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1
$ git ls-remote https://github.com/docker/login-action "refs/tags/v4.6.0*"
dbcb813823bdd20940b903addbd779551569679f	refs/tags/v4.6.0

```

والنسخة المصححة:

```yaml
name: deploy
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  deploy:
    name: Deploy to production
    runs-on: ubuntu-latest
    environment: production
    concurrency:
      group: deploy-production
      cancel-in-progress: false
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
        with:
          registry: registry.example.com
          username: ci
          password: ${{ secrets.REGISTRY_TOKEN }}
      - name: Deploy
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
        run: ./deploy.sh

```

وللتحقق دون الاعتماد على عينك فقط شغلنا أداة فحص ملفات Workflow المسماة zizmor (الإصدار 1.30.1) على الملفين بالأمر `docker run --rm -v "$PWD:/repo:ro" ghcr.io/zizmorcore/zizmor:latest --offline --persona=auditor --format plain /repo`، والمخرج على الملف الأصلي سوف يكون كما يلي (اختصرنا كل ملاحظة إلى سطرها الأول):

```text
warning[artipacked]: credential persistence through GitHub Actions artifacts
warning[excessive-permissions]: overly broad permissions
warning[excessive-permissions]: overly broad permissions
help[template-injection]: code injection via template expansion
help[template-injection]: code injection via template expansion
help[template-injection]: code injection via template expansion
error[unpinned-uses]: unpinned action reference
error[unpinned-uses]: unpinned action reference
info[anonymous-definition]: workflow or action definition without a name
help[concurrency-limits]: insufficient job-level concurrency limits
warning[secrets-outside-env]: secrets referenced without a dedicated environment
warning[secrets-outside-env]: secrets referenced without a dedicated environment
warning[secrets-outside-env]: secrets referenced without a dedicated environment
warning[secrets-outside-env]: secrets referenced without a dedicated environment
14 findings (1 unsafe fixes): 1 informational, 4 low, 7 medium, 2 high

```

وعلى الملف المصحح بنفس الأمر دون `--format plain`:

```text
 INFO zizmor: 🌈 zizmor v1.30.1
 INFO audit: zizmor: 🌈 completed /repo/.github/workflows/deploy.yml
No findings to report. Good job!

```

في الإعداد أعلاه لاحظ التالي:

- الأداة لا تعرف أن `base64` يكشف السر، فهي اعترضت على السطر لأنه يضع السر في نص السكربت، وفهم سبب الخطر عليك أنت، ولهذا هذا التمرين بلا أدوات أولاً.
- التعليق `# v7.0.1` بجانب الرقم يجعل المراجعة مقروءة، وأدوات التحديث مثل Renovate وDependabot تحدث الرقم والتعليق معاً.
- في Gitea Actions تحمل ال Actions من github.com افتراضياً، فنفس القاعدة تنطبق، والفروق بين الصيغتين في [صفحة المقارنة في توثيق Gitea](https://docs.gitea.com/usage/actions/comparison?ref=arabroot.io).

للتفصيل: [الاستخدام الآمن في توثيق GitHub Actions](https://docs.github.com/en/actions/reference/security/secure-use?ref=arabroot.io)، و[قسم الأسرار في دليل Gitea Actions](https://arabroot.io/articles/gitea-actions-%D8%AA%D8%B4%D8%BA%D9%8A%D9%84-ci-cd-%D8%B9%D9%84%D9%89-%D8%AE%D8%A7%D8%AF%D9%85%D9%83/#secrets).

### التمرين 18: لماذا لا نبني Image لكل بيئة؟

خط النشر في فريقكم يبني Image للاختبار Staging من Commit معين، وبعد موافقة الفريق يبني Image آخر للإنتاج Production من نفس ال Commit بنفس ال Dockerfile الذي في [التمرين 1](#kata-devops-1)، والحجة أن «نفس الكود يعطي نفس النتيجة». هل هذا صحيح؟ أثبت إجابتك بأمرين، واكتب الطريقة الصحيحة لترقية Promote الإصدار من بيئة إلى أخرى، واذكر أين تذهب الفروق بين البيئات.

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

تلميح Hint

ابن نفس المجلد مرتين بالخيار `--no-cache` وقارن معرف ال Image الكامل. وفكر فيما يتغير بين البنائين: الوقت، والمكتبات غير المثبتة بأرقام، والتحديثات في ال Image الأساسي.

الحل Answer 

غير صحيح، فبناءان من نفس الكود يعطيان نسختين مختلفتين من ال Image، والمخرج سوف يكون كما يلي:

```text
$ docker build -q --no-cache -t lab-kata-ops-shop:staging .
sha256:d136fd58542dc65716556c27d0701f97bb96be3e68344cb8a6c2c12df446c865
$ docker build -q --no-cache -t lab-kata-ops-shop:prod .
sha256:d481ebea59763ec7282efdbb77a2563d3aeee275dacbb15f3d46fbb37fed7c89

```

والطريقة الصحيحة أن تبني مرة واحدة باسم يحمل رقم ال Commit، ثم تعطي نفس ال Image وسم كل بيئة:

```text
$ docker build -q --no-cache -t lab-kata-ops-shop:git-3f2a9c1 .
$ docker tag lab-kata-ops-shop:git-3f2a9c1 lab-kata-ops-shop:staging
$ docker tag lab-kata-ops-shop:git-3f2a9c1 lab-kata-ops-shop:prod
$ docker images --no-trunc --format '{{.Repository}}:{{.Tag}} {{.ID}}' | grep lab-kata-ops-shop
lab-kata-ops-shop:git-3f2a9c1 sha256:625257182ab894d2b572090ceaa38507281c39d753555f207ed35277d5e1bb72
lab-kata-ops-shop:prod sha256:625257182ab894d2b572090ceaa38507281c39d753555f207ed35277d5e1bb72
lab-kata-ops-shop:staging sha256:625257182ab894d2b572090ceaa38507281c39d753555f207ed35277d5e1bb72

```

في الإعداد أعلاه لاحظ التالي:

- الاختلاف هنا من وقت الإنشاء على الأقل، ولكن في الواقع قد يكون أخطر: `requirements.txt` يثبت `requests` فقط، والمكتبات التي يعتمد عليها مثل `urllib3` و`certifi` قد تنزل بنسخة أحدث في البناء الثاني، فالذي اختبرته في Staging ليس ما ينشر في الإنتاج.
- الفروق بين البيئات (عنوان القاعدة، والأسرار، ومستوى السجلات) مكانها متغيرات البيئة والأسرار عند التشغيل، وليس داخل ال Image.
- في السجل Registry يكون المرجع الأدق هو الهاش Digest وليس الوسم، لأن الوسم يمكن نقله، فاكتب في ملف النشر `image@sha256:...` عندما تريد أن تضمن أن الإنتاج يشغل ما اختبرته بالضبط.

للتفصيل: [قسم ابن مرة واحدة في دليل البيئات وGitOps](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/#build-once).

### التمرين 19: التراجع عن نسخة فيها ترحيل لقاعدة البيانات

نشرتم النسخة 1.8 الساعة الثانية ظهراً، وبعد ربع ساعة ارتفعت الأخطاء في صفحة الطلبات. النسخة 1.8 فيها ترحيل Migration يغير اسم العمود `price` إلى `unit_price` في الجدول `products`، والنشر يتم بملف Compose فيه `image: lab-kata-ops-web:${APP_TAG}`. أمامك أربعة خيارات: (أ) إعادة بناء النسخة 1.7 من Git ونشرها، (ب) تشغيل ال Image السابق 1.7 بتغيير الوسم، (ج) استرجاع نسخة احتياطية لقاعدة البيانات من الصباح، (د) إصلاح سريع ونشر 1.8.1\. أي خيار تختار الآن، وما الذي يمنع الخيار (ب) من النجاح في هذه الحالة تحديداً، وكيف تكتب الترحيل حتى يصبح التراجع آمناً في المرة القادمة؟

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

تلميح Hint

التراجع بالكود سهل، والتراجع بالبيانات صعب. شغل استعلام النسخة 1.7 بعد تنفيذ ترحيل 1.8 وانظر ماذا يحدث. وابحث عن نمط التوسيع ثم الانكماش Expand and Contract.

الحل Answer 

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

```text
$ APP_TAG=1.8 docker compose up -d
 Container lab-kata-ops-web Started
$ curl -s http://127.0.0.1:21412/
shop version 1.8
$ APP_TAG=1.7 docker compose up -d
 Container lab-kata-ops-web Starting
 Container lab-kata-ops-web Started
$ curl -s http://127.0.0.1:21412/
shop version 1.7
$ docker compose up -d   (APP_TAG not set)
error while interpolating services.web.image: required variable APP_TAG is missing a value: set APP_TAG

```

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

```text
alter table products rename column price to unit_price;
--- v1.7 code after rollback
select id, name, price from products;
ERROR:  column "price" does not exist
LINE 1: select id, name, price from products;
                         ^

```

ولذلك يصبح الخيار المتاح الآن هو (د) إذا كان الإصلاح صغيراً وواضحاً، أو أن تعيد تسمية العمود يدوياً ثم تتراجع ب (ب). والدرس للمرة القادمة أن الترحيل يجب أن يكون متوافقاً مع النسخة السابقة: في النسخة 1.8 تضيف العمود الجديد وتنسخ البيانات إليه وتبقي القديم (التوسيع Expand)، وبعد أن تستقر النسخة وتتأكد أنك لن تتراجع تحذف القديم في نسخة لاحقة (الانكماش Contract):

```text
alter table products add column unit_price numeric;
update products set unit_price = price;
--- v1.7 code after rollback
select id, name, price from products;
 id |   name   | price
----+----------+-------
  1 | keyboard |   120
  2 | mouse    |    45
(2 rows)

--- v1.8 code
select id, name, unit_price from products;
 id |   name   | unit_price
----+----------+------------
  1 | keyboard |        120
  2 | mouse    |         45
(2 rows)

```

في الإعداد أعلاه لاحظ التالي:

- الصيغة `${APP_TAG:?set APP_TAG}` تجعل Compose يرفض التشغيل إذا نسيت الوسم، بدل أن ينشر وسماً فارغاً أو `latest`.
- بين التوسيع والانكماش يكتب الكود الجديد في العمودين أو تزامنهما بطريقة أخرى، حتى تبقى البيانات صحيحة إذا تراجعت.
- خطة التراجع تكتب وتجرب قبل النشر وليس بعد ارتفاع الأخطاء، وهذا ما يجعل النشر في يوم عمل عادي ممكناً.

للتفصيل: [دليل طرق نشر التطبيقات من FTP إلى GitOps](https://arabroot.io/articles/%D8%B7%D8%B1%D9%82-%D9%86%D8%B4%D8%B1-%D8%A7%D9%84%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D9%85%D9%86-ftp-%D8%A7%D9%84%D9%89-gitops/)، و[قسم الترحيلات في دليل البيئات وGitOps](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/#migrations)، وقسم [أوقف الضرر أولاً](https://arabroot.io/articles/%D8%A7%D8%B3%D8%AA%D9%83%D8%B4%D8%A7%D9%81-%D8%A7%D9%84%D8%A7%D8%AE%D8%B7%D8%A7%D8%A1-%D9%88%D8%AA%D8%AD%D9%84%D9%8A%D9%84-%D8%A7%D9%84%D8%A7%D8%AF%D8%A7%D8%A1-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/#mitigate-first) في دليل استكشاف الأخطاء.

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

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

```text
| #  | المستوى  | التمرين                                  | التاريخ | الوقت | تلميح؟ | المراجعة القادمة |
|----|----------|------------------------------------------|---------|-------|--------|------------------|
| 1  | مبتدئ    | ترتيب طبقات Dockerfile                   |         |       |        |                  |
| 2  | مبتدئ    | مستخدم غير root                          |         |       |        |                  |
| 3  | مبتدئ    | localhost واسم الخدمة في Compose         |         |       |        |                  |
| 4  | مبتدئ    | git revert لCommit منشور                 |         |       |        |                  |
| 5  | مبتدئ    | حل تعارض Merge                           |         |       |        |                  |
| 6  | مبتدئ    | ترويسات Nginx                            |         |       |        |                  |
| 7  | متوسط    | سر بقي في طبقة                           |         |       |        |                  |
| 8  | متوسط    | البناء متعدد المراحل                     |         |       |        |                  |
| 9  | متوسط    | depends_on وhealthcheck                  |         |       |        |                  |
| 10 | متوسط    | صلاحيات ال Volume                        |         |       |        |                  |
| 11 | متوسط    | الشرطة في proxy_pass                     |         |       |        |                  |
| 12 | متوسط    | WebSocket خلف Nginx                      |         |       |        |                  |
| 13 | متوسط    | سكربت يتكرر بأمان                        |         |       |        |                  |
| 14 | متقدم    | استعادة Commit بالأمر reflog             |         |       |        |                  |
| 15 | متقدم    | cherry-pick -x لإصلاح عاجل               |         |       |        |                  |
| 16 | متقدم    | git bisect run                           |         |       |        |                  |
| 17 | متقدم    | مراجعة ملف CI                            |         |       |        |                  |
| 18 | متقدم    | ابن مرة واحدة ورق الإصدار                |         |       |        |                  |
| 19 | متقدم    | التراجع والترحيلات                       |         |       |        |                  |

```

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

```bash
docker rm -f $(docker ps -aq --filter name=^lab-kata-ops-)
docker network rm lab-kata-ops-net
docker volume rm lab-kata-ops-data
docker rmi lab-kata-ops-git lab-kata-ops-cache:bad lab-kata-ops-cache:good lab-kata-ops-user \
  lab-kata-ops-secret:bad lab-kata-ops-secret:good lab-kata-ops-go:single lab-kata-ops-go:multi \
  lab-kata-ops-vol:bad lab-kata-ops-vol:good lab-kata-ops-shop:git-3f2a9c1 lab-kata-ops-shop:staging \
  lab-kata-ops-shop:prod lab-kata-ops-web:1.7 lab-kata-ops-web:1.8

```

## الخلاصة

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

- رتب Dockerfile من الأقل تغيراً إلى الأكثر، وشغل التطبيق بمستخدم غير root، وابن في مرحلتين حتى لا يصل المترجم إلى السيرفر، ولا تضع سراً في `COPY` أو `ARG` أو `ENV`، فالحذف في طبقة لاحقة لا يحذف شيئاً.
- في Compose اسم الخدمة هو العنوان و`localhost` هو ال Container نفسه، والجاهزية تعرف بفحص صحة وليس بالانتظار، وال Volume الجديد يأخذ ملكية المجلد من ال Image.
- في Nginx الشرطة الأخيرة في `proxy_pass` تغير المسار، والترويسات Host وX-Forwarded-\* وUpgrade لا تصل إلى التطبيق إلا إذا مررتها.
- في Git لا تعد كتابة تاريخ منشور، فاستخدم `revert` و`cherry-pick -x`، وتذكر أن `reflog` ينقذ ما لم يحذفه `gc` بعد، وأن `bisect run` يحول البحث في أربعين Commit إلى ست خطوات.
- في CI ثبت ال Actions بالرقم الكامل، ومرر الأسرار عبر `env` ولا تطبعها، وابن ال Image مرة واحدة ورقه بين البيئات، واكتب الترحيلات بحيث يبقى التراجع بالكود ممكناً.

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

[![](https://arabroot.io/content/images/2026/10/dev-checklist-cover-cover.webp)دليلقبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطورالأخطاء التي لا تظهر إلا بعد نشر تطبيقك في Docker خلف Reverse Proxy، وكيف تعالج كل واحد منها قبل أن يصل إلى المستخدمين.](https://arabroot.io/articles/%D9%82%D8%A7%D8%A6%D9%85%D8%A9-%D8%AA%D8%AD%D9%82%D9%82-%D8%A7%D9%84%D9%85%D8%B7%D9%88%D8%B1-%D9%82%D8%A8%D9%84-%D9%86%D8%B4%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%87/)[![](https://arabroot.io/content/images/2026/10/behind-proxy-cover-cover.webp)دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.](https://arabroot.io/articles/%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D9%83-%D8%AE%D9%84%D9%81-reverse-proxy/)[![](https://arabroot.io/content/images/2026/10/git-workflows-cover-cover.webp)دليلGit في العمل اليومي: الدمج والتراجع دون أن تخسر عملكمتى تستخدم Merge ومتى تستخدم Rebase، وكيف تنقل إصلاحاً إلى فرع الإصدار أو تسترجع عملاً ظننت أنه ضاع.](https://arabroot.io/articles/git-merge-%D9%88-rebase-%D9%88-cherry-pick/)[![](https://arabroot.io/content/images/2026/10/supply-chain-cover-cover.webp)دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.](https://arabroot.io/articles/%D8%A7%D9%85%D9%86-%D8%B3%D9%84%D8%B3%D9%84%D8%A9-%D8%A7%D9%84%D8%AA%D9%88%D8%B1%D9%8A%D8%AF-%D8%A7%D9%84%D8%A8%D8%B1%D9%85%D8%AC%D9%8A%D8%A9-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)[![](https://arabroot.io/content/images/2026/10/environments-gitops-cover-cover.webp)دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%8A%D8%A6%D8%A7%D8%AA-%D9%88%D9%85%D8%B9%D8%A7%D9%8A%D9%86%D8%A9-%D8%A7%D9%84%D9%85%D9%8A%D8%B2%D8%A7%D8%AA-%D9%88-gitops-%D9%84%D9%84%D9%81%D8%B1%D9%82/)[![](https://arabroot.io/content/images/2026/10/iac-terms-cover-cover.webp)دليلمصطلحات البنية كشيفرة IaC التي تحتاجها قبل أي أداةما معنى أن تكتب بنيتك في ملفات داخل Git، ولماذا يفشل السكربت العادي عند تشغيله مرة ثانية، وما الذي تحفظه الأداة في ملف الحالة.](https://arabroot.io/articles/%D8%A7%D9%84%D8%A8%D9%86%D9%8A%D8%A9-%D9%83%D8%B4%D9%8A%D9%81%D8%B1%D8%A9-iac-%D9%84%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6%D9%8A%D9%86/)

وفي بقية سلسلة [تمارين Kata](https://arabroot.io/start/#stage-katas) سوف نطبق نفس الطريقة بإذن الله على [تشخيص الأعطال](https://arabroot.io/articles/%D8%AA%D9%85%D8%A7%D8%B1%D9%8A%D9%86-kata-%D9%81%D9%8A-%D8%AA%D8%B4%D8%AE%D9%8A%D8%B5-%D8%A7%D9%84%D8%A7%D8%B9%D8%B7%D8%A7%D9%84/)، حيث تبدأ من العرض الذي يراه المستخدم وتصل إلى السبب.

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

- أكتوبر 2026: كتابة التمارين التسعة عشر، وتنفيذ كل الحلول على Docker Engine 29.8.0 (Docker Desktop) باستخدام python:3.13-slim (Python 3.13.16) وpostgres:18-alpine وnginx:1.29-alpine (Nginx 1.29.8) وgolang:1.27.1-bookworm وgcr.io/distroless/static-debian13 وalpine:3.22 مع Git 2.49.1، وفحص ملف CI بالأداة zizmor 1.30.1\. ملف Workflow لم يشغل على GitHub أو Gitea فعلاً، والتحقق منه كان بالأداة وبتوثيق GitHub الرسمي.