自架服務 Caddy FrankenPHP PHP Laravel Symfony

PHP-FPM 每個請求都重新 bootstrap Laravel:FrankenPHP 用 Caddy + worker mode 把啟動成本攤掉

PHP-FPM 撐了二十年仍主導 PHP 部署,但每個請求都重新 bootstrap 框架的設計從未被質疑。本文解析 FrankenPHP 如何把 Caddy 與 PHP runtime 收進同一個 Go binary、worker mode 帶來三倍吞吐量的細節、Symfony 與 Laravel 的整合方式,以及在 VPS 上部署時需要注意的記憶體規劃與 long-running 進程陷阱。

PHP 從 CGI 時代延續下來的 process-per-request 模型,是它能跑到處可見的原因,也是現代框架啟動成本卡死在每個請求重來一次的根源。一個典型的 Laravel 應用,每收到一筆 HTTP 請求就得從頭 require 幾千個 PHP 檔、初始化 service container、重建 routing table、解析設定檔,跑完才開始處理業務邏輯。Nginx + PHP-FPM 這套組合過去十五年是事實標準,但所有效能優化招式——OPcache、preload、JIT——都繞著「怎麼讓每次重新啟動更便宜」打轉,從來沒人質疑「為什麼要每次都重啟」。

FrankenPHP 是這幾年裡少數從這個層級重新設計 PHP runtime 架構的專案。它由 Symfony 核心開發者 Kévin Dunglas 主導,採 MIT 授權,2026 年 2 月發到 1.11,3 月跟上 PHP 8.5、首次補齊原生 Windows 支援,GitHub 上累積近 12000 顆星。核心做法極端直接:把 Caddy web server、PHP runtime 跟 worker pool 全部收進同一支 Go binary,請求進來不再經過 FastCGI socket,框架也不再被反覆 bootstrap。

Nginx + PHP-FPM 為什麼是兩個進程兩段 IPC

傳統 PHP 部署的拓樸是 Nginx 在前面收 HTTP 請求,解析完之後把請求透過 FastCGI 協定丟給後面的 PHP-FPM 進程池,PHP-FPM 挑一個閒置 worker 處理請求、執行對應的 PHP 檔案、把結果回給 Nginx,Nginx 再回給客戶端。整個流程牽涉兩個獨立進程、一次跨進程 socket 傳輸、以及 PHP-FPM 內部的 fork 與 worker 排程。

這個架構在 2003 年提出時是極大的進步,它把 mod_php 那種 Apache 跟 PHP 緊耦合的問題拆掉了——Nginx 只負責處理 HTTP,PHP 只負責執行腳本,兩邊獨立調校。代價是每個請求都得付出 IPC 成本,更關鍵的是 PHP-FPM 的 worker 在處理完一個請求之後會清掉所有狀態,包括 require 過的類別、解析過的路由、建好的 service container。下一個請求進來,整套框架要重新跑一次 bootstrap。

OPcache 解的是 PHP 原始碼 → bytecode 的編譯成本,preload 解的是常用類別預先載入記憶體,JIT 解的是熱點程式碼的執行速度。三招疊起來能讓單次請求快不少,但無法解決「應用層的初始化每次都要重來」這件事。Laravel 啟動時要載入服務提供者、解析設定檔、初始化路由註冊、建立 Eloquent 連線設定——這些動作即使 OPcache 把 bytecode cache 住,每次仍然得執行一遍。

FrankenPHP 把 web server 跟 PHP runtime 收進同一個進程

FrankenPHP 的做法是把 Caddy 的 Go 進程嵌入 PHP 的 SAPI 層,PHP runtime 直接以 thread 形式跑在 Caddy 進程內。請求進來由 Caddy 解析 HTTP,呼叫對應的 PHP handler(透過 CGO 跨語言介面)執行腳本,結果直接回給客戶端,全程不離開同一個進程。

