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

البناء متعدد المراحل في Docker لتطبيقات PHP

نشر في 15 Jan 2024· 7 min قراءة
#Docker#PHP#Optimisation

لماذا نستخدم البناء متعدد المراحل؟

يتيح البناء متعدد المراحل (multi-stage builds) فصل بيئة البناء عن بيئة التشغيل. والنتيجة: صور أخف وزناً وأكثر أماناً.

المبدأ بسيط: يحتوي ملف Dockerfile واحد على عدة تعليمات FROM. تبدأ كل واحدة منها مرحلة (stage) جديدة بصورة أساسية خاصة بها. تثبّت المراحل الوسيطة أدوات البناء، أو تحمّل الاعتماديات، أو تُجمّع ملفات الواجهة (assets)؛ ولا تأخذ المرحلة النهائية، عبر COPY --from، إلا النتيجة. وكل ما لم يُنسخ صراحةً (المترجمات، وذاكرة الحزم المؤقتة، والمصادر غير الضرورية) يختفي من الصورة المُسلَّمة.

مشكلة الصور الأحادية

يمكن لصورة PHP تقليدية بكل اعتماديات التطوير أن تتجاوز بسهولة 1 غيغابايت. في الإنتاج، لا تحتاج إلى Composer ولا إلى أدوات الاختبار ولا إلى ملفات المصدر غير المُجمَّعة.

لهذا الحجم عواقب ملموسة: عمليات نشر أبطأ لأن كل خادم يجب أن يحمّل الصورة، وسجلّ صور (registry) يمتلئ بسرعة أكبر، والأهم من ذلك سطح هجوم أوسع. فكل ملف تنفيذي موجود في الصورة (مترجم، عميل Git، مدير حزم) أداة إضافية في متناول المهاجم، ومصدر إضافي للتنبيهات في أدوات فحص الثغرات.

مثال على Dockerfile متعدد المراحل

# Stage 1: Build
FROM composer:2 AS builder
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist
COPY . .
RUN composer dump-autoload --no-dev --optimize

# Stage 2: Production
FROM php:8.3-fpm-alpine
RUN docker-php-ext-install pdo_mysql opcache
COPY --from=builder /app /var/www/html
EXPOSE 9000
CMD ["php-fpm"]

تنطلق المرحلة الأولى، المسمّاة builder، من صورة Composer الرسمية. تنسخ أولاً الملفين composer.json وcomposer.lock فقط، ثم تثبّت الاعتماديات: ما دام هذان الملفان لم يتغيرا، يعيد Docker استخدام الطبقة المخزّنة في الـ cache ولا يُعاد التثبيت، حتى لو تغيّرت شيفرة التطبيق. ويُؤجَّل تنفيذ السكربتات وتوليد الـ autoloader (--no-scripts، --no-autoloader) لأن الشيفرة لم تُنسخ بعد: وبعد نسخها، يولّد الأمر composer dump-autoload --optimize ملف autoloader يتضمن أصناف التطبيق. أما المرحلة الثانية فتنطلق من صورة PHP-FPM مبنية على Alpine، وتثبّت الإضافات اللازمة قبل نسخ الشيفرة كي تبقى هذه الطبقة المكلفة في الـ cache، ثم تأخذ المجلد /app كاملاً.

نسخة أكثر اكتمالاً

في مشروع حقيقي، نذهب عادةً أبعد من ذلك: يجب ألا تعمل العملية بصلاحيات root، ويمكن الاحتفاظ بذاكرة Composer المؤقتة بين عمليتي بناء، ويجب ألا تبقى اعتماديات تجميع الإضافات في الصورة، وكثيراً ما يحتوي تطبيق Symfony على ملفات واجهة أمامية يجب تجميعها. إليك نسخة أكثر اكتمالاً:

# syntax=docker/dockerfile:1

# Stage 1: PHP dependencies
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
    composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative

# Stage 2: front-end assets
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY assets/ assets/
COPY webpack.config.js ./
RUN npm run build

# Stage 3: production
FROM php:8.3-fpm-alpine AS production
RUN apk add --no-cache icu-libs \
    && apk add --no-cache --virtual .build-deps icu-dev $PHPIZE_DEPS \
    && docker-php-ext-install intl pdo_mysql opcache \
    && apk del .build-deps
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
WORKDIR /var/www/html
COPY --from=vendor --chown=www-data:www-data /app ./
COPY --from=assets /app/public/build ./public/build
USER www-data

