Создан  demius PM месяц и 26 дней назад; Обновил  demius PM месяц и 26 дней назад

Попросил chatgpt проанализировать мой php.dockerfile и дать советы по улучшению.

В текущем php.dockerfile есть одна ошибка сборки и несколько проблем с правами, размером и воспроизводимостью.

Критичные замечания

1. Ошибка в создании каталогов

В строках 41–44 отсутствуют &&:

&& chown ... /app/var && chmod ... /app/var \
mkdir -p /data/private

Shell передаст mkdir как аргумент команде chmod. Исправленный вариант:

RUN install -d -o "${UID}" -g "${GID}" -m 0775 \
        /app/var/cache \
        /app/var/log \
    && install -d -o "${UID}" -g "${GID}" -m 0770 \
        /data/private \
        /data/public

install -d одновременно создаёт каталог и назначает владельца/режим, поэтому лучше цепочки mkdir + chown + chmod.

2. Dockerfile не гарантирует права bind-mount

После подключения:

- ./data/private:/data/private
- ./data/public:/data/public

владелец каталогов из образа скрывается владельцем каталогов на хосте. Поэтому chown при сборке гарантирует права только:

  • без монтирования;
  • при первичной инициализации обычного named volume;
  • но не для bind-mount и не всегда для NFS/CIFS.

На хосте каталоги нужно создать под тем же UID/GID:

mkdir -p data/private data/public
chmod 0770 data/private data/public

Если PHP работает с UID/GID текущего разработчика, дополнительный chown не потребуется. Например:

LOCAL_UID="$(id -u)" LOCAL_GID="$(id -g)" docker compose build php

В Compose:

services:
  php:
    build:
      context: .
      dockerfile: php.dockerfile
      target: dev
      args:
        UID: ${LOCAL_UID:-1000}
        GID: ${LOCAL_GID:-1000}
    user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}"
    volumes:
      - ./data/private:/data/private
      - ./data/public:/data/public

Для production с фиксированным UID, например 33:33, каталоги должен заранее подготовить деплой:

install -d -o 33 -g 33 -m 0770 /путь/data/private /путь/data/public

Это действие выполняется на хосте при развёртывании, а не контейнером при каждом старте. PHP-контейнер при этом остаётся полностью non-root.

Если /data/public читает отдельный nginx-контейнер, nginx должен состоять в общей группе либо каталог должен иметь подходящие групповые права и setgid-бит:

chmod 2770 data/public

Пользователь PHP

Сейчас ARG UID/GID меняет только:

  • владельца нескольких каталогов;
  • значение директивы USER.

Он не создаёт пользователя с таким UID и не изменяет www-data. При значениях, отличных от 33:33, процесс получает только числовую учётную запись, а /etc/passwd и конфигурация пула FPM продолжают ссылаться на www-data.

Возможны два подхода.

Фиксированный www-data

Если везде используется Debian UID/GID 33:33, лучше убрать настраиваемость и явно написать:

RUN install -d -o www-data -g www-data -m 0775 /app/var/cache /app/var/log \
    && install -d -o www-data -g www-data -m 0770 /data/private /data/public

USER www-data:www-data

Это наиболее предсказуемый production-вариант.

Настраиваемый UID/GID

Для локальной разработки изменяйте UID/GID существующего www-data, а не запускайте процесс под безымянным числовым пользователем:

ARG UID=1000
ARG GID=1000

RUN groupmod --gid "${GID}" www-data \
    && usermod --uid "${UID}" --gid "${GID}" www-data

USER www-data:www-data

Нужно учитывать возможное совпадение заданных идентификаторов с другими пользователями базового образа.

Запуск Symfony и Composer без root

После установки в конечном образе:

USER www-data:www-data

обычная команда docker compose exec автоматически использует пользователя образа:

docker compose exec php php bin/console cache:clear --env=prod --no-debug
docker compose exec php php bin/console cache:warmup --env=prod --no-debug
docker compose exec php composer install

Для явной гарантии:

docker compose exec --user www-data:www-data php \
  php bin/console cache:warmup --env=prod --no-debug

Одноразовая команда:

docker compose run --rm --no-deps --user www-data:www-data php \
  php bin/console doctrine:migrations:migrate --no-interaction

Проверка:

docker compose exec php id
docker compose exec php php -r 'printf("%s:%s\n", posix_geteuid(), posix_getegid());'

Не следует использовать:

docker compose exec --user root php ...

иначе Composer или Symfony создадут в vendor, var/cache и var/log файлы, недоступные PHP-FPM.

Redis

Установка через PECL в целом правильная, но:

pecl install --onlyreqdeps --force redis

всегда берёт текущую версию. Это делает сборку невоспроизводимой. Версию лучше зафиксировать:

ARG PHP_REDIS_VERSION=<проверенная-версия>

RUN pecl install "redis-${PHP_REDIS_VERSION}" \
    && docker-php-ext-enable redis \
    && rm -rf /tmp/pear

После сборки стоит выполнить:

docker run --rm your-image php --ri redis

