Як побудувати Docker-образ для PHP-застосунку правильно?
Multi-stage: у білдерах Composer і збірка ассетів, у фінальному образі лише runtime, vendor без dev-залежностей і код; шари впорядковані так, щоб зміна коду не інвалідувала шар із залежностями.
Як питають
Чому composer install має йти після копіювання лише composer.json і lock?
Навіщо multi-stage build для PHP-проєкту з фронтендом?
Що має бути в продакшн-образі, а чого не має?
Dockerшари образуmulti-stage
Пояснення
Правильний образ починається з розуміння кешу шарів. Docker перевикористовує шар, поки його вхідні дані не змінились, і перезбирає все після першого зміненого. Код змінюється щодня, composer.lock рідко, тому спершу копіюють лише composer.json і composer.lock, ставлять залежності, і тільки потім копіюють решту. Так зміна одного файлу не перевстановлює vendor.
Multi-stage розділяє збірку й рантайм. Composer живе у своєму стейджі, Node зі збіркою Vite у своєму, а фінальний образ на php:8.4-fpm-alpine отримує лише результат через COPY --from: vendor без dev-залежностей із оптимізованим автолоадером і зібраний public/build. Фінальний образ менший у рази, не містить інструментів збірки й має меншу поверхню атаки.
Продакшн-налаштування PHP входять в образ: php.ini-production як база, opcache з validate_timestamps=0, бо в immutable-образі файли не змінюються, а деплой це заміна контейнера. Версія базового образу пінується до мінорної, процес запускається від www-data, секрети приходять через змінні середовища, а не через .env у шарах. Healthcheck, логи в stdout і graceful shutdown FPM завершують картину. Для нового проєкту альтернативою FPM з nginx є FrankenPHP: один бінарник із вбудованим сервером і worker mode.
КодDOCKERFILE
# syntax=docker/dockerfile:1
# 1. Залежності PHP: шар кешується, доки не зміниться composer.lock
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
COPY . .
RUN composer dump-autoload --optimize --classmap-authoritative
# 2. Фронтенд: Node живе лише тут
FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY resources ./resources
COPY vite.config.js ./
RUN npm run build
# 3. Рантайм: пінована версія, без Composer, Node і dev-залежностей
FROM php:8.4-fpm-alpine AS runtime
RUN docker-php-ext-install pdo_pgsql opcache \
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/opcache.ini $PHP_INI_DIR/conf.d/ # validate_timestamps=0, jit, memory
WORKDIR /var/www
COPY --chown=www-data:www-data --from=vendor /app ./
COPY --chown=www-data:www-data --from=assets /app/public/build ./public/build
USER www-data
HEALTHCHECK CMD php-fpm-healthcheck || exit 1
CMD ["php-fpm"]
Що хоче почути інтервʼюер
Що кеш шарів працює зверху вниз: усе після зміненого шару перезбирається, тому залежності ставляться до копіювання коду.
Що multi-stage дає малий фінальний образ без Node, Composer і dev-залежностей, а також єдину точку правди для збірки в CI і локально.
Що продакшн-налаштування PHP задаються в образі: opcache з validate_timestamps=0, preload за потреби, memory_limit, realpath cache, і php.ini-production як база.
Що образ пінується до конкретної версії PHP і базового образу, запускається від non-root, а секрети не потрапляють у шари.
Що для FPM потрібен окремий nginx або єдиний образ на FrankenPHP, і що healthcheck, логи в stdout і graceful shutdown є частиною правильного образу.
Типові помилки
Копіювати весь проєкт першим шаром: кожна зміна коду перевстановлює всі залежності.
Тягнути dev-залежності, Node і node_modules у продакшн-образ.
Використовувати тег latest або php:8 без мінорної версії й отримувати несподіване оновлення.
Запускати composer install або npm ci на старті контейнера замість на етапі збірки.
Лишати opcache.validate_timestamps=1 у продакшені й платити stat-викликами на кожен запит.
Запускати процес від root і класти .env з секретами в образ.
ПОРАДА
Додайте, що opcache.validate_timestamps=0 у продакшн-образі дає відчутний приріст, і поясніть, чому це безпечно саме в immutable-образі. Згадайте FrankenPHP як варіант без окремого nginx.
Додаткові питанняЗ ВІДПОВІДЯМИ
Docker кешує кожен шар по вмісту й інвалідує всі наступні, щойно один змінився. composer.json і lock змінюються рідко, код щодня. Якщо скопіювати лише їх і поставити залежності, шар з vendor кешується між збірками, а зміна коду перезбирає лише останні шари. Скопіювати все одразу означає ставити залежності з нуля на кожен коміт.
Збірка Vite чи Webpack потребує Node і сотні мегабайт node_modules, які в рантаймі не потрібні. Окремий stage з образом node збирає public/build, а фінальний образ копіює лише результат через COPY --from. Фінальний образ не містить Node, менший у кілька разів і має меншу поверхню атаки. Те саме з Composer: він потрібен лише в білдері.
З цією опцією PHP не перевіряє mtime файлів і не перекомпілює їх, доки процес живий. В immutable-образі код не змінюється після збірки, тому перевірка марна, а деплой це заміна контейнера з новим образом, що й так скидає opcache. Небезпечно це лише там, де файли правлять на місці, наприклад через FTP або bind mount.
PHP-FPM з nginx це перевірена схема з двома процесами й двома образами або одним образом із supervisor. FrankenPHP дає один бінарник із вбудованим сервером Caddy, автоматичним HTTPS, HTTP/3 і worker mode, у якому застосунок лишається в памʼяті як в Octane. Для нового проєкту FrankenPHP спрощує образ і compose, для існуючої інфраструктури з nginx-конфігами FPM лишається нормальним вибором.