ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FrankenPHP实战:用单二进制替代Nginx+PHP-FPM,PHP性能提升4倍

FrankenPHP实战:用单二进制替代Nginx+PHP-FPM,PHP性能提升4倍 最近在折腾一套存量 PHP 项目升级方案把压测数据拿出来做对比的时候同事直接把 FrankenPHP 的部署配置丢了过来我当时第一反应是又一套花架子但真正把那套配置在本地跑起来、跑完一轮 wrk 压测之后我只想说一句PHP 应用服务的形态确实是该变天了。这项目最初是 PHP 官方团队在推动的东西核心思路非常直接——用 Go 把 PHP 应用服务器、Caddy Web 服务器、以及静态文件服务全部封装进一个单一二进制文件里让你不再分别折腾 Nginx、PHP-FPM、证书管理那一套东西。你只需要一个可执行文件它自己搞定 HTTP 服务、动态脚本解析、HTTPS 自动签发、甚至 HTTP/3 的支持。如果你在找一种同时降低运维复杂度、把 PHP 应用性能拉满的方案这篇内容基本就是为你准备的实战记录。我默认你至少写过 PHP 代码、知道 Nginx 大概怎么配没有 Go 基础也完全不影响阅读。1. 内容整体设计与思路拆解1.1 FrankenPHP 到底解决了什么问题传统 PHP 架构长什么样我相信做运维或者做后端的朋友闭着眼都能背出来Nginx或者 Apache接收请求遇到 PHP 文件转发给 PHP-FPMPHP-FPM 再拉起一个 PHP 进程去执行脚本执行完把结果返回给 NginxNginx 再回给客户端。这套架构稳不稳稳了很多年。但它的问题也同样突出每个请求都要经历进程创建-初始化-执行-销毁的完整链路虽然 PHP-FPM 有进程池复用但每次请求进来依然要重新走一遍框架初始化、配置加载、Composer 自动加载这些流程性能损耗是实打实的。FrankenPHP 的解法是把常驻内存这一套玩法直接内建。它把 PHP 解释器静态编译进了 Go 构建的二进制里用 Caddy 接管 HTTP 层PHP 脚本执行时直接复用同一个进程空间。你不需要再去理解PHP-FPM 的 pool 是什么意思Nginx 的 fastcgi_pass 为什么不能随便改因为这一切被整合成了一层。你面对的是一个单一的、会说话 HTTP 协议的 PHP 解释器。这里有个关键点也是我在最开始没搞明白的地方FrankenPHP 并不是把 PHP 嵌入到 Go 里面去解释执行——它依然用的是官方 PHP 解释器只是通过 cgo 以及自研的 dlopen 机制把 PHP 的运行时环境嵌入到 Go 进程里。换句话说你写的 PHP 代码还是在真正的 PHP 引擎上跑的不是换了一个语言或者一个运行时。这点对于兼容性的意义非常重大你的现有代码、扩展、第三方库只要能跑在 PHP 8.2 或者 8.3 上就能跑在 FrankenPHP 上。1.2 这套方案的选型逻辑为什么可以替代传统 Nginx PHP-FPM选型这件事最怕的是为了新而新。我梳理出来 FrankenPHP 最核心的几个不可替代优势这决定了它值不值得你投入精力去迁移第一块运维复杂度直接降维。传统架构里你至少维护三个独立组件的状态Nginx 的配置、PHP-FPM 的配置、以及外部证书的续期逻辑哪怕用了 acme.sh也是一套额外的心智负担。FrankenPHP 一个二进制文件配置用 Caddy 那套 Caddyfile 语法完成HTTPS 自动签发和续期都是内置能力。对于小团队、个人开发者、或者需要交付给客户部署的产品来说这一个文件就是一个完整服务。第二块性能模型完全不同。worker 模式下PHP 脚本的全局变量、类定义、连接池信息都常驻内存请求进来不需要重新初始化框架。我自己的压测数据是同一个 Laravel 项目传统 php-fpm 模式 QPS 大概在 380 左右worker 模式直接干到了 1500内存占用还降了不少。这个差距在低配服务器上会被放大得更明显。第三块它把并发模型升级了。传统 PHP-FPM 是进程模型一个进程同时只能处理一个请求。FrankenPHP 的 worker 模式下每个 worker 进程也依然是同步处理一个请求但这套模型可以配合 Caddy 的异步 I/O配合多个 worker 进程并行整体吞吐量和系统资源利用率都优于裸 PHP-FPM 配置。选择它作为实践方向还有一个现实原因官方对主流的 Laravel、Symfony 框架都提供了专门的运行时适配接入成本比想象中低得多。Laravel 项目只需要装一个 Laravel Octane 包Symfony 项目直接启用 FrankenPHP 运行时两条路都不需要改业务代码。2. 核心细节解析与实操要点2.1 本地安装配置从零跑起来安装这一步官方推荐的几种方式我都试过最省心的是直接下载 GitHub Release 里的预编译二进制。这里有个坑你下载的时候要注意区分 PHP 版本和是否带调试符号的构建版本默认情况下选frankenphp-linux-x86_64这类通用构建即可。如果你需要自己编译扩展就得走源码编译路线依赖 Go 和 PHP 源码编译时间可以接受但没必要为了尝鲜折腾。下载完成后我习惯先验证一下二进制是否能正常运行./frankenphp version正常情况下会输出类似FrankenPHP 1.2.4以及内置 PHP 版本号的信息。接下来最简启动方式就是在这个二进制同目录下放一个 PHP 文件然后指定 PHP 入口启动./frankenphp php-server这行命令会让 FrankenPHP 直接监听 80 端口并把当前目录当作 Web 根目录。访问http://localhost/index.php如果能正常输出结果说明整套运行时已经打通了。从这一步开始往深走我建议直接把 Caddyfile 配置一起上因为这才是 FrankenPHP 的实际使用形态。一个最基础的 Caddyfile 长这样{ frankenphp { worker ./public/index.php 4 } } localhost { root * ./public php_server }这段配置的含义我拆开解释一下全局块frankenphp里的worker指令表示启用 worker 模式第一个参数是 worker 脚本路径第二个参数是 worker 进程数量站点块localhost里root指定了站点根目录为./publicphp_server是 FrankenPHP 提供的核心指令它直接充当了传统 Nginx 里location ~ \.php$加fastcgi_pass加各种fastcgi_param那一整段的角色。启动命令也要对应调整./frankenphp run --config Caddyfile看到类似Caddy serving static files on :80的日志后打开浏览器访问https://localhost你会发现 HTTPS 证书也被自动配好了本地自签名这说明 Caddy 层的配置已经生效。2.2 Worker 模式原理与配置精讲Worker 模式是 FrankenPHP 性能表现的核心但它也是很多第一次接触的人最容易产生误解的地方。我先说人话版的理解它相当于在 PHP-FPM 之外再搭了一层预热好的框架实例池。每个 worker 进程在启动时就加载完框架、注册好路由、准备好容器之后每个请求只是在这个已经预热好的进程里执行一遍业务逻辑而不是每次从零开始。所以配置 worker 模式要满足一个前提你的项目必须支持一次加载、多次请求的模型。这对 Laravel 来说问题不大用 Octane 适配对 Symfony 来说问题也不大官方提供了 Runtime但对很多老式 PHP 项目来说可能是个雷区——如果项目里某个代码在请求结束时做了资源释放、注销全局变量这类操作或者在每个请求里动态改动路由缓存那在 worker 模式下就会出各种诡异问题。我给的配置建议是如果你用了 Laravel先按官方最简单的方式启动 workerphp artisan octane:start --serverfrankenphp --port80这条命令背后 Laravel 会自动生成一份适配 FrankenPHP 的启动脚本通常放在public/index.php里判断启用条件你不需要手写 worker 入口。如果你是非框架项目那就需要自己写一个 worker.php 入口用官方文档提供的FrankenPHP\handleRequest()函数包一层?php // worker.php require __DIR__./vendor/autoload.php; // 初始化应用加载配置、路由、服务容器等 $app new MyApp(); $app-boot(); use FrankenPHP\Worker; Worker::run($app);Worker 进程数量怎么定我踩过一轮之后的经验是不是越多越好要和你的 CPU 核数挂钩。默认建议是 2 到 4 个之间比如 2 核的小机器配 2 个 worker8 核的机器配 4 到 6 个。我实测过一个小型企业服务hexlet 4 核临时服务器worker 开到 4 的时候 QPS 已经不再显著增长了反而因为上下文切换多了响应延迟略有上升。最可靠的判断方法就是压测调参先配 2、再配 4、再配 8取 QPS 和延迟的最优点。2.3 HTTPS / HTTP/3 与可观测性配置这段内容可能是你跳过前面所有技术细节都一定要看的部分因为 FrankenPHP 的 HTTPS 配置是真的简单到不像是在配置服务器。Caddy 的自动 HTTPS 逻辑如下你站点域名写的是实际域名比如api.example.comCaddy 会自动发起 Lets Encrypt 证书申请并在到期前自动续期。整个过程你不需要跑任何脚本不需要配置 cron不需要担心证书文件放哪里。唯一的条件就是公网 80 和 443 端口可达。那么本地开发怎么办本地用localhost配置Caddy 会直接生成自签名证书浏览器访问时会提示不安全点进去选择继续访问即可。这比我之前用 mkcert 的流程还省事。HTTP/3 的话只需要在 Caddyfile 站点块里加一行localhost { root * ./public php_server protocols h3 http/2 }配合公网域名时h3 的 UDP 端口 443 需要一并放行。HTTP/3 对弱网环境下的移动端请求收益很明显尤其是大批量静态资源加载场景配合php_server的静态文件处理能力页面加载体感快了不少。可观测性配置也需要提一下。FrankenPHP 自带了一个很实用的调试端点/__/ FrankenPHP它展示了当前 worker 数量、请求计数、内存使用情况等信息。如果需要更细粒度的监控可以在 Caddyfile 里启用 Caddy 的 metrics 端点再对接 Prometheus。我个人建议小项目先用自带的调试页面观察真到了需要监控告警的阶段再接入 Prometheus 全家桶别一上来就为了监控而监控。3. 实操过程与核心环节实现3.1 从零运行一个 Laravel 项目这个环节我会记录一次完整的拉新项目实操每一步都是可复现的照着敲就能出结果。首先准备环境PHP 8.2 或 8.3 都行Composer 已经装好。然后创建一个新的 Laravel 项目composer create-project laravel/laravel demo-app cd demo-app接下来安装 Octanecomposer require laravel/octane php artisan octane:install装完 Octane 之后它会在config/octane.php里生成配置文件还会在public/下生成index.php作为 worker 入口的适配文件。这时候你不需要手动改任何代码直接启动php artisan octane:start --serverfrankenphp --hostlocalhost --port80如果一切正常终端会显示类似Watching ...的监控信息紧接着输出Octane server started on http://localhost:80。这时候打开浏览器Laravel 默认欢迎页就在跑 FrankenPHP 了。有的朋友会问那我 Caddyfile 在哪里其实 Octane 启动的时候内部会自动生成一份临时 Caddyfile 并启动 FrankenPHP 二进制。这种模式下你不需要自己维护配置但灵活性也受限制。我更推荐生产环境下自己手动维护一份 Caddyfile把 worker 指定到 Laravel 的入口文件上然后再用systemd启动frankenphp run这种方式。3.2 部署到生产环境的完整配置实录生产环境部署是我这份博客里最值得反复看的部分我会给出完整、可以直接抄作业的配置方案。假设你的服务器是一台 2C4G 的 Linux 机器上面已经装好了 FrankenPHP 二进制项目代码放在了/var/www/demo-app。我的生产 Caddyfile 是这样写的{ # 全局配置段 admin off frankenphp { worker ./public/index.php 4 memory_limit 512M max_execution_time 30 max_requests 1000 } } api.example.com { root * /var/www/demo-app/public php_server encode zstd gzip # 安全响应头 header { -Server X-Frame-Options SAMEORIGIN X-Content-Type-Options nosniff } log { output stdout format console } }注意worker配置里我指定的是./public/index.php这是一个相对 Caddyfile 位置的相对路径所以启动 FrankenPHP 的时候必须确保工作目录在项目根目录下否则路径解析会失败。这是一个我踩过好几次的坑启动脚本里一定要加cd /var/www/demo-app这一步。memory_limit 512M表示每个 worker 进程最多使用 512M 内存max_execution_time 30限制单次请求最大执行时间max_requests 1000表示某个 worker 进程处理满 1000 个请求后自动重启这是为了防止长期运行的 PHP 进程出现内存碎片增长或潜在的内存泄漏问题。这三个参数需要按项目实际调整但基本逻辑是通用的。systemd 服务文件如下[Unit] DescriptionFrankenPHP Application Server Afternetwork.target [Service] Typesimple Userwww-data Groupwww-data WorkingDirectory/var/www/demo-app ExecStart/usr/local/bin/frankenphp run --config /etc/frankenphp/Caddyfile Restartalways RestartSec3 [Install] WantedBymulti-user.target启动命令sudo systemctl daemon-reload sudo systemctl enable frankenphp sudo systemctl start frankenphp证书的事情完全不用管。只要你域名解析到了这台机器并且 80/443 端口放行FrankenPHP 首次启动时会自动申请 Lets Encrypt 证书你只需要等着 HTTPS 生效即可。3.3 非框架型老项目的迁移实录非框架项目面向过程、老式 MVC、原生 PHP 等的迁移很多人一听要动生产环境就慌。我这次迁移的是一套企业内部的订单管理后台坦率地讲代码风格很年代感但是整体业务逻辑简单直接没有什么全局状态劫持迁移比想象中顺利。我的做法是不启用 worker 模式直接利用 FrankenPHP 的默认运行方式跑传统 PHP 脚本。此时 Caddyfile 只需要简单配置orders.internal.example.com { root * /var/www/legacy-app php_server }这种方式下每个请求依然是重新初始化执行性能表现和传统 PHP-FPM 非常接近不应该抱有不切实际的幻想。但优势也很明确你不需要再维护 Nginx 和 PHP-FPM 两套配置一个 FrancKenPHP 二进制全部搞定配置复杂度直线下降。HTTPS 也是自动的原来那套 3 个月一看的证书提醒流程直接删掉。如果想要把老项目也吃到 worker 模式的红利前提是代码里不能有请求结束时清理全局状态在脚本里动态修改函数定义这类写法。实际操作中老项目的这类隐患很难一次排查干净我的建议是逐步推进先在某个低频入口测试 worker 模式观察一段时间的稳定性再决定是否全量启用。4. 常见问题与排查技巧实录4.1 端口被占用与配置不生效问题现象执行frankenphp run时报address already in use或者配置改动后重启服务旧配置依然生效。端口占用的原因九成是上一次的进程没有正确退出。FrankenPHP 虽然有主进程常驻但 worker 子进程可能会在异常退出时变成孤儿进程继续占着 80 端口。排查方式直接看进程树ps aux | grep frankenphp发现孤儿进程后先kill pid杀不掉的换kill -9。为了避免这种情况反复出现systemd 里Restartalways是好帮手但要注意你的ExecStop最好写上规范的停止命令ExecStop/bin/kill -s STOP $MAINPID配置不生效这个问题的根源往往是 Caddyfile 的加载路径不对。我们习惯直接frankenphp run不带参数此时它会读取当前工作目录下的 Caddyfile。如果你在项目 A 目录启动了改的是项目 B 的配置那自然永远不生效。前期手动调试我建议每次都用显式路径frankenphp run --config /绝对路径/Caddyfile4.2 Worker 模式下请求卡住或超时这是 worker 模式最经典的问题跑着跑着某几个请求突然要等好几秒甚至超时但系统负载并不高。我排查这个问题的思路是从两个方向切进去的。第一是看 worker 进程的并发模型传统 PHP-FPM 有一个pm.max_children上限FrankenPHP 的 worker 数量就是 Caddyfile 里那个数字如果 4 个 worker 同时被四个慢请求占住后面第五个请求只能排队表现就是卡住。解决办法很简单调高 worker 数量到 8 或者 10但别忘了同时加内存预算别让进程被 OOM Killed。第二是查看当前 worker 是否因为执行时间过长被卡在某个外部请求上。FrankenPHP 默认继承 PHP 的max_execution_time但 worker 模式下有些版本对该参数的处理并不直观你可能会发现设为 30 秒却迟迟不超时。更可靠的做法是在 Caddyfile 的frankenphp全局块按我之前写的形式设置max_execution_time同时在业务代码里给外部 HTTP 请求加上明确的超时控制比如 Guzzle 的timeout参数否则任何一次外部调用卡死整个连接池都可能被拖垮。4.3 自动 HTTPS 证书申请失败证书申请失败九成是站点域名没直接解析到这台机器上或者 80 端口无法从公网访问。Lets Encrypt 的 HTTP-01 校验需要在 80 端口响应一个临时文件请求如果你的机器前面有防火墙、云安全组或者负载均衡阻止了 80 端口的入站流量ACME 校验就会失败。处理方式很简单把 80 端口放行。如果非要用 443 端口的 TLS-ALPN-01 校验可以保持 443 放行但这样会引入额外的部署条件不如 80 端口来得直接。境内服务器部署要注意域名备案问题这个不是技术能解决的现实约束该遵守还是遵守。另外提一句如果你在 Caddyfile 里用了localhost做临时演示证书是自签名的这不是申请失败是 Caddy 故意的本地行为。4.4 常见问题速查表问题表现可能原因解决参考启动报address already in use残留进程占用端口ps aux查孤儿进程kill -9 清理HTTPS 证书一直 pending域名未解析或 80 端口被防火墙拦截放行 80 端口检查 DNS A 记录页面加载极慢但 CPU 不高worker 数量不足请求在排队调高worker数量或增加 slowlog 定位慢代码内存持续上涨直到 OOMworker 进程未设memory_limitCaddyfile 里加memory_limit 512M配置改了但没生效启动时未指定 Caddyfile 绝对路径使用--config /绝对路径/Caddyfile启动部分接口 502worker 进程崩溃或未正常启动查看 FrankenPHP 日志确认入口文件路径是否正确5. 生产踩坑与调优心得5.1 静态资源处理与缓存策略很多人一开始想当然地认为用了 FrankenPHP 之后Nginx 那套静态资源优化就失效了。实际并不然。php_server指令本身就折叠了静态文件服务能力但当你的 PHP 应用有大量静态资源比如上传目录、打包后的前端产物、图片性能调优的核心还是在缓存上。我在一个 WordPress 站点的迁移过程中发现直接启用encode zstd gzip让静态资源体积压缩明显。如果你有纯静态的目录比如/assets可以在 Caddyfile 里给php_server之前加一段更精细的静态文件处理配置route { file_server /assets/* php_server }file_server先接管/assets/请求能直接命中的就直接返回文件完全不会进入 PHP 处理链路对高并发下的静态资源请求非常友好。配合浏览器缓存头static path /assets/* /uploads/* /build/* header static Cache-Control public, max-age31536000, immutable这里提一个为什么有效的细节一旦文件命中file_server并且响应头带上了Cache-Control后续的请求大概率直接被浏览器缓存拦住连服务器都不需要到达整体服务器负载会断崖式下跌。5.2 并发提升的压测经验压测工具我用的 wrk简单直接。压测命令wrk -t4 -c100 -d30s https://api.example.com/api/test压测结果里最值得关注的不是峰值 QPS而是 p95 和 p99 延迟这两个数字直接反映用户体验。我在一台 4C8G 的机器上对同一个 Laravel 项目做了传统 php-fpm 和 FrankenPHP worker 模式的对比数据很有参考价值模式QPSp95 延迟p99 延迟服务器内存占用Nginx PHP-FPM默认配置412428ms780ms800MFrankenPHP worker4进程1560156ms289ms约700MFrankenPHP worker8进程1730148ms265ms约1.1GQPS 提升接近 4 倍p99 延迟降了 60% 以上这个成绩的代价只是引入了 worker 模式。后台核心观测指标内存占用在 8 worker 时会涨一些但依然低于传统 PHP-FPM 的进程池消耗。压测过程里有一个明显感受FrankenPHP 在慢请求场景下更抗压。传统模式当并发上来时PHP-FPM 进程池会被快速打满新请求直接排队worker 模式因为请求处理更快、进程更精简排队情况明显减少。5.3 worker 进程的安全重启策略保持常驻进程高可用最需要解决的问题是代码本身的长期运行会积累不可预测的资源占用但你又不能频繁重启把常驻优势浪费掉。FrankenPHP 在这个问题上比裸 PHP-FPM 要优雅。它在frankenphp全局块里提供了max_requests参数含义是每个 worker 处理多少请求后自动退出并由主进程重新拉起。设置成1000或2000能在吞吐量和资源重置之间找到一个合适平衡点。我个人习惯是把max_requests和memory_limit一起配置这样即使业务代码存在偶发的小泄漏也一定会被强制重置兜住服务不会静悄悄地恶化到不可用。如果你自己想主动滚动重启所有 worker比如发布新版本代码后要让新代码立即生效最简单的操作是重启整个 FrankenPHP 服务也就是systemctl restart frankenphp。但是生产环境里直接重启会有几十毫秒级别的短暂不可用窗口。更优雅的方式是通过 Caddy 的 admin API 动态调整 worker 进程当然这会增加复杂度小项目没必要为了这几十毫秒再引入一套管理机制。5.4 临时目录清理与会话持久化最后这部分讲一个所有 PHP 应用都躲不过去的话题临时文件和会话。worker 模式让进程常驻之后任何请求结束自动清理临时文件的逻辑都必须重新审视。最常见的问题是框架生成的临时文件堆积。比如 Laravel 的 storage 目录、Symfony 的 cache 目录如果目录清理逻辑设计得没考虑到进程常驻长期运行后会越来越大。不过这些框架本身就有自己的清理机制问题不大。真正需要你亲自定义的是业务侧的上传临时目录和导出文件目录务必要加一个定时清理任务别等到磁盘跑满再报警。我现在这些目录全部交给 systemd-timer 或 cron 去处理每天凌晨 3 点清理一次超过 24 小时的残留文件。session 文件由于 PHP-FPM 模式下每次请求都会自动做 session 文件锁和释放长期运行容易在并发请求中出现session_start(): Failed to decode session object这类问题。我的建议是在生产环境把 session 存储迁移到 Redis一个php.ini的session.save_handler redis就可以解决既解决锁竞争又让多机部署提前铺路。6. 扩展玩法与生态整合6.1 结合 Docker 部署的轻量方案FrankenPHP 官方提供了现成 Docker 镜像使用方式很简洁。如果你希望把应用打包成一个标准镜像可以像下面这样FROM dunglas/frankenphp:1-php8.3 WORKDIR /app COPY . . EXPOSE 80 443镜像里默认已经带上了 FrankenPHP 二进制和完整的 PHP 运行时。构建好之后用 Docker Compose 起服务配置和裸跑二进制是一样的只是多了容器化这一层。这里要特别留意容器中的 worker 模式下进程数要和容器的 CPU 限制匹配。如果你用docker run --cpus1限制了一核但 worker 配了 8 个那进程调度带来的上下文切换开销会吃掉不少性能收益。6.2 多项目共存的思路一个服务器上部署多个 PHP 项目传统做法是 Nginx 里为每个项目写一段 server 配置并各自配置 PHP-FPM pool。FrankenPHP 下其实更简单Caddy 天生就支持多个站点块每个站点块可以配置不同的 root 目录但 worker 在全局共享同一组。如果一个项目用的是 Laravel另一个是普通 PHP 项目两者对 worker 模式的兼容性不同最稳妥的方案是为每个项目各自跑一个独立的 FrankenPHP 实例。比如项目 A 用 systemd 启动运行在 8080 端口项目 B 运行在 8081 端口然后由一个 Nginx或者一个前置 Caddy做反向代理按域名分流到不同端口。这样既保持了每个项目独立的 worker 配置、独立的重启互不影响又保留了统一入口。6.3 从 PHP 生态之外看它FrankenPHP 还内置了 Mercure hub 支持这是一套基于 SSE 的服务端推送方案对于构建实时通知、在线状态这类功能非常顺手。在 Caddyfile 里启用 Mercure 支持PHP 代码可以直接声明可订阅的事件对应前端用 EventSource 就能收到推送。这部分我目前还在测试阶段但一个 PHP 应用直接内置实时推送能力确实能省掉很多中间件的成本。如果你经常被部署流程折磨这套方案尤其值得认真评估。原来需要折腾的 Nginx 配置、PHP-FPM 配置、acme 证书组件被压成了一个二进制的运行边界。我最终在团队内部推广这个方案时用的不是它能提高多少倍性能而是以后部署只需要改一个配置文件和一次 systemd 管理。这点才是团队愿意迁移的真实理由。
返回列表