Если composer.json использует Redis напрямую, добавьте платформенное требование:

{
  "require": {
    "ext-redis": "*"
  }
}

PECL redis — это PHP-расширение. Оно не заменяет Composer-пакеты вроде symfony/cache, если приложение использует их API.

Уменьшение размера образа

Объединить установку системных пакетов

Сейчас системные пакеты устанавливаются в пяти слоях, а списки APT вообще не удаляются. Кроме размера, apt-get install git без собственного apt-get update зависит от закешированного списка предыдущего слоя.

Минимально:

RUN apt-get update \
    && apt-get install -y --no-install-recommends \
        libfreetype6-dev \
        libjpeg62-turbo-dev \
        libpng-dev \
        libicu-dev \
        libzip-dev \
        libonig-dev \
        unzip \
    && docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j"$(nproc)" \
        gd \
        intl \
        mbstring \
        opcache \
        pdo_mysql \
        zip \
    && rm -rf /var/lib/apt/lists/*

Дополнительно:

  • pdo обычно уже присутствует в официальном PHP-образе — перед удалением из сборки проверить через php -m;
  • то же стоит проверить для iconv;
  • добавить --no-install-recommends;
  • git устанавливать только в dev;
  • unzip, компиляторы и *-dev не должны оставаться в production runtime.

Практичный вариант автоматической установки и очистки зависимостей — install-php-extensions, но внешний installer-образ необходимо фиксировать по версии или digest:

COPY --from=mlocati/php-extension-installer:<фиксированная-версия> \
    /usr/bin/install-php-extensions /usr/local/bin/

RUN install-php-extensions \
    gd intl mbstring opcache pdo_mysql redis zip

Собирать vendor внутри Composer-стадии

Сейчас vendor копируется с хоста. Это создаёт риски:

  • зависимости могли быть собраны для другой платформы;
  • в образ могут попасть dev-пакеты;
  • сборка зависит от локального состояния;
  • увеличивается build context.

Лучше:

FROM composer:2 AS vendor-prod
WORKDIR /app

COPY composer.json composer.lock symfony.lock* ./

RUN composer install \
    --no-dev \
    --prefer-dist \
    --no-interaction \
    --no-progress \
    --no-scripts

После копирования исходников можно запустить нужные Composer scripts и прогрев кеша под прикладным пользователем. В финальный образ:

COPY --from=vendor-prod --chown=www-data:www-data /app/vendor /app/vendor

Для кеширования BuildKit ещё лучше:

RUN --mount=type=cache,target=/tmp/composer-cache \
    COMPOSER_CACHE_DIR=/tmp/composer-cache \
    composer install ...

Использовать COPY --chown

Вместо копирования файлов от root с последующим рекурсивным chown:

COPY --chown=www-data:www-data ./src /app/src
COPY --chown=www-data:www-data ./config /app/config

Исходный код можно оставить только для чтения, а права записи дать исключительно:

  • /app/var/cache;
  • /app/var/log;
  • /data/private;
  • /data/public.

Не копировать .env в production

Сейчас .env попадает внутрь образа. Если там находятся пароли или токены, они останутся в слоях образа. Production-настройки лучше передавать через:

  • переменные окружения;
  • Docker secrets;
  • Kubernetes Secrets;
  • секрет-хранилище платформы.

Убрать VOLUME из Dockerfile

Директивы:

VOLUME ["/app", ...]

не гарантируют права и могут создавать неожиданные анонимные volumes. Особенно нежелателен VOLUME ["/app"], который способен скрыть содержимое приложения.

Лучше описывать все mounts в Compose/Kubernetes. В Dockerfile оставить только создание каталогов.

Исправить права конфигурации PHP

chmod 550 -R /usr/local/etc/php

назначает исполняемый бит файлам .ini. Это не требуется. Подходящий режим:

RUN find /usr/local/etc/php -type d -exec chmod 0755 {} + \
    && find /usr/local/etc/php -type f -exec chmod 0444 {} +

Либо вообще оставить стандартные права после COPY.

Рекомендуемая структура стадий

Оптимальная схема:

  1. php-runtime — только PHP, runtime-библиотеки и расширения.
  2. composer-prod — установка production-зависимостей.
  3. dev — Composer, Git, отладочные настройки и bind-mount /app.
  4. prod — исходники и production vendor, без Git/Composer/compiler packages.
  5. В prod переключение на USER www-data:www-data.
  6. Прогрев кеша выполняется в build-стадии под www-data, если он не требует production-секретов или доступной БД.
  7. Иначе кеш прогревается отдельной non-root командой при деплое.

Также нужен .dockerignore, исключающий как минимум:

.git
data/
var/cache/
var/log/
node_modules/
.env.local
.env.*.local
vendor/

Главное ограничение: гарантировать запись в подключённые /data/* только средствами Dockerfile невозможно. Гарантия появляется из сочетания одинакового UID/GID, заранее подготовленных каталогов/volumes и запуска PHP-FPM, Composer и Symfony Console под одним non-root пользователем.