في أي فريق برمجي فيه أكثر من مطورين يتكرر المشهد نفسه كل أسبوع: خمسة مطورين يعملون على ثلاث ميزات A وB وC في الوقت نفسه، وفريق ضمان الجودة QA يريد أن يختبر الميزة B وحدها، ومدير المنتج يريد أن يعرض الميزة A على عميل يوم الخميس، والميزة C ما زالت غير مكتملة ويجب ألا تصل إلى الإنتاج Production حتى توافق عليها الإدارة، وفي منتصف كل هذا يصل بلاغ عن خطأ في الإنتاج Production يحتاج إلى إصلاح عاجل Hotfix اليوم وليس الأسبوع القادم. والسؤال الذي يطرحه كل فريق في هذه اللحظة هو: كيف ننظم الفروع Branches والبيئات Environments والنشر Deployment بحيث يحصل كل طرف على ما يريده دون أن يفسد عمل غيره؟
في مقال التطوير على الفرع الرئيسي Trunk-Based Development شرحنا كيف يدمج الفريق عمله في main كل يوم بفروع قصيرة، وفي إدارة النسخ وGit للمبتدئين وmerge وrebase وcherry-pick شرحنا أدوات Git نفسها، أما هذا المقال فيبدأ من النقطة التي يتوقف عندها ذلك المقال، أي من اللحظة التي يخرج فيها الكود من Git إلى السيرفرات، وسوف نناقش في هذا المقال ما يلي:
- الطرق الأولى التي تلجأ إليها أغلب الفرق: سيرفر اختبار مشترك، وبيئة staging فيها خليط من الميزات، وفرع دائم لكل بيئة، ولماذا تفشل كل منها.
- البيئات Environments وغرض كل منها والبيانات التي تستخدمها، وكيف يختلف الإعداد Configuration بين البيئات دون أن يختلف الكود.
- قاعدة «ابن مرة واحدة وانقل الشيء نفسه» Build Once, Promote: Docker Image واحدة تنتقل من dev إلى staging إلى الإنتاج.
- بيئات المعاينة Preview Environments لكل طلب دمج Pull Request، وهي الحل لطلب «QA يريد الميزة B وحدها»، مع Coolify وDokploy وDocker Compose وTraefik وArgo CD، وتكلفتها وحمايتها.
- ال Feature Flags للفصل بين النشر والإطلاق، ثم GitOps بأبسط شكل: مستودع إعداد Config Repository يصف كل بيئة، والترقية Promotion بطلب دمج، والتراجع Rollback بالأمر
git revert. - المسار الكامل للسيناريو أعلاه خطوة خطوة، ثم الإصدارات Releases وترحيل قواعد البيانات Database Migrations بين البيئات.
- تجربة عملية بـ Docker Compose وTraefik تبني Docker Image واحدة وتشغلها كبيئة معاينة وكبيئة staging وكإنتاج، مع ترقية حقيقية في مستودع إعداد صغير، ثم قائمة تحقق وأخطاء شائعة.
الطرق الأولى التي تجربها الفرق ولماذا تفشل
قبل أن نصل إلى الحل الصحيح سوف نمر على الطرق التي تبدأ بها أغلب الفرق، لأن كل واحدة منها تبدو منطقية في يومها الأول، ثم تنكسر عندما يزيد عدد المطورين أو الميزات.
سيرفر اختبار واحد يتشارك فيه الجميع
الطريقة الأولى أن يكون هناك سيرفر اسمه test.example.com، وكل مطور ينتهي من ميزته ينشرها عليه من جهازه أو من فرعه، ثم يخبر فريق QA أن يختبر. والصورة التالية تبين ما يحدث عندما يعمل خمسة مطورين بهذه الطريقة:

والمشكلة هنا ليست في السيرفر وإنما في أن البيئة الواحدة لا يمكن أن تكون في حالتين في الوقت نفسه، فالمطور الثالث ينشر الميزة C فتختفي الميزة A التي كان المطور الأول ينتظر نتيجة اختبارها، ثم ينشر المطور الثاني الميزة B فتختفي C، وفريق QA يفتح الرابط بعد ساعة فيجد شيئاً غير الذي طلب منه اختباره. والأسوأ من ذلك أن قاعدة البيانات Database لا تعود إلى الوراء عندما يعاد نشر كود أقدم، فترحيل Migration أضافته الميزة C يبقى في قاعدة البيانات بعد أن يحل محلها كود لا يعرفه، وتبدأ أخطاء لا علاقة لها بأي ميزة، ويضيع الفريق يوماً في البحث عن خطأ سببه ترتيب النشر وليس الكود.
بيئة staging فيها خليط من الميزات
الطريقة الثانية أكثر تنظيماً في الظاهر: ينشأ فرع اسمه develop أو staging، ويدمج فيه كل مطور ميزته عندما يرى أنها جاهزة، وينشر هذا الفرع تلقائياً على بيئة staging. وهنا تختفي مشكلة المسح، ولكن تظهر مشكلة أخرى، فبيئة staging تحتوي في أي لحظة على خليط عشوائي من ميزات مكتملة وميزات نصف مكتملة، وعندما يجد فريق QA خطأ في الميزة B لا يستطيع أن يعرف بسهولة هل سببه B نفسها أم تداخلها مع C، والأهم أن الإدارة إذا وافقت على A وB ورفضت C فلا يوجد شيء اسمه «staging بدون C»، فيبدأ الفريق في إزالة commits من الفرع أو نسخ commits إلى فرع آخر بالأمر git cherry-pick، والنتيجة أن الذي يصل إلى الإنتاج تركيبة لم يختبرها أحد بهذا الشكل من قبل.
فرع دائم لكل بيئة: dev وstaging وprod
الطريقة الثالثة هي الأكثر انتشاراً، وهي أن يكون لكل بيئة فرع دائم: dev وstaging وprod، والترقية من بيئة إلى التي بعدها تعني دمج فرع في الفرع التالي، والنشر يتم من كل فرع إلى بيئته. وتبدو هذه الطريقة نظيفة على الورق، ولكنها في الواقع تنتج ما يسمى الانجراف Drift بين الفروع، والسبب أن الفروع الثلاثة لا تبقى متطابقة أبداً:
- الإصلاح العاجل يكتب مباشرة على
prodلأنه عاجل، ثم ينسى أحدهم أن يعيده إلىstagingوdev، فيعود الخطأ نفسه مع الإصدار التالي. - الدمج من
stagingإلىprodينقل كل ما في staging، فإذا كان فيه تعديل مقصود لبيئة staging وحدها، مثل عدد نسخ أقل أو إعداد اختبار، فإنه يصل إلى الإنتاج معه، أو يضطر الفريق إلى cherry-pick يدوي في كل مرة. - بعد بضعة أشهر لا يعرف أحد على وجه الدقة ما الفرق بين الفرعين، فيصبح كل دمج إلى
prodحدثاً مخيفاً يحتاج إلى حل تعارضات Merge Conflicts في ليلة الإصدار. - الكود الذي يعمل في الإنتاج نتج عن دمج مختلف، وبالتالي قد يبنى Build من جديد في فرع
prod، وهذا يعني أن الذي يعمل في الإنتاج ليس الشيء نفسه الذي اختبر في staging، وسوف نعود لهذه النقطة في قسم البناء مرة واحدة.
وهذا ليس رأينا وحدنا، فبحث DORA عن التطوير على الفرع الرئيسي يربط الأداء العالي للفرق بأن يكون في المستودع ثلاثة فروع نشطة أو أقل، وبأن تدمج الفروع في الفرع الرئيسي مرة في اليوم على الأقل، وكتب Kostis Kapelonis في مدونة Octopus Deploy مقالاً معروفاً عنوانه توقف عن استخدام الفروع للنشر إلى بيئات GitOps المختلفة يشرح فيه المشكلات نفسها، ويلاحظ أن Helm وKustomize، وهما الأداتان الأشهر لوصف التطبيقات في Kubernetes، تمثلان البيئات بملفات ومجلدات وليس بفروع. لذلك لا تقم بإنشاء فرع دائم لكل بيئة، والسبب أن الفرق بين البيئات هو فرق في الإعداد وليس في الكود، والإعداد مكانه ملف لكل بيئة كما سوف نرى.
وقد يتساءل البعض: وماذا عن GitLab Flow الذي يستخدم فروعاً للبيئات؟ والإجابة أن هذا النمط يناسب فريقاً لا يملك أدوات تفصل الإعداد عن الكود، أو منتجاً يصدر لعملائه نسخاً مختلفة، ولكنه في الفريق الذي ينشر تطبيق ويب واحداً يعيد كل المشكلات أعلاه، وإذا احتجت فعلاً إلى إصلاح إصدار قديم فالحل فرع إصدار قصير Release Branch كما شرحنا في الإصدار من الفرع الرئيسي، وليس فرعاً دائماً لكل بيئة.
البيئات وغرض كل منها والبيانات التي تستخدمها
البيئة Environment هي مكان يعمل فيه التطبيق مع إعداده وبياناته وخدماته المساندة، مثل قاعدة البيانات والذاكرة المؤقتة Cache وخدمة البريد. والفرق الذي يعمل بطريقة سليمة لا يسأل «كم بيئة نحتاج؟» وإنما «ما السؤال الذي تجيب عنه كل بيئة؟»، والصورة التالية تبين السلم الذي تتفق عليه أغلب الفرق:

