FrankenPHP这个项目最近在PHP圈子里讨论度挺高,我趁着一个小项目上线前,专门把部署方案换成了它,前后折腾了一周,踩了不少坑。今天这篇就来聊聊这段时间的实践总结,包括它到底解决了什么问题、Worker模式是怎么回事、生产环境怎么配,以及我在真实项目里遇到的问题和排查思路。如果你正在纠结“传统Nginx+PHP-FPM够不够用”“要不要上常驻内存方案”,这篇应该能帮你省下不少时间。
1. FrankenPHP是什么,为什么它能解决部署的痛
1.1 从PHP-FPM到应用服务器
过去很长一段时间,PHP项目的标准部署组合是“Nginx + PHP-FPM”。这个组合成熟、文档多、踩坑经验也多,但它有个天然的问题:每个请求进来,PHP-FPM都要完整执行一次PHP的生命周期,框架要重新加载配置、重新注册服务、重新初始化容器,然后才开始处理业务逻辑。
对Laravel、Symfony这类重量级框架来说,单纯“启动框架”这一步就要吃掉几十毫秒。平时页面访问量不高感觉不明显,一旦做接口压测或者搞高并发推送服务,瓶颈很快就暴露出来。我见过很多项目,CPU明明没跑满,但QPS上不去,就是因为框架启动开销占比太高。
FrankenPHP的思路是把PHP本身做成一个常驻进程,请求来了直接复用已经初始化好的环境,只执行业务代码。同时它内置了一个Web服务器作为前端,省去了Nginx那一层。这个方案并不是FrankenPHP独创,Swoole、RoadRunner都做过类似的事,但FrankenPHP有个很特别的点:它把Caddy和PHP直接粘在了一个二进制里,部署起来极其省事。
1.2 Caddy在这个项目里的角色
很多人第一次听到FrankenPHP会问:它是不是另一个PHP运行时?不是。它本质上是一个Go语言写的应用服务器,里面打了两个关键组件:一个是Caddy Web Server,另一个是PHP解释器。Caddy负责监听端口、处理HTTP协议、管理HTTPS证书,PHP解释器负责执行业务代码,两者之间通过内部机制高效通信。
所以你可以把它理解为“一台自带完整Web能力、还能跑PHP的服务器”。装上它,不需要单独装Nginx,不需要配置FPM进程池,不需要为“HTTPS证书怎么申请自动续期”操心。Caddy自带Let‘s Encrypt自动申请、自动续期、HTTP/3、静态文件服务、反向代理、重写规则,这些它都包了。
这带来的直接好处是配置量大幅下降。原来一个站点要写Nginx配置、FPM池配置、证书定时任务,现在一个Caddyfile就搞定。我刚开始是用在一个内部API服务上,从决定切换跑通第一个页面,前后不到半小时。
1.3 Worker模式到底快在哪
FrankenPHP支持两种运行方式:普通模式和Worker模式。普通模式下,它类似Nginx+FPM的替代品,每个请求仍然走一遍PHP生命周期,只是部署和证书管理变简单了。真正让它性能起飞的是Worker模式。
Worker模式的原理可以这样理解:PHP脚本启动后不退出,而是常驻内存,等待请求。Caddy收到HTTP请求后,通过进程间通信把请求交给空闲的PHP Worker,Worker处理完再把响应交回来。整个过程不重新执行框架初始化,相当于把Laravel的路由、配置、服务容器一次性加载好,之后每个请求只跑控制器里的业务代码。
我用一个生活化的类比来解释:传统FPM像饭店每次来一位客人都要从洗菜切菜开始炒;Worker模式像厨师已经把所有食材切好备好,客人点菜后直接下锅翻炒,出菜速度自然快很多。
当然,天底下没有免费的午餐。Worker模式要求代码对“请求生命周期”有正确理解,不能随便依赖PHP请求结束时的清理动作,这点后面会细说。
2. 快速上手:从二进制到第一个页面
2.1 环境准备:下载、权限和目录规划
我实际测试用的是官方提供的二进制文件,方式很简单:从FrankenPHP的GitHub Releases页面下载对应平台版本,解压后就能用。当然也可以直接用Docker镜像,这个放到后面的生产章节再讲,先来看看二进制方式怎么跑起来。
下载完成后,我建议把可执行文件放到/usr/local/bin/frankenphp,这样全局命令可以直接调用。然后规划一个项目目录,比如:
/opt/myapp/ ├── public/ │ └── index.php ├── Caddyfile这里的public是Web根目录,Caddyfile是配置文件。第一次跑的时候需要注意:如果服务器的80或443端口被占用了,比如还开着Nginx,记得先停掉或者换一个端口测试。我当时只是在本地开发机测试,直接用8080端口,省事。
2.2 用Caddyfile完成项目配置
FrankenPHP使用Caddy作为配置入口,所以核心就是写一个Caddyfile。最简单的配置是这样:
:8080 { root * /opt/myapp/public php_server }这段配置的意思很直观:监听8080端口,站点根目录指向public目录,php_server这个指令表示“这个站点用PHP处理请求”。
php_server是FrankenPHP提供的一个特殊指令,它的作用相当于“PHP文件交给PHP处理,静态文件有则直接返回,没有则按规则回退到index.php”。对大多数PHP项目来说,这就是你想要的默认行为。
配置好后,在public目录里放一个测试文件:
<?php echo "Hello FrankenPHP";然后执行:
frankenphp run --config /etc/caddy/Caddyfile启动后浏览器访问http://你的IP:8080,看到页面输出,说明整个链路已经通了。
2.3 启动服务与开发调试
我第一次跑的时候遇到一个情况:命令执行后终端直接被Caddy的日志刷屏,看着不太习惯。这里分享几个开发时比较实用的点:
- 前台运行方便看日志,
frankenphp run默认就是前台模式,Ctrl+C停止。 - 想改端口或域名时,直接改Caddyfile里的监听地址,不需要改任何PHP代码。
- 开发阶段建议关闭HTTPS跳转,因为本地没有外网域名,Caddy会自动生成一个内部自签证书,浏览器会报警告。
- 如果只想临时跑一下不写配置,FrankenPHP也支持直接传参,但我个人觉得还是从Caddyfile起步更好,后面加规则、加站点都方便。
有一点要提醒:如果你是在自己的电脑上做本地测试,Caddy可能因为无法绑定80端口而报错,把监听端口改成8080这类高位端口就行,这个问题不太会出现在服务器上。
3. Worker模式实战与性能对比
3.1 开启Worker:配置写法与入口要求
普通模式跑通之后,就该试试Worker模式了。在Caddyfile里,php_server块下可以增加worker配置:
:8080 { root * /opt/myapp/public php_server { worker { command ./public/index.php num_threads 4 } } }这段配置的意思是把public/index.php作为常驻入口脚本,同时启动4个Worker进程(也可以理解为线程)。这里的command指向的是你的PHP入口文件,num_threads建议先设成CPU核心数,后面压测时再调整。
这里有个容易踩坑的点:不是随便拿一个PHP文件就能做Worker入口。因为Worker在启动时会执行一遍文件,之后每次请求进来,都是在这个环境里执行业务逻辑,所以文件里必须有循环获取请求、处理请求、响应用户的逻辑。这一点很像RoadRunner的使用方式。
对于用Laravel这类框架的开发者,官方推荐直接使用Laravel Octane来配合,Octane会自动封装好Worker的请求处理循环,你只需要在Caddyfile里设置好入口脚本就能跑。如果是在普通项目里,可以参考FrankenPHP官方仓库的Worker示例,或者直接用Octane。
3.2 和Laravel Octane的配合
我在生产项目里正好是Laravel,所以直接上了Octane。方式并不复杂:项目里安装laravel/octane扩展后,配置文件里选frankenphp作为服务器驱动,然后启动命令是:
php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8000Octane启动后,Laravel框架整体常驻内存,每个请求进来都直接跑控制器的代码。配合FrankenPHP的Caddy前置,等于把静态文件处理、HTTPS、反向代理和PHP执行都放在了一个体系里。
我一开始还想着自己手写Worker循环,后来发现直接用Octane更省心,框架的清理工作、单测兼容性、缓存问题它都处理过了。如果你是Symfony用户,也有类似的官方封装,不一定要裸写Worker。
3.3 同一台机器的压测数据记录
我把原来跑在Nginx+FPM的API服务切到FrankenPHP Worker模式后,对同一台2核4G的服务器做了简单压测,用的压测工具是wrk,压测路径是一个带数据库查询的Laravel接口。
先看切换前的数据:Nginx+FPM,默认FPM配置下,最大大概能跑到520 QPS,而且延迟到了400ms左右就开始不稳定,偶尔出现504。
再切成FrankenPHP Worker模式,压测结果大概是这样:
| 模式 | 并发数 | QPS | 平均延迟 | 备注 |
|---|---|---|---|---|
| Nginx + PHP-FPM | 64 | 523 | 112ms | 偶发超时 |
| FrankenPHP 普通模式 | 64 | 610 | 96ms | 与FPM接近 |
| FrankenPHP Worker模式 | 64 | 1287 | 45ms | 稳定无超时 |
这里说明一下,压测结果跟项目本身代码质量、数据库连接池、Redis缓存命中率都有关系,不同项目之间差异会很大。我这个项目本身框架初始化成本比较高,所以Worker模式的收益非常明显。如果你的项目本来就是轻量脚本,差距可能没那么夸张。
但有一点是确定性的:Worker模式消除了框架每次初始化的大量CPU开销,在高并发下表现更平滑,不会像FPM那样频繁fork进程。
4. 生产环境部署的各种细节
4.1 用Docker跑FrankenPHP
二进制方式适合开发环境快速验证,生产环境我更推荐用Docker镜像,因为镜像里打包了PHP扩展、Caddy和FrankenPHP本体,版本可控,也方便在调度平台上扩容。
官方镜像名是dunglas/frankenphp,标签有latest、bookworm、alpine等。我用的示例配置如下:
services: frankenphp: image: dunglas/frankenphp:latest ports: - "80:80" - "443:443" volumes: - ./app:/app environment: FRANKENPHP_CONFIG: worker ./public/index.php这个配置把项目代码挂载到容器的/app目录,然后通过环境变量告诉FrankenPHP用Worker模式启动。
体积稍微提一下:官方镜像默认包含了不少PHP扩展,所以镜像会比传统Alpine Nginx镜像大不少。对体积敏感的环境可以自己用官方的Dockerfile做裁剪,把不需要的扩展删掉。我最初对这个问题也有些担心,实际跑起来后觉得可以接受,毕竟一个容器里同时承担了Web服务和PHP,省下的运维复杂度是实实在在的。
4.2 环境变量与配置注入
生产环境里,我们通常不想把敏感配置写进代码库。FrankenPHP本身对Caddyfile支持得很好,同时也支持通过环境变量传递配置。
比较常用的几个环境变量:
FRANKENPHP_CONFIG:注入Caddyfile之外的FrankenPHP配置。SERVER_NAME:配置站点域名,Caddy会根据这个域名自动申请HTTPS证书。APP_ENV、APP_DEBUG:项目自身用的环境变量,容器里正常传递即可。
举个例子,我部署时用的启动方式:
docker run -d \ --name myapp \ -p 80:80 -p 443:443 \ -e SERVER_NAME=api.example.com \ -e APP_ENV=production \ -v /data/app:/app \ dunglas/frankenphp启动后Caddy会自动去Let‘s Encrypt申请api.example.com的证书,自动续期也是内置的。这点比传统Nginx+certbot方案省心很多,我不用再写cron脚本去检查证书续期了。
4.3 多站点与HTTPS
如果你需要在一个服务器上部署多个PHP应用,Caddyfile的多站点能力正好用上。以下是一个双站点配置示例:
api.example.com { root * /srv/api/public php_server } admin.example.com { root * /srv/admin/public php_server }启动后Caddy会自动为两个域名分别申请证书,互不干扰。两个站点共用一个80端口、一个443端口,不像Nginx那样需要多个server块配置,整体看起来清爽很多。
有一点要注意:多站点模式下,Caddyfile文件中的站点块顺序会影响匹配,Caddy会按域名精确匹配,所以只要域名不冲突,顺序问题不大。
4.4 日志、健康检查和服务管理
生产环境的可观测性必须提前考虑。FrankenPHP结合Caddy有几种日志输出方式:默认情况下访问日志和错误日志会输出到标准输出,在Docker容器里用docker logs就能看到。如果部署在Kubernetes里,直接采集 stdout 就行。
如果想让日志更结构化,可以在Caddyfile里配置日志模块,输出成JSON格式,方便接入日志平台。我的做法是在Caddyfile里选择输出到/var/log/caddy/目录并做日志轮转。
健康检查方面,我在容器里额外做了一个小型探活接口,比如在public目录放health.php,返回固定字符串,然后在Docker compose或K8s里配置探针去定期访问。php_server会正常处理这个请求,所以健康检查路径不需要做任何特殊白名单配置。
服务管理上,如果不用Docker而是用二进制方式,可以交给systemd托管。简单写一个Unit文件,指定ExecStart为frankenphp run --config /etc/caddy/Caddyfile,再配置自动重启策略即可。
5. 常见问题和排查记录
5.1 静态文件加载失败
我遇到的第一个问题是:页面样式和图片加载不出来。查了下发现,php_server指令虽然默认会尝试提供静态文件,但具体行为还取决于root路径的配置。如果你用了php_server但还是404,常见原因有两个:一是root路径写错了,导致找不到静态文件;二是请求没有正确落到静态文件目录。
我的解决办法是先在Caddyfile里加一行:
file_server放在php_server旁边或站点块底部。它会明确告诉Caddy遇到存在的文件就直接返回,不要再交给PHP处理。两者的优先级问题,你可以在插件文档里确认一下,经验上php_server本身已经包含了静态文件处理,但显式加file_server会让行为更可控。
5.2 代码改了不生效
Worker模式最大的一个坑就是:代码修改后,如果你没有重启FrankenPHP进程,请求还是会打到旧的常驻内存环境里。我第一次切换Worker模式后,改了控制器代码,刷新页面发现还是旧输出,一度怀疑是不是缓存问题。后来才意识到,框架整体都常驻在内存里,不重启不行。
解决方式很简单:修改代码后,重启FrankenPHP服务。Docker方式就是:
docker restart myapp二进制方式就是重新执行frankenphp run。Octane模式下还可以用php artisan octane:reload只刷新应用状态,不需要完全重启整个服务器。
所以建议是:开发环境尽量不要开Worker模式,或者写好自动重启脚本;生产环境部署流程里一定要在代码发布后加一个重启步骤,否则容易线上排查半天找不出原因。
5.3 Worker模式下内存和连接数异常
用Worker模式跑了一段时间后,我发现RSS内存有缓慢上升的趋势。这个在常驻进程方案中比较常见,不完全算FrankenPHP的问题,而是你的代码需要适配长生命周期。
我排查的方向有三个:一是检查是否在每次请求里创建了大的临时对象而没有释放;二是看数据库连接是否被异常保持;三是确认是否有第三方库在后台缓存了不该缓存的数据。
另外,我自己加了一个监控脚本,定期查看进程RSS是否超过阈值,超过就重启容器。也有同行推荐直接给PHP设置memory_limit,但对于长期运行的Worker,语法层面的限制不如进程级监控直观。
5.4 证书申请失败的问题排查
Caddy的自动HTTPS确实好用,但不代表百分百省心。有几次我在新服务器上启动服务后,发现访问站点一直加载不出来,日志里报ACME证书申请相关错误。排查下来主要是两个原因:一是域名解析还没生效,Let’s Encrypt校验时找不到服务器;二是80端口没有对外开放,ACME挑战无法完成。
Caddy日志里通常会明确提示具体是哪一步失败,照着提示排查相对容易。我的建议是:首次启动前先验证域名解析记录,确保公网能访问你的80端口和443端口。如果你在云服务器上还要检查安全组规则。
5.5 一些容易忽视的兼容性问题
最后说一个代码层面的兼容问题。Worker模式下,PHP全局变量的生命周期变长了,不能假设每个请求结束后$GLOBALS、静态变量、对象属性会自动清空。比如用了APCu缓存,Worker进程间的缓存共享行为会跟FPM模式不同;用了pcntl_fork、exec这类特殊操作,也要确认是否与长生命周期环境冲突。
更通俗地说,你需要把Worker看成一个“常驻后台程序”,而不是“每个请求独立运行的脚本”。我切Worker模式后,重点检查了项目中所有依赖register_shutdown_function、session_start或全局单例的代码。这些都可能在长驻进程下表现异常。框架层面,Laravel配合Octane会自动处理很多细节,但业务代码里仍然可能存在隐患。
6. 最后说点个人体会
从Nginx+FPM切换到FrankenPHP,我个人的感受是:它并没有让PHP变得无所不能,但它确实在“部署体验”和“性能上限”之间找到了一个很好的平衡点。对于追求高吞吐的API服务、CLI常驻任务、或者想简化服务器配置的团队,这个方向很值得试。
尤其是那些被Caddy自动HTTPS、内置静态文件服务、单个二进制部署这几个点吸引的人,放心大胆试。它不会是银弹,但至少能让你的服务器配置干净很多。
如果非要说我在实际操作中有哪个体会最深刻,那就是“Worker模式不是一个开了就完事的开关”。你得接受它带来的新思维:代码要适配常驻进程,部署流程要加重启步骤,调试方式要调整。等这些都适应了,你才会真正感受到它带来的性能优势。希望这篇实践记录能帮你少踩几个坑。