
1. 为什么需要FrankenPHP开发与生产环境脱节的终结者我第一次看到“FrankenPHP”这个名字时第一反应是这又是什么缝合怪项目——毕竟PHP和Go绑在一起听起来确实像个科学怪人。但认真用了一周之后我必须说这个缝合思路其实是把 PHP 圈子里最让人头疼的两个老问题一次性解决了——开发环境与生产环境行为不一致以及 PHP 请求生命周期内的重复初始化开销。先说最直观的使用体验。传统 PHP 项目部署时我们通常要装 Nginx或 Apache、PHP-FPM然后花不少时间配server块、fastcgi_pass、fastcgi_params、Socket 路径、日志切割和证书续期。本地开发时呢又是另一套环境——有人用 Laravel Herd有人用 Docker Compose 起 NginxPHP-FPM有人用 XAMPP。两套环境的行为常常不一致生产环境走了 FPM 的pm.max_children进程池本地却是一次性 PHP 内置服务器生产环境有 OPcache 预热本地因为频繁改代码往往直接禁用生产环境有平滑重载本地改个配置就要重启一整套容器。这些问题单独看都不大但合在一起就是你明明在本地跑得好好的上生产就出诡异问题的根源。FrankenPHP 的做法很直接它把 PHP 直接嵌入到了 Caddy Web 服务器内部。Caddy 本身就是用 Go 写的现代 Web 服务器自带 HTTP/1.1、HTTP/2、HTTP/3、自动 HTTPS 证书、静态文件服务、反向代理这些能力。FrankenPHP 通过 cgo 把 Zend 引擎也就是 PHP 运行时本身编译进了同一个二进制文件里于是你得到一个单进程的、能直接解析 PHP 的应用服务器。本地开发用它生产部署也用它两者行为完全一致。那它到底解决了什么问题我归纳下来主要有四个第一不再需要 NginxFPM 这种两件套甚至三件套的组合一个进程搞定 Web 服务和 PHP 解析第二本地开发默认就带 HTTPSCookie、第三方登录、Service Worker 这类依赖安全上下文的功能再也不会因为http://localhost而踩坑第三Worker 模式让 PHP 代码常驻内存同一个请求的响应时间显著下降这在后面会详细讲第四部署交付物变得无比简单一个二进制加一份 Caddyfile 配置COPY 到服务器上就能跑依赖全都在二进制里。适合谁来用我觉得面还挺广。如果你是个人开发者自己维护一个小型项目或者 API 服务那直接换到 FrankenPHP运维成本会断崖式下降——你不用再维护两套环境脚本和一份 Nginx 配置了。如果你在维护一个有一定流量的经典 PHP 应用比如 WordPress、Laravel、Symfony 项目那 Worker 模式带来的性能提升值得好好压测验证一下很多场景下吞吐量提升明显。即使你是纯前端需要本地起一个 PHP 后端做联调那docker run一行命令起一个带 HTTPS 的 PHP 服务也比折腾 XAMPP 舒服得多。当然它也不是银弹。后面我会详细说它的配置细节和踩坑经验尤其是 Worker 模式下的内存管理和第三方扩展兼容性问题。但整体上FrankenPHP 是目前我在 PHP 部署方案里最推荐尝试的新东西尤其适合那些不想再维护一堆胶水配置的人。2. 十分钟跑通从零启动第一个FrankenPHP服务2.1 环境准备本地安装还是用官方容器FrankenPHP 目前的安装方式大致分四类官方 Docker 镜像、官方二进制包、源码编译、以及通过 Go 自建。我最推荐的是前两种——如果你只是想起来用一下那直接用 Docker 镜像是最省事的如果你打算把它纳入正式项目建议下载对应平台的二进制包因为容器的文件系统隔离和挂载性能在某些场景下会带来额外干扰。如果你是 macOS 用户直接brew install frankenphp就可以了Homebrew 的 formula 维护得挺及时。Linux 用户可以去 GitHub Releases 页面下载对应架构的.tar.gz包解压出来是一个独立的frankenphp可执行文件。Windows 也有对应版本可用但因为我长期在 macOS/Linux 上工作下面的实操以这两个平台为主Windows 的玩法基本一致就是路径和命令行的差异。快速的健康检查frankenphp version正常的话会输出版本信息以及 PHP 版本号。因为 FrankenPHP 天然就是一个 PHP 运行时所以这个命令实际上也可以当作php -v的替代品。2.2 最小可运行配置一个文件启动整个站点Caddy 的配置使用 Caddyfile 格式FrankenPHP 完全兼容只是额外引入了php_server指令以及全局配置里的frankenphp选项。假设你的项目目录结构是这样的myapp/ ├── public/ │ └── index.php └── Caddyfilepublic/index.php内容先随便写个最简单的?php echo Hello from FrankenPHP by leo;然后Caddyfile写入{ # 启用 FrankenPHP 的核心功能 frankenphp } localhost:8080 { root * /your/absolute/path/myapp/public php_server }注意这里root *后面的路径必须是绝对路径。php_server是php_fastcgi在 FrankenPHP 里的替代品——它不再需要指定fastcgi_pass unix://run/php.sock或者127.0.0.1:9000这类上游地址因为 PHP 解释器就跑在进程内部。然后启动cd /your/absolute/path/myapp frankenphp run浏览器打开http://localhost:8080就能看到 PHP 正常输出了。整个过程不需要装 PHP-FPM不需要配 Nginx甚至不是一个多进程架构——就是 Caddy 自己在监听端口同时负责解析 PHP。这里解释一下php_server这个指令干了什么。它的本质是 Caddy 的一个route封装大概逻辑是如果请求的是存在的静态文件图片、CSS、JS就直接返回否则把请求转交给内嵌的 PHP 运行时处理。它同时自动设置了常用的SCRIPT_FILENAME、DOCUMENT_ROOT、QUERY_STRING等环境变量你不需要手动写了——这也是相比传统php_fastcgi配置最舒服的一点。2.3 自动 HTTPS 和本地域名配置Caddy 最出名的功能就是自动申请和续期 HTTPS 证书。FrankenPHP 继承了这一点。如果你在本机用localhost作为域名访问Caddy 不需要证书也能直接支持http://localhost。但如果你有一个真实的域名并且服务器 80/443 端口可达那 Caddyfile 里直接用example.com作为站点地址启动后它就会自动通过 Lets Encrypt 申请证书然后强制 HTTPS 访问。这里有个很容易被忽略的点只要你在 Caddyfile 里写了域名而不是 IP 或 localhostCaddy 默认就会自动启用 HTTPS并且把 HTTP 请求 301 跳到 HTTPS。你不用写任何tls指令它会自动完成证书的申请、续期、OCSP 装订等操作。这也是为什么我经常说在本地开发阶段尽量把域名配成你自己的.test域然后通过 hosts 解析到127.0.0.1这样本地就能全程模拟 HTTPS 环境避免许多只在 HTTPS 下出现的问题。例如你可以在 hosts 文件里加一行127.0.0.1 myapp.test然后 Caddyfile 里第一行写myapp.test启动后用https://myapp.test访问本地项目。Caddy 会生成一个本地自签证书用于开发调试浏览器会提示证书不受信任你可以点击「高级」继续访问或者在系统里手动信任 Caddy 生成的根证书。官方文档有推荐安装 Root CA 的方式设置一次之后就是全绿锁状态。这一套东西在传统 Nginx 下想复现你至少需要下载 mkcert、生成证书、改成 Nginx 配置里的 ssl 路径、记得续期还要处理浏览器信任链。在 FrankenPHP 里这一切就是“把域名写进 Caddyfile”这一个动作。2.4 本地开发时的热重载问题这里要澄清一个容易产生误解的点。很多人以为用了 FrankenPHP 就自动获得代码热重载其实不是。Worker 模式下一节下PHP 进程长驻内存你改了代码如果不重启进程那运行中的代码不会自动更新。但传统模式下每个请求结束后进程就销毁重建改动会立刻生效——代价是性能损耗。所以在本地开发时我的建议是分两种情况如果你主要跑传统模式也就是不使用worker指令那么改代码即时生效不需要任何额外操作。如果你开了 Worker 模式那本地开发会非常痛苦——因为每次改完代码都需要重启 FrankenPHP 进程。官方对此的解法是在 Worker 模式下修改代码后需要重启。实际开发中我采用的方式是用docker compose里配置reload或者写个小脚本监控文件变更自动重启进程但说实话体验只是一般。后来我干脆本地用传统模式生产开 Worker 模式——反正两个模式之间切换只改一行配置没有负担。3. Worker模式FrankenPHP的性能核心打开方式3.1 Worker模式到底改了什么从“请求即生命”到“常驻内存”传统 PHP 的运作模型可以简化为每个请求进来PHP-FPM 分配一个子进程处理处理完这个进程就回收销毁。听起来没毛病但问题是这个“处理”包含了大量重复工作——框架启动、服务提供者注册、路由编译、配置文件读取、数据库连接初始化、依赖注入容器构建……这些在每次请求里都要重新做一遍。Laravel 这类框架首启动可能要 30ms 到 80ms一个简单请求的输出可能只有 0.5ms剩下全是启动开销。Worker 模型不一样。它启动指定数量的 PHP worker 进程每个 worker 只做一次全量初始化——把框架启动完、把路由表加载好、把服务容器准备好——然后进入一个循环读取请求、处理、输出、等待下一个请求。请求结束后进程不销毁内存中的类和配置都还在下一次请求过来直接复用。用一个类比帮助理解传统模式是每次吃饭前都要从买菜、洗菜、切菜开始Worker 模式是每天只准备一次食材营业时间来了客人直接开火炒。省掉的这几分钟就是启动开销。从实现层面看FrankenPHP 的思路是通过 cgo 把 PHP 嵌入 Go 二进制然后通过异步方式与 PHP worker 通信。Go 进程负责处理 HTTP 请求把请求信息通过共享内存/管道传递给 PHP workerPHP worker 处理完再把结果回传。这中间涉及的数据序列化、请求头转换等细节FrankenPHP 已经处理好了我们只需要在配置层开启 Worker 模式并提供启动脚本。3.2 第一次开 Worker 模式的完整配置开启 Worker 模式需要两处配合Caddyfile 里的worker指令和一个作为入口脚本的 PHP 文件。先看 Caddyfile{ frankenphp { worker { command php /your/absolute/path/myapp/public/index.php env APP_ENV prod } } } myapp.test { root * /your/absolute/path/myapp/public php_server }全局配置里的frankenphp块是我的主配置worker块则指定你要跑的入口脚本路径以及需要设置的环境变量。command参数是必须的我写的php /path/to/index.php不是再启动一个 PHP-CLI 进程而是告诉 FrankenPHP 用内置的 PHP 运行时去加载这个入口文件的字节码并作为 worker 运行。这里的index.php不只是 Web 入口——在 Worker 模式下它还兼有“启动脚本”的职责。所有应用级别的初始化代码都会在这个文件加载时执行一次然后文件中的“请求处理部分”会反复执行。框架类应用通常是这样组织的?php // public/index.php use App\Kernel; require_once dirname(__DIR__) . /vendor/autoload_runtime.php; return function (array $context) { return new Kernel($context[APP_ENV], (bool) $context[APP_DEBUG]); };Symfony 的autoload_runtime.php会自动检测运行环境CLI Server 或 Worker只做一次 kernel 实例化后续请求直接复用。Laravel 项目的public/index.php结构略有不同但逻辑一致——应用生命周期通过Illuminate\Foundation\Application管理Worker 模式下你需要在 worker 循环中手动调用$app-handleRequest()之类的方法或者使用专门为 FrankerPHP 写的适配包。实际上Laravel 的public/index.php已经支持运行在同一进程它会通过$request Request::capture()捕获当前请求并返回响应每次请求结束后通过$kernel-terminate()做清理。这个语义在两年前就为 Swoole 等长驻内存模式预留好了现在原样用在 FrankenPHP 上很顺畅。启动后你会看到日志里出现类似Worker started的信息然后命令行会一直挂着再有请求进来时不再打印 PHP 启动/结束日志了——这就是 Worker 进程常驻的直观表现。3.3 Worker数量与资源配置不是越多越好worker指令下面还有其他可调参数不过最核心的其实是两个num和max_requests。{ frankenphp { worker { command php /your/absolute/path/myapp/public/index.php num 16 max_requests 500 env APP_ENV prod } } }我常见很多人只写command不写num那默认数量是CPU核心数 * 2。这个经验公式其实还行但对 PHP 应用来说往往要看你的应用是 CPU 密集还是 IO 密集。如果是数据库查询比较多的应用进程数可以适当调大因为 worker 大部分时间在等待数据库响应CPU 其实是空闲的。如果应用本身就是计算型、图像处理型那进程数太多反而争抢 CPU。max_requests是防止内存泄漏积累的安全阀。任何一个长驻内存模型最怕的就是某个第三方库把对象缓存在全局静态变量里导致内存一点一点涨上去。设置max_requests之后worker 处理完指定数量的请求就主动退出由父进程重新拉一个全新的 worker 顶上来。这相当于给内存泄漏设置了一个自动恢复机制。按我的经验普通应用设 500 到 1000 比较合适设太低会频繁创建/销毁 worker设太高又起不到防护效果。另外注意Worker 模式下 PHP 的$_GET、$_POST等超全局变量并不是每次请求自动清空干净——FrankenPHP 会重建$_SERVER中与请求相关的部分但你在代码里有自己的静态缓存类时一定要确保它的生命周期和请求绑定。这是 Worker 模式里最容易踩的坑我放在后面的避坑章节细说。3.4 和 PHP-FPM 的对比什么时候值得切过来用一张表来理解传统 FPM 与 FrankenPHP Worker 的差别维度Nginx PHP-FPMFrankenPHP Worker进程生命周期每个请求结束后回收常驻内存复用框架启动开销每次请求都付只付一次配置复杂度需配 Nginx 和 PHP-FPM 两边一个 CaddyfileHTTPS 证书另配 certbot/手动自动申请续期本地/生产一致性难容易高并发支持依赖进程数量上限低进程复用事件驱动上限高动态扩展pm.max_children 受限于内存受限于常驻内存总和结论也很直白如果你只是跑一个流量极低的管理后台FPM 完全够用迁移必要不大如果你的应用有日均几万以上请求或者接口平均响应时间比较敏感那 Worker 模式的收益会非常直观——你的框架初始化时间被一次性摊销了同样的机器配置压测得到的 QPS 可能有接近一倍甚至数倍的提升。4. 生产部署方案单机直跑与前置Nginx的分工配合4.1 从 Docker 镜像开始的部署方式FrankenPHP 官方提供了dunglas/frankenphp镜像并且有带 Worker 模式的专用版本。如果你的项目已经在使用 Docker那部署反而是最简单的。项目根目录放一个DockerfileFROM dunglas/frankenphp:latest AS base # 安装项目依赖的 PHP 扩展 RUN install-php-extensions pdo_mysql opcache intl # 复制项目代码 WORKDIR /app COPY . /app # 使用带 worker 入口的部署配置 FROM base AS prod ENV FRANKENPHP_CONFIGworker ./public/index.php COPY --fromcomposer:latest /usr/bin/composer /usr/bin/composer RUN composer install --no-dev --optimize-autoloader EXPOSE 80这里有个细节值得注意环境变量FRANKENPHP_CONFIGworker ./public/index.php是官方镜像支持的启动入口配置方式。它等价于在 Caddyfile 里写worker指令但放在环境变量里会让容器编排更灵活——你可以在 Kubernetes 的 Deployment 里根据自己的环境覆盖这个变量。本地跑这个容器docker build -t myapp . docker run -p 80:80 -p 443:443 myapp就这么简单。不需要额外装 Nginx不需要配置 FPM容器起来就是一个完整的应用服务器自带 HTTP/2/3 和自动 HTTPS。对于本地开发同一套镜像换个环境变量不加 Worker就是传统即时刷新模式。开发和生产用同一个镜像只靠环境变量区分模式一致性已经是这个方案最大的优势。4.2 已有Nginx基础设施下的迁移策略不过如果你的团队已经有成熟的 Nginx 基础设施——比如统一入口做负载均衡、静态资源 CDN、限流逻辑——那硬把 Nginx 拿掉其实没有必要反而要评估迁移成本。我实际做过的方案是这样的Nginx 留在前面专门处理静态文件和进入流量的分流FrankenPHP 作为一个后端服务跑在内部处理 PHP 请求。这种部署下Nginx 配置类似server { listen 80; server_name myapp.example.com; root /var/www/myapp/public; index index.php; # 静态文件由 Nginx 直接处理不必转发到后端 location /static/ { try_files $uri 404; expires 7d; add_header Cache-Control public; } # 其余请求转发给 FrankenPHP location / { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }而 FrankenPHP 那边监听127.0.0.1:8080它在 Caddyfile 里同样配置root、php_server但不再需要配置证书因为 HTTPS 终结发生在 Nginx 那一层。这套组合的优势在于静态文件交出 Nginx 处理PHP 动态请求交给 FrankenPHP 的 Worker 处理两边各干各擅长的活。再加上 Nginx 现有的访问日志、速率限制、upstream 健康检查都无缝保留。唯一需要适应的就是跨进程头部传递的问题——如果你业务里有自定义请求头记得在 Nginx 的proxy_set_header里显式带上。老实讲如果你的目标就是想一步步迁移到 FrankenPHP而不是一次性推翻重来那这个“Nginx 前置 FrankenPHP 后置”的中间方案是最平滑的路径。4.3 健康检查、日志与进程守护生产环境上跑任何服务都得考虑进程挂掉怎么办。FrankenPHP 虽然是 Go 写的比较皮实但宿主机的重启策略、健康检查还是要配好。在 Docker 下我习惯在容器内加一个healthcheckHEALTHCHECK --interval30s --timeout5s --retries3 \ CMD curl -f http://127.0.0.1:8080/health || exit 1在容器内需要一个不依赖业务数据库的轻量/health路由。最偷懒的办法是在public下放一个health.php内容就一行?php http_response_code(200); echo ok;Caddyfile 里对它做访问控制防止被外部直接大量刷health { path /health.php } handle health { rewrite * /health.php php_server }如果是裸机上直接跑二进制用systemd管理进程是最后一道保险。我提供一个极简的 unit 文件思路[Unit] DescriptionFrankenPHP server Afternetwork.target [Service] ExecStart/usr/local/bin/frankenphp run --config /etc/frankenphp/Caddyfile Restartalways RestartSec3 Userwww-data Groupwww-data EnvironmentFRANKENPHP_CONFIGworker /var/www/myapp/public/index.php [Install] WantedBymulti-user.target日志方面Caddy 默认输出到 stdoutsystemd 下用journalctl -u frankenphp就能看到。如果你想把访问日志单独拎出来审计可以在 Caddyfile 里加log指令将访问日志写到指定文件注意文件所在目录权限要放开。5. 实测性能、避坑排查与真实数据参考5.1 一个量级参考传统模式与Worker模式的性能差距性能数据需要区别对待不同机器、不同应用、不同压测工具都会有偏差我这里只给出一个量级参考帮你建立体感。我拿一个标准的 Laravel 11 应用做过测试机器是 4 核 8G 云主机压测工具用的是wrk -t4 -c100 -d30s。传统 PHP-FPM 模式同环境下的 NginxFPM 8.1约为 750 QPS平均响应延迟约 130ms。同样的机器和代码切到 FrankenPHP Worker 模式后QPS 到了 2100 左右平均延迟掉到 45ms。涨幅约 2.8 倍。当然这不是绝对标准——如果你的业务里有大量外部 API 调用或数据库慢查询那瓶颈不在 PHP 进程启动上提升幅度会明显缩小。但无论如何对于框架型应用“免启动开销”这个收益是真金白银的。如果进一步看细节Worker 模式下降幅最明显的是 TTFB首字节时间。传统模式里TTFB 包含了一次完整的框架解析、路由匹配、容器构建大约要 70ms 起步而 Worker 模式下 TTFB 通常在 5ms 以内后续的 API 响应时间才是真正的业务逻辑耗时。有件事我想强调压测时一定要分清“无状态接口”和“真实业务请求”。很多群里流传的“FrankenPHP 性能翻倍”的截图压测的都是 hello world 页面。对 hello world 来说传统模式的开销占比最大所以升幅看起来夸张。真实业务请求的升幅通常会打折扣因为 Worker 省掉的部分只占总耗时的其中一段。5.2 最容易踩的坑Worker模式下的状态残留与内存泄漏Worker 模式最大的坑不是性能问题而是代码的“生命周期假设”被打破了。传统 PHP 模式下每个请求结束所有变量、静态属性、单例对象都会随着进程销毁而清理。所以即使你的代码里有全局状态也不会跨请求污染。但 Worker 模式下进程是常驻的所有静态属性、类常量、已被加载的类定义都会保留到进程退出为止。举个例子假设你有类似这样的代码class SomeService { private static ?PDO $connection null; public function getConnection(): PDO { if (self::$connection null) { self::$connection new PDO(...); } return self::$connection; } }在 FPM 模式下每个请求进来时静态属性都被初始化成 null所以代码是安全的。但在 Worker 模式下self::$connection第一次请求初始化后就不会再重置第二次请求直接复用同一个 PDO 实例。如果你的某个数据库连接状态依赖于特定请求的上下文比如 session 中的切换数据库第二次请求很可能就把你带沟里了。再比如有代码这样写if (!isset($_SESSION[user_id])) { // 登录逻辑 }在 FPM 下这本来没问题因为每个请求都会重新检查 session。但在 Worker 模式下如果某个变量被提升成了全局或者静态存储你在请求 A 里设置的$_SESSION[user_id]请求 B 可能直接看到了——你要意识到$_SESSION的存储目录在 Worker 模式下的行为并没有本质差异它还是走 PHP 的 Session 处理机制但如果你的框架做了不当的全局缓存就会跨请求串数据。所以启用 Worker 模式前建议做一次全局搜索把代码里所有使用static属性做缓存的类都过一遍。没有上下文依赖的缓存可以留着比如缓存一个固定配置值有上下文依赖的话必须改成请求级变量或者每次请求内重置。内存泄漏也是长驻内存模型的通病。常见来源包括循环引用未消除的对象、日志处理队列不释放、自定义进程内统计数组不断膨胀。我建议至少盯一下进程 RSSResident Set Size用ps aux | grep frankenphp就能看到。如果内存持续上涨不回落多半是某个库或你自己的代码在往静态数组里无限塞东西。此时要么修代码要么把max_requests调低让进程定期自动重建。5.3 第三方扩展和旧代码兼容性排查思路Worker 模式对 PHP 扩展的兼容性也常常是问题。并非所有 PHP 扩展都设计为可以在同一进程内长时间运行——尤其是一些偏底层或者偏 FP-M 场景的扩展实际上PHP 扩展本身和 FPM 无直接绑定但存在部分扩展假设请求结束就清理状态。我的排查思路是这样的先在传统模式下跑通全部功能。如果传统模式下就有问题那不是 FrankenPHP 的锅。启用 Worker 模式后按功能区逐片测试登录、文件上传、导出、定时任务入口、第三方支付回调。遇到异常行为先想到“跨请求状态残留”。复现办法是连续请求两三次同一个接口观察第二次开始是否出现与第一次不同的表现。如果确定是某个扩展导致的内存增长或状态错乱查它的官方文档确认是否支持长驻内存场景。实测结果表明绝大多数常用扩展PDO、OPcache、Redis、AMQP 等在 Worker 模式下工作正常问题多出在项目自身的静态缓存逻辑上。5.4 实际运行中的几个细节问题与排查清单除了上面说的状态残留我在实际部署中还遇到过几个比较有代表性的问题值得单独列出来。第一个是时区与 locale 相关的“偶尔变慢”。因为 Worker 模式重用进程如果代码某处调用了date_default_timezone_set()那这个设置会残留到后续请求里导致后续请求的时区被污染。这个问题隐蔽性强排查起来的特征是“同一个接口响应时区时对时错”。解决办法是把时区配置在php.ini或入口文件的全局位置统一设置不要在各业务代码里频繁改。第二个是OPcache 的热更与进程重启配合。Worker 模式下 OPcache 的启用是天然合适的代码不会被重复编译。但问题是你更新代码后OPcache 不会立刻感知文件变化所以可能需要在部署流程最后执行一步kill -USR2 frankenphp_pid来重置 OPcache 状态实际上Caddy 的配置变更也能触发一次原生 reload但 OPcache 缓存内容不一定清空。实践上我习惯在版本发布后直接重启一次 FrankenPHP 进程一步到位。第三个是容器编排下的优雅下线。在 Kubernetes 中滚动更新时旧 Pod 需要处理完正在进行的请求再退出。Go 服务的 shutdown 逻辑通常很完善FrankenPHP 也不例外——它会等待当前的 PHP worker 完成当前请求再退出。但要注意如果你在超时设置上配得太短比如terminationGracePeriodSeconds设成 5 秒恰好碰上慢查询就可能出现请求被强制终止的情况。建议至少给到 30 秒。第四个是本地路径与容器路径不一致导致的 Worker 启动失败。这个在 Docker 部署时最常见。command php ./public/index.php里的路径是相对路径但是容器的工作目录如果设置不对进程起不来或 worker 启动即退出。排查办法是看启动日志里面通常会明确写“failed to open /path/to/script”。把路径改为容器内的绝对路径就能解决。第五个是关于环境变量传递。在工作进程中$_ENV和getenv()所读取的变量来自进程启动时的环境。你通过 Caddyfile 的env指令设置的环境变量会传递给 PHP worker但如果你在容器编排工具里动态注入一些运行时变量比如 Pod 名称、版本号就需要确认它们是否在command启动之前已经进入系统环境。如果入口脚本里使用环境变量做配置最好在入口位置dump一次把关键变量打印到日志里方便排查。我把上面这些常见问题整理成一个简单的检查清单部署前对照过一遍会省很多事[ ] 入口脚本是否能被php entry.php正常启动单独执行一次确认无致命错误[ ] 通过ps观察 worker 进程数量是否与num一致[ ] 连续访问同一接口 10 次以上对比每次响应时间是否稳定[ ] 检查$_SESSION、Redis 连接、数据库连接的跨请求复用是否正常[ ] 观察frankenphp进程 RSS 在压测后是否回落[ ] 确认生产环境的 OPcache 配置没有 miss[ ] 模拟一次版本发布验证代码更新后是否需要手动重启进程6. 我对FrankenPHP的总体评价与适合采用它的信号最后聊聊我自己的判断依据和使用建议。FrankenPHP 不是那种激进到让你完全抛弃现有基础设施的项目。它有非常明确的兼容性目标——只要你写的 PHP 代码符合常规规范那么把它塞给 FrankenPHP 基本不需要大改。我用过的感觉很像是当年 PHP 5 升 PHP 7 时的跨越底子还是 PHP但运行模型变了性能上了一个明显的台阶。适合迁移的信号我总结成这样几条你用的是 Symfony、Laravel、API Platform 这类启动开销大的框架且接口平均响应时间一直感觉“快不起来”。你维护的项目很小却被 Nginx FPM 的配置拖累得每次新环境搭建都要一两个小时。你需要本地 HTTPS 环境但一直没时间搭 mkcert 流程。你想让开发环境和生产环境尽量一致少点“我本地能跑但服务器不行”的幺蛾子。你想尝试 HTTP/3QUIC带来的弱网优化但觉得给 Nginx 加http3模块太折腾。不适合的信号也有你的团队把 PHP 代码写得很“野”——到处依赖静态全局变量、滥用$_SESSION、没有任何单元测试。这类代码迁移到任何长驻内存模型都会出问题不是 FrankenPHP 的问题是代码模型的问题。你的业务高度依赖某些冷门 PHP 扩展且该扩展不支持长驻进程场景——这种属于需要先做兼容性技术验证的情况验证通过再迁不迟。从个人项目到小团队项目再到我已经在负责的某个中大型 API 服务FrankenPHP 目前跑下来最让我感动的一点是它把一个栈的所有复杂度封装成了一个二进制。早期我用 NginxFPM 部署一套 WordPress 就要写两份配置现在用一份 Caddyfile 完成静态文件、PHP 处理、HTTPS 证书、HTTP/2/3 支持。以前调试“本地能跑生产不行”要开着两个环境的对比页面来回比对现在直接本地开 Worker 模式模拟生产行为问题范围一下小了很多。如果要用一句话收尾就是PHP 这门语言这些年不断带回新的东西而 FrankenPHP 是把现代 Web 服务器的优点和 PHP 的成熟生态缝合得最舒服的一次尝试。它的名字听起来像怪物但用了之后你会觉得这个怪物挺可爱。