Docker прочно вошёл в стандартный стек PHP-разработчика. Но часто встречаю ситуацию: образ собирается минут 10, весит полтора гигабайта, а в продакшене контейнер стартует медленнее, чем хотелось бы. В этой статье разберём, как строить компактные, быстрые и безопасные Docker-образы для PHP-приложений.
Зачем вообще оптимизировать образы
Раздутый образ — это не просто неэстетично. Это:
- Долгое время сборки в CI/CD пайплайне
- Медленная доставка образа на сервер (
docker pull) - Больший attack surface: каждый лишний пакет — потенциальная уязвимость
- Повышенное потребление памяти на сервере
Оптимизация образа напрямую влияет на скорость деплоя и стоимость инфраструктуры.
Выбор базового образа
Первый и самый важный шаг. Многие начинают с:
FROM php:8.3-apache
Это удобно, но образ весит ~500 MB. Для большинства задач лучше взять:
FROM php:8.3-fpm-alpine
Alpine Linux — дистрибутив размером около 5 MB. PHP-образ на его базе весит ~80–100 MB до установки расширений.
Сравнение базовых образов PHP 8.3
| Образ | Размер | Использование |
|---|---|---|
php:8.3 | ~490 MB | Разработка, дебаггинг |
php:8.3-apache | ~520 MB | Простые проекты с Apache |
php:8.3-fpm | ~460 MB | Production с Nginx (Debian) |
php:8.3-fpm-alpine | ~95 MB | Production (рекомендуется) |
php:8.3-cli-alpine | ~85 MB | CLI-скрипты, очереди |
Многоступенчатая сборка (Multi-stage build)
Это главный инструмент оптимизации. Суть: используем несколько FROM в одном Dockerfile. Финальный образ содержит только то, что нужно в runtime.
Пример для Laravel-приложения
# ============================================================
# Stage 1: Установка зависимостей Composer
# ============================================================
FROM composer:2.7 AS composer-deps
WORKDIR /app
# Копируем только файлы, нужные для установки зависимостей
COPY composer.json composer.lock ./
# Устанавливаем без dev-зависимостей, без скриптов
RUN composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--prefer-dist
# Копируем исходники и генерируем автолоадер
COPY . .
RUN composer dump-autoload --optimize --no-dev
# ============================================================
# Stage 2: Сборка фронтенда (если нужно)
# ============================================================
FROM node:20-alpine AS frontend-build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY resources/ ./resources/
COPY vite.config.js ./
RUN npm run build
# ============================================================
# Stage 3: Финальный production-образ
# ============================================================
FROM php:8.3-fpm-alpine AS production
# Устанавливаем только нужные системные зависимости
RUN apk add --no-cache \
libpng-dev \
libjpeg-turbo-dev \
libwebp-dev \
libzip-dev \
icu-dev
# Устанавливаем PHP-расширения
RUN docker-php-ext-configure gd \
--with-jpeg \
--with-webp && \
docker-php-ext-install -j$(nproc) \
gd \
pdo_mysql \
zip \
intl \
opcache
# Копируем только артефакты из предыдущих стадий
COPY --from=composer-deps /app/vendor ./vendor
COPY --from=composer-deps /app .
COPY --from=frontend-build /app/public/build ./public/build
# Конфигурация OPcache
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# Запускаем как непривилегированный пользователь
RUN addgroup -g 1000 appgroup && \
adduser -u 1000 -G appgroup -s /bin/sh -D appuser
USER appuser
EXPOSE 9000
CMD ["php-fpm"]
Итог: финальный образ не содержит Composer, Node.js, исходники npm — только то, что нужно для работы приложения.
OPcache: обязательная настройка для production
PHP без OPcache в продакшене — распространённая ошибка. OPcache компилирует PHP-код в байт-код и кеширует его в памяти.
Файл docker/php/opcache.ini:
[opcache]
opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; В production НЕ проверяем изменения файлов
opcache.revalidate_freq=0
opcache.fast_shutdown=1
opcache.jit=1255 ; JIT (PHP 8+)
opcache.jit_buffer_size=100M
Важно:
validate_timestamps=0означает, что изменения файлов не будут подхватываться без перезапуска контейнера. Это нормально для production, но неудобно при разработке.
Кеширование слоёв Docker
Docker кеширует каждый слой. Порядок инструкций критически важен: слои, которые меняются редко, должны идти раньше.
Плохо — кеш инвалидируется при любом изменении кода
COPY . .
RUN composer install
Хорошо — кеш инвалидируется только при изменении composer.json/lock
COPY composer.json composer.lock ./
RUN composer install
COPY . .
Логика проста: composer.json меняется значительно реже, чем исходный код. Выносим установку зависимостей перед копированием всего проекта.
.dockerignore: что не должно попасть в контекст сборки
Без .dockerignore Docker отправляет в контекст сборки всё — включая node_modules, .git, кеши. Это замедляет сборку и увеличивает образ.
Минимальный .dockerignore для PHP-проекта:
.git
.gitignore
node_modules
vendor
.env
.env.*
*.log
storage/logs/*
storage/framework/cache/*
storage/framework/sessions/*
tests
docs
docker/dev
README.md
.phpunit.cache
Частые ошибки
1. Устанавливаете dev-зависимости в production-образ
# Неправильно
composer install
# Правильно
composer install --no-dev --optimize-autoloader
Dev-зависимости (PHPUnit, PHPStan, Faker и т.д.) не нужны в production и увеличивают образ на десятки мегабайт.
2. Запускаете контейнер от root
Контейнер, работающий от root, при компрометации даёт злоумышленнику root-права на хосте (в некоторых конфигурациях). Всегда создавайте отдельного пользователя:
RUN addgroup -g 1000 appgroup && \
adduser -u 1000 -G appgroup -s /bin/sh -D appuser
USER appuser
3. Объединяете все RUN в один слой без причины
Иногда советуют объединять все RUN в одну команду. Это снижает количество слоёв, но убивает кеш при любом изменении. Объединяйте только логически связанные команды:
# Хорошо: установка и очистка в одном слое (чтобы не сохранять кеш apk)
RUN apk add --no-cache --virtual .build-deps \
gcc \
make && \
docker-php-ext-install pdo_mysql && \
apk del .build-deps
4. Копируете .env в образ
Файл .env содержит секреты. Он не должен попадать в образ. Передавайте переменные окружения через docker-compose.yml или систему оркестрации:
services:
app:
environment:
DB_HOST: db
DB_PASSWORD: ${DB_PASSWORD} # Из .env на хосте
5. Не указываете версии пакетов
# Плохо: сборка недетерминирована
RUN apk add libpng-dev
# Лучше: фиксируем версию
RUN apk add libpng-dev=1.6.43-r0
Без фиксации версий сборка сегодня и через месяц может дать разные результаты.
Практические советы
Используйте --no-cache при установке пакетов Alpine:
RUN apk add --no-cache libzip-dev
Без этого кеш apk сохраняется в слое и увеличивает образ.
Проверяйте размер слоёв:
docker history my-php-app:latest
Это покажет, какой слой сколько весит и где спрятаны лишние мегабайты.
Используйте dive для анализа образа:
# Установка
brew install dive
# Анализ
dive my-php-app:latest
dive показывает содержимое каждого слоя и помогает найти лишние файлы.
Разделяйте образы для dev и production:
В разработке удобно иметь Xdebug, отладочные инструменты, composer внутри контейнера. Используйте target для сборки нужной стадии:
# Dev-образ (со всеми инструментами)
docker build --target development -t myapp:dev .
# Production-образ
docker build --target production -t myapp:prod .
FAQ
Q: Нужно ли использовать Alpine для всех PHP-проектов?
Не обязательно. Alpine основан на musl libc вместо glibc, что иногда вызывает проблемы с отдельными расширениями (особенно старыми). Если возникают сложности — берите php:8.3-fpm-slim (Debian Slim): компромисс между размером и совместимостью.
Q: Стоит ли включать JIT в PHP 8?
Для типичных веб-приложений JIT даёт минимальный прирост (иногда даже замедляет). JIT эффективен для вычислительно-интенсивных задач: рендеринг, обработка данных, математика. Для CRUD-приложений — скорее маркетинг.
Q: Как обновлять образ безопасно?
Используйте теги с версиями (php:8.3.10-fpm-alpine), а не latest. Обновляйте образы регулярно (еженедельно) для получения security-патчей. Автоматизируйте это через Dependabot или Renovate.
Q: Нужен ли supervisor внутри контейнера?
Нет, если вы следуете принципу «один процесс на контейнер». Очереди, cron, php-fpm — отдельные контейнеры. Управление процессами — задача оркестратора (Docker Compose, Kubernetes), а не supervisor.
Q: Как ускорить сборку в CI/CD?
Используйте BuildKit и кеш между сборками:
# GitHub Actions
- uses: docker/build-push-action@v5
with:
cache-from: type=gha
cache-to: type=gha,mode=max
Вывод
Оптимизация Docker-образов для PHP — не разовая акция, а часть культуры разработки. Ключевые принципы:
- Alpine или Slim вместо полного Debian-образа
- Multi-stage build — отделяем сборку от runtime
- Порядок слоёв — редко меняемое вперёд
- OPcache — обязателен в production
- Непривилегированный пользователь — всегда
- .dockerignore — не забывайте про него
Грамотно собранный образ для Laravel-приложения весит 150–200 MB вместо 600–800 MB и собирается повторно за 30 секунд вместо 5 минут.
Инвестиция небольшая, а выигрыш ощутим с первого же деплоя.