跳到內容
getnextpdf.com

容器化 NextPDF 應用程式

你想要一個小型、可重現的 Docker 映像,執行原生且在處理程序內的 NextPDF core 引擎——composer require nextpdf/core,在你的 PHP 處理程序內產生 PDF。本頁建置的正是那個:一個只帶引擎實際所需擴充的 php:8.4 映像,最終層中無開發相依套件、已打包的字型、一個非 root 的執行時使用者、為正式環境調校的 opcache,以及一個在缺少任何東西時讓建置失敗的驗證步驟。

本頁針對原生引擎。Chrome 橋接 (透過 nextpdf/artisanwriteHtmlChrome)與 Connect 伺服器是各自獨立的執行環境,有它們自己、更重的映像——橋接需要安裝無頭 Chromium,Connect 則是一個長存的服務。請勿在這個映像中加入瀏覽器或伺服器;原生引擎兩者皆不需要。

開始前,請確認下列要件都已就位:

  • 你的應用程式有一份已提交的 composer.jsoncomposer.lock,並以 nextpdf/core 作為相依套件。
  • 你擁有你打算嵌入的字型檔,且你有權嵌入它們。
  • 你能對你的應用程式目錄執行 docker build

這是一篇維運操作指南。這裡幾乎沒有 PHP;工作落在 Dockerfile 與一些環境設定上。

映像必須滿足引擎真正的平台限制,不多不少。直接從套件讀取,nextpdf/core 需要 php: >=8.4 <9.0 與這些 PHP 擴充:

擴充引擎為何需要它
ext-mbstring文字與編碼的多位元組字串處理
ext-intlUnicode、地區設定與國際化支援
ext-gd點陣影像解碼與處理
ext-openssl簽署與安全雜湊的密碼學
ext-zlibPDF 物件的串流(Flate)壓縮
ext-curl引擎對外呼叫的 HTTP 用戶端

把那些對應到官方的 php:8.4 映像。opensslcurlzlib 已編入官方 PHP 映像,因此你docker-php-ext-install 它們。mbstringgdintl隨附,必須安裝,而且各需要它們的系統開發標頭先就位——mbstring 另外需要 libonig-dev(Oniguruma)建置相依套件。請勿加入套件未列出的引擎擴充——每個額外的 docker-php-ext-install 都是你不需要的建置時間與攻擊面。這個映像確實安裝的唯一非引擎擴充是 opcache:它是一個執行時效能擴充,未在官方映像上隨附啟用,而下方的 opcache 調校仰賴它存在(見「為正式環境調校 opcache」)。

這是一個兩階段建置。第一階段安裝排除開發套件的 Composer 相依套件;第二階段是出貨的精簡執行時映像。

先在 Dockerfile 旁加入一個 .dockerignore。它的首要工作是把主機環境——一個主機建置的 vendor/、本地祕密檔案與建置快取——完全擋在建置情境之外,讓 COPY . /var/www/app 只出貨你打算出貨的內容:更小、更快、更安全的建置,既不會洩漏本地的 .env 祕密,也不會把數十 MB 的主機 vendor/ 帶進映像。

排除 vendor/ 還很重要,因為目錄 COPY 是一個合併,不是取代。下方的 Dockerfile 會在 COPY --from=vendor ... /var/www/app/vendor 之前執行 RUN rm -rf /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 安裝(無開發相依套件)

標題為「多階段 Composer 安裝(無開發相依套件)」的區段

出貨的映像不得含有開發工具。composer install 上的 --no-dev 旗標是承重的那一行:它略過 nextpdf/core 與你應用程式中 require-dev 底下的一切——測試執行器、靜態分析器與變異工具——這些在正式環境中都毫無立足之地。把它與 --optimize-autoloader 搭配,讓 autoloader 是一個產生的類別映射,而非每請求對檔案系統的掃描。

在其餘原始碼之前複製 composer.jsoncomposer.lock,讓 Docker 快取相依層,並只在 lock 檔變更時才重新解析。由於那次首次安裝只對 lock 檔執行——沒有應用程式原始碼——--optimize-autoloader 在那裡只為 vendor 樹建置類別映射;你應用程式自己的類別尚未存在。這就是為什麼執行時階段在複製原始碼之後執行一次 composer dump-autoload --optimize --no-dev --no-scripts:它把應用程式的類別折入同一個最佳化的類別映射中。請勿在一個你也在其中開發的 worktree 裡執行另一次 composer dump-autoload(那會把一個正式環境的類別映射提交進開發樹);重建屬於映像之中,在原始碼複製之後,如上所示。

原生引擎從它能讀取的字型檔解析字型,而非從 OS 安裝的字型。安裝 fonts-* 套件或執行 fc-cache 對原生路徑可見的東西毫無作用,因此這個映像不安裝任何系統字型。請把你的 .ttf / .otf 檔打包在 resources/fonts/ 之下;上方的 COPY . /var/www/app 已會把它們帶進映像。

把檔案弄進映像只完成了一半工作。裸的原生引擎讀取任何字型搜尋環境變數—— NEXTPDF_FONTS_PATHnextpdf/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、暖機與鎖定模式,以及唯讀檔案系統處理,全都存放在專屬頁面上——請勿在此重複它們。請閱讀 在正式環境為原生引擎佈建字型 了解完整模式,並註冊你所打包的同一個目錄。

