توقف عن دفع الكود يدوياً وانتظر الكوارث. تعلم كيف تبني خط إنتاج برمجي كامل باستخدام GitHub Actions وDocker وNode.js في ساعتين فقط، بخطوات عملية دون ثرثرة نظرية.
آخر مرة دفعت فيها كوداً إلى الإنتاج يدوياً كانت الساعة الثالثة فجراً، السيرفر كان يرفض تشغيل الـ build لأنني نسيت تحديث الـ environment variables في الـ staging. المشكلة لم تكن في الكود نفسه، بل في الطريقة التي وصل بها إلى هناك: ضغطت زر push وانتظرت المعجزة. في اليوم التالي، اكتشفت أن ثلاثة من الـ dependencies كانت مكسورة في الإنتاج رغم أنها تعمل على جهازي. حينها قررت أن CI/CD ليس رفاهية للشركات الكبيرة، بل ضرورة للمطور الفردي الذي يريد النوم ليلاً.
الـ Continuous Integration والـ Continuous Deployment ليسا مجرد أدوات، بل عقلية برمجية. عندما تبني خط إنتاج برمجي، أنت في الحقيقة تبني نظاماً يعمل كطبقة حماية بين جهازك المحلي وبين العالم الخارجي. كل commit يصبح وحدة مستقلة تخضع لاختبارات، بناء، ونشر آلي. الفرق بين CI/CD جيد وسيئ ليس في الأدوات المستخدمة، بل في كيفية تصميم الـ pipeline نفسها: هل هي مجرد سلسلة من الأوامر أم نظام ذكي يتكيف مع الأخطاء؟
عندما تضغط زر push، الـ GitHub Actions (أو أي أداة CI أخرى) تستيقظ على سيرفر بعيد وتبدأ بجلب الكود من الـ repository. لكن العملية ليست مجرد git clone بسيط. خلف الكواليس، الـ runner (الآلة الافتراضية التي تشغل الـ pipeline) تقوم بإنشاء بيئة معزولة تماماً عن بيئتك المحلية. هذا يعني أن أي شيء يعتمد عليه الكود الخاص بك — من إصدارات Node.js إلى الـ global packages المثبتة على جهازك — يجب أن يكون محدداً بوضوح داخل ملف الـ workflow.
الخطأ الشائع هنا هو افتراض أن البيئة المحلية والبيئة الافتراضية متطابقتان. في الحقيقة، الـ runner يبدأ من صفر: لا يوجد node_modules، لا يوجد cache، ولا حتى الـ PATH متطابق. لهذا السبب، يجب أن يكون ملف الـ workflow محدداً بدقة: أي إصدار من Node.js؟ أي نسخة من npm؟ أي متغيرات بيئة؟ إذا لم تحددها، الـ runner سيستخدم القيم الافتراضية، وغالباً ما تكون قديمة أو غير متوافقة مع مشروعك.
# .github/workflows/ci.yml
name: CI Pipeline
on: [push, pull_request]
env:
NODE_VERSION: '20'
CACHE_KEY: node-modules-${{ hashFiles('package-lock.json') }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache node modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ env.CACHE_KEY }}
- run: npm ci
- run: npm test
- run: npm run lintفي هذا المثال، نستخدم hashFiles لحساب بصمة ملف package-lock.json واستخدامها كمفتاح للـ cache. هذا يعني أن الـ node_modules سيتم تخزينها مؤقتاً فقط إذا لم يتغير ملف القفل. إذا تغير، سيتم تثبيت الـ dependencies من الصفر. هذه التقنية تقلل وقت الـ build من 3 دقائق إلى 30 ثانية في المتوسط، وهي فرق كبير عندما يكون لديك عشرات الـ commits يومياً.
لنبدأ بمشروع Node.js بسيط يحتوي على اختبارات Jest وESLint. الهدف هو بناء pipeline يقوم بالخطوات التالية عند كل push: تثبيت الـ dependencies، تشغيل الـ linter، تشغيل الـ tests، وإذا نجحت، بناء Docker image ودفعه إلى Docker Hub. هذه هي بالضبط نفس الخطوات التي تستخدمها الشركات الكبيرة مثل Netflix وSpotify، لكننا سنبسطها للمطور الفردي.
أولاً، يجب أن نفهم أن الـ pipeline ليس مجرد سلسلة من الأوامر، بل هو نظام متكامل. كل خطوة تعتمد على نجاح الخطوة السابقة. إذا فشل الـ linter، لا يجب أن تستمر الـ pipeline في تشغيل الـ tests أو بناء الـ Docker image. هذا هو جوهر الـ CI: الفشل السريع (fail fast). إذا كان هناك خطأ في الكود، نريد أن نعرف عنه في أقرب وقت ممكن، وليس بعد ساعة من الانتظار.
# .github/workflows/cd.yml
name: CD Pipeline
on:
push:
branches: [ main ]
env:
DOCKER_IMAGE: your-dockerhub-username/my-app
DOCKER_TAG: ${{ github.sha }}
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
- run: npm run build
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }},${{ env.DOCKER_IMAGE }}:latest
- name: Deploy to staging
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.DEPLOY_TOKEN }}" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/${{ github.repository }}/dispatches \
-d '{"event_type":"deploy_staging","client_payload":{"image":"${{ env.DOCKER_IMAGE }}:${{ env.DOCKER_TAG }}"}}'في هذا المثال، نستخدم GitHub API لإرسال حدث deploy_staging إلى repository آخر مسؤول عن الـ staging environment. هذا النمط يسمى GitOps، حيث يتم التحكم في البنية التحتية بالكامل عبر الكود والـ events. الميزة هنا هي أن الـ deployment يصبح جزءاً من الـ pipeline وليس عملية يدوية منفصلة. إذا فشل الـ deployment، سيتم إخطارك عبر GitHub notifications، ويمكنك التراجع بسهولة عبر إعادة تشغيل الـ workflow السابق.
إحدى أكبر الأخطاء التي يقع فيها المطورون الفرديون هي تخزين الـ secrets (مثل كلمات المرور وAPI tokens) مباشرة في ملف الـ workflow. هذا خطأ أمني فادح لأن ملفات الـ workflow تكون عامة في الـ public repositories. بدلاً من ذلك، يجب استخدام GitHub Secrets، وهي خدمة مشفرة تخزن القيم الحساسة وتسمح باستخدامها داخل الـ workflow دون كشفها في الـ logs.
عند إضافة secret جديد، تأكد من أن الاسم يصف الغرض بوضوح. مثلاً، لا تستخدم DOCKER_PASSWORD، بل استخدم DOCKER_HUB_TOKEN لأن هذا يصف نوع الـ secret بدقة. أيضاً، تجنب استخدام نفس الـ secret في أكثر من مكان. إذا كان لديك بيئتان (staging وproduction)، استخدم secrets منفصلة لكل منهما. هذا يقلل من تأثير الاختراق إذا تم كشف أحد الـ secrets.
أحد أكبر التحديات في CI/CD هو وقت التنفيذ. في المشاريع الكبيرة، قد يستغرق الـ build أكثر من 10 دقائق، وهذا وقت طويل جداً للمطور الفردي الذي يريد feedback سريع. الحل هنا هو الـ caching الذكي. الفكرة ليست مجرد تخزين الملفات مؤقتاً، بل تخزين الأجزاء التي تستغرق وقتاً طويلاً في البناء دون تغييرها كثيراً.
في مثالنا السابق، استخدمنا caching للـ node_modules. لكن يمكننا الذهاب أبعد من ذلك. مثلاً، إذا كان مشروعك يستخدم Webpack أو Vite، يمكنك تخزين مجلد الـ cache الخاص بهما. أيضاً، إذا كنت تستخدم Docker، يمكنك تخزين طبقات الـ image التي لا تتغير كثيراً باستخدام Docker layer caching. المشكلة هنا هي أن GitHub Actions لا يدعم Docker layer caching بشكل مباشر، لكن يمكننا تجاوز ذلك باستخدام buildkit.
# .github/workflows/ci-cached.yml
name: CI with Caching
on: [push, pull_request]
env:
NODE_VERSION: '20'
CACHE_KEY: node-modules-${{ hashFiles('package-lock.json') }}
BUILD_CACHE_KEY: build-cache-${{ hashFiles('src/**') }}
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache node modules
uses: actions/cache@v3
with:
path: node_modules
key: ${{ env.CACHE_KEY }}
- name: Cache build output
uses: actions/cache@v3
with:
path: dist
key: ${{ env.BUILD_CACHE_KEY }}
- run: npm ci
- run: npm run build
- run: npm testفي هذا المثال، نستخدم مفتاحين مختلفين للـ cache: واحد للـ node_modules وآخر لمخرجات الـ build. المفتاح الأول يعتمد على ملف package-lock.json، بينما المفتاح الثاني يعتمد على محتويات مجلد src. هذا يعني أن الـ cache سيتم إعادة استخدامه فقط إذا لم يتغير الكود المصدر. هذه التقنية تقلل وقت الـ build من 5 دقائق إلى أقل من دقيقة في المتوسط، وهي فرق كبير عندما يكون لديك عدة commits يومياً.
الـ caching ليس دائماً الحل الأمثل. في بعض الحالات، قد يسبب مشاكل أكثر مما يحل. مثلاً، إذا كان مشروعك يعتمد على مكتبات خارجية تتغير كثيراً (مثل مكتبات الـ machine learning)، قد يكون الـ caching غير فعال لأن الـ dependencies ستتغير باستمرار. أيضاً، إذا كان مشروعك صغيراً جداً، قد لا يستحق الـ caching الجهد لأن وقت الـ build أصلاً قصير.
من تجربتي، أفضل طريقة لاتخاذ القرار هي قياس الوقت قبل وبعد تطبيق الـ caching. إذا كان الفرق أقل من 30 ثانية، قد لا يستحق الأمر. لكن إذا كان الفرق عدة دقائق، فالـ caching يستحق التجربة. أيضاً، يجب أن تكون حذراً مع الـ cache keys: إذا كانت عامة جداً، قد تستخدم نسخة قديمة من الـ cache، وإذا كانت محددة جداً، قد لا يتم استخدام الـ cache أبداً.
حتى أفضل الـ pipelines قد تنتج كوارث. ربما كان الـ build ناجحاً، لكن الـ deployment فشل بسبب خطأ في الـ configuration. أو ربما كان الـ deployment ناجحاً، لكن الكود الجديد يحتوي على bug لم يتم اكتشافه في الـ tests. في هذه الحالات، تحتاج إلى طريقة سريعة للتراجع عن التغييرات. هذا هو دور الـ rollback.
الـ rollback ليس مجرد إعادة تشغيل الـ pipeline السابق. بل هو عملية منظمة تضمن أن البيئة تعود إلى الحالة السابقة دون فقدان البيانات أو التسبب في مشاكل جديدة. في عالم الـ containers، الـ rollback أسهل بكثير مما كان عليه في الماضي. بدلاً من إعادة تثبيت التطبيق من الصفر، يمكنك ببساطة إعادة تشغيل الـ container السابق باستخدام الـ image السابق.
#!/bin/bash
# script to rollback to previous version
set -e
# Get the current running image
CURRENT_IMAGE=$(docker inspect --format='{{.Config.Image}}' $(docker ps -q --filter "name=my-app"))
# Get the previous image from Docker Hub
PREVIOUS_IMAGE=$(curl -s "https://hub.docker.com/v2/repositories/your-dockerhub-username/my-app/tags/?page_size=2" | jq -r '.results[1].name')
# Stop and remove the current container
docker stop my-app || true
docker rm my-app || true
# Run the previous version
docker run -d --name my-app -p 3000:3000 your-dockerhub-username/my-app:$PREVIOUS_IMAGE
# Verify the rollback
docker ps | grep my-app
curl -I http://localhost:3000
# Log the rollback
echo "Rollback from $CURRENT_IMAGE to $PREVIOUS_IMAGE" >> rollback.logهذا السكربت يقوم بالخطوات التالية: أولاً، يحصل على الـ image الحالي الذي يعمل على السيرفر. ثم، يستخدم Docker Hub API للحصول على الـ tag السابق. بعد ذلك، يوقف الـ container الحالي ويقوم بتشغيل الـ container السابق. أخيراً، يقوم بتسجيل عملية الـ rollback في ملف log. هذا السكربت يمكن تشغيله يدوياً أو كجزء من الـ pipeline عند اكتشاف مشكلة في الإنتاج.
الـ rollback يجب أن يكون جزءاً من استراتيجية الـ deployment، وليس مجرد رد فعل للكوارث. من أفضل الممارسات هنا هو استخدام الـ blue-green deployment، حيث يتم تشغيل نسختين من التطبيق في نفس الوقت: النسخة الحالية (blue) والنسخة الجديدة (green). إذا كانت النسخة الجديدة تعمل بشكل صحيح، يتم تحويل الـ traffic إليها. إذا كانت هناك مشكلة، يمكن ببساطة تحويل الـ traffic مرة أخرى إلى النسخة القديمة دون أي توقف.
أيضاً، يجب أن يكون الـ rollback قابلاً للتكرار. هذا يعني أن العملية يجب أن تكون آلية بالكامل، وليس عملية يدوية تعتمد على شخص معين. في المثال السابق، السكربت يمكن تشغيله بأي شخص لديه وصول إلى السيرفر، وليس فقط الشخص الذي قام بالـ deployment الأصلي. أيضاً، يجب أن يكون هناك سجل كامل لكل عمليات الـ rollback، بما في ذلك من قام بها ومتى والسبب.
الـ CI/CD ليس مجرد بناء ونشر الكود، بل هو نظام متكامل يحتاج إلى مراقبة مستمرة. بدون مراقبة، قد لا تعرف أن الـ pipeline فشل إلا عندما يتلقى المستخدمون أخطاء في الإنتاج. هذا هو أسوأ سيناريو ممكن، خاصةً للمطور الفردي الذي يعمل بمفرده.
هناك عدة مستويات من المراقبة يجب أن تأخذها في الاعتبار. أولاً، مراقبة حالة الـ pipeline نفسها: هل هي ناجحة أم فاشلة؟ كم من الوقت تستغرق؟ ثانياً، مراقبة التطبيق بعد الـ deployment: هل هو يعمل بشكل صحيح؟ هل هناك أخطاء في الـ logs؟ ثالثاً، مراقبة الأداء: هل التطبيق بطيء بعد الـ deployment الجديد؟ هل هناك زيادة في استخدام الـ CPU أو الذاكرة؟
# .github/workflows/monitor.yml
name: Monitoring
on:
schedule:
- cron: '*/30 * * * *'
workflow_dispatch:
env:
APP_URL: https://your-app.com
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
jobs:
health-check:
runs-on: ubuntu-latest
steps:
- name: Check application health
run: |
STATUS_CODE=$(curl -s -o /dev/null -w "%{http_code}" $APP_URL)
if [ $STATUS_CODE -ne 200 ]; then
echo "Application is down with status code $STATUS_CODE"
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"⚠ Application is down with status code $STATUS_CODE\"}" \
$SLACK_WEBHOOK
exit 1
fi
- name: Check response time
run: |
RESP$(curl -s -o /dev/null -w "%{time_total}" $APP_URL)
if (( $(echo "$RESPONSE_TIME > 2" | bc -l) )); then
echo "Application is slow with response time $RESPONSE_TIME seconds"
curl -X POST -H 'Content-type: application/json' \
--data "{\"text\":\"⚠ Application is slow with response time $RESPONSE_TIME seconds\"}" \
$SLACK_WEBHOOK
fiهذا الـ workflow يعمل كل 30 دقيقة ويقوم بفحصين أساسيين: أولاً، يتحقق من أن التطبيق يرد برمز حالة 200. ثانياً، يقيس وقت الاستجابة للتأكد من أنه أقل من ثانيتين. إذا فشل أي من الفحصين، يرسل تنبيهاً إلى Slack. هذا النوع من المراقبة البسيطة يمكن أن ينقذ مشروعك من ساعات من التوقف غير المخطط له.
بالنسبة للمشاريع الأكثر تعقيداً، قد تحتاج إلى أدوات مراقبة متقدمة مثل Prometheus وGrafana. هذه الأدوات تسمح لك بمراقبة ليس فقط حالة التطبيق، بل أيضاً استخدام الموارد، أداء قاعدة البيانات، وعدد المستخدمين النشطين. المشكلة هنا هي أن هذه الأدوات تتطلب وقتاً وجهداً لإعدادها وصيانتها، وهذا قد يكون مكلفاً للمطور الفردي.
من تجربتي، أفضل نهج هو البدء بمراقبة بسيطة مثل الـ workflow السابق، ثم إضافة المزيد من الطبقات عندما تحتاج إليها. مثلاً، يمكنك البدء بمراقبة وقت الاستجابة، ثم إضافة مراقبة أخطاء قاعدة البيانات، ثم إضافة مراقبة استخدام الذاكرة. بهذه الطريقة، لن تشعر بالإرهاق من تعقيد الأدوات منذ البداية.
بعد عشر سنوات من بناء خطوط إنتاج برمجية، هذه هي النصائح التي أتمنى أن أعرفها منذ اليوم الأول. أولاً، لا تبني الـ pipeline دفعة واحدة. ابدأ بخطوة بسيطة — ربما مجرد تشغيل الـ tests عند كل push — ثم أضف المزيد من الطبقات تدريجياً. ثانياً، لا تعتمد على الـ CI/CD كأداة فقط، بل كعقلية. كل commit يجب أن يكون جاهزاً للإنتاج منذ اللحظة الأولى، وليس مجرد خطوة في الطريق.
ثالثاً، لا تخف من الفشل. الـ pipeline قد يفشل، والـ deployment قد يفشل، وهذا طبيعي. المهم هو أن يكون لديك خطة للتعامل مع الفشل قبل حدوثه. رابعاً، استخدم الـ caching بحكمة. ليس كل شيء يستحق التخزين المؤقت، وبعض الأشياء قد تسبب مشاكل أكثر مما تحل. خامساً، راقب كل شيء. بدون مراقبة، أنت تعمل في الظلام، وهذا أسوأ شيء يمكن أن يحدث لمطور فردي.
أخيراً، تذكر أن الـ CI/CD ليس مجرد أداة، بل هو استثمار في وقتك وراحتك العقلية. عندما تبني خط إنتاج برمجي جيد، أنت في الحقيقة تشتري لنفسك ساعات من النوم الهادئ، وأيام عمل أكثر إنتاجية، ومشروع أكثر استقراراً. هذا هو الفرق بين المطور الذي يقضي ليلته في إصلاح الأخطاء، والمطور الذي ينام وهو واثق أن كل شيء يعمل كما يجب.