跳转到内容
getnextpdf.com

在 serverless 平台上运行 NextPDF

原生的、进程内运行的 NextPDF 核心引擎是几乎理想的 serverless 工作负载。它是在你的进程内运行的纯 PHP——composer require nextpdf/core,构建一份文档,取回字节即可。没有需要派生的外部二进制程序,没有无头浏览器,没有需要保活的守护进程,也没有连向旁车(sidecar)服务的 socket。一个构建 PDF 的函数冷启动、运行你的 PHP、返回字节,然后退出。这干净地对应到 AWS Lambda(通过 Bref 运行时)、Google Cloud Run 与 AWS App Runner。

本页涵盖如何把那个原生引擎部署到这三种运行时,以及它们所施加的一小组真实约束:

  • 运行时文件系统不是持久的:Lambda 只保证一个可写的 /tmp,而容器运行时(Cloud Run、App Runner)拥有一个临时的、容器作用域的文件系统——无论哪种方式,字体都必须随部署包或镜像一起携带,并在 PHP 中注册(引擎不读取任何字体路径环境变量);
  • 冷启动会为自动加载和任何字体预热付出代价,因此每个容器只预热一次 FontRegistry,而不是每次调用都预热;
  • 包体积、内存与超时必须按构建本身来设定规格,而不是按一个琐碎请求来设定。

本页针对原生引擎。Chrome 桥接(通过建议的 nextpdf/artisan 包使用 writeHtmlChrome)则是另一个更重的故事:它通过 symfony/process 外壳调用一个无头 Chromium,而一个普通的 Lambda zip 或精简容器并不包含它。在 Lambda 上运行 Chromium 意味着需要一个带浏览器及其共享库的自定义层、大得多的包,以及长得多的冷启动——超出本页范围。裸引擎不需要这些。

开始之前,请确认这些部件已就位:

  • 你的应用已提交 composer.jsoncomposer.lock,并且 nextpdf/core 为其依赖项。
  • 你拥有打算嵌入的字体文件,并且你有权嵌入它们。
  • 你拥有目标平台的工具链——Lambda 用 Bref CLI 与 serverless 框架,或 Cloud Run / App Runner 用容器构建。

直接从包中读取,nextpdf/core 要求 php: >=8.4 <9.0 以及一小组 PHP 扩展——ext-mbstringext-intlext-gdext-opensslext-zlibext-curl。标准 Bref PHP 层捆绑了其中的每一个。官方 php:8.4 容器镜像开箱提供 opensslcurlzlib,但 mbstringgdintl 被捆绑——它们需要安装系统依赖项,并用 docker-php-ext-install 启用扩展(参见 Docker 部署指南)。在 Bref 上没有什么古怪的东西需要编译;在容器路径上,你为引擎在镜像构建时启用那三个扩展。

让这种契合干净利落的,是引擎做的那些事:

  • 核心路径无子进程。 构建一份文档并调用 getPdfData() 自始至终都是进程内 PHP。symfony/process 依赖项的存在是为了可选的 Chrome 桥接,而不是为了原生渲染——原生 PDF 生成从不派生进程。
  • 无持久状态。 每次调用都构建一份全新文档并返回字节。除了温热的容器之外,没有任何东西必须在请求之间存活;你会利用那个温热容器来做字体预热(见下文),但绝不为正确性而依赖它。
  • 无需可写工作目录。 引擎在内存中构建 PDF 并将其作为字符串返回;只有当调用 save() 时它才会触碰磁盘。在 serverless 上你不会这么做——你返回字节——因此缺少持久文件系统从不会咬到构建路径。

唯一的硬约束:没有持久可写文件系统

标题为“唯一的硬约束:没有持久可写文件系统”的章节

部署文件系统不是持久的,但模型因运行时而异。AWS Lambda 只保证一个可写的 /tmp(默认 512 MB,可配置至最高 10 GB);函数文件系统的其余部分是只读的。容器运行时(Cloud Run、App Runner)拥有一个临时的、容器作用域的可写文件系统,而不是仅有 /tmp 的模型——但写到那里的任何东西都会在容器被回收时丢失,因此它是临时草稿空间,而非存储。在每种情况下,都优先使用 /tmp 或一个已配置的卷来暂存,并且绝不把对应用镜像路径的写入当作持久存储来依赖。由此产生两个后果。

绝不要指望 save() 产生持久输出。 NextPDF\Core\Document 同时暴露 save(string $path): voidgetPdfData(): string。在 serverless 上你使用 getPdfData() 并返回或上传字节——不要把对应用目录的写入当作持久存储。如果你必须暂存一个文件(例如,为了分块上传到对象存储),请写到 /tmp(或一个已配置的卷)之下并做清理,同时记住:在温热容器上,这块草稿空间会跨调用持续存在,并计入其容量上限。

