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

أتمتة البناء وقابلية الاستنساخ

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

أتمتة البناء وقابلية الاستنساخ

خط أنابيب CI لا يكون موثوقاً إلا بقدر موثوقية عملية البناء التي يُشغّلها. أوضح لك الدرس الثاني تشريح خط الأنابيب — المراحل والمُشغّلات والمُحفّزات. يذهب هذا الدرس مستوىً أعمق: كيف تكتب سكريبتات البناء التي تُنفّذها تلك المراحل، وتُثبّت الاعتمادات حتى لا تنجرف أبداً، وتُهيكل بناءً يُنتج نفس البرنامج الثنائي بالضبط على حاسوب المطوّر وعلى مُشغّل GitHub Actions وعلى خادم الإنتاج؟ هذه الخصائص الثلاث — بناء آلي، واعتمادات مُثبَّتة، وبناء منعزل (hermetic) — هي الأساس الذي يُفرّق بين إعداد CI هاوٍ وإعداد على مستوى Google.

لماذا تهم قابلية الاستنساخ في النطاق الواسع: في Google، كل ملف ثنائي قابل للاستنساخ bit-for-bit. هذا يعني أن فريق الأمن يستطيع إعادة إنتاج أي مُخرج شُحن في أي وقت، ومراجعته بحثاً عن ثغرات CVE اكتُشفت لاحقاً، وإعادة بنائه من المصدر في أي نقطة تاريخية. Netflix وMeta يتمسكان بنفس المعيار. بدون قابلية الاستنساخ، لا يمكنك التراجع أو المراجعة أو فهم ما يعمل في الإنتاج بشكل موثوق.

سكريبتات البناء: العقد بين الكود وخط الأنابيب

كل مرحلة في CI تستدعي سكريبتاً. ذلك السكريبت هو الوصف التنفيذي الموثوق لكيفية بناء برنامجك. القاعدة الذهبية: إذا نفّذ إنسان خطوةً مرةً واحدة يدوياً، يجب أن تكون قابلة للتعبير عنها كسكريبت يُشغّله خط الأنابيب تلقائياً — بلا مطالبات تفاعلية، ولا متغيرات بيئة ضمنية، ولا اعتمادات على مستوى النظام غير مُعلنة.

اكتب سكريبتات البناء بهذه الخصائص:

  • الخروج عند أول خطأ: استخدم set -euo pipefail في Bash. بدونها، يُهمَل فشل npm install بصمت ويُبلَّغ عن خط الأنابيب باللون الأخضر. -e تخرج عند الخطأ، -u تعامل المتغيرات غير المضبوطة كأخطاء، -o pipefail تصطاد الفشل داخل الأنابيب.
  • إصدارات الأدوات الصريحة: لا تستدعِ node وتأمل في الأفضل. ثبّت الإصدار باستخدام .nvmrc أو .tool-versions (asdf) أو وسم صورة Docker. المُشغّل الذي يثبّت Node 18 اليوم قد يُحدَّث إلى Node 22 الربع القادم.
  • التثبيت الذي لا يعتمد على الشبكة: استخدم npm ci بدلاً من npm install. استخدم pip install --no-index حين تتوفر مرآة. استخدم go mod download مع وكيل وحدات نمطية. إخفاقات الشبكة في CI هي المصدر الأعلى للبنيات المتقلبة.
  • خطوات متكررة بأمان (idempotent): يجب أن تكون كل خطوة آمنة للتشغيل مرة أخرى. تجنب الآثار الجانبية كإلحاق الملفات أو تغيير الحالة المشتركة بين الخطوات.