بعض التوضيحات:

  • --mount=type=cache يحتفظ بذاكرة Composer المؤقتة بين عمليتي بناء على الجهاز نفسه، دون أن يكتبها أبداً في الصورة.
  • --ignore-platform-reqs ضروري لأن صورة Composer لا تملك إصدار PHP نفسه ولا الإضافات نفسها الموجودة في الصورة النهائية. يولّد Composer ملف platform_check.php الذي يتحقق من إصدار PHP عند الإقلاع: وبالتالي يُكتشف أي عدم توافق فوراً.
  • --classmap-authoritative ينتج classmap كاملة، تُولَّد بعد نسخ الشيفرة، ويتجنّب أي بحث عن الملفات على القرص عند تحميل صنف ما.
  • تُثبَّت اعتماديات التجميع (icu-dev، $PHPIZE_DEPS) وتُحذف في تعليمة RUN نفسها: لا تبقى في الصورة سوى مكتبات التشغيل مثل icu-libs.
  • تجلب المرحلة assets بيئة Node.js التي لا مكان لها في الإنتاج: لا يُنسخ إلا المجلد public/build.

لا تنسَ أيضاً ملف .dockerignore. من دونه، يرسل COPY . . إلى عملية البناء مجلد vendor/ المحلي، الذي سيستبدل المجلد الذي ثبّتته المرحلة للتو، إضافة إلى .git وملفات البيئة المحلية:

.git
vendor/
node_modules/
var/
.env.local
docker-compose*.yml

بناء مرحلة محددة

يوقف الخيار --target البناء عند المرحلة المحددة. وهذا مفيد لتصحيح مرحلة وسيطة، أو لإعادة استخدام ملف Dockerfile نفسه مع مرحلة للتطوير:

docker build --target production -t myapp:1.4.2 .
docker build --target vendor -t myapp:vendor .

# Compare sizes and inspect layers
docker image ls myapp
docker history myapp:1.4.2

مع BuildKit، المفعّل افتراضياً في الإصدارات الحديثة من Docker، لا تُبنى إلا المراحل اللازمة للهدف، وتُبنى المراحل المستقلة (هنا vendor وassets) بالتوازي.

تتيح الآلية نفسها وصف مرحلة تطوير تنطلق من صورة الإنتاج ولا تضيف إليها سوى Xdebug وأدوات الاختبار. وبذلك يتشارك التطوير والإنتاج الأساس نفسه، ويختار Docker Compose المرحلة التي يبنيها عبر المفتاح build.target.

فوائد ملموسة

  • حجم أصغر: من 800 ميغابايت إلى أقل من 150 ميغابايت
  • الأمان: لا أدوات تطوير في الإنتاج
  • ذاكرة Docker المؤقتة: تُخزَّن كل مرحلة في الـ cache بشكل مستقل
  • قابلية إعادة الإنتاج: النتيجة نفسها في كل البيئات

ممارسات جيدة

استخدم Alpine كصورة أساسية لتقليص سطح الهجوم. ثبّت فقط إضافات PHP الضرورية. واضبط OPcache للإنتاج بالمعاملات المثلى.

RUN echo "opcache.memory_consumption=256" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.max_accelerated_files=20000" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=0" >> /usr/local/etc/php/conf.d/opcache.ini

بدلاً من سلسلة من أوامر echo، يكون ملف docker/php/opcache.ini خاضع لإدارة الإصدارات ومنسوخ داخل الصورة (كما في النسخة المكتملة أعلاه) أسهل قراءة. ويمكنه أيضاً تفعيل التحميل المسبق (preloading) الذي يوفّره Symfony وضبط ذاكرة realpath المؤقتة:

opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.preload=/var/www/html/config/preload.php
opcache.preload_user=www-data
realpath_cache_size=4096K
realpath_cache_ttl=600

مع validate_timestamps=0 لم يعد PHP يتحقق مما إذا كانت الملفات قد تغيّرت: هذا بالضبط ما نريده في صورة ثابتة، لكنه يعني أنه يجب ألا تعدّل أبداً شيفرة حاوية قيد التشغيل. يتم النشر باستبدال الحاوية.

وأخيراً، رتّب تعليمات Dockerfile من الأقل تغيّراً إلى الأكثر تغيّراً (حزم النظام، ثم الإضافات، ثم الاعتماديات، ثم الشيفرة)، وثبّت إصدارات الصور الأساسية حتى تُنتج عمليتا بناء للـ commit نفسه النتيجة نفسها.

يُستخدم هذا النهج بنجاح على منصات مثل Keytchens لنشر تطبيقات Symfony مع تقليص زمن البناء بنسبة 60%.