التكامل المستمر للحاويات والمستودعات الموحدة
التكامل المستمر للحاويات والمستودعات الموحدة
تسود واقعَين معماريين في CI بكبرى شركات التقنية: تقريبًا كل وحدة قابلة للنشر هي صورة حاوية، وكثير من الفرق تشترك في مستودع واحد (Monorepo). يتصادم هذان الواقعان بشدة في CI — خطوط الأنابيب السذاجة تبني وتختبر كل شيء في كل عملية دفع، محرقةً مئات الدقائق من وقت التنفيذ في كل commit. يتناول هذا الدرس كيف تتعامل فرق الإنتاج مع كليهما: بناء الصور بكفاءة داخل CI وتشغيل الأهداف المتأثرة فقط حين يتغير جزء محدد من قاعدة الشيفرة.
بناء صور الحاويات في CI
ملف Dockerfile هو شيفرة مصدرية حتمية. خط أنابيب CI مسؤول عن بنائه، وإضافة وسم للنتيجة، ورفعها إلى سجل الصور، وإتاحة الـ digest للنشر. المتطلبات الأربعة غير القابلة للتفاوض لبناء الصور في الإنتاج هي:
- BuildKit إلزامي. أمر
docker buildالكلاسيكي تسلسلي ولا يمتلك واجهة تخزين مؤقت عن بُعد. فعِّل BuildKit بضبطDOCKER_BUILDKIT=1أو باستخدامdocker/build-push-actionالذي يفعِّله تلقائيًا. BuildKit يُوازي المراحل، ويتجاوز المراحل غير المُستخدَمة، ويدعم التعليمة--mount=type=cacheللتخزين المؤقت للتبعيات داخل البناء. - ترتيب الطبقات يحدد معدل إصابة التخزين المؤقت. انسخ الملفات النادرة التغيير (ملفات القفل، الإعدادات الثابتة) مبكرًا؛ وانسخ شيفرة التطبيق أخيرًا. تعليمة
COPY . .في الخطوة 3 من 10 تُبطل التخزين المؤقت لكل commit. - البناء متعدد المراحل يُبقي الصور صغيرة. مرحلة البناء المحتوية على مُجمِّعات وSDKs وأدوات اختبار يجب ألا تُشحن إلى الإنتاج. مرحلة
FROMالأخيرة يجب أن تكون صورة أساسية distroless أو slim تحتوي فقط على الثنائي والتبعيات الضرورية. - ادفع دائمًا بـ digest وانشر بـ digest. الوسوم أسماء مستعارة متغيرة. يجب أن يسجِّل خط الأنابيب الـ
sha256digest للصورة المرفوعة (متاح من مخرجاتdocker/build-push-action) ويمرره إلى مرحلة النشر.
push: ${{ github.event_name != 'pull_request' }} كما هو موضح. بناء الصورة (وفحصها اختياريًا) في PR يُعطيك تغذية راجعة سريعة دون تلويث السجل بآلاف الصور غير المراجَعة. عند الدمج في main، تُبنى الصورة مجددًا من نفس التخزين المؤقت وتُرفع — البناء الثاني يكاد يكون مجانيًا لأن ذاكرة تخزين الطبقات دافئة.
المحفزات بفلترة المسارات
في مستودع عادي، تعديل ملف README لا يجب أن يُعيد بناء واختبار التطبيق بأكمله. تقصر فلاتر المسارات الـ workflows على تغييرات الملفات ذات الصلة. تدعم GitHub Actions هذا أصليًا عبر المفتاحين paths و paths-ignore على أحداث push وpull_request.
النمط services/api/** يستخدم صيغة glob: ** يطابق أي عدد من أجزاء المسار بما فيها الصفر. الأنماط الشائعة في الإنتاج:
src/**/*.ts— أي ملف TypeScript في أي مكان ضمنsrc/!docs/**— استبعاد التغييرات فيdocs/(نفي بـ!)packages/shared/**— تغييرات في مكتبة مشتركة تعتمد عليها خدمات كثيرة.github/workflows/api.yml— ملف الـ workflow نفسه؛ تغييره يجب أن يُعيد تشغيل الـ workflow
docs/ فقط وكان workflow CI مُفلتَرًا لتجاوز تغييرات الوثائق، فإن الفحص المطلوب لن يُشغَّل أبدًا — وتعتبر GitHub الفحص المتجاوَز غير مكتمل، مما يُعيق الدمج. الحل: استخدم workflow خفيفًا منفصلًا للتغييرات الخاصة بالوثائق يمر دائمًا، أو اضبط الفحص المطلوب بخيار "التجاوز يُحسب نجاحًا" في إعدادات حماية الفروع.
بناء الأهداف المتأثرة في المستودعات الموحدة
المستودع الموحد (Monorepo) يحتضن خدمات ومكتبات وتطبيقات متعددة في مستودع واحد. نظام البناء الداخلي لـ Google (Blaze، مفتوح المصدر باسم Bazel) ريادي في مفهوم بناء الأهداف المتأثرة: ابنِ فقط واختبر الإغلاق الانتقالي للأهداف التي تعتمد على الملفات المتغيرة. هذه هي الفكرة الجوهرية التي تمكِّن Google من تشغيل CI على ملايين الأسطر وعشرات الآلاف من الأهداف مع إبقاء التغذية الراجعة لكل commit أقل من 10 دقائق.
الأدوات الأربعة الرائدة لتحليل الأهداف المتأثرة في المستودعات مفتوحة المصدر:
- Nx (JavaScript/TypeScript وغيرها عبر ملحقات) —
nx affected --target=buildيحسب الرسم البياني للمتأثرين باستخدام رسم التبعيات ونطاق الـ commit. - Turborepo (JavaScript/TypeScript) —
turbo run build --filter=[HEAD^1]يشغِّل الحزم المتأثرة فقط باستخدام التخزين المؤقت المبني على hash المحتوى. - Bazel (متعدد اللغات) — يستخدم الرسم البياني الكامل للتبعيات لإيجاد الأهداف العكسية المعتمدة على الملفات المتغيرة.
- Pants (Python، Java، Go، Scala) —
pants --changed-since=origin/main testيشغِّل نفس منطق الأهداف المتأثرة بمحرك استعلام خاص بـ Pants.
Nx Affected في GitHub Actions
أكثر إعداد شائع لمستودعات JavaScript/TypeScript الموحدة يستخدم Nx. المفتاح هو حساب commit الأساس (BASE) الذي يمثل آخر حالة معروفة جيدة — عادةً الفرع الأساس للـ PRs، أو الـ commit السابق للدفعات إلى main.
fetch-depth: 0 غير قابل للتفاوض في المستودعات الموحدة. أدوات الأهداف المتأثرة تقارن HEAD الحالي مع commit الفرع الأساس. بدون التاريخ الكامل، ليس لـ git diff ما يقارنه فيُخطئ أو يعود إلى إعادة بناء كل شيء — ما يُبطل الغرض كله. اضبط دائمًا fetch-depth: 0 في خطوة checkout بسير عمل المستودعات الموحدة.
ملفات Dockerfile لكل خدمة في مستودع موحد
كل خدمة في المستودع الموحد لها Dockerfile خاص بها، لكن سياق بناء Docker يجب أن يشمل شيفرة المكتبات المشتركة التي تقع خارج مجلد الخدمة. الحل هو دائمًا تعيين سياق البناء إلى جذر المستودع والإشارة إلى Dockerfile بمساره:
docker build services/api/ بمجلد الخدمة كسياق، فـ Docker لا يمكنه الوصول إلى libs/shared-utils/ — فهو خارج السياق. تعليمة Dockerfile COPY libs/shared-utils ... ستفشل بـ "path not found". ابنِ دائمًا من جذر المستودع. للمستودعات الموحدة الكبيرة، استخدم .dockerignore بقوة لاستبعاد الخدمات الأخرى وnode_modules الخاصة بها من السياق — وإلا فإن السياق المُرسَل إلى BuildKit قد يبلغ غيغابايتات.
التخزين المؤقت عن بُعد للمستودعات الموحدة
بناء الأهداف المتأثرة يحل مسألة أيّ أهداف تُشغَّل. التخزين المؤقت عن بُعد يحل مسألة هل تُشغَّل أصلًا. إذا بُني الهدف X من hash المصدر H ونتيجته موجودة بالفعل في التخزين المؤقت عن بُعد، تجاوز التنفيذ كليًا واستعد المخرج. هكذا تحقق Google أوقات بناء تراكمية تقترب من الصفر: الغالبية العظمى من الأهداف تُصيب التخزين المؤقت في كل تشغيل CI.
- Nx Cloud — تخزين مؤقت عن بُعد مُدار لمساحات عمل Nx. اتصل بـ
nx connectوأضفNX_CLOUD_ACCESS_TOKENإلى الأسرار. معدلات الإصابة بالتخزين المؤقت 80-95% شائعة للفرق النشطة. - Turborepo Remote Cache — مدمج في
turbo runبعلامتي--apiو--tokenتشيران إلى خادم Vercel أو خادم مستضاف ذاتيًا. - Bazel Remote Cache — أي نقطة نهاية gRPC/HTTP؛ مشروع
bazel-remoteمفتوح المصدر من Google يعمل على GCS أو S3.
أنماط الفشل الشائعة
- الاستنساخ السطحي في بناءات الأهداف المتأثرة —
fetch-depth: 1(القيمة الافتراضية في GitHub Actions) تعني أنgit diffلا يرى تاريخًا؛ فتعيد الأداة بناء كل شيء. اضبطfetch-depth: 0. - غياب
.dockerignore— إرسال مستودع موحد بحجم 2 غيغابايت كسياق بناء Docker يضيف 30-90 ثانية لكل بناء صورة. حافظ على ملف.dockerignoreفي الجذر يستبعد.gitوملفات الاختبارات والخدمات الأخرى. - الرفع في PRs — يمتلئ السجل بصور غير مراجَعة من كل commit في PR. قيِّد
push: trueبشرطgithub.event_name != 'pull_request'. - الوسم الضمني latest في الإنتاج — النشر بـ
:latestيجعل التراجع غير موثوق ويجعل معرفة ما يعمل مستحيلة دون فحص الحاوية المباشرة. انشر دائمًا بـ digest غير قابل للتغيير أو بوسم SHA المنسوب إلى commit. - مشاركة ملف workflow واحد لجميع الخدمات — ملف YAML واحد بمصفوفة لجميع الخدمات يبني كل شيء في كل دفع، مُبطلًا فائدة بناء الأهداف المتأثرة. أعطِ كل خدمة (أو مجموعة خدمات) ملف workflow خاصًا بها مع فلاتر مسارات مناسبة.