Docker والتكامل المستمر
أصبح Docker عنصراً لا غنى عنه في خطوط أنابيب CI/CD الحديثة. فهو يضمن الاتساق بين بيئتي الاختبار والإنتاج.
المبدأ بسيط: بدلاً من إعادة بناء البيئة على كل جهاز CI (إصدار PHP، الامتدادات، اعتماديات النظام)، نبني صورة (image) مرة واحدة، ونختبرها، ثم ننشر هذه الصورة نفسها تماماً. وتختفي عبارة «إنها تعمل على جهازي» الشهيرة، لأن الجهاز أصبح جزءاً من المُخرَج المُسلَّم. كما يصبح خط الأنابيب مستقلاً عن مزوّد CI: فالأوامر نفسها docker build وdocker run تعمل على GitHub Actions وGitLab CI وJenkins.
ملف Dockerfile متعدد المراحل
يقوم كل شيء على ملف Dockerfile متعدد المراحل (multi-stage): مرحلة أساسية مشتركة، وهدف test يحتوي على اعتماديات التطوير، وهدف production مخفف. ويختار خط الأنابيب الهدف عبر الخيار --target.
# syntax=docker/dockerfile:1
FROM php:8.3-fpm-alpine AS base
RUN apk add --no-cache icu-libs libzip \
&& apk add --no-cache --virtual .build-deps $PHPIZE_DEPS icu-dev libzip-dev \
&& docker-php-ext-install intl zip pdo_mysql opcache \
&& apk del .build-deps
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/app
FROM base AS vendor
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-progress
FROM base AS test
ENV APP_ENV=test
COPY composer.json composer.lock ./
RUN composer install --no-scripts --no-autoloader --prefer-dist --no-progress
COPY . .
RUN composer dump-autoload
FROM base AS production
ENV APP_ENV=prod
COPY --from=vendor /var/www/app/vendor ./vendor
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev \
&& php bin/console cache:warmup \
&& chown -R www-data:www-data var
USER www-data
ترتيب التعليمات ليس اعتباطياً: يُنسخ composer.json وcomposer.lock قبل بقية الشيفرة. وما دامت الاعتماديات لم تتغير، يعيد Docker استخدام الطبقة التي تحتوي على vendor/، ولا يُعاد إلا نسخ الشيفرة المصدرية. وهذا ما يجعل عمليات البناء اللاحقة سريعة.
لا تنسَ أيضاً ملف .dockerignore، الذي يتجنب إرسال ملفات غير مفيدة أو حساسة إلى عملية البناء، وإبطال التخزين المؤقت مع كل تعديل محلي:
.git
vendor
var
node_modules
.env.local
.env.*.local
خط أنابيب GitHub Actions مع Docker
إليك نسخة مبسّطة من خط الأنابيب: مهمة تبني صورة الاختبار وتشغّل PHPUnit داخلها، ثم، إذا نجحت الاختبارات، تبني مهمة ثانية صورة الإنتاج وتدفعها إلى GitHub Container Registry.
name: CI/CD Pipeline
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build test image
run: docker build --target test -t app:test .
- name: Run tests
run: docker run --rm app:test php bin/phpunit
build-and-push:
needs: test
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Build and push production image
run: |
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
docker build --target production -t "$IMAGE:$GITHUB_SHA" -t "$IMAGE:latest" .
docker push --all-tags "$IMAGE"
يُشتق اسم الصورة من المستودع (ghcr.io/owner/repository)، ويُحوَّل إلى أحرف صغيرة لأن السجلات (registries) ترفض الأحرف الكبيرة. وتعتمد المصادقة على GITHUB_TOKEN الخاص بسير العمل، الذي لا يحتاج إلا إلى الصلاحية packages: write. تحصل كل صورة على وسمين: الـ SHA الخاص بالـ commit، الذي يبيّن بدقة ما يعمل في الإنتاج ويسهّل التراجع، وlatest. لكن كل مهمة ما زالت تعيد بناء كل شيء من الصفر، لأن الـ runners لا تحتفظ بذاكرة Docker المؤقتة بين تنفيذين.
تحسين التخزين المؤقت
التخزين المؤقت لطبقات Docker أمر حاسم لسرعة خط الأنابيب:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build with cache
uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
يخزن type=gha الطبقات في ذاكرة GitHub Actions المؤقتة. ومع mode=max، تُصدَّر أيضاً طبقات المراحل الوسيطة (مثل vendor)، لا طبقات الصورة النهائية فقط. وعندما تتشارك عدة عمليات بناء الذاكرة المؤقتة نفسها، امنح كل واحدة منها scope خاصاً بها حتى لا تكتب إحداها فوق الأخرى.
خط أنابيب كامل وقابل للتتبع
تعتمد النسخة التالية المبدأ نفسه مع إجراءات Docker الرسمية: وسوم يولّدها docker/metadata-action، وذاكرة مؤقتة منفصلة لكل هدف، واختبارات تُنفَّذ مع خدماتها بفضل Docker Compose، وطلبات دمج (pull requests) تُختبر دون نشر أي شيء.
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Build test image
uses: docker/build-push-action@v6
with:
context: .
target: test
tags: app:test
load: true
cache-from: type=gha,scope=test
cache-to: type=gha,scope=test,mode=max
- name: Run tests
run: docker compose -f compose.ci.yaml run --rm app php bin/phpunit
- name: Clean up
if: always()
run: docker compose -f compose.ci.yaml down -v
build-and-push:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
with:
context: .
target: production
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=production
cache-to: type=gha,scope=production,mode=max
النقاط الأساسية:
load: trueيحمّل صورة الاختبار في خادم Docker الخاص بالـ runner لتشغيلها بعد ذلك؛ ومن دون هذا الخيار، يحتفظ Buildx بالنتيجة في ذاكرته المؤقتة الخاصة.docker/login-actionيسجّل الدخول إلى GHCR باستخدامGITHUB_TOKENالخاص بالـ workflow: لا حاجة إلى إنشاء رمز شخصي، يكفي منح المهمة صلاحيةpackages: write.docker/metadata-actionيولّد الوسوم: ينتجtype=shaوسماً من نوعsha-1a2b3c4، ولا يُضافlatestإلا على الفرع الافتراضي. وهكذا ترتبط كل صورة في الإنتاج بـ commit محدد، ويكون التراجع بإعادة نشر الوسم السابق.- مهمة النشر لا تُنفَّذ إلا على
main: تُختبر الـ pull requests لكنها لا تنشر شيئاً.
الاختبارات بالتوازي
- استخدم Docker Compose لتشغيل خدمات الاختبار (قاعدة البيانات، Redis)
- شغّل مجموعات الاختبارات بالتوازي في حاويات منفصلة
- نظّف الموارد بعد كل تنفيذ
يشغّل الملف compose.ci.yaml المستخدم أعلاه قاعدة البيانات بجانب صورة الاختبار، ولا ينفّذ الاختبارات إلا بعد أن يصبح MySQL جاهزاً فعلاً:
services:
app:
image: app:test
depends_on:
mysql:
condition: service_healthy
environment:
DATABASE_URL: mysql://root:root@mysql:3306/test
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: test
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1"]
interval: 5s
timeout: 5s
retries: 10
الشرط service_healthy أساسي: فمجرد depends_on ينتظر بدء الحاوية فقط، لا أن يقبل MySQL الاتصالات. وللتوازي، يمكن لمصفوفة GitHub Actions تشغيل عدة مهام بالصورة نفسها، تنفّذ كل منها مجموعة PHPUnit مختلفة (--testsuite unit، --testsuite integration). أما الأمر الختامي down -v، المنفَّذ حتى عند الفشل بفضل if: always()، فيحذف الحاويات والمجلدات الدائمة (volumes).
أخطاء شائعة
- أسرار داخل الصورة: لا تنسخ أبداً ملف
.env.localأو مفتاحاً إلى الصورة، ولا تمرّر الأسرار عبرARG، الذي يبقى مرئياً في سجل الصورة. تُحقن أسرار الإنتاج عند تشغيل الحاوية. - نشر صورة الاختبار: من دون
--target production، يبني Docker المرحلة الأخيرة من ملف Dockerfile. تأكد من أنها المرحلة التي تتوقعها. - اسم صورة بأحرف كبيرة: تفرض السجلات أسماء بأحرف صغيرة، وهذا يسبب مشكلة إذا احتوى اسم مؤسسة GitHub على أحرف كبيرة.
- ذاكرة مؤقتة تُبطَل باستمرار: تعليمة
COPY . .موضوعة في وقت مبكر جداً، أو غياب.dockerignore، يؤدي إلى إعادة بناء الاعتماديات مع كل commit.
خلاصة
ملف Dockerfile متعدد المراحل، وذاكرة Buildx مؤقتة مضبوطة جيداً، وصور موسومة حسب الـ commit، وخدمات اختبار ينسّقها Compose: بهذه العناصر الأربعة، يبني خط الأنابيب مرة واحدة ما يختبره وينشره.
تتيح هذه الاستراتيجية تقليص زمن النشر من 30 دقيقة إلى أقل من 5 دقائق.