這個設計帶來幾個直接的副作用。IPC 開銷消失——不再需要把請求序列化成 FastCGI 封包、寫進 socket、被另一個進程讀出來。worker pool 由 Caddy 自己管理,能用 Go 的 goroutine 模型處理 I/O 等待,比 PHP-FPM 的 fork-and-wait 模型更省記憶體。TLS、HTTP/2、HTTP/3、自動 Let’s Encrypt 憑證、Brotli 壓縮這些 Caddy 本身的功能直接繼承下來,不需要再前置一臺 Nginx 處理。

部署上的差別也很實際。原本一臺跑 Laravel 的 VPS 上會有兩個 systemd unit(nginx 跟 php8.x-fpm),兩份設定檔,兩個監控目標,兩份 log。換成 FrankenPHP 之後變成一個 binary、一份 Caddyfile、一份 log,systemd unit 也只需要管一個進程。記憶體佔用通常比兩個進程加起來低 20% 到 40%,視 worker 數量而定。

Worker mode 把框架 bootstrap 從每個請求挪到啟動時

只是把 Nginx + PHP-FPM 換成 FrankenPHP classic mode,效能差距其實有限。Tideways 在 2025 年底的 benchmark 顯示 Nginx + FPM 約 7023 req/s、FrankenPHP classic 約 6934 req/s,差距落在 1% 以內。真正的突破來自 worker mode。

Worker mode 的概念跟 Laravel Octane、Roadrunner、Swoole 同一條路:應用程式在 worker 進程啟動時跑一次 bootstrap,之後每個請求都從一個已經初始化好的 instance 處理。框架的 service container、路由表、設定快取全部留在記憶體裡,請求進來只要走 routing → controller → response,不必再 require 任何啟動檔。

數字上的差異很可觀。Symfony 7.4 在 Nginx + PHP-FPM 上跑出來大約 1240 req/s、P95 延遲 45ms;同一個應用換到 FrankenPHP worker mode 之後拉到 3850 req/s、P95 延遲 8ms。三倍以上的吞吐量直接從「不要每次都 bootstrap」這件事換來。Laravel 透過 Octane 接 FrankenPHP 也能拿到類似幅度的提升,官方 Docker image 直接內建這套整合。

代價是程式碼必須處理 long-running 進程的副作用。靜態變數不會在請求間清空、Symfony 的 service 如果持有 request-specific 狀態要實作 ResetInterface、資料庫連線池要小心避免長時間閒置斷線。多數現代框架都已經處理好這些細節,但土法煉鋼的 legacy 程式碼搬過去可能會踩雷。FrankenPHP 提供 max_requests 參數讓 worker 處理一定數量請求後自動重啟,當作記憶體洩漏的逃生機制。

Caddy 副作用:HTTPS、HTTP/3、Mercure 全部內建

FrankenPHP 既然底層是 Caddy,Caddy 的所有功能也跟著拿到。自動申請 Let’s Encrypt 或 ZeroSSL 憑證、自動續約、自動 HTTP → HTTPS 轉址,這些在傳統部署裡得另外設一段 certbot cron 或裝 acme.sh,現在開機就能用。HTTP/3(QUIC)也是預設開啟,對行動裝置或弱網路環境的延遲改善有感。

容易被忽略的是 Mercure hub 整合。Mercure 是 Symfony 生態內推的 Server-Sent Events 標準,做即時推播(chat、notification、live dashboard)比 WebSocket 簡單,FrankenPHP 直接內建一個 Mercure server,PHP 程式碼 publish 一筆事件,前端瀏覽器透過 EventSource API 接收。原本要另外架 Mercure hub 或上付費 SaaS 才能跑的場景,現在不必額外服務就能跑起來。

部署在 VPS 上的實際選擇

FrankenPHP 提供三種部署方式。最簡單是官方 Docker image:FROM dunglas/frankenphp 加上自己的程式碼就能跑,內建 PHP 8.4 跟 Caddy。第二種是 standalone binary,把 PHP runtime 跟 Caddy 編進單一執行檔,適合不想用 Docker 的環境,可以直接放進 systemd unit。第三種是把 FrankenPHP 當作 Caddy module 編進現有的 Caddy 安裝,適合已經在用 Caddy 處理多個站點的場景。

