◀ العودة إلى المدونة
Docker

Docker في خطوط CI/CD

نشر في 05 Apr 2024· 9 min قراءة
#Docker#CI/CD#DevOps

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 دقائق.