
做了十多年 PHP我的部署栈很长时间都是 Nginx PHP-FPM。不是说这套组合不好而是它把一件本该简单的事拆得特别碎进程管理、Unix socket 权限、HTTPS 证书续期、cgi 参数、gzip每个环节都要单独维护。更难受的是框架项目每次请求都要把 Laravel 或 Symfony 从头引导一遍同样的 autoload、同样的容器编译几十毫秒就这么白白烧掉了。后来看到 FrankenPHP 出现直接把整套 Web 能力合并进一个二进制甚至能让 PHP 应用像 Java 的 Tomcat 那样常驻内存我立刻找项目试了试一跑就是两个月这篇文章就是这段时间的完整实践记录。FrankenPHP 的本质其实很简单它用 Go 把 Caddy 和 PHP 运行时打进了同一个进程你不再需要 Nginx、PHP-FPM、certbot 这几个东西分头作战。Caddy 自带的自动 HTTPS、HTTP/2、HTTP/3、日志、静态文件能力全部继承PHP 则由内置运行时处理和转发。对普通 PHP 项目它可以直接当 NginxPHP-FPM 的平替用对 Laravel、Symfony 这类框架它还能开启 worker 模式让应用启动一次就常驻内存把框架引导时间从每次请求里抹掉。这篇文章适合这样几种人正在纠结要不要换掉传统 PHP-FPM 部署方式的团队、被框架性能瓶颈困扰的前后端一体项目、以及单纯想把手里的服务器方案做减法的个人开发者。我会从架构原理、安装配置、worker 模式实战到压测数据和踩坑排查完整讲一遍尽量让看完的人可以直接照着落地。1. 为什么我会从 Nginx PHP-FPM 换到 FrankenPHP1.1 老架构到底痛在哪里先别急着吐槽我喜新厌旧。传统 NginxPHP-FPM 真的是一个成熟的、经历过十几年考验的方案问题不在它能不能跑而在于维护它的隐性成本。我在团队里接手过好几个遗留项目光是排查线上问题就要在 Nginx access log、error log、PHP-FPM slow log、应用框架日志之间来回切换。每次新开一个站点都要复制一整套 vhost 配置里面是密密麻麻的 location 块、fastcgi_pass 参数、socket 权限设置一旦写错就出现经典的 502 或空白页。真正让我下定决心换方案的是 PHP 本身的短生命周期模型。传统模式下每个请求都会经历一次完整的启动框架 - 注册服务 - 编译容器 - 执行控制器 - 销毁应用循环。像 Laravel 这种重框架空跑一次就要消耗三五十毫秒压测一上来 QPS 就被压死在几百。我当时算了一笔账如果能把应用常驻内存去掉这部分的重复开销等于不换业务代码就白赚几倍性能。这个思路在 PHP 圈并不新鲜Swoole、RoadRunner 都在做但它们的落地门槛都不低要么要改代码风格要么要引入额外的调度器。直到我试了 FrankenPHP才觉得这个平衡点终于对了。1.2 FrankenPHP 的设计思路把 Web 服务器和 PHP 合一FrankenPHP 是 API Platform 作者 Kévin Dunglas 发起的项目思路很直接既然 Caddy 本身就是用 Go 写的模块化 Web 服务器那就把 PHP 运行时作为一个模块塞进去。于是你得到一个单一的可执行文件里面同时具备 Web 服务器、TLS、HTTP/2/3、静态文件、PHP 运行时等能力。部署的时候不再需要一组进程配合一个frankenphp进程就是全部。理解它最关键的词是合一。传统架构里Nginx 收到 HTTP 请求后要通过 FastCGI 协议转给另一个进程空间里的 PHP-FPMPHP-FPM 再拉起或复用 worker 进程执行 PHP 脚本结果还要原路返回。FrankenPHP 省掉了这层跨进程辗转Go 侧的 Caddy 直接和 PHP 运行时通信。更妙的是它保留了一个类似 FastCGI 的经典模式让你可以无缝替换旧架构同时提供 worker 模式让 PHP 应用可以常驻内存。这种先兼容再优化的设计是我在评估各种新方案时最看重的一点——它可以让我不用改业务代码就先迁移上线风险可控。2. 理解 FrankenPHP 的两种运行模式2.1 经典模式先把它当 NginxPHP-FPM 的平替FrankenPHP 的经典模式基本就是 Caddy 的php_server指令在干活。它对标的是传统 Nginx vhost 里的fastcgi_pass配置但做了极致的简化。你不需要自己写location ~ \.php$这种正则块也不需要配置fastcgi_param全家桶Caddy 会自动把请求路由到内置的 PHP 运行时。一个小站点用经典模式跑起来Caddyfile 大概是这样# Caddyfile - 经典模式 example.com { root * /srv/app/public php_server encode zstd gzip }三行配置静态文件分发、PHP 解析、压缩、HTTPS 全部搞定。目录下如果存在与请求路径对应的静态文件Caddy 直接返回没有对应文件时再把请求交给 PHP 入口脚本处理。这个行为非常接近 Nginx 里常见的 try_files 逻辑但不用你手动写。我建议第一次尝鲜的人从经典模式开始。你只需要把原来 Nginx 的 root 指到新配置把 80/443 端口让出来流量切过来就能跑。这个阶段不需要考虑代码改动也不需要考虑状态清理纯粹是在验证服务器本身的稳定性。2.2 Worker 模式PHP 从用完即焚变成常驻服务经典模式解决的是运维复杂度和 HTTPS 自动化的痛点但性能提升有限因为每个请求照样要引导一次框架。真正让 FrankenPHP 和传统方案拉开差距的是 worker 模式。要理解 worker 模式可以拿餐厅打比方。传统 PHP 的模式像是每次来一桌客人厨房都要重新生火、烧水、热锅做完这一桌就关火收拾。worker 模式则是厨房一直开着火厨师和食材都在岗客人进门直接下锅炒菜。省掉的是每次请求都要重复的生火加热过程也就是 PHP 的框架引导、自动加载、服务容器编译、数据库连接初始化。在 Caddyfile 里开启 worker 模式只需要在php_server里指定一个 worker 脚本# Caddyfile - worker 模式 example.com { root * /srv/app/public php_server { worker /srv/app/worker.php } }这个 worker.php 是 worker 模式的唯一入口结构非常简单?php // worker.php - worker 模式的唯一入口 require __DIR__./../vendor/autoload.php; // 这里写只执行一次的初始化逻辑 // 比如创建数据库连接、加载框架容器 while (true) { // 每来一个请求下面的闭包执行一次 frankenphp_handle_request(function () { // 这里就是传统模式里 index.php 干的事 // 处理请求并输出响应 echo Hello, FrankenPHP; }); }我自己的理解是frankenphp_handle_request()是贯穿整个 worker 模式的核心函数。它声明当前 worker 已经准备好接收一个新请求然后阻塞等待直到收到一个请求后执行闭包里的业务代码把响应交还给 Go 侧的 Caddy 进程发送给客户端。闭包返回后循环继续worker 进入下一个请求的处理。框架初始化只做一次后面所有请求都复用同一套已就绪的应用实例。2.3 两种模式怎么选我的判断标准不是所有应用都适合直接上 worker。我在这两个月里得出的选型标准其实很简单一是代码风格。如果你维护的是 WordPress、老 PHP 程序、或者一堆依赖全局变量和请求结束清理的代码建议老老实实用经典模式。worker 模式下请求不再销毁进程任何残留的全局状态都会串到下一个请求去这种问题排查起来非常痛苦。二是业务性质的收益。像 Laravel、Symfony 这类重框架框架引导占请求耗时的比重很高上 worker 收益立竿见影。如果只是一个输出 hello world 的轻接口经典模式和 worker 模式差距不大反而 worker 的模式约束给你添麻烦。三是团队信心。我在迁移第二个项目时是先让它以经典模式灰度跑了一周确认日志、监控、异常上报都正常才切到 worker。这个节奏让我后面排查问题时能明确把变量缩小到 worker 常驻这一个维度上。3. 环境准备与安装配置3.1 Docker 方式最快跑起来如果你不想折腾本机编译Docker 是 FrankenPHP 的首选打开方式。官方维护的镜像就是dunglas/frankenphp直接拉对应 PHP 版本即可。我当前在生产环境用的 tag 是dunglas/frankenphp:1-php8.3镜像里已经预编译好了 PHP 扩展省掉了自己折腾编译环境的时间。先看最简启动方式docker run -d \ -p 80:80 -p 443:443 \ -v $PWD:/app \ -v caddy_data:/data \ -v caddy_config:/config \ dunglas/frankenphp:1-php8.3这里面两个 volume 值得单独说一下。caddy_data是 Caddy 用来存放自动签发的 TLS 证书的地方生产环境必须持久化否则每次容器重建都要重新申请证书既慢还可能触发 Lets Encrypt 的频率限制。caddy_config存放 Caddy 的配置同样建议持久化。项目代码挂载到/app容器内默认工作目录就是这个路径。如果想直接进 worker 模式官方镜像支持用环境变量指定 worker 脚本路径例如# docker-compose.yml 片段 services: frankenphp: image: dunglas/frankenphp:1-php8.3 restart: unless-stopped ports: - 80:80 - 443:443 volumes: - ./:/app - caddy_data:/data - caddy_config:/config environment: FRANKENPHP_WORKER: /app/public/index.php设置FRANKENPHP_WORKER后容器启动时会把入口脚本自动配置为 worker 脚本。不过要注意不同小版本的镜像对 worker 脚本路径的解析规则略有差异如果遇到 404 或者 worker 没有生效优先去看容器日志里 Caddy 加载的配置内容确认 worker 指令有没有真正带上。3.2 二进制方式原生部署不想用容器的话官方在 GitHub Releases 页面提供了多种平台的预编译二进制包。下载解压后里面就是一个名为frankenphp的可执行文件。它已经内置了 PHP 运行时不需要你本机单独安装 PHP。我用这种方式在两台裸机服务器上部署过体感比编译源码省事得多。二进制方式启动非常简单frankenphp run --config Caddyfile执行后FrankenPHP 会读取当前目录或指定路径的 Caddyfile按配置启动站点。如果确实需要自己编译官方也支持go install github.com/dunglas/frankenphp/cmd/frankenphplatest这类方式从源码构建。但注意自己编译需要系统有 Go 工具链以及 PHP 的开发头文件而且要处理 cgo 相关的编译环境非必要不建议折腾。普通使用场景直接下载官方 release 就够了。3.3 生产环境 HTTPS 与静态资源Caddy 最出名的能力之一就是自动 HTTPS。只要 Caddyfile 里写了域名并且服务器 80 和 443 端口可达Caddy 就会自动向 Lets Encrypt 申请证书并定时续期。这个过程完全自动不需要配置证书路径也不需要写 certbot 的 cron 任务。我在生产环境用的 Caddyfile 大概是这样的# Caddyfile - 生产环境示例 example.com { root * /srv/app/public php_server encode zstd gzip header { Strict-Transport-Security max-age31536000; includeSubDomains X-Content-Type-Options nosniff X-Frame-Options SAMEORIGIN } }几点说明。encode zstd gzip是 Caddy 会根据客户端支持的压缩算法自动选择最优压缩方式比 Nginx 里手动维护 gzip 配置省心。header区块用来统一加安全响应头我列的是三个最基础的你可以根据自己业务的合规要求继续加。HTTP 到 HTTPS 的跳转、HSTS 的预加载这类事情Caddy 在自动 HTTPS 模式下基本都帮你处理了不需要再写 redirect 规则。有一个坑我要特意提醒域名没解析到位或者 80 端口被防火墙挡住的时候Caddy 的证书申请会一直失败但日志里往往只有一句ACME相关的报错。排查顺序应该是先确认 DNS 解析再确认 80/443 端口的外部可达性最后才去看证书问题。4. Worker 模式实战让框架项目跑出应用服务器性能4.1 手写最小 worker 脚本前面给过一段最小 worker 脚本这里详细说说它到底该怎么组织。第一步是在项目里新增一个worker.php放在框架入口之外、能被自动加载的位置。脚本顶部require引入 Composer 的 autoload然后执行一次性的初始化逻辑。第二步是进入while (true)循环循环体里只有一次frankenphp_handle_request()调用业务逻辑写在闭包里。我在本地测试时用的是这样的 Caddyfile# 本地开发用 { http_port 8080 } localhost { root * ./public php_server { worker ./worker.php } }然后启动服务frankenphp run --config Caddyfile浏览器访问http://localhost:8080看到Hello, FrankenPHP就说明 worker 模式通了。这一步验证的是最核心的链路Go 进程收到 HTTP 请求把请求信息交给常驻的 PHP workerPHP 执行闭包输出内容Caddy 再把响应返回给客户端。4.2 Laravel 和 Symfony 的现成集成说实话绝大多数现实项目不会真的手写 worker.php因为框架生态已经把这一步封装好了。我在 Laravel 项目上用的是官方推荐的 Laravel Octane 方案。Octane 是 Laravel 官方团队出的高性能应用服务器抽象层它的 server 列表里直接内置了 frankenphp 驱动。启动命令很简单php artisan octane:start \ --serverfrankenphp \ --host0.0.0.0 \ --port8080Octane 内部会帮我们完成 worker 脚本的调度和请求生命周期管理我不用关心while (true)循环的具体写法。代码更新后执行php artisan octane:reload就能平滑重载 worker这在本地开发时非常实用不用整个进程干掉重来。Symfony 那边走的则是 Symfony Runtime 的自动检测机制。当应用运行在 FrankenPHP 环境里时Runtime 会自动切换到对应的 worker 运行时框架自身对常驻内存做了适配。API Platform 官方镜像现在默认就基于 FrankenPHPSymfony 用户迁移过去的路是现成的。我建议使用框架时优先走官方集成自己手写 worker 只有在处理无框架的纯 PHP 脚本时才需要。4.3 常驻内存模式下的代码纪律这部分是 worker 模式里最容易出问题的环节。PHP-FPM 模式下每个请求结束进程就销毁开发者长期养成的习惯是不用管清理。但在 worker 模式下同一个进程的生命周期跨越成百上千个请求代码里的任何状态残留都会被无限放大。第一条纪律是不要用exit或die。传统 PHP 里这两个函数很常见但在 worker 的循环里一旦执行整个常驻进程直接退出这个请求就会变成 502。需要中断逻辑时应该用异常或条件返回。第二条纪律是警惕全局状态。静态变量、全局数组、单例里缓存的业务数据都会在多个请求间共享。我踩过一个很典型的坑某个第三方 SDK 在请求结束时把结果写进了一个静态数组本来 P 到 FPM 模式下没人注意切到 worker 后第二个请求莫名其妙读到了第一个请求的数据排查了半天才定位到是这个全局数组在作怪。第三条纪律是连接复用和断线重连。MySQL、Redis 连接在 worker 模式下不会每次请求都新建这是性能提升的来源之一但也意味着连接可能被服务端超时断开。数据库报server has gone away这类错误时需要捕获异常后重建连接而不是把进程杀掉重来。现在很多框架的连接管理器已经处理了这种重连逻辑但老代码里手写的 PDO 连接必须自己检查。4.4 代码更新与平滑重启worker 模式把 PHP 代码镜像进了内存这就带来一个直接后果你更新了服务器上的代码文件运行中的 worker 不会自动感知。很多团队第一次从 PHP-FPM 迁移过来部署完发现线上还是旧代码就是这个原因。我的处理方式是在部署脚本里把重启作为固定步骤。容器化部署就直接docker compose restart使用 Octane 的项目可以用php artisan octane:reload做一次轻量重载。如果追求零感知就把应用放到多个容器后面滚动发布新镜像等新的 worker 进程全部 ready 后再摘掉旧容器实测对业务请求完全没有感知。另外我建议在监控里加上 worker 进程的内存曲线和存活状态。常驻进程都会缓慢内存增长这不一定是泄漏但一旦曲线斜率异常要能第一时间告警出来而不是等到 OOM 后整批 worker 被系统杀掉。5. 实测效果与性能表现5.1 同环境下压测对比性能数据容易被人抬杠所以先说清楚测试条件同一台本地开发机同一个中等复杂度的 Laravel 项目同一个简单的列表接口数据库数据量一致压测工具是 wrk4 个线程、200 个并发连接压测时长 30 秒。我记录到的典型数据大概是这样运行模式平均响应时间P95 响应时间吞吐量req/sNginx PHP-FPM58ms120ms320FrankenPHP 经典模式52ms105ms360FrankenPHP Worker 模式14ms32ms1100这个表只是想说明数量级差异不是让你拿去跟别人 PK。真正有意义的是 Worker 模式相比经典模式在平均响应时间和 P95 响应时间上的缩减以及随之而来的吞吐量提升。接口越重、框架引导占比越高这个差距就越明显。5.2 性能提升的主要来源性能提升并不是 Go 比 Nginx 强多少而是架构模型改变带来的复利。第一块是省掉了框架引导。每次请求少做一次 autoload、服务容器构建、服务提供者注册这些固定成本在 worker 模式下全部被摊到进程启动的那一次。第二块是连接复用。数据库和 Redis 的长连接一直在池子里不需要每次握手连接建立和销毁的耗时直接归零。第三块是 Go 侧的网络处理能力。Caddy 本身对 HTTP/1.1、HTTP/2、HTTP/3 的支持是开箱即用的静态资源的处理完全走 Go 的高并发路径不占用 PHP worker 的时间。第四块是早期提示。Caddy 支持 103 Early Hints 响应可以在 PHP 还没执行完之前就把 CSS、JS 等关键子资源的预加载提示推给浏览器首屏体验提升也很明显。5.3 长期运行稳定性观察性能提升是一回事稳定性才是生产环境敢不敢用的关键。我其中一个项目在 worker 模式下连续运行了两周多没有出现过一次 PHP 进程崩溃。单个 worker 的内存稳定在 80 到 150MB 区间10 个 worker 加起来大概是 1GB 上下和 PHP-FPM 常驻池子的资源占用处于同一水平。日志方面FrankenPHP 会把 PHP 的 stdout 和 stderr 统一接到自己的标准输出上容器环境下直接走docker logs就能看到应用日志不需要再单独收集 PHP-FPM 的日志文件。我在排查慢请求时会把 Caddy 访问日志和框架日志对照着看定位速度比以前快了不少。6. 常见问题与排查技巧实录6.1 高频问题速查表实践过程中遇到的问题我整理成了一张速查表供遇到类似情况时对照排查现象可能原因处理办法502 Bad Gatewayworker 脚本路径配置错误或脚本启动即崩溃先单独执行一次 worker 脚本确认无致命错误再检查 Caddyfile 里的 worker 路径修改代码不生效worker 常驻内存未重启部署流程里固定加入重启步骤Octane 项目用octane:reload数据库连接失效长连接被 MySQL/Redis 服务端断开捕获连接异常后重建连接或使用连接池中间件内存缓慢爬升常驻进程里存在状态泄漏排查全局变量和静态变量配合健康检查自动重启异常 worker响应偶尔变慢某个 worker 被阻塞在慢查询或网络请求上压测时关注 P95定位阻塞代码适当增加 worker 数量header 已发送警告worker 模式下输出缓冲行为与传统模式不同避免在入口处直接 echo使用框架的响应对象统一输出HTTPS 证书不生成域名未解析到位或 80 端口不可达按顺序检查 DNS、防火墙、ACME 日志6.2 我实际踩过的几个坑第一个坑是全局状态串请求前面提过这里再说一下排查方法。我的经验是复现时用两个不同的请求交替访问同一个接口如果第二个请求拿到第一个请求的数据基本可以断定是全局状态残留。排查范围从静态变量开始再到单例服务和第三方 SDK逐个隔离。第二个坑是第一次部署后忘记重启 worker导致测试人员对着旧代码测了半天。这个问题的根因是我把更新代码和重启进程当成了两个独立动作。现在我的部署脚本里代码同步和重启永远绑定在一起缺少任何一个步骤脚本直接报错退出。第三个坑是文件锁和 session 冲突。传统模式下每次请求都是新进程写文件、加锁、释放锁的时序比较宽松。worker 模式下多个并发请求在同一个进程池里复用时文件锁竞争和 session 文件写入的冲突明显变多。我把 session 存储改成了 Redis文件型缓存也挪到了内存或 Redis 里这类问题基本绝迹。第四个坑是关于ob_start()和header()的使用习惯。老代码里常见先用ob_start()开启缓冲再header()再echo的模式在 worker 循环里偶尔会触发奇怪的输出顺序问题。建议新代码尽量用 PSR-7 响应对象老代码上 worker 前先做一轮输出相关代码的重构。6.3 几个值得尝试的进阶方向跑稳之后可以沿着这几个方向继续深挖。一是 worker 数量的调优我这里实践下来一般是按 CPU 核心数设置再留 1 到 2 个冗余处理突发流量具体数值还得结合你自己的接口耗时和压测结果来定。二是接入 MercureFrankenPHP 对 Mercure 协议有内置支持实时推送、SSE 这类场景可以直接在这套服务器里解决不需要再单独部署一个推送服务。三是静态资源的外移既然 Go 处理静态文件已经很快但生产环境把图片、视频这类大文件交给 CDN 或对象存储仍然是性价比最高的方案PHP worker 只专注处理动态请求。对不熟悉的项目我强烈建议灰度切换的策略先让少量流量走到 FrankenPHP 上观察一周的日志、错误率、慢请求分布再逐步放量。技术选型最怕的不是新方案有 bug而是你还没摸清它的脾气就全线压上。最后分享一个我个人的小习惯。每次切换架构我都会先把旧方案的配置、命令、监控面板截图留档等新方案稳定运行一个月后再清理。这个习惯救过我很多次因为再好的新技术也难免有考虑不到的环境细节留一条退路你排查问题的心态会稳很多。FrankenPHP 是一个值得长期跟进的服务器方向但要不要切换、什么时候切换最终还是得结合你自己项目的实际情况来判断。