實務上多數團隊會走 Docker 路線,理由不只是部署簡單,更重要的是版本管理——PHP 8.4 跟 8.5 之間有微妙的相容性差異,用容器固定版本可以避免升級系統時意外踩到。Laravel 應用的標準 Dockerfile 大約 30 行,Octane install + Composer install + 應用程式 copy 就完成,配合 Kamal 2 或 Dokploy 之類的部署工具能直接接 CI/CD。

1
2
3
4
5
6
7
8
9
10
11
12
FROM dunglas/frankenphp:1.12-php8.4

COPY . /app
WORKDIR /app

RUN install-php-extensions pdo_mysql redis intl opcache zip \
&& composer install --no-dev --optimize-autoloader \
&& php artisan octane:install --server=frankenphp

ENV SERVER_NAME=":443"
ENV OCTANE_WORKERS=4
ENV OCTANE_MAX_REQUESTS=500

需要注意的是 worker mode 的記憶體佔用模型跟 PHP-FPM 不同。FPM 是「每個 worker 處理完請求釋放記憶體」,FrankenPHP worker mode 是「每個 worker 持有應用程式狀態直到重啟」。記憶體規劃要按 worker 數量 × 單個進程持有的應用記憶體計算,512MB VPS 跑 4 個 worker、一個吃 80MB 的 Laravel 應用,剩下能用的記憶體就不到 200MB。先抓 baseline 再調整 worker 數量比較穩,CPU 核數 × 2 是 worker 數量常見的起點。

不該用 FrankenPHP 的場景

不是所有 PHP 工作負載都適合搬到 worker mode。傳統 CMS(WordPress、Drupal)的外掛生態裡有大量假設「每個請求都是新進程」的程式碼——全域變數、全域靜態、未 unset 的物件——在 long-running worker 內會慢慢累積記憶體或留下髒狀態。除非外掛數量可控、有時間逐一審核,否則 WordPress 跑 worker mode 多半會出事,留在 PHP-FPM 反而省心。

共享主機環境也不適合。FrankenPHP 假設整臺機器(或整個容器)只跑一個 PHP 應用,沒辦法像 PHP-FPM 那樣同一個 daemon 跑多個 pool 服務不同網站。多站點需求得換成「每個站點一個 FrankenPHP 容器」的拓樸,配上前置 reverse proxy 做路由分流,運維成本會比原本一臺 Nginx 加多個 PHP-FPM pool 高。

長期推估上,FrankenPHP 的市佔還很難跟 PHP-FPM 比。但對於新建的 Laravel 或 Symfony 應用、特別是用 API-first 架構處理高併發的場景,把 worker mode 列入評估清單已經是基本動作,而不是早期採用者的冒險選擇。

把 PHP runtime 收回單一進程的下一步

PHP-FPM 是這個生態二十年沒人動的基石,動它的成本曾經太高、回報曾經太低。FrankenPHP 把這個假設翻過來——不只是把 web server 跟 PHP runtime 收進同一個 binary,更把 long-running PHP server 這條 Laravel Octane、Swoole 走過的路重新打通,並且做到對既有框架的相容性夠高、學習曲線夠平。

對手上有 Laravel、Symfony 或其他現代 PHP 應用、又願意花一點時間整合 worker mode 的團隊,FrankenPHP 是這兩年最值得評估的部署選項。NCSE Network 提供臺灣是方電訊機房的 VPS 主機,配備 Intel Gold CPU 與 NVMe SSD,能穩定承載長時間運行的 worker 進程與資料庫連線壓力,需要評估 PHP 應用部署架構或從 FPM 切換到 worker mode 的團隊,可以到 ncse.tw 了解更多細節。

需要穩定的雲端主機?

NCSE Network 提供企業級 VPS,7 天免費試用,臺灣是方電訊機房,99% SLA 保證。

查看 VPS 方案 →