官方 PHP 映像預設以 root 執行。一個 PDF 產生器不需要 root,因此請建立一個非特權使用者並切換到它。上方的 Dockerfile 加入一個系統使用者 appuser,帶有固定的高 UID(10001),把應用程式樹的擁有權給它,並以 USER appuser 結束,讓容器啟動的每個處理程序都是非特權的。

請在你能做到之處讓應用程式在執行時保持唯讀。引擎讀取它的字型檔,並只寫入它的輸出與一個選用的已剖析字型快取,因此只要輸出路徑與任何快取目錄是可寫掛載,一個 readOnlyRootFilesystem 容器便可運作。把它與你協調器中卸除的 Linux capabilities 及一個 no-new-privileges 旗標結合,以達到縱深防禦。

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 include(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_consumptionmax_accelerated_files

所示的 CMD 是一個一次性的 CLI 產生器,而 opcache.enable_cli=0 對它而言是正確的。 一個短命的 php bin/generate.php 處理程序啟動、編譯、算繪一次然後退出,因此它無法與下個請求共享的 opcode 快取毫無益處——讓 CLI opcache 維持關閉,且不付出它任何記憶體成本。opcache 只有在處理程序被重用時才賺回它的價值:一個 FPM/Apache SAPI,或一個真正長存的 CLI worker(一個佇列消費者或一個 RoadRunner 風格的伺服器)。只有那種常駐的 CLI worker 才會設定 opcache.enable_cli=1;對此處的一次性產生器,請維持它為 0

如果你確實執行一個使用 opcache preloading 的設定(一個帶 opcache.preload 腳本的長存 FPM worker),請設定 opcache.preload=/path/to/preload.php,並加上 opcache.preload_user=appuser,讓 preload 以非特權使用者執行。在沒有實際的 opcache.preload 腳本時,opcache.preload_user 毫無作用,這就是它不在上方基準設定中的原因——除非你也設定了 opcache.preload,否則請勿加入它。

加入一個驗證步驟,讓一個錯誤建置的映像大聲失敗,而非在第一個請求時產生豆腐字或一個 fatal。NextPDF 隨附一個 CLI,其 doctor 指令檢視執行中的 PHP 環境,並就引擎在意的確切擴充回報——opensslzlibmbstringgdcurlintl。套件宣告了 "bin": ["bin/nextpdf"],因此在一個消費端應用程式中,Composer 把該執行檔安裝在 vendor/bin/nextpdf(而非 bin/nextpdf,那是 nextpdf/core 套件自己內部的路徑)。在建好的映像中執行它:

Terminal window
docker run --rm your-app:latest php vendor/bin/nextpdf doctor

一個健康的結果確認 PHP 8.4 與每個必要擴充都已載入。把同一個呼叫接入建置(或一個 CI 煙霧任務)中,讓一個缺失的擴充使管線停止:

Terminal window
# 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-libslibpngfreetypelibjpeg-turbooniguruma),讓刪除建置群組不會解連 intl.so / gd.so / mbstring.so。這與 Debian 區塊以 apt-mark 強制的保留執行時函式庫規則相同。
  • 請勿安裝 fonts-* 套件。 它們對原生引擎不可見。請改打包字型檔——見上方連結的字型頁面。
  • Premium 與 ionCube 是另一個映像的顧慮。 ionCube 編碼的 NextPDF Pro / Enterprise 建置需要在映像中安裝 ionCube Loader,並與容器的確切 PHP 建置相符(8.4、NTS 與 ZTS)。那超出 core 映像的範圍;如果你部署 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 執行。把它與你協調器中的唯讀根檔案系統及卸除的 capabilities 結合。
  • 釘住基底映像。 在正式環境中把 php:8.4 釘到一個 digest,讓重建不會默默拉到一個改變過的基底,並以一定節奏重建以刻意地接收安全修補。
  • 把字型與授權擋在公開層之外。 只打包你有權嵌入的字型,並絕不把一份 premium 授權檔烘焙進一個公開推送的映像中——請改在執行時掛載它。

本指南不作任何規範性標準主張。平台事實直接從 nextpdf/core 套件讀取:php: >=8.4 <9.0 限制與必要擴充 ext-mbstringext-intlext-gdext-opensslext-zlibext-curl。驗證指令是真正的 nextpdf CLI doctor 處理器——在 nextpdf/core 中宣告為 "bin": ["bin/nextpdf"],因此在一個消費端應用程式中安裝於 vendor/bin/nextpdf——它就同一組擴充進行回報。原生引擎透過 NextPDF\Typography\FontRegistry(目錄建構子引數/addFontDirectory())並經由 NextPDF\Core\DocumentFactory 接入來註冊字型;NEXTPDF_FONTS_PATHnextpdf/laravel 套件的 fonts_path 設定鍵(env('NEXTPDF_FONTS_PATH', resource_path('fonts'))),而非 nextpdf/core 所讀取的變數。登錄表行為記載於「另請參閱」下方連結的字型頁面。