أساسيات التكامل المستمر

التكامل المستمر للحاويات والمستودعات الموحدة

18 دقيقة الدرس 9 من 28

التكامل المستمر للحاويات والمستودعات الموحدة

تسود واقعَين معماريين في CI بكبرى شركات التقنية: تقريبًا كل وحدة قابلة للنشر هي صورة حاوية، وكثير من الفرق تشترك في مستودع واحد (Monorepo). يتصادم هذان الواقعان بشدة في CI — خطوط الأنابيب السذاجة تبني وتختبر كل شيء في كل عملية دفع، محرقةً مئات الدقائق من وقت التنفيذ في كل commit. يتناول هذا الدرس كيف تتعامل فرق الإنتاج مع كليهما: بناء الصور بكفاءة داخل CI وتشغيل الأهداف المتأثرة فقط حين يتغير جزء محدد من قاعدة الشيفرة.

بناء صور الحاويات في CI

ملف Dockerfile هو شيفرة مصدرية حتمية. خط أنابيب CI مسؤول عن بنائه، وإضافة وسم للنتيجة، ورفعها إلى سجل الصور، وإتاحة الـ digest للنشر. المتطلبات الأربعة غير القابلة للتفاوض لبناء الصور في الإنتاج هي:

  1. BuildKit إلزامي. أمر docker build الكلاسيكي تسلسلي ولا يمتلك واجهة تخزين مؤقت عن بُعد. فعِّل BuildKit بضبط DOCKER_BUILDKIT=1 أو باستخدام docker/build-push-action الذي يفعِّله تلقائيًا. BuildKit يُوازي المراحل، ويتجاوز المراحل غير المُستخدَمة، ويدعم التعليمة --mount=type=cache للتخزين المؤقت للتبعيات داخل البناء.
  2. ترتيب الطبقات يحدد معدل إصابة التخزين المؤقت. انسخ الملفات النادرة التغيير (ملفات القفل، الإعدادات الثابتة) مبكرًا؛ وانسخ شيفرة التطبيق أخيرًا. تعليمة COPY . . في الخطوة 3 من 10 تُبطل التخزين المؤقت لكل commit.
  3. البناء متعدد المراحل يُبقي الصور صغيرة. مرحلة البناء المحتوية على مُجمِّعات وSDKs وأدوات اختبار يجب ألا تُشحن إلى الإنتاج. مرحلة FROM الأخيرة يجب أن تكون صورة أساسية distroless أو slim تحتوي فقط على الثنائي والتبعيات الضرورية.
  4. ادفع دائمًا بـ digest وانشر بـ digest. الوسوم أسماء مستعارة متغيرة. يجب أن يسجِّل خط الأنابيب الـ sha256 digest للصورة المرفوعة (متاح من مخرجات docker/build-push-action) ويمرره إلى مرحلة النشر.
# Dockerfile — بناء متعدد المراحل لخدمة Go FROM golang:1.23-alpine AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download # مخزَّن مؤقتًا ما لم يتغير go.sum COPY . . RUN CGO_ENABLED=0 go build -o /bin/api ./cmd/api # المرحلة الأخيرة: scratch + ca-certs فقط FROM gcr.io/distroless/static-debian12 COPY --from=builder /bin/api /api ENTRYPOINT ["/api"]
# .github/workflows/ci.yml — بناء ودفع صورة حاوية name: CI on: push: branches: [main] pull_request: jobs: build-image: runs-on: ubuntu-latest permissions: contents: read packages: write id-token: write # مطلوب لمصادقة SLSA outputs: digest: ${{ steps.build.outputs.digest }} steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract image metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }}/api tags: | type=sha,format=short # abc1234 type=ref,event=pr # pr-42 type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push id: build uses: docker/build-push-action@v6 with: context: . file: ./services/api/Dockerfile push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max provenance: true # مصادقة SLSA provenance sbom: true # إنشاء SBOM - name: Print digest run: echo "Pushed ${{ steps.build.outputs.digest }}"
لا ترفع الصور في Pull Requests. اضبط push: ${{ github.event_name != 'pull_request' }} كما هو موضح. بناء الصورة (وفحصها اختياريًا) في PR يُعطيك تغذية راجعة سريعة دون تلويث السجل بآلاف الصور غير المراجَعة. عند الدمج في main، تُبنى الصورة مجددًا من نفس التخزين المؤقت وتُرفع — البناء الثاني يكاد يكون مجانيًا لأن ذاكرة تخزين الطبقات دافئة.