#!/usr/bin/env bash # build.sh — سكريبت بناء احترافي لخدمة Node.js set -euo pipefail readonly ARTIFACT_DIR="dist" readonly REQUIRED_NODE="20" # 1. التحقق من صحة البيئة ACTUAL_NODE=$(node --version | grep -oP '\d+' | head -1) if [[ "$ACTUAL_NODE" != "$REQUIRED_NODE" ]]; then echo "ERROR: Expected Node ${REQUIRED_NODE}, got ${ACTUAL_NODE}" >&2 exit 1 fi # 2. تثبيت الاعتمادات بالضبط من ملف القفل — بلا انجراف للشبكة npm ci --prefer-offline # 3. توليد الكود (protobuf، OpenAPI، i18n) قبل التصريف npm run codegen # 4. فحص الأنواع والكود والاختبارات بالتوازي حيث أمكن npm run type-check & npm run lint & wait # فشل سريع إذا فشل أي مهمة خلفية npm run test:unit -- --ci --runInBand # 5. بناء الإنتاج بمُخرج قابل للاستنساخ NODE_ENV=production npm run build # 6. التحقق من حجم الحزمة (يفشل إذا تجاوزت 500 كيلوبايت) BUNDLE_KB=$(du -sk "$ARTIFACT_DIR" | cut -f1) if (( BUNDLE_KB > 500 )); then echo "ERROR: Bundle too large — ${BUNDLE_KB} KB (limit: 500 KB)" >&2 exit 1 fi echo "Build complete. Artifact: ${ARTIFACT_DIR}/ (${BUNDLE_KB} KB)"
ضع سكريبت البناء في المستودع، لا في YAML الخاص بـ CI. سكريبت Shell من 200 سطر داخل بلوك run: في GitHub Actions غير قابل للاختبار ولا يمكن تشغيله محلياً. ضعه في scripts/build.sh واستدعِه من YAML بسطر واحد. يستطيع المهندسون الآن تشغيل نفس البناء تماماً محلياً بـ bash scripts/build.sh.

تثبيت الاعتمادات: لا مفاجآت في الإنتاج

الاعتمادات غير المُثبَّتة هي المصدر الأكثر شيوعاً لأعطال "كانت تعمل الأسبوع الماضي". النمط يتكرر عبر كل بيئة تقنية: تُصدر حزمة انتقالية تصحيحاً، ملف القفل غائب أو قديم، وفجأة يفشل بناؤك على قاعدة كود لم تتغير. في كبرى الشركات التقنية، كل اعتماد — مباشر وانتقالي — مُثبَّت بالضبط.

ماذا يعني التثبيت في كل بيئة تقنية:

  • Node.js: ادفع package-lock.json أو yarn.lock إلى المستودع. ثبّت باستخدام npm ci (يُخطئ إذا كان ملف القفل غير متزامن مع package.json). لا تدفع node_modules/ أبداً.
  • Python: ادفع requirements.txt المُولَّد بـ pip-compile (من pip-tools)، الذي يُحل ويُثبّت جميع الاعتمادات الانتقالية. أو استخدم poetry.lock. لا تستخدم requirements.txt بنطاقات إصدار مفتوحة كـ requests>=2.0.
  • Go: ادفع go.sum. تتحقق أدوات Go من المجموع الاختباري تشفيرياً — أي اعتماد مُعدَّل يُفشل البناء. شغّل go mod tidy قبل الدفع للحفاظ على نظافته.
  • صور Docker الأساسية: لا تستخدم FROM ubuntu:latest أبداً. ثبّت على digest: FROM ubuntu:24.04@sha256:abc123.... وسوم الصور قابلة للتغيير — نفس الوسم قد يشير إلى مجموعة طبقات مختلفة غداً.
  • إجراءات CI: في GitHub Actions، ثبّت إجراءات الطرف الثالث على SHA محدد لـ commit، لا على وسم. uses: actions/checkout@v4 قابل للتغيير؛ uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 مُثبَّت للأبد.
# .github/workflows/ci.yml — اعتمادات مُثبَّتة في كل طبقة name: CI on: push: branches: [main] pull_request: jobs: build: runs-on: ubuntu-24.04 # تثبيت إصدار نظام تشغيل المُشغّل steps: # تثبيت على SHA محدد لـ commit، لا على وسم قابل للتغيير - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af with: node-version-file: ".nvmrc" # يقرأ إصدار Node من ملف في المستودع cache: "npm" - name: تثبيت الاعتمادات بالضبط run: npm ci # يفشل إذا كان package-lock.json قديماً - name: البناء run: bash scripts/build.sh - name: رفع الحزمة uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0e0d8da78 with: name: dist path: dist/ retention-days: 7

البناء المنعزل (Hermetic): عزل كل متغير

البناء المنعزل هو البناء المُعزول تماماً عن البيئة المحيطة. بنفس الكود المصدري ونفس المدخلات، يُنتج البناء المنعزل مُخرجاً متطابقاً bit-for-bit بصرف النظر عن الجهاز الذي يُشغّله، أو ما هو مثبّت على ذلك الجهاز، أو وقت اليوم. هذا هو المعيار الذهبي. Bazel من Google وBuck2 من Meta هما نظاما بناء منعزل مبنيان لهذا الغرض ويفرضانه على مستوى سلسلة الأدوات.