- البيئة المحلية Local: جهاز المطور، ويعمل فيها التطبيق غالباً عبر Docker Compose مع قاعدة بيانات تبنى من بيانات أولية Seed Data، والسؤال الذي تجيب عنه: هل يعمل الكود الذي أكتبه الآن؟
- بيئة CI: ال Runner الذي يشغل الاختبارات الآلية Automated Tests مع كل push، كما شرحنا في Gitea Actions، وهي بيئة تنشأ وتحذف مع كل تشغيل، والسؤال: هل كسر هذا التغيير شيئاً معروفاً؟
- بيئة المعاينة Preview: نسخة كاملة من التطبيق لكل طلب دمج، تعيش ما دام الطلب مفتوحاً، والسؤال: هل هذه الميزة بالذات تعمل كما طلب صاحبها؟ وهي موضوع قسم كامل أدناه.
- بيئة dev أو integration: ينشر إليها كل ما يدمج في
mainتلقائياً، والسؤال: هل تعمل الميزات معاً بعد الدمج؟ - بيئة staging: أقرب ما تكون إلى الإنتاج في الإصدارات والإعداد وحجم السيرفرات وطريقة النشر، وإليها يرقى الإصدار المرشح Release Candidate، والسؤال: هل هذا الإصدار جاهز للمستخدمين؟
- بيئة الإنتاج Production: المستخدمون الحقيقيون والبيانات الحقيقية، ولا ينشر إليها إلا ما مر بالبيئات قبلها.
ولاحظ عمود البيانات في الصورة، فالقاعدة أن بيانات العملاء الحقيقية لا تخرج من الإنتاج إلى أي بيئة أخرى، والسبب أن بيئات الاختبار والمعاينة يصل إليها عدد أكبر من الناس، وحمايتها أضعف، ونسخها الاحتياطية أقل ضبطاً، فإذا احتاجت staging إلى بيانات تشبه الإنتاج فاستخدم نسخة مجهولة الهوية Anonymised تستبدل فيها الأسماء والأرقام والبريد الإلكتروني ببيانات وهمية، وبيئات المعاينة تبنى من بيانات أولية Seed يكتبها الفريق ويحفظها في المستودع.
الإعداد يختلف بين البيئات والكود لا يختلف
ما الذي يجعل staging مختلفة عن الإنتاج إذا كان الكود واحداً؟ والإجابة أنه الإعداد فقط: عنوان قاعدة البيانات، ومفاتيح الخدمات الخارجية، واسم النطاق، وحالة ال Feature Flags. وقد صاغ منهج The Twelve-Factor App هذه القاعدة بوضوح، فهو يطلب فصلاً صارماً بين الإعداد والكود، وأن يحفظ الإعداد في متغيرات البيئة Environment Variables، ويقترح اختباراً بسيطاً: هل تستطيع أن تنشر الكود مفتوح المصدر الآن دون أن تكشف أي باسورد أو مفتاح؟ فإذا كانت الإجابة لا فالإعداد ما زال داخل الكود. والمنهج نفسه يدعو في مبدأ التشابه بين التطوير والإنتاج Dev/Prod Parity إلى أن تبقى البيئات متشابهة قدر الإمكان، لذلك لا تقم بكتابة شروط مثل if env == "staging" في الكود، والسبب أن كل شرط من هذا النوع مسار في الكود لا يعمل إلا في بيئة واحدة، وبالتالي لا يختبر في البيئة التي قبلها.
أما الأسرار Secrets مثل الباسورد ومفاتيح API فلا تحفظ في المستودع ولا في ملف الإعداد العادي، وإنما في مخزن الأسرار الذي توفره أداة النشر، مثل أسرار Gitea Actions أو متغيرات البيئة المخفية في Coolify وDokploy، ولكل بيئة أسرارها الخاصة، فمفتاح بوابة الدفع في staging مفتاح اختبار Test Key، ولا يوضع مفتاح الإنتاج في أي بيئة أخرى.
ابن ال Docker Image مرة واحدة ثم انقلها كما هي
هذه أهم قاعدة في المقال، وهي التي تجعل كلمة «اختبرناه في staging» تعني شيئاً. الفكرة أن ال CI يبني Docker Image واحدة من commit محدد، ويرفعها إلى ال Registry، ثم تنتقل هذه ال Image نفسها من بيئة إلى أخرى، ولا يبنى الكود من جديد في أي مرحلة بعد ذلك. والصورة التالية تبين ذلك:

والسبب أن كل عملية بناء جديدة قد تنتج شيئاً مختلفاً حتى لو كان الكود نفسه، فال Base Image مثل node:22 قد تحدثت منذ البناء الأول، ومكتبة لم يثبت رقم إصدارها قد صدرت لها نسخة جديدة، وأداة البناء على سيرفر الإنتاج قد تكون غير التي على سيرفر الاختبار، والنتيجة أن الإنتاج يشغل شيئاً لم يختبره أحد. وهذه القاعدة قديمة، فكتاب Continuous Delivery لمؤلفيه Jez Humble وDavid Farley يضعها تحت عنوان Only Build Your Binaries Once، ويضع بجانبها قاعدة أخرى هي أن تنشر بالطريقة نفسها إلى كل بيئة، وأن يبقى الإعداد الخاص بكل بيئة خارج الحزمة.
وفي Docker يتحقق ذلك بالتمييز بين أمرين:
- الوسم Tag: اسم يشير إلى Image، مثل
app:1.4.2أوapp:sha-a3cdf027، ويمكن نقله ليشير إلى Image أخرى، لذلك لا تستخدمlatestفي أي بيئة، والسبب أنك لن تعرف ما الذي يعمل فعلاً. - ال Digest: بصمة المحتوى نفسه، مثل
sha256:eb08dd1a…، ولا تتغير ما دام المحتوى نفسه، فإذا تطابق ال digest في staging والإنتاج فهما يشغلان البايتات نفسها.
والممارسة المتفق عليها أن يوسم البناء بوسمين: وسم بأول ثمانية أحرف من ال commit مثل sha-a3cdf027 حتى تعرف من أي كود جاءت ال Image، ووسم بالإصدار مثل 1.4.2 عندما يصبح البناء إصداراً، وهذا ما يفعله ال workflow في دليل Gitea Actions. وتوصي أفضل ممارسات Argo CD بالأمر نفسه في ملفات الإعداد، أي أن تشير إلى وسم ثابت أو commit SHA وليس إلى شيء قد يتغير معناه من يوم إلى آخر.
بيئة معاينة لكل طلب دمج: الحل لطلب «اختبر الميزة B وحدها»
نعود الآن إلى السيناريو، ففريق QA يريد الميزة B وحدها، ومدير المنتج يريد الميزة A وحدها، وكلاهما لا يريد أن ينتظر حتى تدمج الميزة وتصل إلى staging. والحل أن تنشأ لكل طلب دمج Pull Request بيئة خاصة به، فيها الكود الموجود في فرعه فقط، وتحذف تلقائياً عند دمج الطلب أو إغلاقه، وهذه هي بيئة المعاينة Preview Environment، وتسمى أيضاً Review App أو Ephemeral Environment. والصورة التالية تبين دورة حياتها:

والخطوات كما يلي:
- المطور يفتح طلب دمج من الفرع
feature-bإلىmain. - ال CI يشغل الاختبارات ويبني Docker Image موسومة برقم الطلب أو ب SHA آخر commit في الفرع، ويعيد البناء مع كل push جديد على الفرع.
- أداة النشر تشغل نسخة من التطبيق باسم خاص بالطلب، وتعطيها نطاقاً فرعياً مثل
pr-123.preview.example.com. - الرابط يكتب تلقائياً في تعليق على طلب الدمج، فيجده فريق QA ومدير المنتج دون أن يسألوا أحداً.
- فريق QA يختبر الميزة B وحدها ويوافق على الطلب أو يطلب تعديلات، ومدير المنتج يعرض الميزة A على العميل من رابط طلبها.
- عند دمج الطلب أو إغلاقه تحذف ال Containers وقاعدة البيانات والمسار والأسرار.
والجزء الذي يجعل هذا ممكناً دون أن تضيف سجلاً في DNS لكل طلب هو السجل العام Wildcard، فسجل واحد مثل *.preview.example.com يشير بكل الأسماء الفرعية إلى سيرفر المعاينة، كما شرحنا في السجل العام Wildcard، ومعه شهادة TLS واحدة من نوع Wildcard لا تصدر إلا بالتحقق من ملكية النطاق عبر ال DNS (DNS Challenge)، ثم يوجه ال Reverse Proxy كل اسم إلى ال Container الخاص به.
بيئات المعاينة في Coolify وDokploy
إذا كنت تستخدم منصة نشر PaaS على سيرفرك فأغلب العمل جاهز. ففي Coolify تنشأ بيئة معاينة عند فتح طلب الدمج أو تحديثه وتحذف عند إغلاقه أو دمجه، ونطاقها يبنى من قالب يحتوي على رقم الطلب {{pr_id}}، ويستطيع تطبيق GitHub App أن يكتب حالة النشر والرابط في تعليق على الطلب، ولبيئات المعاينة متغيرات بيئة منفصلة عن متغيرات الإنتاج، وتنص الوثائق صراحة على ألا تعطى بيئات المعاينة بيانات اعتماد الإنتاج. وفي Dokploy تنشأ بيئات المعاينة مع فتح الطلب وتتحدث مع كل commit وتحذف عند إغلاقه أو دمجه، وتحتاج إلى سجل Wildcard يشير إلى السيرفر، ولها حد أقصى لعدد المعاينات لكل تطبيق قيمته الافتراضية 3، ويمكن قصرها على الطلبات التي تحمل Label معيناً.
ولاحظ أن وثائق المنصتين تركز على GitHub، فإذا كانت مستودعاتك على Gitea فتأكد من دعم مزودك قبل أن تعتمد عليها، وقد ناقشنا هذا الفرق مع بقية الفروق بين المنصتين في مقارنة Portainer وCoolify وDokploy.
بيئات المعاينة بـ Docker Compose وTraefik دون منصة
إذا لم تكن تستخدم منصة فالفكرة نفسها تبنى بأدوات تعرفها: ملف Compose واحد للتطبيق يأخذ اسم البيئة من متغير، وTraefik يقرأ ال Labels من كل Container ويوجه الاسم pr-123.preview.example.com إليه دون أن تعدل إعداده، وخطوة في ال CI تنفذ docker compose -p pr-123 up -d عند فتح الطلب وتنفذ docker compose -p pr-123 down -v عند إغلاقه، والخيار -p يعطي كل طلب اسم مشروع Project مستقلاً فلا تتداخل Containers الطلبات ولا ال Volumes الخاصة بها. وهذا بالضبط ما نبنيه في التجربة العملية أدناه على جهازك.
بيئات المعاينة في Kubernetes مع Argo CD
وفي Kubernetes يكون لكل طلب دمج Namespace خاص به، ويتولى إنشاءه وحذفه Argo CD عبر مولد طلبات الدمج Pull Request Generator في ال ApplicationSet، وهو يدعم GitHub وGitLab وGitea وBitbucket وAzure DevOps، ويسأل المزود عن الطلبات المفتوحة كل 30 دقيقة افتراضياً أو فوراً عبر Webhook، وينشئ تطبيقاً لكل طلب، ويحذف التطبيق عندما يغلق الطلب. والمثال التالي مبني على مثال الوثائق لمستودع على Gitea:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: shop-previews
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- pullRequest:
gitea:
owner: myorg
repo: shop
api: https://git.example.com/
tokenRef:
secretName: gitea-token
key: token
requeueAfterSeconds: 300
template:
metadata:
name: 'shop-pr-{{.number}}'
spec:
project: previews
source:
repoURL: https://git.example.com/myorg/shop.git
targetRevision: '{{.head_sha}}'
path: deploy/preview
kustomize:
images:
- 'registry.example.com/shop:sha-{{.head_short_sha}}'
destination:
server: https://kubernetes.default.svc
namespace: 'shop-pr-{{.number}}'
syncPolicy:
automated:
prune: true
syncOptions:
- CreateNamespace=trueفي الإعداد أعلاه لاحظ التالي:
{{.number}}رقم طلب الدمج، ومنه يبنى اسم التطبيق واسم ال Namespace، فلكل طلب مساحته المعزولة.targetRevisionيشير إلى SHA آخر commit في الفرع، وال Image موسومة بأول أحرفه، وهذا يتطلب أن يكون ال CI قد بنى ال Image ورفعها قبل أن يصل إليها Argo CD.prune: trueيحذف الموارد التي لم تعد موجودة في الإعداد، وCreateNamespace=trueينشئ ال Namespace عند أول مزامنة.
وتفاصيل Kubernetes وArgo CD نفسها خارج هذا المقال، وهي في مرحلة Kubernetes وGitOps في خارطة الطريق.
ما الذي تكلفه بيئات المعاينة وكيف تحميها
بيئات المعاينة ليست مجانية، فكل طلب مفتوح يعني تطبيقاً يعمل وغالباً قاعدة بيانات معه، وعشرون طلباً مفتوحاً تعني عشرين نسخة على السيرفر. وفيما يلي ما يجب أن تقرره قبل أن تفعلها:
- قاعدة البيانات Database: لكل معاينة قاعدة بيانات خاصة بها تبنى من البيانات الأولية Seed مع تشغيل الترحيلات Migrations، والسبب أن ترحيل الميزة C إذا طبق على قاعدة بيانات مشتركة فسوف يكسر معاينة الميزة B، وهذا يعيدك إلى مشكلة السيرفر المشترك.
- الخدمات الخارجية: بوابة الدفع وخدمة البريد وأي API خارجي تستخدم في المعاينة حسابات اختبار Sandbox، والبريد يذهب إلى صندوق التقاط مثل Mailpit وليس إلى عملاء حقيقيين.
- الأسرار Secrets: أسرار خاصة بالمعاينات وصلاحياتها ضيقة، ولا يوضع فيها أي سر من الإنتاج، والسبب أن كود الفرع لم يراجعه أحد بعد، وأي سطر فيه يستطيع أن يقرأ متغيرات البيئة ويرسلها إلى أي مكان.
- المستودعات العامة Public Repositories: لا تفعل المعاينات لطلبات الدمج القادمة من Forks في مستودع عام، والسبب أن أي شخص يفتح طلباً يستطيع عندها أن يشغل كوده على سيرفرك، وهذا التحذير موجود صراحة في وثائق Dokploy وCoolify.
- الحماية Access: روابط المعاينة تحتوي على ميزات لم تعلن بعد، ويعثر عليها الناس ومحركات البحث، لذلك ضعها خلف دخول موحد SSO كما في حماية أي تطبيق باستخدام authentik Forward Auth، أو على الأقل خلف قائمة عناوين أو Basic Auth كما في حماية تطبيقات الويب على مستوى HTTP، وأضف الترويسة
X-Robots-Tag: noindex. - الحدود والتنظيف: ضع حداً لعدد المعاينات المفتوحة، وأوقف المعاينة التي لم يفتحها أحد منذ أيام، وتأكد أن الحذف يحذف ال Volumes أيضاً، وإلا فسوف تجد بعد شهر أن القرص امتلأ بقواعد بيانات لطلبات أغلقت منذ زمن.
Feature Flags: انشر الكود وأطلق الميزة لاحقاً
بيئات المعاينة تحل مشكلة الاختبار قبل الدمج، ولكن ماذا عن الميزة C التي يجب أن تدمج في main حتى لا يطول فرعها، ويجب في الوقت نفسه ألا يراها المستخدمون حتى توافق الإدارة؟ والحل هو ال Feature Flag، أي شرط في الكود قيمته تأتي من الإعداد، فالكود يصل إلى كل البيئات بما فيها الإنتاج، والميزة لا تعمل إلا في البيئة أو للمستخدمين الذين يفتح لهم ال Flag. وبهذا ينفصل النشر Deployment، أي وصول الكود إلى السيرفر، عن الإطلاق Release، أي ظهور الميزة للمستخدمين، وقد شرحنا ال Feature Flags وطريقة كتابتها في Feature Flags في مقال Trunk-Based، والأدوات المخصصة لها مثل Unleash وFlagsmith في مرحلة منصات للمطورين في خارطة الطريق.
وما يهمنا هنا هو علاقة ال Flags بالبيئات، فال Flag يسمح بأن تكون الميزة C مفعلة في dev وstaging حتى يختبرها فريق QA مع بقية الميزات، ومطفأة في الإنتاج، ثم تفعل في الإنتاج لمجموعة المختبرين أو الموظفين فقط، وهو ما يسميه Pete Hodgson في مقاله عن Feature Toggles تشغيل الميزة لمستخدمين داخليين قبل الجميع، ثم تفعل للجميع دون نشر جديد. وينبه المقال نفسه إلى أن كل Flag له تكلفة مستمرة مثل المخزون، لذلك احذف ال Flag والمسار القديم من الكود بعد أن تطلق الميزة للجميع، والسبب أن ال Flags المنسية تتحول إلى مسارات لا يعرف أحد هل ما زالت تعمل، والقصة التالية تبين ما يمكن أن يحدث.
GitOps ببساطة: مستودع يصف كل بيئة
بقي سؤال واحد: كيف ننقل ال Image من dev إلى staging إلى الإنتاج؟ والطريقة التقليدية أن يدخل أحدهم إلى السيرفر وينفذ أوامر النشر بيده أو يضغط زراً في أداة ما، والمشكلة أنه بعد شهر لا أحد يعرف من نشر ماذا ومتى، ولا ما هي الحالة التي يجب أن تكون عليها كل بيئة، كما رأينا في قصة Knight Capital. والطريقة التي تسمى GitOps تقلب هذا، فالحالة المطلوبة Desired State لكل بيئة تكتب في ملفات داخل مستودع Git، وأداة آلية تجعل البيئة تطابق هذه الملفات، والصورة التالية تبين الدورة كاملة:

وقد جمع مشروع OpenGitOps الفكرة في أربعة مبادئ: أن تكون الحالة المطلوبة مكتوبة بشكل تصريحي Declarative، وأن تحفظ بطريقة لها نسخ وسجل كامل لا يعدل Versioned and Immutable، وأن تسحبها برامج آلية من مصدرها Pulled Automatically، وأن تراقب هذه البرامج الحالة الفعلية باستمرار وتعيدها إلى الحالة المطلوبة Continuously Reconciled. وبالكلام العملي:
- ال CI في مستودع الكود يبني ال Image مرة واحدة ويرفعها إلى ال Registry، ولا ينشر شيئاً بنفسه.
- الترقية تعني طلب دمج في مستودع الإعداد يغير سطراً واحداً: وسم ال Image في مجلد البيئة.
- المراجع يوافق على الطلب، وحماية الفرع Branch Protection في Gitea أو غيره تمنع الدمج دون موافقة، فالموافقة على النشر إلى الإنتاج هي الموافقة على طلب الدمج نفسه.
- المتحكم Controller مثل Argo CD أو Flux يسحب التغيير من المستودع، أو تنفذه مهمة في ال CI إذا لم يكن عندك Kubernetes.
- البيئة تصبح مطابقة للمستودع، وإذا عدل أحدهم شيئاً يدوياً على السيرفر فإن المتحكم يكتشف الانحراف Drift ويعيده.
والنتيجة أنك حصلت على ثلاثة أشياء مجاناً: سجل تدقيق Audit Trail هو نفسه git log يخبرك من رقى ماذا ومتى ومن وافق، وموافقات هي نفسها مراجعة طلبات الدمج، وتراجع Rollback هو نفسه git revert للـ commit الذي رقى الإصدار. وتوصي أفضل ممارسات Argo CD بأن يكون مستودع الإعداد منفصلاً عن مستودع الكود، والسبب أن سجله يبقى نظيفاً لا يحتوي إلا على تغييرات النشر، وأن من يكتبون الكود ليسوا بالضرورة من يحق لهم النشر إلى الإنتاج، وأن تعديل الإعداد وحده لا يجب أن يشغل بناء كاملاً للكود.
شكل مستودع الإعداد مع Kubernetes
مع Kubernetes يكون للتطبيق مجلد أساسي base فيه الملفات المشتركة، ومجلد لكل بيئة فيه الفروق فقط، وهذا هو نمط Kustomize المسمى Overlays:
config-repo/
├── apps/
│ └── shop/
│ └── base/
│ ├── deployment.yaml
│ ├── service.yaml
│ └── kustomization.yaml
└── envs/
├── dev/
│ └── shop/kustomization.yaml # tag 1.4.2, 1 replica, dev secrets
├── staging/
│ └── shop/kustomization.yaml # tag 1.4.2, 2 replicas
└── prod/
└── shop/kustomization.yaml # tag 1.4.1, 4 replicas, prod domainوملف البيئة لا يحتوي إلا على ما يختلف، والسطر الذي يتغير مع كل ترقية هو الوسم في الحقل images كما تشرح وثائق Kustomize:
# envs/prod/shop/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: shop-prod
resources:
- ../../../apps/shop/base
images:
- name: registry.example.com/shop
newTag: "1.4.2"
replicas:
- name: shop
count: 4وإذا كنت تستخدم Helm فالفكرة نفسها: Chart واحد وملف values-staging.yaml وvalues-prod.yaml لكل بيئة، والترقية تغير image.tag في ملف البيئة. ولاحظ أن الترقية بين البيئات هنا نسخ قيمة من ملف إلى ملف على الفرع نفسه، وليست دمج فرع في فرع، وهذا ما يحل مشكلة الانجراف التي شرحناها في بداية المقال.
الشكل نفسه لفريق بدون Kubernetes
أغلب الفرق الصغيرة تعمل على سيرفر أو اثنين بـ Docker Compose، وGitOps لا يحتاج Kubernetes، فالمستودع نفسه يمكن أن يحتوي على ملف Compose واحد وملف إعداد لكل بيئة:
deploy/
├── compose.yaml # one file for every environment
└── envs/
├── pr-12/release.env # preview: TAG=sha-..., FEATURE_B=on
├── staging/release.env # TAG=1.4.2, FEATURE_B=off
└── prod/release.env # TAG=1.4.1, FEATURE_B=offوالذي يطبق الإعداد هنا مهمة في ال CI، تعمل عند الدمج في main في مستودع الإعداد، وتتصل بسيرفر البيئة التي تغير ملفها، وتنفذ docker compose --env-file envs/prod/release.env up -d، أو منصة مثل Coolify أو Dokploy تنشر من هذا المستودع. وصحيح أنك تفقد هنا ميزة المراقبة المستمرة التي يقدمها Argo CD، فلن يكتشف أحد التعديل اليدوي على السيرفر تلقائياً، ولكنك تحتفظ بأهم ما في GitOps: ملف لكل بيئة، وترقية بطلب دمج، وسجل في Git، وتراجع ب git revert. وهذا الشكل هو الذي نطبقه في التجربة العملية.
المسار الكامل للفريق خطوة خطوة
الآن نجمع كل ما سبق ونطبقه على السيناريو الذي بدأنا به: خمسة مطورين، وثلاث ميزات A وB وC، وفريق QA يريد B وحدها، ومدير منتج يريد عرض A، وميزة C يجب ألا تظهر في الإنتاج، وإصلاح عاجل اليوم. والصورة التالية تبين المسار كاملاً بخطوات مرقمة:

- فروع قصيرة: كل مطور يعمل على فرع قصير من
mainمثلfeature-b، ويفتح طلب دمج مبكراً، ويقسم الميزة الكبيرة إلى أجزاء تدمج خلال يوم أو يومين كما شرحنا في مقال Trunk-Based. - معاينة للعرض: طلب الدمج 41 للميزة A ينشئ
pr-41.preview.example.com، ومنه يعرض مدير المنتج الميزة على العميل، فلا يحتاج أن ينتظر الدمج ولا أن يرى الميزات الأخرى. - معاينة للاختبار: طلب الدمج 42 للميزة B ينشئ
pr-42.preview.example.com، ويختبر فريق QA الميزة B وحدها عليه، وموافقته على الطلب جزء من شروط الدمج. - الميزة غير الجاهزة خلف Flag: الميزة C تدمج أجزاؤها الجاهزة في
mainوال Flag الخاص بهاFEATURE_Cمطفأ في الإنتاج، فتصل إلى كل البيئات كوداً ولا تظهر للمستخدمين. - البناء مرة واحدة: كل دمج في
mainيشغل ال CI الذي يبني Docker Image واحدة ويوسمها ب SHA ال commit ويرفعها إلى ال Registry. - النشر التلقائي إلى dev: ال CI يحدث وسم dev في مستودع الإعداد تلقائياً، فتعمل في dev دائماً آخر نسخة من
mainبكل الميزات، ومنها C مفعلة حتى تختبر مع غيرها. - الترقية إلى staging بطلب دمج: عندما يقرر الفريق إصداراً يوسم ال commit بالرقم
v1.4.2، ويفتح طلب دمج في مستودع الإعداد يغير وسم staging إلى1.4.2، فيشغل فريق QA اختبارات الانحدار Regression Tests على staging ويوافق. - الترقية إلى الإنتاج بطلب دمج موافق عليه: طلب دمج آخر يغير وسم prod إلى
1.4.2، ولا يدمج إلا بموافقة صاحب الصلاحية، والمتحكم أو مهمة ال CI ينشر ال Image نفسها التي اختبرت في staging بال digest نفسه، ثم تفعل الإدارة ال Flag للميزة C عندما توافق عليها، دون نشر جديد. - الإصلاح العاجل: يكتب الإصلاح على فرع قصير من
mainفي طلب الدمج 44، ويمر بالمسار نفسه ولكن بمراجعة أسرع: CI ثم دمج ثم بناء ثم ترقية إلى staging ثم إلى الإنتاج في الساعة نفسها، وإذا كانت فيmainميزات لا تريدها في هذا الإصدار فهي خلف ال Flags الخاصة بها، أما إذا كان لا بد من إصلاح إصدار قديم فالطريقة الصحيحة فرع إصدار قصير يأخذ الإصلاح منmainبالأمرgit cherry-pickكما في مقال merge وrebase وcherry-pick، وليس العكس. - التراجع: إذا ظهرت مشكلة بعد الترقية فهناك طريقتان: إطفاء ال Flag إذا كانت المشكلة في ميزة خلف Flag، وهذا يحدث في ثوان، أو عكس commit الترقية في مستودع الإعداد بالأمر
git revert، فيعود الإنتاج إلى ال Image السابقة التي ما زالت في ال Registry.
ولاحظ أنه لا يوجد في هذا المسار فرع اسمه staging ولا prod، ولا يوجد بناء في أي مرحلة بعد الخطوة الخامسة، ولا يوجد شخص يدخل إلى سيرفر الإنتاج لينشر بيده.
الإصدارات وترحيل قواعد البيانات بين البيئات
الإصدار Release هنا وسم Tag على commit في main يحمل رقماً يتبع الترقيم الدلالي Semantic Versioning، أي MAJOR.MINOR.PATCH: يزيد الرقم الأول عند تغيير لا يتوافق مع ما قبله، والثاني عند إضافة ميزة متوافقة، والثالث عند إصلاح خطأ متوافق، فالانتقال من 1.4.1 إلى 1.4.2 يخبر الجميع أنه إصلاح لا يكسر شيئاً. وطريقة الوسم وفروع الإصدار القصيرة مشروحة في مقال Trunk-Based، أما ما يخص البيئات فنقطتان:
- سجل التغييرات Changelog وملاحظات الإصدار Release Notes: إذا كتب الفريق رسائل ال commits بصيغة متفق عليها مثل Conventional Commits، أي
feat:للميزة وfix:للإصلاح، فإن أدوات مثل git-cliff أو release-please تولد سجل التغييرات من ال commits تلقائياً، ويبقى السجل نفسه مكتوباً للبشر كما يقترح Keep a Changelog، وهذا ما يقرؤه فريق QA قبل اختبار staging وما يرسل إلى العملاء. - رقم الإصدار يظهر في التطبيق: اجعل التطبيق يعرض رقم إصداره و SHA ال commit في صفحة أو في نقطة فحص الصحة Health Check، كما سوف ترى في التجربة، والسبب أن أول سؤال عند أي مشكلة هو «ما الإصدار الذي يعمل الآن في هذه البيئة؟».
ترحيل قاعدة البيانات دون أن يكسر الإصدار السابق
ال Docker Image تنتقل بين البيئات كما هي، ولكن قاعدة البيانات لا تنتقل، فلكل بيئة قاعدة بياناتها، والترحيل Migration الذي يغير الجداول يطبق على كل منها عند ترقيتها، وهنا مشكلة: أثناء النشر وبعده بقليل قد يعمل الإصدار القديم والجديد معاً، وإذا احتجت إلى التراجع فإن الإصدار القديم سوف يعمل على قاعدة بيانات عدلها الإصدار الجديد. والحل نمط التوسيع ثم التقليص Expand/Contract، ويسمى أيضاً Parallel Change، وهو أن يقسم التغيير غير المتوافق إلى ثلاث مراحل:
- التوسيع Expand: إصدار يضيف العمود أو الجدول الجديد دون أن يحذف القديم، ويكتب الكود في الاثنين.
- الترحيل Migrate: نقل البيانات القديمة إلى الشكل الجديد، وتحويل كل من يقرأ إلى القراءة من الجديد.
- التقليص Contract: بعد أن تستقر كل البيئات على الإصدار الجديد، إصدار لاحق يحذف العمود القديم.
وبهذا يبقى كل إصدار متوافقاً مع قاعدة البيانات التي تركها الإصدار الذي قبله، فيصبح التراجع بالأمر git revert آمناً. لذلك لا تقم بإعادة تسمية عمود أو حذفه في الإصدار نفسه الذي يتوقف فيه الكود عن استخدامه، والسبب أن التراجع عن هذا الإصدار سوف يعيد كوداً يبحث عن عمود لم يعد موجوداً. وتصميم الجداول نفسه موضوع تصميم قواعد البيانات: التطبيع والتوسع.
تجربة عملية: Image واحدة وثلاث بيئات ومستودع إعداد
تشغيل Gitea وGitea Actions وArgo CD لتجربة صغيرة عمل ثقيل، لذلك سوف نعرض هنا الآليات الأساسية وحدها على جهازك: Registry محلي، وTraefik يوجه الأسماء *.localhost إلى ال Containers، وDocker Image نبنيها مرة واحدة ونشغلها كبيئة معاينة pr-12 وكبيئة staging وكإنتاج prod بإعداد مختلف لكل منها، ثم مستودع إعداد صغير نرقي فيه الإصدار ونتراجع عنه ب Git. وقد نفذنا الخطوات على Docker Engine 29.8.0 مع Docker Compose v5.5.1 وTraefik v3.7.13 وRegistry 3.1.2، وكل المنافذ على 127.0.0.1 فقط.
ال Registry وTraefik
نبدأ بمجلد lab-envs وفيه ثلاثة مجلدات: platform للخدمات المشتركة، وapp للكود، وdeploy لمستودع الإعداد. وهذا ملف platform/compose.yaml:
name: lab-envs-platform
services:
traefik:
image: traefik:v3.7.13
container_name: lab-envs-traefik
command:
- --entrypoints.web.address=:80
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=lab-envs-net
ports:
- "127.0.0.1:18471:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [lab-envs-net]
registry:
image: registry:3
container_name: lab-envs-registry
ports:
- "127.0.0.1:15471:5000"
networks:
lab-envs-net:
name: lab-envs-netcd lab-envs/platform
docker compose up -dفي الإعداد أعلاه لاحظ التالي:
exposedbydefault=falseيجعل Traefik يتجاهل أي Container لا يحملtraefik.enable=true، وهذا هو الإعداد الصحيح دائماً كما في دليل Traefik.- ربطنا ال Docker socket للقراءة فقط لأنها تجربة محلية، أما على سيرفر حقيقي فاقرأ لماذا يحتاج ال Docker socket إلى حماية خاصة.
- ال Registry هنا بدون TLS ولا باسورد، وDocker يقبل ذلك للعنوان
127.0.0.1فقط، وفي الواقع سوف تستخدم Registry الذي يأتي مع Gitea كما في دليل Gitea Actions.
التطبيق: نبنيه مرة واحدة
التطبيق صفحة صغيرة من Nginx ترجع JSON فيه اسم البيئة والإصدار وال commit وحالة ال Flag وعنوان قاعدة البيانات، وكل ذلك من متغيرات البيئة، وهذا ملف app/Dockerfile:
FROM nginx:1.29-alpine
ARG VERSION=dev
ARG GIT_SHA=unknown
LABEL org.opencontainers.image.version=$VERSION \
org.opencontainers.image.revision=$GIT_SHA
ENV APP_VERSION=$VERSION GIT_SHA=$GIT_SHA
COPY default.conf.template /etc/nginx/templates/default.conf.templateوملف app/default.conf.template، وال Image الرسمية ل Nginx تستبدل المتغيرات فيه بقيمها من البيئة عند تشغيل ال Container:
server {
listen 80;
default_type application/json;
location / {
return 200 '{"env":"${APP_ENV}","version":"${APP_VERSION}","commit":"${GIT_SHA}","feature_b":"${FEATURE_B}","db":"${DATABASE_URL}"}\n';
}
}ولاحظ أن رقم الإصدار و SHA ال commit يدخلان في ال Image وقت البناء لأنهما صفة للكود، أما اسم البيئة وال Flag وقاعدة البيانات فلا تدخل في ال Image لأنها صفة للبيئة. وقد بنينا قبل ذلك الإصدار السابق 1.4.1 من commit سابق ورفعناه إلى ال Registry بالطريقة نفسها، لأنه الإصدار الذي يعمل في الإنتاج قبل التجربة. والآن نبني الإصدار الجديد من آخر commit، وهو ما يفعله ال CI بعد الدمج:
cd ../app
SHA=$(git rev-parse --short=8 HEAD); echo $SHA
docker build -q --build-arg VERSION=1.4.2 --build-arg GIT_SHA=$SHA \
-t 127.0.0.1:15471/lab-envs-app:1.4.2 \
-t 127.0.0.1:15471/lab-envs-app:sha-$SHA .
docker push -q 127.0.0.1:15471/lab-envs-app:1.4.2
docker push -q 127.0.0.1:15471/lab-envs-app:sha-$SHAوالمخرج سوف يكون كما يلي:
a3cdf027
sha256:eb08dd1a8636173e754ccfabcc3939871bcf1349258b1c29ea5044d8117154ed
127.0.0.1:15471/lab-envs-app:1.4.2
127.0.0.1:15471/lab-envs-app:sha-a3cdf027بناء واحد، ووسمان يشيران إلى ال Image نفسها، ويمكنك أن تتأكد من ال Registry نفسه:
docker buildx imagetools inspect 127.0.0.1:15471/lab-envs-app:1.4.2 | head -3
docker buildx imagetools inspect 127.0.0.1:15471/lab-envs-app:sha-a3cdf027 | head -3Name: 127.0.0.1:15471/lab-envs-app:1.4.2
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:eb08dd1a8636173e754ccfabcc3939871bcf1349258b1c29ea5044d8117154ed
Name: 127.0.0.1:15471/lab-envs-app:sha-a3cdf027
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:eb08dd1a8636173e754ccfabcc3939871bcf1349258b1c29ea5044d8117154edمستودع الإعداد: ملف Compose واحد وملف لكل بيئة
في المجلد deploy ملف compose.yaml واحد لكل البيئات:
# One file for every environment; only the .env file differs.
services:
app:
image: ${IMAGE}:${TAG}
container_name: lab-envs-${ENV_NAME}
environment:
APP_ENV: ${ENV_NAME}
FEATURE_B: ${FEATURE_B}
DATABASE_URL: ${DATABASE_URL}
labels:
- traefik.enable=true
- traefik.http.routers.${ENV_NAME}.rule=Host(`${ENV_NAME}.localhost`)
- traefik.http.services.${ENV_NAME}.loadbalancer.server.port=80
networks: [lab-envs-net]
networks:
lab-envs-net:
external: trueوملف لكل بيئة، وهذه محتويات الملفات الثلاثة في أول commit في المستودع، أي قبل أي ترقية:
# envs/pr-12/release.env
ENV_NAME=pr-12
IMAGE=127.0.0.1:15471/lab-envs-app
TAG=sha-a3cdf027
FEATURE_B=on
DATABASE_URL=postgres://preview@db-pr-12/app_seed
# envs/staging/release.env
ENV_NAME=staging
IMAGE=127.0.0.1:15471/lab-envs-app
TAG=1.4.1
FEATURE_B=off
DATABASE_URL=postgres://app@db-staging/app_anon
# envs/prod/release.env
ENV_NAME=prod
IMAGE=127.0.0.1:15471/lab-envs-app
TAG=1.4.1
FEATURE_B=off
DATABASE_URL=postgres://app@db-prod/appولاحظ أن بيئة المعاينة تستخدم وسم ال SHA لأنها تتبع الفرع وليس إصداراً، وأن Flag الميزة B مفعل فيها فقط، وأن كل بيئة تشير إلى قاعدة بيانات مختلفة، والعناوين هنا نصوص للعرض فقط ولا توجد قواعد بيانات فعلية في التجربة. الآن نشغل الإنتاج على الإصدار القديم ونشغل بيئة المعاينة، وهذا ما تفعله مهمة ال CI عند فتح طلب الدمج 12:
cd ../deploy
git init -q . && git add . && git commit -qm "environments: staging and prod on 1.4.1, preview pr-12"
docker compose -p lab-envs-prod --env-file envs/prod/release.env up -d
docker compose -p lab-envs-pr-12 --env-file envs/pr-12/release.env up -d
curl -s http://prod.localhost:18471/
curl -s http://pr-12.localhost:18471/والمخرج سوف يكون كما يلي:
{"env":"prod","version":"1.4.1","commit":"a77e72c1","db":"postgres://app@db-prod/app"}
{"env":"pr-12","version":"1.4.2","commit":"a3cdf027","feature_b":"on","db":"postgres://preview@db-pr-12/app_seed"}والإنتاج ما زال على الإصدار 1.4.1 الذي لا يعرف الميزة B أصلاً، وبيئة المعاينة تعرض الميزة B مفعلة على قاعدة بيانات المعاينة. ولاحظ أن الاسم pr-12.localhost يصل إلى 127.0.0.1 دون أي تعديل في DNS أو في ملف hosts، والسبب أن الأسماء تحت localhost محجوزة للجهاز نفسه، وcurl والمتصفحات الحديثة توجهها إلى 127.0.0.1 مباشرة. وإذا ظهر لك 404 page not found بعد التشغيل مباشرة فانتظر ثوان قليلة حتى يلتقط Traefik ال Container الجديد ثم أعد الطلب، فقد ظهرت لنا هذه الرسالة في أول طلب ثم اختفت.
الترقية إلى staging ثم إلى الإنتاج Production بcommit
فريق QA وافق على الميزة B في المعاينة، ودمج الطلب، والآن نرقي الإصدار 1.4.2 إلى staging، والترقية كلها تعديل سطر في ملف واحد:
sed -i 's/^TAG=1.4.1/TAG=1.4.2/' envs/staging/release.env
git diffdiff --git a/envs/staging/release.env b/envs/staging/release.env
index ed2738c..b18d36a 100644
--- a/envs/staging/release.env
+++ b/envs/staging/release.env
@@ -1,5 +1,5 @@
ENV_NAME=staging
IMAGE=127.0.0.1:15471/lab-envs-app
-TAG=1.4.1
+TAG=1.4.2
FEATURE_B=off
DATABASE_URL=postgres://app@db-staging/app_anonوهذا الفرق هو بالضبط ما سوف يراه المراجع في طلب الدمج على Gitea، وبعد الموافقة والدمج تنفذ مهمة ال CI النشر:
git -c user.name='Sara (QA lead)' -c [email protected] commit -qam "promote 1.4.2 to staging"
docker compose -p lab-envs-staging --env-file envs/staging/release.env up -d
curl -s http://staging.localhost:18471/
docker inspect --format '{{.Name}} {{.Config.Image}} {{.Image}}' lab-envs-pr-12 lab-envs-staging{"env":"staging","version":"1.4.2","commit":"a3cdf027","feature_b":"off","db":"postgres://app@db-staging/app_anon"}
/lab-envs-pr-12 127.0.0.1:15471/lab-envs-app:sha-a3cdf027 sha256:eb08dd1a8636173e754ccfabcc3939871bcf1349258b1c29ea5044d8117154ed
/lab-envs-staging 127.0.0.1:15471/lab-envs-app:1.4.2 sha256:eb08dd1a8636173e754ccfabcc3939871bcf1349258b1c29ea5044d8117154edفي المخرج أعلاه لاحظ التالي:
- بيئة المعاينة وبيئة staging تشغلان وسمين مختلفين، ولكن ال digest واحد
sha256:eb08dd1a…، أي أن ما اختبره فريق QA في المعاينة هو البايتات نفسها التي تعمل الآن في staging. - الفرق الوحيد بينهما هو الإعداد:
envوfeature_bوdb، وهذا ما يجب أن يكون. - الميزة B موجودة في كود staging ولكن ال Flag الخاص بها مطفأ، وهذا هو الفصل بين النشر والإطلاق.
بعد اختبار الانحدار على staging يرقى الإصدار إلى الإنتاج بالطريقة نفسها، والفرق أن طلب الدمج هذه المرة يوافق عليه صاحب صلاحية الإصدار:
sed -i 's/^TAG=1.4.1/TAG=1.4.2/' envs/prod/release.env
git -c user.name='Omar (release manager)' -c [email protected] commit -qam 'promote v1.4.2 to prod'
docker compose -p lab-envs-prod --env-file envs/prod/release.env up -d
curl -s http://prod.localhost:18471/
git log --format='%h %an %s' -- envs/{"env":"prod","version":"1.4.2","commit":"a3cdf027","feature_b":"off","db":"postgres://app@db-prod/app"}
ea38e11 Omar (release manager) promote v1.4.2 to prod
f1d3d0d Sara (QA lead) promote 1.4.2 to staging
b6e1720 Release Bot environments: staging and prod on 1.4.1, preview pr-12والسجل الأخير هو سجل التدقيق Audit Trail: من رقى ماذا إلى أي بيئة ومتى، دون أي أداة إضافية، والأسماء فيه أسماء وهمية ضبطناها بالخيار -c user.name لنحاكي أشخاصاً مختلفين، فأول commit كتبناه باسم Release Bot.
التراجع والحذف
لنفرض أن مشكلة ظهرت في الإنتاج بعد الترقية، فالتراجع عكس commit الترقية ثم تطبيق الإعداد من جديد:
git revert --no-edit HEAD
docker compose -p lab-envs-prod --env-file envs/prod/release.env up -d
curl -s http://prod.localhost:18471/
git log --oneline[main ed928a2] Revert "promote v1.4.2 to prod"
Date: Tue Oct 6 01:57:40 2026 +0300
1 file changed, 1 insertion(+), 1 deletion(-)
{"env":"prod","version":"1.4.1","commit":"a77e72c1","db":"postgres://app@db-prod/app"}
ed928a2 Revert "promote v1.4.2 to prod"
ea38e11 promote v1.4.2 to prod
f1d3d0d promote 1.4.2 to staging
b6e1720 environments: staging and prod on 1.4.1, preview pr-12ولاحظ أن التراجع لم يحذف التاريخ وإنما أضاف إليه commit جديداً، فما زال واضحاً أن الإصدار رقي ثم أعيد، وأن التراجع لم يحتج إلى بناء لأن ال Image السابقة ما زالت في ال Registry، لذلك لا تقم بحذف Images الإصدارات السابقة من ال Registry بسرعة، والسبب أنها هي خطة التراجع. وأخيراً يغلق طلب الدمج 12 فتحذف بيئة معاينته:
docker compose -p lab-envs-pr-12 --env-file envs/pr-12/release.env down
curl -s http://pr-12.localhost:18471/ Container lab-envs-pr-12 Stopping
Container lab-envs-pr-12 Stopped
Container lab-envs-pr-12 Removing
Container lab-envs-pr-12 Removed
404 page not foundوإذا طلبت الرابط في الثواني الأولى بعد الحذف فقد ترى Bad Gateway بدل 404، والسبب أن Traefik لم يحذف المسار بعد بينما ال Container قد حذف، وهذا ما ظهر لنا في أول محاولة.
ماذا يحدث إذا كتبت ال Labels بالشكل الآخر؟
في أول نسخة من ملف deploy/compose.yaml كتبنا ال Labels بصيغة المفتاح والقيمة Mapping، أي traefik.http.routers.${ENV_NAME}.rule: Host(...)، فعملت بيئة المعاينة وحدها، ولما شغلنا staging بجانبها توقفت البيئتان عن العمل وأصبحت كل الطلبات ترجع 404 page not found، وفي سجل Traefik ظهر السطر التالي:
ERR HTTP router defined multiple times with different configurations configuration=["app-lab-envs-pr-12-9d6a1541928be9f475412cc4402c119e2aca8598767c41cdfca9f6c4db519174","app-lab-envs-staging-45ccb59d17238e37d2cb106c8cee64a42ced5b8c68241702118bddb0ed85785d"] providerName=docker routerName=${ENV_NAME}ولاحظ routerName=${ENV_NAME}، فالمتغير لم يستبدل بقيمته، والسبب أن Docker Compose يستبدل المتغيرات في القيم ولا يستبدلها في المفاتيح، فحصلت البيئتان على Router بالاسم الحرفي نفسه وبقاعدتين مختلفتين، فرفضهما Traefik معاً. والحل كتابة ال Labels بصيغة القائمة - key=value كما في الملف أعلاه، فتصبح السلسلة كلها قيمة ويستبدل المتغير فيها. وهذا مثال صغير على أن البيئة الثانية تكشف أخطاء لا تظهر مع بيئة واحدة، ولهذا تختبر طريقة النشر نفسها في staging قبل الإنتاج.
التنظيف
نحذف ما أنشأناه فقط بأسمائه، دون أي أمر prune قد يحذف موارد مشاريع أخرى على جهازك:
cd lab-envs/deploy
docker compose -p lab-envs-staging --env-file envs/staging/release.env down
docker compose -p lab-envs-prod --env-file envs/prod/release.env down
cd ../platform
docker compose down -v
docker rmi 127.0.0.1:15471/lab-envs-app:1.4.1 127.0.0.1:15471/lab-envs-app:1.4.2 127.0.0.1:15471/lab-envs-app:sha-a3cdf027قائمة تحقق وأخطاء شائعة
قبل أن تعتمد طريقة النشر في فريقك راجع البنود التالية:
- هل يوجد فرع رئيسي واحد طويل العمر، وفروع الميزات تعيش يوماً أو يومين؟ وهل لا يوجد فرع اسمه
stagingأوprod؟ - هل تبنى Docker Image مرة واحدة لكل commit، وتوسم ب SHA ال commit وبرقم الإصدار، ولا يستخدم
latestفي أي بيئة؟ - هل الفرق بين البيئات موجود في ملفات إعداد ومتغيرات بيئة فقط، ولا يوجد في الكود شرط على اسم البيئة؟
- هل لكل طلب دمج بيئة معاينة بقاعدة بيانات خاصة وبيانات أولية، وأسرار خاصة، وحماية بالدخول الموحد، وتحذف عند الإغلاق مع ال Volumes؟
- هل الترقية إلى staging وإلى الإنتاج طلب دمج في مستودع الإعداد، والدمج إلى ملف الإنتاج يحتاج موافقة؟
- هل staging تشبه الإنتاج في الإصدارات وطريقة النشر والإعداد، وتختلف عنه في البيانات والأسرار فقط؟
- هل خطة التراجع مكتوبة ومجربة: إطفاء ال Flag، أو
git revertلل commit الترقية، مع ترحيلات متوافقة مع الإصدار السابق؟ - هل يعرض التطبيق رقم إصداره و SHA ال commit حتى تعرف ما يعمل في كل بيئة؟
وأشهر الأخطاء التي تتكرر في الفرق:
- فرع لكل بيئة: يبدأ بسيطاً وينتهي بانجراف بين الفروع وتعارضات ليلة الإصدار وإصلاحات عاجلة لا تعود إلى بقية الفروع.
- إعادة البناء في كل بيئة: الإنتاج يشغل Image لم يختبرها أحد، وأي فرق في ال Base Image أو في مكتبة يظهر لأول مرة أمام المستخدمين.
- معاينات بأسرار الإنتاج: كود لم يراجع بعد يعمل بمفاتيح الإنتاج وأحياناً على قاعدة بيانات الإنتاج، وهذا تسريب ينتظر وقته.
- staging تختلف عن الإنتاج: إصدار قاعدة بيانات مختلف، أو Reverse Proxy مختلف، أو نشر يدوي في staging وآلي في الإنتاج، فتنجح الاختبارات هناك وتفشل هنا لأسباب لا علاقة لها بالكود.
- لا توجد خطة تراجع: الفريق يكتشف وقت المشكلة أن الترحيل حذف عموداً يحتاجه الإصدار السابق، أو أن Image الإصدار السابق حذفت من ال Registry.
- نشر يدوي على السيرفر: تعديل سريع على الإنتاج «مؤقتاً» لا يكتب في مستودع الإعداد، فيمسحه النشر التالي أو يبقى سراً لا يعرفه إلا من نفذه.
الخلاصة
وصلنا لنهاية الموضوع، وأهم ما فيه:
- السيرفر المشترك والـ staging المختلطة والفرع الدائم لكل بيئة تفشل كلها لسبب واحد: أنها تخلط بين الكود والإعداد، أو بين ميزات لا علاقة بينها.
- لكل بيئة سؤال تجيب عنه وبيانات تناسبها، والبيانات الحقيقية لا تخرج من الإنتاج، والفرق بين البيئات إعداد ومتغيرات بيئة وليس فروعاً ولا شروطاً في الكود.
- ابن ال Docker Image مرة واحدة من commit محدد، ووسمها ب SHA وبالإصدار، وانقل ال digest نفسه بين البيئات، فما اختبره QA هو ما يصل إلى المستخدمين.
- بيئة المعاينة لكل طلب دمج تحل طلب «اختبر الميزة B وحدها» و«اعرض الميزة A على العميل»، بشرط قاعدة بيانات خاصة وأسرار خاصة وحماية بالدخول الموحد وحذف عند الإغلاق.
- ال Feature Flags تفصل النشر عن الإطلاق، فتصل الميزة C إلى الإنتاج كوداً ولا تظهر حتى توافق الإدارة، ثم يحذف ال Flag بعد الإطلاق.
- GitOps يعني أن كل بيئة ملف في مستودع إعداد، والترقية طلب دمج، والموافقة مراجعة، والسجل
git log، والتراجعgit revert، ويعمل مع Kubernetes وArgo CD ومع Docker Compose ومهمة CI. - الترحيلات تتبع نمط التوسيع ثم التقليص حتى يبقى التراجع آمناً، والإصلاح العاجل يمر بالمسار نفسه بمراجعة أسرع.
دليلTrunk-Based Development: فروع قصيرة ودمج يوميلماذا تنفجر الفروع الطويلة يوم الإصدار، وكيف يدمج الفريق عمله في main كل يوم بأمان مع CI وFeature Flags.
دليلGit في العمل اليومي: الدمج والتراجع دون أن تخسر عملكمتى تستخدم Merge ومتى تستخدم Rebase، وكيف تنقل إصلاحاً إلى فرع الإصدار أو تسترجع عملاً ظننت أنه ضاع.
دليلGitea Actions: تشغيل CI/CD وبناء Docker Images على سيرفركاجعل سيرفرك يختبر الكود ويبني Docker Images تلقائياً مع كل push، دون أن يخرج كودك أو أسرارك إلى خدمة CI خارجية.
دليلتثبيت Coolify لنشر التطبيقات من Git على خادمككيف تثبت Coolify على سيرفرك وتنشر تطبيقاتك من Git بضغطة زر كما تفعل في Heroku أو Vercel.
دليلتثبيت Dokploy لنشر تطبيقاتك من Git على خادمككيف تثبت Dokploy بأمان وتنشر منه تطبيقاتك من Git على سيرفرك بنطاق وشهادة HTTPS، كما تفعل في Vercel.سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة العملية على Docker Engine 29.8.0 وDocker Compose v5.5.1 وTraefik v3.7.13 وRegistry 3.1.2 وnginx 1.29.8.