في مقال لماذا تتمرن دون ذكاء اصطناعي؟ شرحنا لماذا تضعف المهارة التي لا تستخدمها، وكيف تتمرن بطريقة Kata وحدك أو مع فريقك، وهذا المقال يطبق الطريقة على مجال DevOps: ملف Dockerfile الذي يبني في عشرين ثانية بدل ثانية واحدة، وملف Compose الذي لا يصل فيه التطبيق إلى قاعدة البيانات، وإعداد Nginx الذي يأكل جزءاً من الرابط، وأوامر Git التي تحتاجها في اللحظة التي ضاع فيها عمل يومين، وملف CI يطبع التوكن Token في السجل. والسبب في اختيار هذه المواضيع أن الخطأ فيها لا يظهر عادة كرسالة واضحة، وإنما كنشر بطيء أو 502 أو سر مكشوف، والذي يحل المشكلة بسرعة هو من يعرف ماذا يفعل كل سطر، وليس من ينسخ إعداداً جاهزاً.
وطريقة الاستخدام قصيرة: أغلق أي مساعد ذكاء اصطناعي، واقرأ التمرين، واضبط مؤقتاً على الوقت المقترح، ثم اكتب إجابتك كاملة في ملف قبل أن تفتح أي شيء، سواء كانت الإجابة سطراً في Dockerfile أو أمر Git أو جملة تشرح فيها السبب. المسموح لك هو التوثيق الرسمي و--help، وإذا علقت فافتح التلميح Hint ثم حاول مرة أخرى، وبعدها فقط افتح الحل Answer وقارنه بإجابتك، وفي النهاية سجل الوقت في جدول المتابعة وارجع إلى التمرين بعد أسبوع.
وسوف نناقش في هذا المقال ما يلي:
- تجهيز المختبر 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. - جدول متابعة تنسخه لتسجل تقدمك، وروابط المقالات التي تشرح كل موضوع بالتفصيل.
والصورة التالية تبين التمارين التسعة عشر موزعة على المستويات الثلاثة:

تجهيز المختبر 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 أولاً:
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 [email protected] \
&& 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 ويختلف عندك):
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:
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. وهذا ما رأيناه عند البناء بعد تعديل سطر واحد في الكود (اختصرنا المخرج إلى الخطوات):
#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:
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
وبعد تعديل الكود مرة أخرى وإعادة البناء، والمخرج سوف يكون كما يلي:
#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 عن ترتيب الطبقات وذاكرة البناء، وقسم نظافة ال Image في قائمة تحقق المطور.
التمرين 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 يصبح طريقاً إلى السيرفر نفسه. والحل أن تنشئ مستخدماً عادياً وتنتقل إليه:
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"]
والتحقق بأربعة أوامر، والمخرج سوف يكون كما يلي:
$ 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، وقسم المستخدم والصلاحيات في قائمة تحقق المطور.
التمرين 3: التطبيق لا يصل إلى قاعدة البيانات في Compose
هذا ملف compose.yaml فيه قاعدة PostgreSQL، و«تطبيق» نمثله هنا ب Docker Image نفسه لأنه يحتوي على pg_isready وpsql، والتطبيق يقرأ عنوان القاعدة من المتغير DB_HOST:
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 ظهر هذا المخرج من التطبيق:
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:
environment:
DB_HOST: db
وعند التشغيل مرة أخرى، والمخرج سوف يكون كما يلي:
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، لأن الانتظار بعدد ثوان ثابت ليس حلاً. وطريقة التفريق بين المشكلتين في رسالة الخطأ نفسها: مع localhost ترى العنوان 127.0.0.1 و::1، ومع اسم الخدمة ترى عنوان ال Container الآخر في الشبكة.
للتفصيل: الشبكات في توثيق Compose، وقسم شبكات Compose في دليل شبكات Docker.
التمرين 4: التراجع عن Commit وصل إلى الفرع الرئيسي
هذا تاريخ الفرع main، وقد دفعته Push إلى المستودع المشترك قبل ساعة، وسحبه Pull اثنان من زملائك:
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 المحدد، والتاريخ القديم يبقى كما هو، فيسحبه زملاؤك كأي تغيير عادي. والمخرج الكامل للتجربة، بما فيها الطريقة الخاطئة أولاً، سوف يكون كما يلي:
$ 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 للمبتدئين، وتوثيق git revert.
التمرين 5: تعارض في ملف الإعداد عند الدمج
في الفرع main رفع أحد الزملاء قيمة timeout في الملف app.conf من 30 إلى 45 لتقارير المبيعات، وفي الفرع feature/longer-timeout رفعها زميل آخر إلى 60. عند الدمج ظهر ما يلي:
$ 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:
printf 'server_port = 8080\ntimeout = 60\nlog_level = info\n' > app.conf
git add app.conf
git commit --no-edit
git log --oneline --graph
والمخرج سوف يكون كما يلي:
* 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، وتوثيق git merge.
التمرين 6: التطبيق خلف Nginx لا يعرف اسم الموقع ولا عنوان الزائر
تطبيق يسجل لكل طلب الرابط واسم الموقع Host وعنوان الزائر، ويعمل خلف 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 طبع التطبيق ما وصله:
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، تعيد اسم الموقع الأصلي وتخبر التطبيق بعنوان الزائر وبالبروتوكول الذي وصل به:
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 ونفس الطلب، والمخرج سوف يكون كما يلي:
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، وقسم انتحال العنوان Spoofing في نفس الدليل.
المستوى الثاني: متوسط Intermediate
في هذا المستوى يعمل كل شيء في الظاهر، والخطأ يظهر في الأمان أو الحجم أو في أول إعادة تشغيل، لذلك اقرأ المخرجات بعناية قبل أن تحكم.
التمرين 7: حذفنا ملف التوكن، فهل اختفى من ال Image؟
يحتاج البناء إلى توكن لمستودع npm خاص، ومفتاح API يمرر كمتغير بناء. كتب أحد الزملاء هذا الملف، وحذف الملف .npmrc بعد استخدامه، وبنى بالأمر docker build --build-arg API_KEY=sk_live_FAKE_9f8e7d -t lab-kata-ops-secret:bad .:
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 يأتي بعده:
$ 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، وفي الطبقة التالية علامة الحذف فقط:
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
والمخرج سوف يكون كما يلي:
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 واحد فقط، ولا يكتب في أي طبقة ولا في التاريخ:
# 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}"
والبناء يمرر السرين من ملف ومن متغير بيئة:
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")
والمخرج سوف يكون كما يلي:
#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، وقسم الأسرار داخل ال Images في دليل اكتشاف الأسرار المسربة.
التمرين 8: Docker Image حجمه 1.38 GB لبرنامج من عشرة أسطر
خدمة Go صغيرة ترد بالنص hello from go على المنفذ 8080، وهذا ال 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:
$ 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:
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"]
والتحقق، والمخرج سوف يكون كما يلي:
$ 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، وقسم نظافة ال Images الأساسية في دليل أمن سلسلة التوريد.
التمرين 9: depends_on موجود والتطبيق يفشل في أول تشغيل
بعد إصلاح التمرين 3 حذف أحد الزملاء الانتظار sleep لأنه «غير احترافي»، وأصبح الملف هكذا:
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"
والنتيجة في كل تشغيل على سيرفر جديد:
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:
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"
والمخرج سوف يكون كما يلي (اختصرنا سجلات القاعدة):
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، وقسم ترتيب التشغيل في قائمة تحقق المطور.
التمرين 10: Permission denied عند الكتابة في ال Volume
بعد التمرين 2 أصبحت كل خدماتكم تعمل بمستخدم غير root، وهذه خدمة تكتب ملفاتها في /data:
FROM alpine:3.22
RUN adduser -D -u 10001 app
USER app
CMD ["sh", "-c", "echo hello > /data/out.txt && ls -ln /data"]
وعند التشغيل مع Volume مسمى:
$ 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 لا يستطيع الكتابة فيه:
$ 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 الجديد عند أول تركيب:
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 والتشغيل مرة أخرى، والمخرج سوف يكون كما يلي:
$ 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، وقسم ال Volumes والصلاحيات في قائمة تحقق المطور.
التمرين 11: ال API يعطي 404 خلف Nginx فقط
ال API يعرف المسارات /users/7 و/orders، وتريد أن يصل إليه الزوار عبر /api/، فكتبت:
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/ بالشرطة /. والتطبيق في المختبر يطبع سطر الطلب كما وصله حرفياً، والمخرج سوف يكون كما يلي:
--- 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 بشرطة يحدث أمران خاطئان، والمخرج سوف يكون كما يلي:
$ 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، وقسم التطبيق تحت مسار فرعي في دليل تطبيقك خلف Reverse Proxy.
التمرين 12: المحادثة المباشرة تعمل محلياً وتفشل خلف Nginx
صفحة المحادثة تفتح اتصال WebSocket على /ws/chat. مباشرة إلى التطبيق يرد بالرمز 101، ومن خلال Nginx يرد التطبيق نفسه بالرمز 400. هذا الإعداد، والطلب الذي يمثل ما يرسله المتصفح:
server {
listen 80;
location /ws/ {
proxy_pass http://lab-kata-ops-backend:8000;
}
}
$ 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 عندما لا يطلب الزائر ترقية:
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;
}
}
وبعد إعادة التحميل ونفس الطلب، والمخرج سوف يكون كما يلي:
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 للمفتاح الذي أرسلناه، أي أن التطبيق أكمل المصافحة Handshake فعلاً. - السطر
proxy_http_version 1.1أصبح القيمة الافتراضية منذ Nginx 1.29.7 حسب التوثيق، ونتركه لأن كثيراً من السيرفرات تعمل بنسخ أقدم، وبدونه فيها ترسل الطلبات بالإصدار 1.0 الذي لا يعرف الترقية. - بدون
proxy_read_timeoutيغلق Nginx الاتصال إذا لم يرسل التطبيق شيئاً خلال 60 ثانية، والبديل أن يرسل التطبيق رسائل Ping دورية.
للتفصيل: صفحة WebSocket proxying في توثيق Nginx، وقسم WebSocket في دليل تطبيقك خلف Reverse Proxy.
التمرين 13: سكربت الإعداد ينجح مرة واحدة فقط
هذا سكربت يجهز سيرفراً جديداً، ويشغله خط النشر في كل مرة:
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 كأن شيئاً لم يحدث:
$ 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
والنسخة المتكررة بأمان:
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 جديد بعد ثلاث مرات، والمخرج سوف يكون كما يلي:
$ 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يعامل النص كما هو، فلا تفسر النقاط في القيمة كتعبير نمطي.
المستوى الثالث: متقدم Advanced
تمارين هذا المستوى قرارات أكثر منها أوامر، والإجابة الكاملة فيها جملة تشرح لماذا اخترت هذه الطريقة وليس غيرها.
التمرين 14: reset --hard أضاع عمل يومين
كان عندك ثلاثة Commits محلية لم تدفعها بعد، وأردت أن تلغي تعديلاً لم تحفظه فكتبت بالخطأ:
$ 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 وتعطيه فرعاً باسم، والمخرج سوف يكون كما يلي:
$ 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، وتوثيق git reflog.
التمرين 15: إصلاح عاجل لنسخة منشورة دون بقية التطوير
النسخة 1.4 منشورة من الفرع release/1.4، والفرع main فيه عمل على صفحة دفع جديدة لم تجهز بعد، وفي وسطه إصلاح لخطأ في حساب المجموع يجب أن يصل إلى 1.4 اليوم:
$ 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 الأصلي:
$ 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، وتوثيق git cherry-pick.
التمرين 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:
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
والمخرج سوف يكون كما يلي:
$ 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 <[email protected]>
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، وتوثيق git bisect.
التمرين 17: مراجعة ملف CI قبل دمجه
هذا ملف Workflow بصيغة GitHub Actions، وهي نفس الصيغة التي تفهمها Gitea Actions تقريباً، وصل في طلب دمج Pull Request وطلب صاحبه الموافقة:
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
ملاحظات المراجعة من الأخطر:
- السطر
echo ... | base64يطبع السر بشكل آخر، والإخفاء التلقائي يبحث عن القيمة كما هي فقط، وتوثيق GitHub نفسه يقول إن الإخفاء غير مضمون لأن السر قد يتحول بطرق كثيرة، فأي شخص يقرأ السجل يفك ال Base64 في ثانية. واحذف سطري Debug بالكامل، فلا يوجد سبب لطباعة سر. actions/checkout@mainوdocker/login-action@v3مراجع متحركة، فالفرع والوسم يمكن نقلهما إلى كود آخر في أي لحظة، والكود الجديد يعمل بأسرارك. والحل هو التثبيت Pinning برقم ال Commit الكامل، وهو حسب توثيق GitHub الطريقة الوحيدة لاستخدام Action كإصدار لا يتغير، وهذا ما استغله المهاجمون في حادثة tj-actions/changed-files في مارس 2025 التي رويناها في دليل Gitea Actions، وشرحنا التثبيت بالتفصيل في دليل أمن سلسلة التوريد.- كتابة
${{ secrets.X }}داخلrunتضع القيمة في نص السكربت قبل تشغيله، فإذا احتوت على علامات تنصيص أو$(...)تنفذ كأوامر، والطريقة الصحيحة أن تمررها فيenvويقرأها السكربت من متغير البيئة. - لا يوجد قسم
permissions، فالتوكن الافتراضي للمهمة يأخذ صلاحيات المستودع الافتراضية، والصحيح أن تبدأ منcontents: read. actions/checkoutيحفظ التوكن في إعداد Git داخل مجلد العمل افتراضياً، وأي خطوة لاحقة أو ملف يرفع كناتج Artifact قد يحمله، فاستخدمpersist-credentials: false.- النشر إلى الإنتاج بلا
environmentيعني أن أسرار الإنتاج متاحة لأي مهمة في المستودع، وبلاconcurrencyقد يعمل نشران في نفس الوقت.
وللحصول على الأرقام الكاملة للوسوم استخدمنا git ls-remote، والمخرج سوف يكون كما يلي:
$ 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
والنسخة المصححة:
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، والمخرج على الملف الأصلي سوف يكون كما يلي (اختصرنا كل ملاحظة إلى سطرها الأول):
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:
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.
للتفصيل: الاستخدام الآمن في توثيق GitHub Actions، وقسم الأسرار في دليل Gitea Actions.
التمرين 18: لماذا لا نبني Image لكل بيئة؟
خط النشر في فريقكم يبني Image للاختبار Staging من Commit معين، وبعد موافقة الفريق يبني Image آخر للإنتاج Production من نفس ال Commit بنفس ال Dockerfile الذي في التمرين 1، والحجة أن «نفس الكود يعطي نفس النتيجة». هل هذا صحيح؟ أثبت إجابتك بأمرين، واكتب الطريقة الصحيحة لترقية Promote الإصدار من بيئة إلى أخرى، واذكر أين تذهب الفروق بين البيئات.
الوقت المقترح: 20 دقيقة.
تلميح Hint
ابن نفس المجلد مرتين بالخيار --no-cache وقارن معرف ال Image الكامل. وفكر فيما يتغير بين البنائين: الوقت، والمكتبات غير المثبتة بأرقام، والتحديثات في ال Image الأساسي.
الحل Answer
غير صحيح، فبناءان من نفس الكود يعطيان نسختين مختلفتين من ال Image، والمخرج سوف يكون كما يلي:
$ 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 وسم كل بيئة:
$ 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:...عندما تريد أن تضمن أن الإنتاج يشغل ما اختبرته بالضبط.
التمرين 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 السابق الموجود في السجل كما هو، لأنه نفس ما كان يعمل أمس ولا يحتاج بناء، والخيار (أ) يعيد مشكلة التمرين السابق، والخيار (ج) يحذف كل طلبات العملاء منذ الصباح. والتراجع بتغيير الوسم يعمل في المختبر كما يلي:
$ 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
لكن في هذه الحالة تحديداً الترحيل غير اسم العمود، والكود القديم لا يجده، فالتراجع بالكود وحده يعطي أخطاء أكثر من النسخة الجديدة:
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):
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، وقسم الترحيلات في دليل البيئات وGitOps، وقسم أوقف الضرر أولاً في دليل استكشاف الأخطاء.
جدول المتابعة
انسخ الجدول التالي إلى ملف عندك، واكتب في كل مرة تحل فيها تمريناً التاريخ والوقت الذي أخذه وهل احتجت التلميح، وارجع إلى كل تمرين احتجت فيه التلميح أو تجاوزت الوقت بعد أسبوع، وإلى التمارين التي حللتها بسرعة بعد شهر:
| # | المستوى | التمرين | التاريخ | الوقت | تلميح؟ | المراجعة القادمة |
|----|----------|------------------------------------------|---------|-------|--------|------------------|
| 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 لأنها تحذف ما يخص مشاريع أخرى على نفس الجهاز:
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 مرة واحدة ورقه بين البيئات، واكتب الترحيلات بحيث يبقى التراجع بالكود ممكناً.
وهذه المقالات التي تشرح كل موضوع في التمارين بالتفصيل:
دليلقبل أن تنشر تطبيقك على خادمك: قائمة تحقق للمطورالأخطاء التي لا تظهر إلا بعد نشر تطبيقك في Docker خلف Reverse Proxy، وكيف تعالج كل واحد منها قبل أن يصل إلى المستخدمين.
دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.
دليلGit في العمل اليومي: الدمج والتراجع دون أن تخسر عملكمتى تستخدم Merge ومتى تستخدم Rebase، وكيف تنقل إصلاحاً إلى فرع الإصدار أو تسترجع عملاً ظننت أنه ضاع.
دليلأمن سلسلة التوريد البرمجية Software Supply Chain للمبتدئينكل ما بين كود غيرك وسيرفرك قد يحمل كود المهاجم، وهنا تعرف التهديدات الحقيقية بحوادث موثقة والدفاع المناسب لكل حلقة.
دليلمن الفرع إلى الإنتاج: البيئات وGitOps للفرقكيف يختبر فريقك كل ميزة وحدها ويعرضها على العميل دون أن تصل ميزة غير جاهزة إلى الإنتاج.
دليلمصطلحات البنية كشيفرة IaC التي تحتاجها قبل أي أداةما معنى أن تكتب بنيتك في ملفات داخل Git، ولماذا يفشل السكربت العادي عند تشغيله مرة ثانية، وما الذي تحفظه الأداة في ملف الحالة.وفي بقية سلسلة تمارين Kata سوف نطبق نفس الطريقة بإذن الله على تشخيص الأعطال، حيث تبدأ من العرض الذي يراه المستخدم وتصل إلى السبب.
سجل التحديثات
- أكتوبر 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 الرسمي.