use NextPDF\Core\Document;
// Right for serverless: get the bytes, return or upload them.
$pdf = $document->getPdfData(); // string of PDF bytes, built in memory
// Avoid on serverless: save() writes to disk. On Lambda the application
// directory is read-only; on Cloud Run / App Runner it is writable but
// ephemeral (lost on container recycle). Neither is durable storage.
// $document->save('/var/task/out.pdf'); // not durable — return the bytes instead

不要在运行时安装 OS 字体,也不要依赖自动字体发现;请为生产环境捆绑你的字体文件。 在 Lambda 上,只读文件系统会直接阻止 apt-get install fonts-*;在容器运行时上,任何运行时安装都会落在一个临时文件系统上,并在下一次回收时丢失。况且这本来也无济于事,因为原生引擎不读取任何 OS/fontconfig 字体——它只从你注册的文件中解析字体。所以对生产环境而言,字体文件必须随部署制品一起出货。如果你刻意把字体文件拉取到 /tmp 或一个已配置的卷中,你必须用字体注册表显式注册它们,并接受随之而来的冷启动与可靠性代价——这不是推荐的生产模式。

原生引擎通过 NextPDF\Typography\FontRegistry字体文件解析字体,而不是从 fontconfig 或 OS 安装的字体解析。在 serverless 上这是不可妥协的:部署后没有持久文件系统可供放置字体,因此它们要随包(一个 Lambda zip 或层)或随镜像(Cloud Run / App Runner)一起出货。

把你的 .ttf / .otf / .ttc 文件捆绑到项目中的某个目录下——惯例是 resources/fonts/——以便它们被包含进制品中。然后在 PHP 中注册那个目录。引擎读取任何字体路径环境变量:NEXTPDF_FONTS_PATHnextpdf/laravelfonts_path 配置键的默认值(env('NEXTPDF_FONTS_PATH', resource_path('fonts'))),仅由那个框架集成消费,而不是由 nextpdf/core 消费。一个裸函数必须用捆绑的目录来构造注册表:

use NextPDF\Typography\FontRegistry;
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
// Register the directory the deployment artifact bundled the fonts into.
// On Lambda/Bref the code root is /var/task; adjust for your runtime.
$registry = new FontRegistry(__DIR__ . '/resources/fonts');
// (equivalently, $registry->addFontDirectory(__DIR__ . '/resources/fonts');)
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
$document = $factory->create();

这就是 serverless 关于字体的全部关切。文件命名规则、完整的注册表 API,以及非持久文件系统的处理,都在专门的页面上——不要在这里重复。请阅读 在生产环境中为原生引擎配置字体 以获得完整模式,并注册你所捆绑的同一个目录。Docker 部署指南 涵盖了 Cloud Run / App Runner 情形下等价的镜像侧捆绑。

冷启动:每个容器只预热一次 FontRegistry

标题为“冷启动:每个容器只预热一次 FontRegistry”的章节

冷启动会为 PHP 引导、Composer 的优化自动加载器,以及首次构建触发的任何字体解析付出代价。你无法避免引导,但你可以把字体工作移出热路径,并跨温热调用复用它。

FontRegistryDocumentFactory 构造一次,放在 handler 之外,让它们与容器同寿,并在每次温热调用时被复用。可选地用你确知会用到的字体文件调用 warmup(),让它们在初始化期间被解析,而不是在首次渲染时才解析,随后 lock() 注册表,使其已解析的状态被冻结,从而没有任何按调用的变更可能产生竞争:

use NextPDF\Typography\FontRegistry;
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
// Container-scoped, built once at cold start (module scope, not per request).
$fontsDir = __DIR__ . '/resources/fonts';
$registry = new FontRegistry($fontsDir);
// Parse the fonts you will actually use now, so the first render does not.
$registry->warmup([
$fontsDir . '/liberation/LiberationSans-Regular.ttf',
$fontsDir . '/liberation/LiberationSans-Bold.ttf',
]);
// Freeze the parsed state for the life of the warm container.
$registry->lock();
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
// Each invocation: fresh document from the shared, warm factory.
$handler = static function (array $event) use ($factory): string {
$document = $factory->create();
$document->addPage();
$document->cell(0, 10, 'Hello from serverless', newLine: true);
return $document->getPdfData();
};

要在 lock() 之前调用 warmup()——注册表一旦被锁定就会冻结,因此在那之后的预热会引发配置错误。请把在预热时加载失败的字体视为部署时错误,而非运行时细节:在启动时校验你打算预热的每个字体路径确实存在且能被解析,若有任何一个不成立就让部署(或你的健康检查)失败,而不要让一个打错的路径稍后才以缺失字形的形式浮现。把预热列表保持为典型调用所需的字体;预热一个你很少使用的大字体家族只会拉长每一次冷启动。

