容器化 NextPDF 应用
你想要一个小巧、可重现的 Docker 镜像,运行原生的、进程内的
NextPDF 核心引擎 —— composer require nextpdf/core,在你的 PHP
进程内生成 PDF。本页正好构建那个:一个 php:8.4
镜像,仅装引擎实际需要的扩展、最终层里没有开发依赖、捆绑的字体、一个非 root
运行时用户、为生产调好的 opcache,以及一个在缺东西时让构建失败的验证步骤。
本页仅针对原生引擎。Chrome 桥
(通过 nextpdf/artisan 的 writeHtmlChrome)和 Connect
服务器是各自独立的运行时,带有它们自己的、更重的镜像 ——
桥需要一个无头 Chromium 安装,Connect 需要一个长期运行的服务。不要往这个镜像里加浏览器或服务器;原生引擎两者都不需要。
开始之前,确认这些部分都已就位:
- 你的应用有已提交的
composer.json和composer.lock,并把nextpdf/core作为一个依赖。 - 你有打算嵌入的字体文件,并且你有权嵌入它们。
- 你能针对你的应用目录运行
docker build。
这是一篇运维 how-to。这里几乎没有 PHP;工作在于 Dockerfile 和少量环境设置。
引擎实际需要什么
标题为“引擎实际需要什么”的章节镜像必须满足引擎真正的平台约束,仅此而已。直接从包里读出来,nextpdf/core 要求
php: >=8.4 <9.0 以及这些 PHP 扩展:
| 扩展 | 引擎为何需要它 |
|---|---|
ext-mbstring | 文本和编码的多字节字符串处理 |
ext-intl | Unicode、区域设置和国际化支持 |
ext-gd | 栅格图像解码和处理 |
ext-openssl | 用于签名和安全哈希的密码学 |
ext-zlib | PDF 对象的流(Flate)压缩 |
ext-curl | 引擎出站调用的 HTTP 客户端 |
把这些映射到官方 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”)。
生产 Dockerfile
标题为“生产 Dockerfile”的章节这是一个两阶段构建。第一阶段在排除开发包的情况下安装 Composer 依赖;第二阶段是要发布的精简运行时镜像。
首先在 Dockerfile 旁边加一个 .dockerignore。它的首要任务是把宿主环境 ——
一个宿主构建的 vendor/、本地密钥文件和构建缓存 ——
彻底排除在构建上下文之外,这样 COPY . /var/www/app 只发布你想要的内容:更小、更快、更安全的构建,既不会泄露本地
.env 密钥,也不会把数兆的宿主 vendor/ 带进镜像。
排除 vendor/ 也很重要,因为一个目录 COPY 是一次合并,而非替换。下面的
Dockerfile 在 COPY --from=vendor ... /var/www/app/vendor 之前运行
RUN rm -rf /var/www/app/vendor,因此在这个镜像里,一个宿主 vendor/
永远无法在干净的依赖树之下幸存。但如果你哪天移除了那道 rm -rf
保险,上下文里一个宿主构建的 vendor/
会先落地,而 vendor 阶段的复制只会覆盖干净树所含的那些路径 —— 任何多出的宿主文件(一个陈旧或开发期安装的包、一个孤立的类)便会在其下幸存。把
vendor/ 排除在上下文之外,无论 rm -rf 在不在,都堵住那个洞。
# .dockerignore — keep the host environment out of the build context.vendor/.git/.env.env.local.env.*.localvar/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 /appCOPY 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/appRUN rm -rf /var/www/app/vendorCOPY --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/appUSER appuser
CMD ["php", "bin/generate.php"]依赖阶段以 --no-scripts 运行,这样没有任何应用 post-install
钩子会针对一个不完整的树运行;把任何应用构建步骤(资产编译、缓存预热)放到代码复制之后的一个后续阶段里运行。
多阶段 Composer 安装(无开发依赖)
标题为“多阶段 Composer 安装(无开发依赖)”的章节要发布的镜像绝不能包含开发工具。composer install 上的 --no-dev
标志是承重的那一行:它跳过 nextpdf/core
和你应用里 require-dev 下的一切 —— 测试运行器、静态分析器和变异工具 ——
它们在生产里都没有立足之地。把它和 --optimize-autoloader
配对使用,让自动加载器是一份生成好的类映射,而不是每请求一次的文件系统扫描。
把 composer.json 和 composer.lock 复制在源码其余部分之前,这样
Docker 缓存依赖层,并且只在锁文件改变时才重新解析。因为那第一次安装是仅针对锁文件运行的 ——
没有应用源码 —— 那里的 --optimize-autoloader 只为
vendor 树构建类映射;你应用自己的类还不在场。这就是为什么运行时阶段在复制源码之后运行一次
composer dump-autoload --optimize --no-dev --no-scripts:它把应用的类折叠进同一份优化后的类映射。不要在一个你同时开发的
worktree 里运行单独的 composer dump-autoload(它会把一份生产类映射提交进一个开发树);这次重建属于镜像之内,在源码复制之后,如上所示。
把字体捆绑进镜像
标题为“把字体捆绑进镜像”的章节原生引擎从它能读取的字体文件解析字体,而非从操作系统安装的字体。安装
fonts-* 包或运行 fc-cache 对原生路径没有任何可见作用,因此这个镜像不安装任何系统字体。把你的
.ttf / .otf 文件捆绑在 resources/fonts/ 下;上面的 COPY . /var/www/app
已经把它们带进了镜像。
把文件弄进镜像只是工作的一半。裸的原生引擎不读取任何字体搜索环境变量 ——
NEXTPDF_FONTS_PATH 是 nextpdf/laravel 包 fonts_path 配置键的默认值
(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、预热并锁定模式,以及只读文件系统处理都存在于专门的页面上 —— 不要在这里重复它们。阅读 在生产环境为原生引擎配置字体 了解完整模式,并注册你捆绑的同一个目录。
以非 root 用户运行
标题为“以非 root 用户运行”的章节官方 PHP 镜像默认以 root 运行。一个 PDF 生成器不需要
root,因此创建一个非特权用户并切换到它。上面的 Dockerfile 添加了一个系统用户
appuser,带一个固定的高 UID(10001),把应用目录树的所有权交给它,并以
USER appuser 结尾,这样容器启动的每个进程都是非特权的。
在你能做到的地方,让应用在运行时保持只读。引擎读取它的字体文件,只写出它的输出和一个可选的解析后字体缓存,因此只要输出路径和任何缓存目录是可写挂载,一个
readOnlyRootFilesystem 容器就能工作。把这与丢弃的 Linux 权能以及你编排器里的一个
no-new-privileges 标志结合起来,做纵深防御。
面向生产的 opcache
标题为“面向生产的 opcache”的章节opcache 在长寿命 PHP worker 上才划算 —— 一个 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=1opcache.enable_cli=0opcache.memory_consumption=192opcache.interned_strings_buffer=16opcache.max_accelerated_files=20000opcache.validate_timestamps=0opcache.validate_timestamps=0 意味着缓存从不重新检查源文件 ——
对一个不可变镜像是正确的,因为代码改变的唯一方式是一个新镜像。把
memory_consumption 和 max_accelerated_files 调到你应用的类数量。
所示的 CMD 是一个一次性的 CLI 生成器,对它而言 opcache.enable_cli=0
是正确的。 一个短寿命的 php bin/generate.php
进程启动、编译、渲染一次然后退出,因此一份它无法与下一个请求共享的操作码缓存毫无收益 ——
让 CLI opcache 保持关闭,不付它任何内存成本。opcache
只在进程被复用的地方才挣回它的本钱:一个 FPM/Apache SAPI,或一个真正长期运行的
CLI worker(一个队列消费者或一个 RoadRunner 风格的服务器)。只有那种常驻 CLI
worker 才会设 opcache.enable_cli=1;对于这里这个一次性生成器,把它保持为
0。
如果你确实运行一个使用 opcache 预加载的配置(一个带 opcache.preload
脚本的长寿命 FPM worker),就设 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/入口点不同。对于一个队列 worker 或一个 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那些扩展所链接的运行时 库(icu-libs、libpng、freetype、libjpeg-turbo、oniguruma),这样删除构建组就不会取消链接intl.so/gd.so/mbstring.so。这与 Debian 块用apt-mark强制执行的保留运行时库规则是同一条。 - 不要安装
fonts-*包。 它们对原生引擎不可见。改为捆绑字体文件 —— 参见上面链接的字体页。 - Premium 和 ionCube 是一个不同的镜像关切。 ionCube 编码的 NextPDF Pro / Enterprise 构建需要在镜像里安装 ionCube Loader 并与容器的确切 PHP 构建(8.4,NTS 对 ZTS)相匹配。那超出一个核心镜像的范围;如果你部署 premium,请遵循 ionCube Loader 设置 的 Docker 部分。
- 把宿主
vendor/排除在构建上下文之外。.dockerignore(排除vendor/、.git/和本地缓存)把宿主树彻底排除在上下文之外 —— 这正是让构建小、快且没有泄露的本地密钥的原因。它也守住目录合并情形:一个目录COPY是一次合并,而非替换,因此一个到达上下文的宿主构建vendor/会先落地,而COPY --from=vendor /app/vendor /var/www/app/vendor只会覆盖干净依赖树所含的那些路径。在这个 Dockerfile 里,vendor 复制之前的RUN rm -rf /var/www/app/vendor已经移除任何这样的目录,因此那种残留在这里不会发生;只有当你丢掉那道rm -rf保险时,合并风险才会回来,这就是为什么.dockerignore的排除才是持久的修复。
安全注意
标题为“安全注意”的章节- 不发布开发依赖。
--no-dev把测试和分析工具及其传递包排除在运行时镜像及其攻击面之外。 - 以非特权运行。 最后的
USER appuser确保没有容器进程以 root 运行。把它与一个只读根文件系统和你编排器里丢弃的权能配对使用。 - 固定基础镜像。 在生产里把
php:8.4固定到一个摘要,这样一次重建就不会悄悄拉到一个变了的基础,并按一个节奏重建以有意地拾取安全补丁。 - 把字体和许可排除在公开层之外。 只捆绑你有权嵌入的字体,并且绝不要把一个 premium 许可文件烤进一个公开推送的镜像 —— 改为在运行时挂载它。
合规性
标题为“合规性”的章节本指南不作任何规范性标准主张。平台事实直接从 nextpdf/core
包里读出:php: >=8.4 <9.0 约束以及所需扩展 ext-mbstring、ext-intl、ext-gd、ext-openssl、
ext-zlib 和 ext-curl。验证命令是真正的 nextpdf CLI
doctor 处理器 —— 在 nextpdf/core 里声明为 "bin": ["bin/nextpdf"],因而在一个消费它的应用里装在
vendor/bin/nextpdf —— 它就同一组扩展报告。原生引擎通过 NextPDF\Typography\FontRegistry(目录构造参数 /
addFontDirectory())注册字体,并经由
NextPDF\Core\DocumentFactory 接入;NEXTPDF_FONTS_PATH 是 nextpdf/laravel
包的 fonts_path 配置键(env('NEXTPDF_FONTS_PATH', resource_path('fonts'))),不是一个 nextpdf/core 读取的变量。注册表行为记录在
See also 下链接的字体页上。
- 在生产环境为原生引擎配置字体:这个镜像所依赖的字体文件命名、注册表 API 以及预热并锁定模式。
- 把一个大的生成 PDF 作为 HTTP 响应流式发送:从框架控制器服务一个已构建文档的内存模型。
- 用 Cloudflare 在边缘渲染:当一个进程内容器不是合适的运行时时。
- ionCube Loader 设置:ionCube 编码 premium 构建的那个独立镜像关切。