لا تحتاج إلى Bazel للحصول على معظم الفائدة. قائمة التحقق العملية للبناء المنعزل:

  • تشغيله داخل حاوية: ابنِ داخل صورة Docker تحتوي كل أداة بإصدار مُثبَّت. نظام تشغيل المضيف يصبح غير ذي صلة.
  • لا شبكة أثناء البناء: يجب أن تكون جميع الاعتمادات موجودة في الصورة أو في طبقة الكاش قبل خطوة التصريف. البناء الذي يجلب من الإنترنت في منتصف التصريف غير منعزل — الإنترنت يتغير.
  • حذف الطوابع الزمنية والبذور العشوائية: تُضمّن كثير من المصرّفات طوابع زمنية للبناء. استخدم SOURCE_DATE_EPOCH (متغير بيئة معياري) لتجميد الطابع الزمني. استخدم بذرة عشوائية ثابتة في أي خطوة توليد.
  • تخزين المدخلات لا المخرجات مؤقتاً: خزّن طبقة الاعتمادات المُحمَّلة مؤقتاً مفهرسةً بهاش ملف القفل. لا تخزّن الحزم المبنية مؤقتاً عبر الفروع — الكاش القديم مصدر شائع لإخفاقات خفية.
Hermetic Build Pipeline Hermetic Build Container (pinned image) Source Code git checkout Dep Cache keyed on lockfile hash Build Step compile + link Test Step unit + integration Artifact binary / image SOURCE_DATE_EPOCH=fixed no timestamps / no RNG Network: DISABLED all deps pre-fetched
حاوية بناء منعزلة: الكود المصدري وكاش الاعتمادات المُجلَبة مسبقاً هما المدخلات الوحيدة؛ الشبكة معطّلة أثناء التصريف؛ epoch ثابت يُزيل عدم حتمية الطوابع الزمنية.
# Dockerfile — صورة بناء منعزلة (متعددة المراحل لخدمة Go) # كل أداة مُثبَّتة؛ لا مدير حزم يعمل وقت التصريف FROM golang:1.23.4-bookworm@sha256:7ea4ab8abb4d4b3c0b1e25c4b3e5b8cf5a4a7c8e AS builder WORKDIR /app # انسخ ملف القفل أولاً — الطبقة مُخزَّنة مؤقتاً حتى يتغير go.sum COPY go.mod go.sum ./ RUN go mod download -x # تحميل جميع الوحدات إلى الكاش # انسخ الكود المصدري (فقدان الكاش عند تغيير المصدر فقط) COPY . . # بناء قابل للاستنساخ: تثبيت الطابع الزمني، حذف معلومات التصحيح، تعطيل CGO RUN SOURCE_DATE_EPOCH=1700000000 \ CGO_ENABLED=0 \ GOOS=linux GOARCH=amd64 \ go build -trimpath \ -ldflags="-s -w -buildid=" \ -o /bin/api ./cmd/api # ---- صورة التشغيل النهائية (scratch = بلا shell، بلا نظام تشغيل، أقل سطح هجوم) ---- FROM scratch COPY --from=builder /bin/api /api COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ ENTRYPOINT ["/api"]

التخزين المؤقت: مضاعف السرعة الذي يجب ألا يُفسد النتائج

الكاش ضروري لسرعة خط الأنابيب — تثبيت Node.js الذي يستغرق 3 دقائق يصبح 5 ثوانٍ عند الإصابة بالكاش. لكن كاشاً فاسداً يمكنه إخفاء الإخفاقات الحقيقية لأسابيع. طبّق هذه القواعد:

  • مفتاح الكاش على المدخلات لا الوقت: يجب أن يتضمن مفتاح الكاش هاش ملف القفل (hashFiles('**/package-lock.json'))، ونظام التشغيل، وإصدار Node. الكاش المفهرس فقط على اسم الفرع سيُقدّم نتيجة قديمة بعد تغيير الاعتمادات.
  • استعادة بلا ثقة عمياء: بعد استعادة الكاش، شغّل npm ci دائماً — خيار --prefer-offline يستخدم الكاش لكنه يتحقق من السلامة. لا تتخطَّ خطوة التثبيت لمجرد إصابة الكاش.
  • افصل كاش البناء عن كاش الاختبار: خزّن المُخرجات المُصرَّفة بشكل منفصل عن الحزم المُحمَّلة. مُخرج تصريف سيء في الكاش يُسبّب إخفاقات خفية يصعب تشخيصها جداً.
  • مسح الكاش بقوة عند التغييرات الكبرى: أضف إلى بادئة مفاتيح الكاش رقماً تزيده يدوياً (v2-deps-...). ازده كلما اشتبهت في فساد الكاش — مسح كاش واحد أسرع من ساعات تحقيق.