المحفزات بفلترة المسارات

في مستودع عادي، تعديل ملف README لا يجب أن يُعيد بناء واختبار التطبيق بأكمله. تقصر فلاتر المسارات الـ workflows على تغييرات الملفات ذات الصلة. تدعم GitHub Actions هذا أصليًا عبر المفتاحين paths و paths-ignore على أحداث push وpull_request.

# تشغيل فقط عند تغيير الملفات ضمن services/api/ أو Dockerfile on: push: branches: [main] paths: - 'services/api/**' - 'Dockerfile' - '.github/workflows/api.yml' pull_request: paths: - 'services/api/**' - 'Dockerfile' - '.github/workflows/api.yml'

النمط services/api/** يستخدم صيغة glob: ** يطابق أي عدد من أجزاء المسار بما فيها الصفر. الأنماط الشائعة في الإنتاج:

  • src/**/*.ts — أي ملف TypeScript في أي مكان ضمن src/
  • !docs/** — استبعاد التغييرات في docs/ (نفي بـ !)
  • packages/shared/** — تغييرات في مكتبة مشتركة تعتمد عليها خدمات كثيرة
  • .github/workflows/api.yml — ملف الـ workflow نفسه؛ تغييره يجب أن يُعيد تشغيل الـ workflow
فلاتر المسارات يمكنها تجاوز فحوص الحالة المطلوبة بصمت. قواعد حماية الفروع في GitHub تتطلب نجاح فحوص CI معينة قبل الدمج. إذا مسَّ PR ملفات 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.
Monorepo affected-target build graph Monorepo Dependency Graph — Affected Targets shared-utils CHANGED api-service AFFECTED auth-lib AFFECTED gateway AFFECTED billing-service SKIPPED Build + Test Skipped (لا تبعية على الملفات المتغيرة)
عند تغيُّر shared-utils، يُعاد بناء api-service وauth-lib وgateway فقط (التي تعتمد عليها بشكل انتقالي). billing-service لا يُمسّ ويُتجاوز كليًا.

Nx Affected في GitHub Actions

أكثر إعداد شائع لمستودعات JavaScript/TypeScript الموحدة يستخدم Nx. المفتاح هو حساب commit الأساس (BASE) الذي يمثل آخر حالة معروفة جيدة — عادةً الفرع الأساس للـ PRs، أو الـ commit السابق للدفعات إلى main.

# .github/workflows/ci.yml — أهداف Nx المتأثرة في مستودع موحد name: CI (Monorepo) on: push: branches: [main] pull_request: jobs: affected: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # التاريخ الكامل مطلوب لـ git diff - uses: actions/setup-node@v4 with: node-version: 20 cache: npm - run: npm ci - name: Derive base and head SHAs uses: nrwl/nx-set-shas@v4 # يضبط متغيري NX_BASE وNX_HEAD - name: Lint affected run: npx nx affected --target=lint --base=$NX_BASE --head=$NX_HEAD --parallel=3 - name: Test affected run: npx nx affected --target=test --base=$NX_BASE --head=$NX_HEAD --parallel=3 - name: Build affected run: npx nx affected --target=build --base=$NX_BASE --head=$NX_HEAD --parallel=3 - name: Docker build affected run: | npx nx affected --target=docker-build --base=$NX_BASE --head=$NX_HEAD
fetch-depth: 0 غير قابل للتفاوض في المستودعات الموحدة. أدوات الأهداف المتأثرة تقارن HEAD الحالي مع commit الفرع الأساس. بدون التاريخ الكامل، ليس لـ git diff ما يقارنه فيُخطئ أو يعود إلى إعادة بناء كل شيء — ما يُبطل الغرض كله. اضبط دائمًا fetch-depth: 0 في خطوة checkout بسير عمل المستودعات الموحدة.

ملفات Dockerfile لكل خدمة في مستودع موحد

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

# سياق البناء = جذر المستودع؛ Dockerfile داخل مجلد الخدمة docker build \ --file services/api/Dockerfile \ --tag ghcr.io/myorg/api:$SHA \ --build-arg SERVICE=api \ . # <-- السياق هو جذر المستودع وليس services/api/ # داخل services/api/Dockerfile، المكتبات المشتركة متاحة: # COPY libs/shared-utils ./libs/shared-utils # COPY services/api ./services/api
نطاق سياق بناء Docker خطأ شائع في المستودعات الموحدة. إذا شغَّلت 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 خاصًا بها مع فلاتر مسارات مناسبة.