Bref 以已发布的层和一个 serverless.yml 插件的形式,为 Lambda 提供 PHP 运行时。php-84 运行时已经出货 nextpdf/core 所需的扩展,因此你部署你的代码与字体,并把一个函数指向某个 handler。一个最小的 serverless.yml

service: nextpdf-serverless
provider:
name: aws
region: us-east-1
runtime: provided.al2023
plugins:
- ./vendor/bref/bref
functions:
generate:
handler: handler.php
description: Generate a PDF with the native NextPDF engine
runtime: php-84
memorySize: 1024 # size to the build; see "Sizing" below
timeout: 30 # seconds; raise for large documents
# The Lambda filesystem is read-only except /tmp. Fonts ship in the
# package under resources/fonts and are registered in the handler.

handler 用温热的、容器作用域的工厂构建文档并返回字节。对于 HTTP API,把它们以 base64 编码、配上 application/pdf 内容类型返回,以便 API Gateway 将主体视为二进制;对于 invoke 或队列触发器,把字节上传到对象存储并返回 key:

handler.php (outline)
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Cold-start: built once per container, reused across warm invocations. ---
$fontsDir = __DIR__ . '/resources/fonts';
$registry = new FontRegistry($fontsDir);
$registry->warmup([$fontsDir . '/liberation/LiberationSans-Regular.ttf']);
$registry->lock();
$factory = new DocumentFactory($registry, new ImageRegistry(maxCacheBytes: 0));
// --- Per-invocation handler. ---
return static function (array $event) use ($factory): array {
$document = $factory->create();
$document->addPage();
$document->cell(0, 10, 'Invoice', newLine: true);
// getPdfData() materializes the whole PDF in memory and returns it.
$bytes = $document->getPdfData();
return [
'statusCode' => 200,
'isBase64Encoded' => true,
'headers' => ['Content-Type' => 'application/pdf'],
'body' => base64_encode($bytes),
];
};

在把流量接到包之前,先校验它包含一个健康的环境。nextpdf/core 出货一个安装在 vendor/bin/nextpdf 的 CLI,其 doctor 命令会准确报告引擎所需的那些扩展。请针对同一个运行时镜像或层运行它一次,以确认 PHP 8.4 与每个所需扩展都已就位。

Cloud Run 与 App Runner 运行的是一个容器,而非一个打成 zip 的函数,因此构建产物是来自 将 NextPDF 应用容器化 的 Docker 镜像,而非一个 Bref 包。原生引擎的约束完全相同:把字体捆绑进镜像、在 PHP 中注册捆绑的目录、以非特权方式运行,并把文件系统当作非持久的。与 Lambda 仅有 /tmp 的模型不同,Cloud Run / App Runner 容器拥有一个临时的、容器作用域的可写文件系统——但它在每次回收时都会被重置,因此请用 /tmp(在 Cloud Run 上是 tmpfs)或一个已配置的卷做草稿,并且绝不把对应用镜像路径的写入当作持久存储来依赖。

与 Lambda 的差异是运维性的,而非结构性的:

  • 容器可以跨请求保持温热(在一个并发设置下),因此上文中容器作用域的 FontRegistry/DocumentFactory 预热会在许多请求上获得回报,而不只是下一次调用。
  • 你通过 HTTP 提供服务(一个 FPM 或内建 PHP 服务器 SAPI),而非一个 invoke 事件,因此你通过框架的响应返回字节。对于大型文档,请把它们作为流式响应返回——参见 将生成的大型 PDF 作为 HTTP 响应流式返回
  • 请求超时与内存是在服务上设置的(Cloud Run 服务超时 / 内存;App Runner 实例配置),而非按函数设置。

