作为一个常年折腾PHP部署方案的老兵,我最近把手上几个服务的运行方式整体换了一遍:从Nginx + PHP-FPM换成了FrankenPHP。这名字最早是在Symfony社区的邮件列表里看到的,当时还觉得就是个噱头,直到自己动手压了一轮测,才意识到这东西确实有东西。FrankenPHP本质上是把Caddy、PHP解释器、静态文件服务全部揉进一个二进制文件里的应用服务器,特别适合跑Laravel、Symfony这类现代PHP框架。这篇文章我会从安装、原理、Worker模式实测、Caddyfile配置到踩坑经验,全流程还原一遍,想尝鲜的朋友可以直接照着做。
1. 先搞清楚:FrankenPHP到底是什么
1.1 它不是一个“新语言”,而是一个PHP应用服务器
很多人第一次听到FrankenPHP会误解成又一个PHP框架,其实完全不是。它底层是Go语言写的Caddy服务器,然后把PHP解释器以嵌入方式编译进同一个可执行文件里。你在命令行敲一条frankenphp run,它既负责接收HTTP请求、管理TLS证书,又直接执行PHP代码。
对比一下传统架构:
- 传统方式:Nginx负责HTTP,解析完请求后通过FastCGI协议转给PHP-FPM进程,PHP-FPM再去启动PHP子进程处理。
- FrankenPHP:Caddy接收请求后,直接把请求交给嵌入的PHP运行时,省掉了一层进程间通信。
这个省掉的过程对性能影响很大,因为FastCGI每次通信都有额外的数据封包、协议解析开销,请求量大的时候这部分损耗非常明显。
1.2 为什么它能成为PHP-FPM的替代者
PHP-FPM做了十几年,本身没有大毛病,但有几个绕不开的痛点:第一,每个请求都要重新执行一遍框架的启动流程,Laravel这种重框架光初始化容器就要几十毫秒;第二,配置PHP-FPM的进程池、慢日志、socket参数,每条都很磨人;第三,Nginx的配置和PHP-FPM的配置是两套系统,出了问题要两头排查。
FrankenPHP把静态文件处理、HTTPS证书、HTTP/2和HTTP/3、PHP执行环境全部收敛到一份Caddyfile里,从“多组件拼装”变成了“单文件运行”,运维心智负担降了一个量级。对我来说最大的吸引点是它能跑Worker模式,也就是让PHP进程常驻内存,处理完一个请求不销毁,直接复用执行环境处理下一个请求。这部分对性能的提升是质变,后面我会专门讲。
1.3 适合谁用,解决什么问题
如果你的项目满足下面任一条件,FrankenPHP值得认真试:
- 用的是Laravel、Symfony这类启动成本高的现代框架,想提升接口吞吐量。
- 受够了配置Nginx + PHP-FPM的双层组合,想要一个更简洁的部署方案。
- 需要自动HTTPS,不想手动管理Let's Encrypt证书。
- 想尝鲜HTTP/3,但也希望兼容老客户端。
如果只是跑一个简单的WordPress站点或静态页面,换不换差别不大,传统方案反而文档更多、网上的踩坑资料更全。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 快速上手:从安装到跑起第一个应用
2.1 安装方式选哪种
FrankenPHP提供了三种主流安装方式:官方二进制、Docker镜像、源码编译。我建议优先用官方二进制,因为它最符合“单文件部署”的定位,下载之后加个可执行权限就能用。
bash复制# 从GitHub Releases页下载对应平台的二进制
# 这里以Linux amd64为例
wget https://github.com/dunglas/frankenphp/releases/latest/download/frankenphp-linux-x86_64
chmod +x frankenphp-linux-x86_64
mv frankenphp-linux-x86_64 /usr/local/bin/frankenphp
Docker方式也简单,适合已经在用容器编排的团队:
bash复制docker run -p 8080:80 -p 443:443 -v $PWD:/app dunglas/frankenphp
源码编译适合需要自己编译PHP扩展的场景,比如要加入一个官方二进制里没有的扩展。编译前依赖要装libbrotli-dev、libpcre2-dev、libcurl4-openssl-dev等,比较耗时,不是特殊需要不用折腾。
2.2 第一条命令:运行传统PHP应用
安装好之后,先不要上Caddyfile,直接跑一条最简命令验证环境:
bash复制frankenphp php-server -r /path/to/webroot -l :8080
这条命令会以传统模式启动一个PHP服务器:每个请求都会启动一次PHP进程,处理完销毁。好处是完全兼容任意PHP项目,坏处是性能提升有限。把浏览器指向http://localhost:8080,能正常输出就说明环境没问题。
我当时跑的是一个老的ThinkPHP项目,没有任何代码改动直接就能跑,这点兼容性做得很到位。
2.3 第一次部署容易踩的坑
有几个问题我刚开始没注意,浪费了不少时间:
- 端口冲突。FrankenPHP默认会接管80和443端口,如果机器上已有Nginx在跑,直接启动会报端口占用。先停掉现有Web服务,或者用
-l参数指定别的端口。 - PHP版本差异。官方二进制内置的PHP版本可能和项目要求不一致,比如项目用了老语法只支持7.x,那最好用Docker镜像指定版本,或者自己编译。
- 扩展缺失。运行时会提示缺少某个扩展(最常见的如
intl、pdo_mysql),这不是FrankenPHP的问题,而是编译时没包含进去,换对应扩展版本的Docker镜像就能解决。
注意:
php-server传统模式和PHP-FPM是类似的,优势在自动HTTPS和HTTP/3,但最核心的Worker模式能力没用上。所以跑通之后,早点上Worker模式才是正经事。
3. 核心玩法Worker模式:原理、配置与实战
3.1 Worker模式的工作机制
Worker模式是FrankenPHP最核心的卖点。一句话概括:让PHP进程启动一次,处理无限个请求,而不是每个请求来了都重新启动一个进程。
解释一下它内部的工作流程:
- 启动时,FrankenPHP会拉起一个常驻的PHP Worker进程,执行你指定的
worker.php脚本。 - Worker脚本中的初始化代码只执行一次:容器注册、服务鉴权、配置加载、数据库连接池初始化。
- Worker脚本执行到
frankenphp_handle_request()时进入等待状态,挂起当前请求。 - 收到新请求时,FrankenPHP唤醒Worker,把请求数据填充到超全局变量里,继续执行
frankenphp_handle_request()的后续代码。 - 请求处理完后,Worker回到等待状态,准备处理下一个请求。
换句话说,你的框架代码的“启动阶段”只跑一次,之后每一秒都在干正事。
这个机制对PHP-FPM是降维打击:PHP-FPM模式下,同一个Laravel项目跑100个请求,框架启动代码就执行100次;Worker模式下,框架启动代码只执行1次,剩下99次都直接处理业务逻辑。
3.2 用Worker模式跑起Laravel项目
用Laravel项目来演示最直观,因为Laravel官方生态对FrankenPHP支持已经非常成熟,Laravel Octane从1.5.0版本起就内置了FrankenPHP驱动。
前提条件:
- Laravel项目版本9.x以上,或者已安装Laravel Octane包
- PHP 8.1以上
- FrankenPHP二进制版本不低于1.1.0
安装Octane:
bash复制composer require laravel/octane
php artisan octane:install --server=frankenphp
Octane会自动修改composer.json的scripts片段,并生成frankenphp相关的Worker脚本。启动方式:
bash复制php artisan octane:start --server=frankenphp --host=0.0.0.0 --port=8080
跑起来之后你会看到类似输出:
code复制FrankenPHP server started on http://0.0.0.0:8080
实测时我注意到一个细节:octane:start本质上是调用frankenphp二进制,所以你的环境变量PATH里需要能定位到它,否则会报frankenphp: command not found。
3.3 手写Worker脚本的要点
如果不用Octane,比如跑Symfony或者自己的轻量框架,就需要自己写Worker脚本。先看一个最简示例:
php复制<?php
// worker.php
require __DIR__.'/vendor/autoload.php';
// 初始化应用,只执行一次
$kernel = new AppKernel('prod', false);
$kernel->boot();
while (true) {
// 等待并处理一个请求
$ret = frankenphp_handle_request(function () use ($kernel) {
$request = Request::createFromGlobals();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);
});
// 返回非0值时,说明发生了致命错误,需要退出重启
if ($ret) {
$kernel->shutdown();
exit($ret);
}
}
三个核心点:
frankenphp_handle_request()只能调用一次,且必须在循环里。FrankenPHP会在每次请求结束时自动恢复超全局变量的状态,防止请求间数据污染。- 回调函数只负责当前这次请求的处理。闭包内是请求级别的代码,闭包外是进程级别的常驻代码。
- 返回值非0代表异常退出。正常情况下这个函数会一直阻塞到下一个请求到来,不会立刻返回。
运行自定义Worker的方式:
bash复制frankenphp worker /path/to/worker.php --listen :8080
3.4 Worker模式的注意事项与避坑清单
Worker模式虽然是性能利器,但不代表无脑用。我从实际项目中总结了一组重点:
- 静态属性和全局变量务必小心。传统PHP-FPM模式里,每次请求结束进程就销毁,静态属性里残留的数据不会污染下一个请求。Worker模式下进程常驻,如果在静态属性里缓存了请求相关的用户数据,下一个请求就会读到错数据。
- 数据库连接要支持复用。PDo连接本身可以复用没问题,但如果框架层面每次请求都开启一个新连接且不关闭,连接数会直线飙升,最终把数据库拖垮。使用连接池或让框架复用长连接是必须的。
- 内存泄漏是最大敌人。任何没有释放的大对象在常驻进程里都会被“记住”,日积月累OOM。需要定期观察
ps aux里的RSS内存增长情况,一旦发现平滑增长,必须定位到具体的缓存代码。 - 热更新不能靠扫目录。传统模式每次请求都重新读文件,所以改了代码立刻生效。Worker模式里PHP文件只加载一次,改了文件不会生效。Octane提供了
octane:reload命令,需要配置好队列、数据库连接等监听器,确保重载时重建容器。自研框架则需要自己处理信号,比如监听SIGUSR1后退出当前进程,让FrankenPHP自动拉一个新的Worker起来。
提示:从团队协作角度,强烈建议在CI/CD流程中增加
octane:reload或对应的Worker重启步骤,否则线上更新了代码但Worker还跑着旧代码,排查起来极其隐蔽。
4. Caddyfile配置:把HTTPS、HTTP/3和反向代理一次搞定
4.1 从纯命令行到Caddyfile
php-server和worker命令适合快速启动,但要发挥全部能力,得用Caddyfile。Caddyfile是FrankenPHP配置的主战场,它融合了Web服务器配置、反向代理、TLS证书管理、性能调优等功能。
一个基础的Caddyfile:
code复制example.com {
root * /var/www/project/public
php_server
encode zstd gzip
}
上面这段配置做了四件事:
- 监听
example.com的80和443端口。 - 自动申请并续期Let's Encrypt证书。
php_server指令自动处理所有PHP文件的执行,同时静态文件直接由Caddy返回。encode开启压缩,zstd和gzip同时启用,客户端能解压哪个就用哪个。
启动方式:
bash复制frankenphp run --config /path/to/Caddyfile
4.2 自动HTTPS与证书管理的经验
FrankenPHP继承自Caddy,所以自动HTTPS是零配置的。首次访问时会自动申请证书,后续临近到期会自动续期。整个流程无感知,非常省心。
不过有几个实际问题需要注意:
- 域名的DNS解析要指向服务器IP,否则证书申请会失败。
- 国内服务器访问Let's Encrypt可能不稳定,曾遇到证书申请超时的情况。可以考虑把TLS交给云厂商的负载均衡器处理,或者用DNS Challenge方式验证域名所有权。
- 本地测试时不想要证书,可以在配置里显式关掉自动HTTPS:
code复制{
auto_https off
}
http://localhost:8080 {
root * /var/www/project/public
php_server
}
4.3 生产环境的常用配置片段
我最终落到生产环境的配置大概是这样的:
code复制{
# 全局配置
admin off
auto_https disable_redirects
# 开启HTTP/3
servers {
protocols h1 h2 h3
}
}
example.com {
root * /data/www/example/public
php_server
encode zstd gzip
# 静态资源强缓存
@static path *.css *.js *.png *.jpg *.jpeg *.gif *.ico *.svg *.woff2
header @static Cache-Control "public, max-age=31536000, immutable"
# 屏蔽常见恶意文件路径
@deny path /\.env /\.git/*
respond @deny 404
}
这里解释几个关键点:
admin off是关闭Caddy的API管理端口(默认监听2019端口),生产环境必关,否则有安全隐患。servers里指定h1 h2 h3,客户端支持哪种协议就用哪种,HTTP/3基于QUIC,在高丢包网络环境下表现明显更好。php_server会自动帮处理路径重写,像Laravel这类前后端路由都由public/index.php接管的项目,不需要再写复杂的try_files逻辑,一个指令全搞定。
4.4 反向代理与多站点共存
旧项目不一定能立刻全部迁到FrankenPHP上,可以用Caddyfile做反向代理,让FrankenPHP统一入口,后端继续跑旧服务。
比如一个老的PHP-FPM接口:
code复制api.example.com {
reverse_proxy 127.0.0.1:9000
}
或一个Go服务:
code复制go.example.com {
reverse_proxy 127.0.0.1:8081
}
这样就能做到一个域名入口,流量按需转发到新旧系统,迁移过程不用一蹴而就。
5. 实际压测:跑出来的数据才是硬道理
5.1 测试环境与压测方法
纸上谈兵没意思,直接看数据。我在一台4核8G的云服务器上做了对比测试。
硬件与软件环境:
- 操作系统:Ubuntu 22.04
- CPU:4 vCPU
- 内存:8GB
- PHP版本:FrankenPHP内置8.3
- 框架:Laravel 11
- 压测工具:wrk
压测命令:
bash复制wrk -t4 -c100 -d30s http://localhost:8080/api/hello
测试接口是Laravel自带的一个简单路由,返回JSON。分别测了三种运行方式:FrankenPHP传统模式、FrankenPHP Worker模式、Nginx + PHP-FPM。
5.2 三种模式的结果对比
我跑了三轮,取了中间值,结果如下:
| 运行方式 | 吞吐量(Requests/sec) | 平均延迟(ms) | P99延迟(ms) |
|---|---|---|---|
| Nginx + PHP-FPM | 458 | 218 | 420 |
| FrankenPHP 传统模式 | 512 | 195 | 389 |
| FrankenPHP Worker模式 | 1730 | 58 | 118 |
单看数据,最突出的是Worker模式:比传统PHP-FPM的吞吐量高约2.8倍,平均延迟从218毫秒降到58毫秒,P99从420毫秒降到118毫秒。这个差距非常明显。
5.3 对结果要有清醒认识
上面的数据只是印证了Worker模式“快”这个事实,但不是说任何项目都能提升3倍。
Worker模式的最大收益来自“减少重复初始化”。如果你的项目是重业务逻辑、重DB查询、重缓存读写,那初始化开销在总耗时中占比不高,提升就没有那么夸张。反过来,如果项目是个重路由、重容器构建、多服务注册的框架应用,初始化成本占比高,Worker模式收益就大。
另外,压测环境和真实业务差异很大。线上有慢查询、外部API调用、连接池竞争、垃圾回收波动,数据会打折扣。所以建议在上线前用自己的业务流量做一次对比,别拿特性压测的数据当生产指标的保证。
6. 常见问题与排查技巧实录
6.1 Worker模式下会话状态异常
这是最常踩的坑。PHP传统模式会把$_SESSION写入文件或Redis,每次请求重新读取,所以没问题。Worker模式下进程常驻,如果框架把会话数据放进静态属性或内存缓存,命令A处理时设置的值,可能被命令B读到。
解决方案是确认框架的会话驱动是外部存储(Redis、Database、File),不要把会话数据放在内存里。排查方法很简单:在A接口写一个值,然后在B接口读出来看是否异常。或是用绝对干净的请求测试,不带任何Cookie,看看响应是否还残留上一次会话的信息。
6.2 Worker频繁重启和内存无限增长
长时间跑Worker,最担心的就是内存泄漏。建议做一个监控脚本,定期抓取Worker进程的内存使用情况:
bash复制ps -eo pid,rss,cmd | grep worker.php | grep -v grep
如果RSS值稳定或小幅波动,正常;如果持续线性增长,基本判定代码里有请求间累积的数据没有释放。
常见泄漏点优先级排序:
- 全局数组不断追加数据(如日志、定时任务收集指标)
- 静态属性缓存了按请求变化的数据
- 数据库连接没关闭且频繁新建
- 使用了循环内未清理的临时大对象
定位到代码后,可以用php -m先确认实际加载的扩展,再排查具体模块。
更稳妥的兜底办法是设定期限重启Worker。比如Laravel Octane的--max-requests参数可以指定处理多少请求后自动重启当前Worker,这个机制非常有效:
bash复制php artisan octane:start --server=frankenphp --max-requests=500
如果不想每次都手动重启,也可以用Supervisor守护,让它在检测到内存超过阈值时自动杀掉并拉起重置的Worker。
6.3 静态资源404或缓存策略不对
php_server指令本身会自动处理静态文件,但如果目录权限不对或路径写错,会看到一堆404。排查思路:
- 确认Caddyfile里的
root指向的是Laravel的public或Symfony的public,不是项目根目录。 - 确认目录权限至少755,文件权限至少644,Caddy进程有读取权限。
- 用
curl -I /assets/app.css看响应头,确认静态文件不是由PHP处理,而是Caddy直接返回的。
另外,主题皮肤改版后刷新总看到旧内容,就是缓存策略做得太激进。建议区分版本:为带版本号的静态资源设置immutable缓存,为不带版本号的资源设置no-cache,让浏览器每次向服务端验证。
6.4 与容器编排和监控系统的配合
实际部署过程中,FrankenPHP和Kubernetes这类容器编排系统结合时,有一个头痛点:Worker模式下,请求进来时会独占一个进程,如果同时进来的请求数大于Worker数,后面的请求会产生等待。需要合理配置Worker数量。
我采用的方案是:先压测看单Worker支持多少并发,再根据线上峰值估算Worker数,一般留出1.5倍冗余。Kubernetes里通过HPA(Horizontal Pod Autoscaler)根据CPU和请求数自动扩容Pod,实测下来效果不错。监控方面,重点看Prometheus指标中的请求耗时、Worker进程内存、PHP错误日志这三个维度。
6.5 问题排查速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 请求偶发502 | Worker崩溃退出 | 看FrankenPHP错误日志、PHP错误日志,定位是否命中致命错误 |
| 内存逐渐涨满 | 静态属性或全局数组泄漏 | 用ps定期记录RSS变化,配合Xdebug或dump定位累积点 |
| 改了代码不生效 | Worker进程持有旧代码 | 执行octane:reload或重启Worker进程 |
| 登录状态串号 | 会话数据存了内存 | 检查Session驱动是否用了外部存储 |
| 自动证书申请失败 | DNS解析未生效 | 确认域名A记录指向本机,手动执行一次证书申请看具体报错 |
| 压缩后CPU偏高 | zstd/gzip同时开且级别高 | 调低压缩级别,或仅在大带宽场景开压缩 |
我在实际使用中的几点体会
把FrankenPHP跑上生产环境之后,最大的感受不是“快了多少”,而是“从头到尾只需要关注一份配置、一个进程”,部署体验改善是肉眼可见的。以前排查Nginx和PHP-FPM之间的参数不匹配、socket权限、慢日志格式不统一这些问题,能花掉半天时间,现在日志统一从FrankenPHP侧看,问题定位路径短了很多。
如果你正在维护一个中等流量的PHP项目,并且受够了传统PHP-FPM的性能瓶颈和运维复杂度,建议先拿一台不重要的机器跑一个月,重点观察Worker模式的内存稳定性和业务兼容性。如果项目本身没有静态属性滥用、没有内存泄漏的坏味道,迁移成本其实很低。从长远看,应用服务器常驻内存的模式已经成为现代PHP的主流方向,FrankenPHP正好把这条路上的最后一公里也给铺平了。