نمط الإخفاق "يعمل في CI لكن ليس محلياً": يعني هذا دائماً تقريباً أن مُشغّل CI لديه مُخرج مُخزَّن مؤقتاً من بناء سابق لا يمتلكه المطوّر محلياً. الحل ليس التصحيح محلياً — بل إضافة خطوة تشغيل CI بـ --no-cache والتحقق من تكرار الإخفاق في حالة نظيفة. إذا تكرر، البناء غير منعزل. إذا لم يتكرر، لديك مشكلة تسمّم كاش. كلاهما حرج ويجب إصلاحه.

Makefile ومُشغّلات المهام: الجسر بين البيئة المحلية و CI

أعلى نمط استثمارياً لقابلية الاستنساخ هو امتلاك أمر واحد يُنفّذ بالضبط ما يُنفّذه CI. يُعدّ Makefile (أو Taskfile.yml لبيئة Go/YAML) الواجهة المعيارية: make build، وmake test، وmake lint — نفس الأهداف، نفس الأوامر، سواء شغّلها إنسان أو خط أنابيب.

# Makefile — واجهة موحّدة للبيئة المحلية و CI .PHONY: deps build test lint docker-build ci REGISTRY := ghcr.io/acme/api IMAGE_TAG := $(shell git rev-parse --short HEAD) deps: npm ci --prefer-offline build: deps bash scripts/build.sh lint: npx eslint src/ --max-warnings 0 npx tsc --noEmit test: deps npx jest --ci --coverage --runInBand docker-build: docker build \ --build-arg SOURCE_DATE_EPOCH=1700000000 \ --tag $(REGISTRY):$(IMAGE_TAG) \ --tag $(REGISTRY):latest \ . # الهدف الوحيد الذي يُشغّله CI — مطابق لما يُشغّله الإنسان محلياً ci: lint test build docker-build @echo "CI complete: $(REGISTRY):$(IMAGE_TAG)"
اختبار الإعداد في دقيقتين: بعد إعداد سكريبتات البناء، أعطِ مستودعك لزميل لم يرَ المشروع من قبل. إذا استطاع تشغيل git clone ... && make ci والحصول على بناء أخضر خلال دقيقتين، فبناؤك قابل للاستنساخ. إذا واجه أخطاء خاصة بالبيئة، فتلك الأخطاء ستظهر على مُشغّل CI أيضاً في نهاية المطاف.

أنماط الإخفاق الشائعة في الإنتاج

حتى الفرق ذات النوايا الحسنة تصطدم بهذه المشاكل المتكررة:

  • وسوم صور أساسية عائمة: كان FROM node:lts يشير إلى Node 18 العام الماضي؛ اليوم يشير إلى Node 22. كل اختباراتك تعمل الآن على وقت تشغيل مختلف عن الإنتاج.
  • أدوات نظام ضمنية: سكريبت بناء يستدعي jq أو yq موجودين على حاسوب المطوّر لكن غائبين على مُشغّل جديد. البناء يفشل في CI برسالة "command not found" غامضة.
  • اختبارات حساسة للمنطقة الزمنية: اختبار يُؤكّد أن new Date().toLocaleDateString() === "1/1/2025" ينجح في UTC+0 ويفشل في UTC+3. مُشغّل CI يستخدم UTC؛ أجهزة المطوّرين لا.
  • ظروف سباق في الخطوات المتوازية: خطوتا بناء تكتبان في نفس مجلد المُخرجات. على مُشغّل سريع تتصادمان؛ على مُشغّل بطيء لا. الإخفاق غير حتمي ومتقطّع.

كل هذه انتهاكات لقابلية الاستنساخ. الحل في كل حالة هو نفسه: تخلّص من الافتراض المحيطي الذي سبّبه — ثبّت الصورة، وأعلن الأداة في Dockerfile، وجمّد المنطقة الزمنية بـ TZ=UTC، وسَلسِل الخطوات المتعارضة.