其余一切——扩展集、字体注册、getPdfData() 输出调用——都与 Lambda handler 是同一份代码。

  • 包与镜像体积。 制品携带 vendor/(仅生产环境——用 --no-dev 安装)与你捆绑的字体。字体占主导:一个完整的 CJK 家族有数十兆字节。只出货你实际渲染的字体,以把 Lambda 包保持在其限制之内、把镜像保持精简,这也会缩短冷启动。捆绑的 Liberation 家族(resources/fonts/liberation/)体积小,并覆盖度量兼容的 Helvetica 替换。
  • 内存。 getPdfData() 在内存中构建整份文档并将其作为一个字符串返回,因此峰值内存大致等于一份完成的 PDF 加上构建的工作集。请把函数/容器的内存按你生成的最大文档来设定,而非按平均值。在 Lambda 上,内存也会按比例放大 CPU,因此尽管每毫秒费率更高,更多内存往往意味着更快的构建与更便宜的运行——请把两者都测一测。一份几页的文档在 512–1024 MB 下很从容;图像繁重或多页的文档则需要更多。
  • 超时。 主导请求预算的是构建,而非传输。请把函数超时设在最坏情况构建时间之上并留出余量。如果某份文档大到可能导致超时,请把生成移到一个异步触发器(一个队列驱动的 Lambda 或一个 Cloud Run 作业)上,由它把结果写到对象存储,而不是阻塞一个同步请求。
  • /tmp 体积。 如果你在 /tmp 之下暂存任何东西,请计入其容量上限,并记住它会跨温热调用持续存在——请做清理,否则一个长寿命容器会慢慢把它填满。
  • 不要对应用目录做持久的 save() 部署文件系统不是持久的——Lambda 的应用目录是只读的(只有 /tmp 接受写入),而 Cloud Run / App Runner 容器文件系统虽可写却是临时的。请使用 getPdfData() 并返回/上传字节;若必须暂存,请暂存到 /tmp 或一个已配置的卷之下。
  • 不要依赖自动字体发现。 不要在运行时安装 OS 字体,也不要依赖自动字体发现;请为生产环境捆绑你的字体文件。原生引擎不读取任何 OS/fontconfig 字体——它只解析你注册的文件。如果你刻意把字体文件拉取到 /tmp 或一个已配置的卷中,你必须用字体注册表显式注册它们,并接受随之而来的冷启动与可靠性代价。请捆绑并注册这些文件。参见上文链接的字体页面。
  • NEXTPDF_FONTS_PATH 对裸引擎不起任何作用。 它是 nextpdf/laravel 的配置默认值,而不是 nextpdf/core 读取的变量。一个仅设置该变量的裸 Bref handler 不会注册任何字体,并渲染出豆腐块(tofu)。
  • Chrome 桥接不契合一个普通函数。 writeHtmlChrome 需要一个无头 Chromium 与 symfony/process 子进程路径。把 Chromium 放到 Lambda 上需要一个带浏览器及其库的自定义层、大得多的包,以及长长的冷启动。原生引擎与 writeHtml 都不需要这些——在 serverless 上优先使用它们。
  • 冷启动代价是自动加载加字体解析。 在生产环境安装时使用 --optimize-autoloader,并每个容器只预热一次注册表。不要预热你很少使用的字体。
  • API Gateway 需要二进制处理。 返回 isBase64Encoded: true 并配上 Content-Type: application/pdf,并把 API 配置为把 application/pdf 当作二进制媒体类型,否则客户端会收到损坏的字节。
  • Premium 与 ionCube 是更重的制品关切。 经 ionCube 编码的 NextPDF Pro / Enterprise 构建需要与运行时中确切 PHP 构建相匹配的 ionCube Loader,而一个原装 Bref 层并不包含它。这超出了核心 serverless 部署的范围。
  • 不出货任何 dev 依赖项。--no-dev 安装,使测试与分析工具永不进入函数包或镜像。
  • 构建前先校验输入。 由请求输入驱动的 PDF 构建是一个内存耗尽向量;请在边界处、在任何构建工作运行之前拒绝超出范围或过大的输入,并限制并发,以免高流量把峰值内存倍增成一次内存溢出失败。
  • 让字体与许可证远离公共制品。 只捆绑你有权嵌入的字体,并且绝不把一个 premium 许可证文件烘焙进一个公开推送的镜像或层——请在运行时通过环境值或密钥管理器提供它。
  • 最小权限。 只给函数/服务它所需的 IAM 权限(例如,对那个唯一的输出 bucket 的写入权限),并像 Docker 指南所示那样以非特权方式运行容器。

本指南不提出任何规范性标准主张。平台事实直接读取自 nextpdf/core 包:php: >=8.4 <9.0 约束,以及所需扩展 ext-mbstringext-intlext-gdext-opensslext-zlibext-curl。标准 Bref PHP-8.4 运行时层捆绑了全部六个;官方 php:8.4 镜像提供 opensslcurlzlib,但 mbstringgdintl 必须在镜像构建中用 docker-php-ext-install 安装并启用(参见 Docker 页面)。输出调用是真正的核心接口 NextPDF\Core\Document::getPdfData(): string(其磁盘版同胞是 save(string $path): void)。字体通过 NextPDF\Typography\FontRegistry 注册——其目录构造参数 / addFontDirectory(),并配以用于冷启动模式的 warmup(array $fontFiles)lock()——经由 NextPDF\Core\DocumentFactory::create() 接入。NEXTPDF_FONTS_PATHnextpdf/laravel 包的 fonts_path 配置键(env('NEXTPDF_FONTS_PATH', resource_path('fonts'))),而不是 nextpdf/core 读取的变量。nextpdf CLI 的 doctor 命令在包中声明为 "bin": ["bin/nextpdf"],并在使用方应用中安装于 vendor/bin/nextpdf。Bref 运行时名称与 AWS Lambda / Cloud Run / App Runner 的行为,都是那些厂商已记录的特性。