Перейти к содержимому
getnextpdf.com

Контейнеризация приложения NextPDF

Вам нужен небольшой, воспроизводимый образ Docker, который запускает нативный движок ядра NextPDF, работающий внутри процессаcomposer require nextpdf/core, генерируя PDF внутри вашего процесса PHP. Эта страница строит именно это: образ php:8.4 только с теми расширениями, которые движку реально нужны, без зависимостей разработки в финальном слое, с упакованными шрифтами, пользователем не из-под root, opcache, настроенным для продакшена, и шагом проверки, который проваливает сборку, если чего-то не хватает.

Эта страница только для нативного движка. Мост Chrome (writeHtmlChrome через nextpdf/artisan) и сервер Connect — это отдельные среды выполнения с собственными, более тяжёлыми образами: установка headless Chromium для моста, долгоживущая служба для Connect. Не добавляйте в этот образ браузер или сервер; нативному движку не нужен ни тот, ни другой.

Прежде чем начать, убедитесь, что эти части на месте:

  • У вашего приложения есть зафиксированные composer.json и composer.lock, с nextpdf/core в качестве зависимости.
  • У вас есть файлы шрифтов, которые вы намерены встраивать, и у вас есть право встраивать их.
  • Вы можете запустить docker build против каталога вашего приложения.

Это операционное руководство. PHP здесь почти нет; работа состоит в Dockerfile и нескольких настройках окружения.

Образ должен удовлетворять реальным платформенным ограничениям движка и не более того. Читая их прямо из пакета, nextpdf/core требует php: >=8.4 <9.0 и эти расширения PHP:

РасширениеЗачем оно движку
ext-mbstringОбработка многобайтовых строк для текста и кодировок
ext-intlПоддержка Unicode, локалей и интернационализации
ext-gdДекодирование и обработка растровых изображений
ext-opensslКриптография для подписания и безопасного хеширования
ext-zlibСжатие потоков (Flate) объектов PDF
ext-curlHTTP-клиент для исходящих вызовов движка

Сопоставьте их с официальным образом php:8.4. openssl, curl и zlib уже скомпилированы в официальном образе PHP, поэтому вы их не docker-php-ext-install. mbstring, gd и intl не входят в комплект и должны быть установлены, и каждому сначала нужны системные заголовки разработки — mbstring дополнительно нужна сборочная зависимость libonig-dev (Oniguruma). Не добавляйте расширения движка, которые пакет не перечисляет, — каждый лишний docker-php-ext-install — это время сборки и поверхность атаки, которые вам не нужны. Единственное расширение не из движка, которое этот образ устанавливает, — это opcache: это расширение производительности времени выполнения, не включённое включённым в официальном образе, и приведённая ниже настройка opcache зависит от его наличия (см. «Opcache для продакшена»).

Это двухэтапная сборка. Первый этап устанавливает зависимости Composer с исключёнными пакетами разработки; второй этап — это лёгкий runtime-образ, который поставляется.

Сначала добавьте .dockerignore рядом с Dockerfile. Его основная задача — полностью удержать окружение хоста — собранный на хосте vendor/, локальные секретные файлы и кэши сборки — вне контекста сборки, чтобы COPY . /var/www/app поставлял только то, что вы намерены: меньшие, более быстрые и более безопасные сборки, которые не могут утечь локальные секреты .env и не несут мегабайты хостового vendor/ в образ.

Исключение vendor/ важно ещё и потому, что COPY каталога — это слияние, а не замена. Приведённый ниже Dockerfile выполняет RUN rm -rf /var/www/app/vendor перед COPY --from=vendor ... /var/www/app/vendor, поэтому в этом образе хостовый vendor/ никогда не может уцелеть под чистым деревом зависимостей. Но если вы когда-либо уберёте эту защиту rm -rf, собранный на хосте vendor/ в контексте окажется первым, а копия с этапа vendor лишь перезапишет пути, которые содержит чистое дерево, — любые лишние хостовые файлы (устаревший или dev-установленный пакет, осиротевший класс) затем уцелеют под ней. Удержание vendor/ вне контекста закрывает эту брешь независимо от rm -rf.

# .dockerignore — keep the host environment out of the build context.
vendor/
.git/
.env
.env.local
.env.*.local
var/cache/
storage/
node_modules/
*.log

Исключайте реальные локальные секретные файлы (.env, .env.local, .env.*.local), а не сплошной .env.* — этот шаблон также отбрасывает несекретные шаблоны вроде .env.example, которые вы хотите поставить, чтобы образ нёс документированную базовую конфигурацию. Держите в контексте любой зафиксированный несекретный env-шаблон; исключайте только файлы, которые действительно содержат локальные секреты.

# syntax=docker/dockerfile:1
# ---- Stage 1: dependencies (no dev) ---------------------------------------
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
# Install production dependencies only. --no-dev excludes phpunit, phpstan,
# infection, and the other require-dev tooling from the shipped image.
# --optimize-autoloader builds a class map for the *vendor* tree here; the
# application's own classes are not present in this stage yet, so they are
# optimized after the source copy in the runtime stage (see below).
RUN composer install \
--no-dev \
--no-interaction \
--no-progress \
--prefer-dist \
--optimize-autoloader \
--no-scripts
# ---- Stage 2: runtime ------------------------------------------------------
FROM php:8.4-cli AS runtime
# System headers for the gd, intl, and mbstring extensions that need compiling.
# The PHP image already provides openssl, curl, and zlib, so those are NOT
# listed; gd, intl, and mbstring are installed below. opcache has no system
# headers and is installed in the same step. mbstring is built against
# Oniguruma, so libonig-dev is in the *-dev set and its runtime lib (libonig5)
# is preserved by the same detection below.
#
# Build the *-dev headers (which pull in the runtime libs), compile the
# extensions, then mark only the runtime shared libraries the extensions
# actually link against so they survive the --auto-remove purge of the headers.
# Removing libicu / libpng / libjpeg / libfreetype / libonig here would unlink
# intl.so, gd.so, or mbstring.so at runtime ("undefined symbol" / "cannot open
# shared object file").
RUN set -eux; \
savedAptMark="$(apt-mark showmanual)"; \
apt-get update; \
apt-get install -y --no-install-recommends \
libicu-dev \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libonig-dev; \
docker-php-ext-configure gd --with-freetype --with-jpeg; \
docker-php-ext-install -j"$(nproc)" gd intl mbstring opcache; \
# Detect the runtime .so dependencies of the just-built extensions and
# mark them manual so --auto-remove keeps them while dropping the headers.
apt-mark auto '.*' > /dev/null; \
apt-mark manual $savedAptMark > /dev/null; \
find /usr/local/lib/php/extensions -type f -name '*.so' -exec \
sh -c 'ldd "$1" 2>/dev/null \
| awk "/=>/ { print \$3 }" \
| grep -E "^/" \
| xargs -r dpkg-query -S 2>/dev/null \
| cut -d: -f1 \
| sort -u \
| xargs -r apt-mark manual' _ {} \; ; \
apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \
rm -rf /var/lib/apt/lists/*
# Production opcache settings (see the opcache section below). The opcache
# extension is installed above (docker-php-ext-install opcache); this file only
# tunes it.
COPY docker/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# A static Composer binary for the one optimized-autoloader rebuild below. It is
# copied into the build but the final stage runs no Composer at request time.
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/app
# Application code, then the vendor tree from the dependency stage. The .dockerignore
# should already keep a host vendor/ out of the context; the rm here is a second line
# of defense so a stale host-built vendor/ can never merge under the clean one (a
# directory COPY merges, it does not replace).
COPY . /var/www/app
RUN rm -rf /var/www/app/vendor
COPY --from=vendor /app/vendor /var/www/app/vendor
# Now that the application source is present, regenerate the optimized class map
# so the APP's own classes are in the optimized autoloader, not just the vendor
# packages. --no-dev keeps require-dev out; --no-scripts avoids running
# application hooks during the image build.
RUN composer dump-autoload \
--optimize \
--no-dev \
--no-interaction \
--no-scripts \
&& rm -f /usr/bin/composer
# The bundled fonts live at /var/www/app/resources/fonts. The native engine does
# NOT read any font-path environment variable — the entrypoint registers that
# directory in PHP (see "Bundle fonts into the image" below). There is no ENV
# line for fonts here.
# Run as a non-root user (see the non-root section below).
RUN useradd --system --no-create-home --uid 10001 appuser \
&& chown -R appuser:appuser /var/www/app
USER appuser
CMD ["php", "bin/generate.php"]

Этап зависимостей выполняется с --no-scripts, чтобы никакой post-install-хук приложения не запускался против неполного дерева; запускайте любой шаг сборки приложения (компиляцию ассетов, прогрев кэша) на более позднем этапе после копирования кода.

Многоэтапная установка Composer (без dev-зависимостей)

Заголовок раздела «Многоэтапная установка Composer (без dev-зависимостей)»

Поставляемый образ не должен содержать инструментов разработки. Флаг --no-dev для composer install — несущая строка: он пропускает всё под require-dev в nextpdf/core и вашем приложении — тестовый запускатель, статический анализатор и инструменты мутаций, — ничему из чего нет места в продакшене. Парьте его с --optimize-autoloader, чтобы автозагрузчик был сгенерированной картой классов, а не сканированием файловой системы на каждый запрос.

Копируйте composer.json и composer.lock перед остальным исходным кодом, чтобы Docker кэшировал слой зависимостей и перерешал, только когда меняется lock-файл. Поскольку та первая установка выполняется против одного lock-файла — без исходного кода приложения — --optimize-autoloader там строит карту классов только для vendor-дерева; собственных классов вашего приложения ещё нет. Именно поэтому runtime-этап один раз выполняет composer dump-autoload --optimize --no-dev --no-scripts после копирования исходного кода: это вкладывает классы приложения в ту же оптимизированную карту классов. Не запускайте отдельный composer dump-autoload в рабочем дереве, в котором вы также разрабатываете (это зафиксировало бы продакшен-карту классов в dev-дерево); перестроение принадлежит образу, после копирования исходного кода, как показано выше.

Нативный движок разрешает шрифты из файлов шрифтов, которые он может прочитать, а не из установленных в ОС шрифтов. Установка пакетов fonts-* или запуск fc-cache не делает ничего, что нативный путь может увидеть, поэтому этот образ не устанавливает системных шрифтов. Упакуйте свои файлы .ttf / .otf под resources/fonts/; приведённый выше COPY . /var/www/app уже переносит их в образ.

Доставка файлов в образ — это лишь половина работы. Голый нативный движок читает никакую переменную окружения поиска шрифтов — NEXTPDF_FONTS_PATH — это значение по умолчанию ключа конфигурации fonts_path пакета nextpdf/laravel (env('NEXTPDF_FONTS_PATH', resource_path('fonts'))), и оно потребляется только этой интеграцией с фреймворком, а не nextpdf/core. Простая точка входа php bin/generate.php только с этой установленной переменной не регистрирует никаких шрифтов и отрисовывает тот самый «тофу», для предотвращения которого существует этот образ. Точка входа должна зарегистрировать упакованный каталог в PHP:

use NextPDF\Typography\FontRegistry;
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
// Register the directory the Dockerfile bundled the fonts into.
$registry = new FontRegistry('/var/www/app/resources/fonts');
// (equivalently, $registry->addFontDirectory('/var/www/app/resources/fonts');)
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
$doc = $factory->create();

Это вся забота Docker о шрифтах. Правила именования файлов, API реестра, паттерн прогрева-и-блокировки и обработка файловой системы только для чтения — всё это живёт на выделенной странице — не дублируйте их здесь. Прочитайте Подготовка шрифтов для нативного движка в продакшене для полного паттерна и зарегистрируйте тот же каталог, который упаковали.

Официальные образы PHP по умолчанию работают от root. Генератору PDF root не нужен, поэтому создайте непривилегированного пользователя и переключитесь на него. Приведённый выше Dockerfile добавляет системного пользователя appuser с фиксированным высоким UID (10001), даёт ему владение деревом приложения и заканчивается USER appuser, чтобы каждый процесс, запускаемый контейнером, был непривилегированным.

Держите приложение только для чтения во время выполнения, где можете. Движок читает свои файлы шрифтов и записывает только свой вывод и необязательный кэш разобранных шрифтов, поэтому контейнер readOnlyRootFilesystem работает, пока путь вывода и любой каталог кэша — это записываемые монтирования. Сочетайте это со сброшенными возможностями Linux и флагом no-new-privileges в вашем оркестраторе для глубокоэшелонированной защиты.

Opcache окупается для долгоживущих воркеров PHP — пула FPM или процесса Apache mod_php, который обслуживает много запросов из одного прогретого процесса. Эти процессы компилируют ваши классы один раз и затем никогда не делают stat исходных файлов на горячем пути, что и даёт вам именно opcache.validate_timestamps=0. Opcache не включён из коробки в официальном образе php:8.4, поэтому приведённый выше Dockerfile устанавливает его с docker-php-ext-install opcache (вы можете равнозначно docker-php-ext-enable opcache, если расширение уже скомпилировано). Приведённый ниже файл conf.d — это настройка, а не шаг включения — он ничего не делает, пока расширение не загружено. Поставляйте его как включение conf.d (docker/opcache.ini, скопированное в Dockerfile):

opcache.enable=1
opcache.enable_cli=0
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0

opcache.validate_timestamps=0 означает, что кэш никогда не перепроверяет исходные файлы — правильно для неизменяемого образа, поскольку единственный способ изменить код — это новый образ. Настройте memory_consumption и max_accelerated_files под число классов вашего приложения.

Показанный CMD — это одноразовый CLI-генератор, и opcache.enable_cli=0 для него правилен. Короткоживущий процесс php bin/generate.php стартует, компилирует, отрисовывает один раз и завершается, поэтому кэш опкодов, которым он не может поделиться со следующим запросом, не даёт выгоды — оставьте CLI-opcache выключенным и не платите его затрат по памяти. Opcache оправдывает себя только там, где процесс переиспользуется: SAPI FPM/Apache или по-настоящему долгоживущий CLI-воркер (потребитель очереди или сервер в стиле RoadRunner). Только такого рода резидентный CLI-воркер задал бы opcache.enable_cli=1; для одноразового генератора здесь оставьте 0.

Если вы всё же запускаете конфигурацию, использующую предзагрузку opcache (долгоживущий воркер FPM со скриптом opcache.preload), задайте opcache.preload=/path/to/preload.php и добавьте opcache.preload_user=appuser, чтобы предзагрузка выполнялась от непривилегированного пользователя. Без фактического скрипта opcache.preload opcache.preload_user ничего не делает, поэтому его нет в базовой конфигурации выше — не добавляйте его, если вы также не задаёте opcache.preload.

Добавьте шаг проверки, чтобы неправильно собранный образ давал громкий сбой, вместо того чтобы выдавать «тофу» или фатальную ошибку на первом запросе. NextPDF поставляет CLI, чья команда doctor инспектирует работающую среду PHP и сообщает именно о тех расширениях, которые волнуют движок — openssl, zlib, mbstring, gd, curl и intl. Пакет объявляет "bin": ["bin/nextpdf"], поэтому в потребляющем приложении Composer устанавливает исполняемый файл по пути vendor/bin/nextpdf (а не bin/nextpdf, который является путём внутри самого пакета nextpdf/core). Запустите его внутри собранного образа:

Окно терминала
docker run --rm your-app:latest php vendor/bin/nextpdf doctor

Здоровый результат подтверждает, что PHP 8.4 и каждое требуемое расширение загружены. Подключите тот же вызов в сборку (или дымовое CI-задание), чтобы отсутствующее расширение останавливало конвейер:

Окно терминала
# Fail the pipeline if the engine's environment is not healthy.
docker run --rm your-app:latest php vendor/bin/nextpdf doctor || exit 1

Для сквозной проверки отрисуйте одну страницу через свою собственную точку входа и утвердите вывод, как описывает страница о шрифтах для дымовой проверки шрифта.

  • php:8.4-fpm или -apache вместо -cli. Используйте SAPI, под которым ваше приложение действительно обслуживается. Список расширений идентичен; различаются только базовый тег и CMD/точка входа. Для воркера очереди или CLI-пакетного задания -cli правилен.
  • Alpine (php:8.4-alpine) нужны другие имена пакетов. Приведённые выше строки apt-get — для образа по умолчанию на базе Debian. На Alpine устанавливайте заголовки *-dev как виртуальную сборочную группу (apk add --no-cache --virtual .build-deps icu-dev libpng-dev freetype-dev libjpeg-turbo-dev oniguruma-dev) и, после шага docker-php-ext-install gd intl mbstring opcache, apk del .build-deps — но сначала apk add --no-cache runtime-библиотеки, с которыми связываются расширения (icu-libs, libpng, freetype, libjpeg-turbo, oniguruma), чтобы удаление сборочной группы не разлинковало intl.so / gd.so / mbstring.so. Это то же правило «сохрани runtime-библиотеки», которое блок Debian обеспечивает через apt-mark.
  • Не устанавливайте пакеты fonts-*. Они невидимы нативному движку. Вместо этого упаковывайте файлы шрифтов — см. страницу о шрифтах по ссылке выше.
  • Premium и ionCube — это забота другого образа. Сборки NextPDF Pro / Enterprise, закодированные ionCube, нуждаются в установленном в образе загрузчике ionCube, согласованном с точной сборкой PHP контейнера (8.4, NTS против ZTS). Это вне области образа ядра; если вы развёртываете premium, следуйте разделу Docker в Настройке загрузчика ionCube.
  • Держите хостовый vendor/ вне контекста сборки. .dockerignore (исключающий vendor/, .git/ и локальные кэши) полностью удерживает хостовое дерево вне контекста — это и делает сборку малой, быстрой и свободной от утёкших локальных секретов. Он также защищает случай слияния каталогов: COPY каталога — это слияние, а не замена, поэтому собранный на хосте vendor/, достигший контекста, окажется первым, а COPY --from=vendor /app/vendor /var/www/app/vendor лишь перезапишет пути, которые содержит чистое дерево зависимостей. В этом Dockerfile RUN rm -rf /var/www/app/vendor перед копией vendor уже удаляет любой такой каталог, поэтому здесь этот осадок не может возникнуть; риск слияния возвращается, только если вы убираете защиту rm -rf, поэтому исключение в .dockerignore — это устойчивое исправление.
  • Не поставляйте dev-зависимости. --no-dev держит инструменты тестирования и анализа, и их транзитивные пакеты, вне runtime-образа и его поверхности атаки.
  • Запускайте непривилегированно. Финальный USER appuser гарантирует, что никакой процесс контейнера не работает от root. Сочетайте его с файловой системой root только для чтения и сброшенными возможностями в вашем оркестраторе.
  • Закрепите базовый образ. Закрепите php:8.4 на дайджест в продакшене, чтобы пересборка не могла молча вытянуть изменённую базу, и пересобирайте по графику, чтобы намеренно подхватывать патчи безопасности.
  • Держите шрифты и лицензии вне публичных слоёв. Упаковывайте только шрифты, которые вы имеете право встраивать, и никогда не запекайте файл премиум-лицензии в публично пушимый образ — вместо этого монтируйте его во время выполнения.

Это руководство не делает нормативного заявления о стандартах. Платформенные факты читаются прямо из пакета nextpdf/core: ограничение php: >=8.4 <9.0 и требуемые расширения ext-mbstring, ext-intl, ext-gd, ext-openssl, ext-zlib и ext-curl. Команда проверки — это реальный обработчик doctor CLI nextpdf — объявленный как "bin": ["bin/nextpdf"] в nextpdf/core и потому установленный по пути vendor/bin/nextpdf в потребляющем приложении — который сообщает о том же наборе расширений. Нативный движок регистрирует шрифты через NextPDF\Typography\FontRegistry (аргумент-каталог конструктора / addFontDirectory()), подключённый через NextPDF\Core\DocumentFactory; NEXTPDF_FONTS_PATH — это ключ конфигурации fonts_path пакета nextpdf/laravel (env('NEXTPDF_FONTS_PATH', resource_path('fonts'))), а не переменная, которую читает nextpdf/core. Поведение реестра документировано на странице о шрифтах по ссылке в разделе «См. также».