FrankenPHP 与 RoadRunner 现代运行时

FrankenPHP 与 RoadRunner 现代运行时实战:PHP-FPM 阻塞模型的瓶颈与常驻内存动机、FrankenPHP 内嵌 Caddy 的架构与 worker 模式(Caddyfile、自动 HTTPS、HTTP/2/3)、RoadRunner 的 Go 主进程与 PHP worker 通信协议、请求生命周期与内存泄漏防治、平滑重启与优雅停机、Docker 多阶段构建与迁移清单。

引言

传统 PHP 部署模型是「一次请求、一次启动」:Nginx 把请求转发给 PHP-FPM,FPM 分配一个 worker 进程,脚本从头执行、加载框架、连接数据库、渲染响应,然后销毁全部内存。这个模型简单可靠,但每一次请求都要重新 bootstrap 整个框架——一次 Laravel 启动就要数百毫秒和几十 MB 内存。现代运行时的核心思路,是把「启动一次、常驻服务」引入 PHP,让应用像 Go/Node 那样在内存里长住,只把请求当作事件来处理。

本文聚焦两条最主流的常驻路线:FrankenPHP(把 PHP 编译进 Caddy Web 服务器)与 RoadRunner(Go 写的主进程 + PHP worker 进程)。它们都不需要改写代码即可获得数倍吞吐,但也引入了 FPM 时代没有的新问题——内存泄漏、状态污染、优雅停机。本文从架构讲起,给出可运行的配置、压测方法与迁移清单。

前置阅读:异步编程与常驻内存 、性能调优 。


目录


1. PHP-FPM 的瓶颈在哪里

1.1 每请求 bootstrap 的成本

一次典型的 Laravel 请求在 FPM 下的开销分布:

阶段耗时(冷)说明
进程启动 + ini 解析1~3 ms每个 worker 首次
Composer autoload 建立5~20 ms数千个 classmap 项
框架 bootstrap30~120 ms服务容器、Provider、中间件注册
数据库连接建立5~30 ms无连接池时每次重连
业务逻辑变化真正的有效工作

也就是说,大量 CPU 花在了「把环境准备好」而不是「干活」上。OPcache 能省掉编译,但省不掉对象构造与容器装配。

1.2 常驻内存能省掉什么

把 bootstrap 提到进程启动时执行一次,之后每个请求只跑「业务段」:

FPM:      [bootstrap][业务]   [bootstrap][业务]   [bootstrap][业务]
常驻:      [bootstrap]  →  [业务] [业务] [业务] [业务] ...

代价是:全局变量、静态属性、单例在请求之间不会自动重置,必须显式清理;任何一次内存泄漏都会随时间累积。

记忆:常驻运行时用「一次性 bootstrap」换吞吐,但把「请求隔离」的责任从运行时转移给了开发者。


2. FrankenPHP 架构:PHP 内嵌 Caddy

2.1 为什么是 Caddy

FrankenPHP 是一个用 Go 写的 PHP SAPI 实现,它把 PHP 解释器作为库嵌入 Caddy。Caddy 本身是生产级 Web 服务器,天然带自动 HTTPS(Let’s Encrypt)、HTTP/2、HTTP/3(QUIC)、优雅重载。传统栈是「Nginx → FastCGI → FPM → PHP」,FrankenPHP 把这条链压成「Caddy(含 PHP)」,少了一跳 FastCGI 序列化。

传统:  Client → Nginx → FastCGI → PHP-FPM → PHP
FrankenPHP: Client → Caddy (Go) → 内嵌 PHP  ← 同一进程,无 socket 往返

2.2 用 Docker 起步

FROM dunglas/frankenphp:1-php8.3

RUN install-php-extensions pdo_mysql redis opcache intl zip
COPY . /app/public/
WORKDIR /app

# 生产用 worker 模式
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]

2.3 Caddyfile 配置

{
    frankenphp {
        num_threads 8          # 每线程一个 PHP 解释器实例
        worker /app/public/index.php 16   # 预启动 16 个 worker
    }
    auto_https on
}

example.com {
    root * /app/public
    encode zstd br gzip
    php_server {
        try_files {path} {path}/index.php
    }
}

num_threads 决定并发解释器数,worker 指令后的数字是预置 worker 数量。worker 数不必等于线程数——一个线程可轮流服务多个 worker。

记忆:FrankenPHP = Caddy(Go)内嵌 PHP SAPI;frankenphp { num_threads N; worker <入口> M } 控制线程与常驻 worker 数,省去 FastCGI 一跳。


3. Worker 模式与请求生命周期

3.1 worker 脚本写法

FrankenPHP 的 worker 模式要求入口脚本自己实现「取请求 → 处理 → 重置」的循环:

<?php
// public/index.php
$handler = static function () use ($kernel) {
    $request  = \Illuminate\Http\Request::capture();
    $response = $kernel->handle($request);
    $response->send();
    $kernel->terminate($request, $response);
};

$maxRequests = (int) ($_SERVER['MAX_REQUESTS'] ?? 500);
for ($i = 0; $i < $maxRequests; $i++) {
    frankenphp_handle_request($handler);   // 阻塞等待下一个请求
    gc_collect_cycles();                   // 主动回收循环引用
}

frankenphp_handle_request() 内部会在两次调用之间重置 PHP 的 superglobal($_GET/$_SERVER 等),但不会重置你自己写的单例、静态缓存、容器里绑定的对象。

3.2 请求之间必须重置的东西

项目风险处理方式
静态属性缓存跨请求串数据每请求显式清空
单例里的用户态用户 A 的数据泄漏给用户 B用请求作用域重建
已开事务悬挂连接请求末尾 rollback 未提交事务
累加的日志/计数器内存单调增长定期重启或重置

Laravel 官方提供了 Illuminate\Foundation\Application::flush() 与 Octane 的 RequestTerminated 事件来辅助重置,自研框架则要自己保证。

记忆:worker 模式只重置 superglobal,不重置你的单例——「谁持有跨请求状态,谁负责清理」是常驻应用的第一条纪律。


4. RoadRunner:Go 驱动的应用服务器

4.1 架构

RoadRunner 的主进程是 Go 二进制,负责 HTTP、gRPC、队列、WebSocket 等协议处理;PHP 侧是一个常驻的 worker 进程池,通过 Goridge(自定义二进制协议,基于管道/socket)与 Go 主进程通信。请求字节从 Go 传到 PHP,PHP 返回响应字节,Go 再写回客户端。

Client → RoadRunner(Go):路由/TLS/限流/静态文件
             │  Goridge(管道)
             ▼
        PHP worker 进程池(常驻,复用框架)

因为 Go 层处理了网络与协议,PHP 侧只需专注业务,且天然支持 gRPC、队列、KV、指标等插件。

4.2 安装与 .rr.yaml

composer require spiral/roadrunner-cli spiral/roadrunner-http
./vendor/bin/rr get-binary       # 下载与当前 PHP 匹配的 rr 二进制
# .rr.yaml
version: "3"

rpc:
  listen: tcp://127.0.0.1:6001

server:
  command: "php ./vendor/bin/rr-worker"   # 或用 Laravel Octane 的入口
  relay: pipes
  env:
    - APP_ENV: production

http:
  address: 0.0.0.0:8080
  middleware: ["gzip", "static"]
  pool:
    num_workers: 8
    max_jobs: 500            # 处理 500 个请求后回收 worker
    supervisor:
      max_worker_memory: 128  # MB,超过则重启该 worker
  static:
    dir: public
    forbid: [".php", ".htaccess"]

max_jobs 与 max_worker_memory 是防治内存泄漏的第一道防线:worker 处理固定请求数或被内存超限后,被 Go 主进程优雅回收并重启,无需人工干预。

4.3 与 Laravel Octane 结合

Laravel 应用通常不手写 worker,而是通过 Octane 适配:

composer require laravel/octane
php artisan octane:install --server=roadrunner
php artisan octane:start --server=roadrunner --workers=8 --max-requests=500

Octane 会在请求之间自动调用容器的重置钩子,并清理已注册的 Request 实例,减少手工重置的工作量。

记忆:RoadRunner = Go 主进程 + PHP 常驻 worker,靠 Goridge 通信;max_jobs 与 max_worker_memory 是内存泄漏的兜底阀。


5. 内存泄漏与状态污染防治

5.1 定位泄漏

常驻进程最怕「每请求泄漏几百 KB」。用周期性采样观察:

// 在每个请求处理完后记录
$mem = memory_get_usage(true);
error_log(sprintf('req=%d mem=%.2fMB peak=%.2fMB',
    $i, $mem / 1048576, memory_get_peak_usage(true) / 1048576));

若 mem 随请求数单调上升,几乎可以断定存在引用未被释放。常见来源:

  • 全局事件监听器在每次请求重复注册(Event::listen 未去重);
  • 静态属性 static $cache = [] 只增不减;
  • 长生命周期对象互相持有引用形成环,需要 gc_collect_cycles() 才回收;
  • 数据库连接/游标未关闭。

5.2 用限制代替修复

即使短期无法定位所有泄漏,也可以先用「主动回收 + 定期重启」兜底:

if (memory_get_usage(true) > 200 * 1024 * 1024) {
    // 让当前 worker 处理完本请求后退出,由管理器重启
    exit(0);
}

配合 RoadRunner 的 max_worker_memory 或 FrankenPHP 的 MAX_REQUESTS 上限,可把单次泄漏的影响限制在一个 worker 生命周期内。

记忆:先量化(每请求记内存),再定位(事件重复注册/静态缓存/引用环),最后兜底(内存上限触发 worker 重启)。


6. 平滑重启与优雅停机

6.1 为什么要优雅停机

常驻进程重启时,若直接 kill,正在处理的请求会被截断,用户看到 502。优雅停机要求:停止接收新请求 → 等待在途请求完成 → 再退出。

6.2 FrankenPHP / Caddy

# 重载配置,Caddy 会平滑切换,不中断在途连接
frankenphp reload --config /etc/caddy/Caddyfile

# 发送 SIGUSR1 触发 worker 轮换
docker kill --signal=USR1 <container>

6.3 RoadRunner

./rr reset        # 平滑重启所有 worker
./rr workers      # 查看 worker 池状态
./rr serve -d     # 后台运行

RoadRunner 在收到新代码部署后,rr reset 会逐个替换 worker,保证服务不中断。

6.4 部署流水线里的位置

# 部署脚本片段
- git pull
- composer install --no-dev --optimize-autoloader
- php artisan config:cache && php artisan route:cache
- ./rr reset          # 或 frankenphp reload

记忆:优雅停机 = 停止收新 + 等在途 + 再退;FrankenPHP 用 reload/SIGUSR1,RoadRunner 用 rr reset。


7. 容器化部署与调优

7.1 多阶段构建

FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --optimize-autoloader

FROM dunglas/frankenphp:1-php8.3 AS runtime
RUN install-php-extensions pdo_mysql opcache
COPY --from=vendor /app/vendor /app/vendor
COPY . /app
RUN php artisan config:cache && php artisan route:cache

7.2 关键调优参数

参数建议说明
num_threads / num_workers= CPU 核数 × 1.5过高会争抢 CPU
max_jobs / MAX_REQUESTS300~1000越短越安全、越短越费
max_worker_memory128~256 MB依据应用实际占用设定
OPcache validate_timestamps生产设为 0免去每次 stat 检查
OPcache jittracing计算密集场景收益明显

OPcache 预加载(opcache.preload)在常驻模式下收益尤其大,因为整个进程生命周期只需加载一次。

7.3 容器信号与 PID 1

Go 主进程应作为容器的 PID 1,直接接收 SIGTERM 并转发给 worker。避免在 shell 里包一层导致信号无法传递:

# 正确:Go 二进制直接作为入口
ENTRYPOINT ["/usr/local/bin/frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
# 错误:sh -c "frankenphp ..." 会吞掉信号

记忆:常驻容器里 Go 主进程必须是 PID 1;OPcache 关时间戳校验、开预加载,是常驻模式的两个免费提速。


8. 压测对比与选型

8.1 基准测试

用 wrk 或 k6 对比同一应用的三种部署:

wrk -t4 -c200 -d30s --latency http://localhost:8080/api/ping

# k6 脚本化
k6 run --vus 200 --duration 30s script.js

在典型 Laravel「返回 JSON」场景下,常驻运行时相对 FPM 的吞吐提升通常在 2~5 倍,P99 延迟下降更明显,因为省掉了每请求的 bootstrap 与连接重建。

8.2 选型对照

维度FrankenPHPRoadRunnerSwoole
语言Go(Caddy)GoC++ 扩展
协议支持HTTP/1.1/2/3、自动 HTTPSHTTP、gRPC、队列、WSHTTP、TCP、WS
部署复杂度低(单二进制)中(需 rr 二进制)低(PECL 扩展)
生态适配Laravel Octane、SymfonyLaravel Octane、SpiralLaravel Octane、Hyperf
适用需要 HTTPS/HTTP3 的边缘服务需要 gRPC/队列一体化深度定制协程应用

如果团队已经在用 Nginx + FPM 且不想大改运维,FrankenPHP 的迁移成本最低(一个二进制替换整套栈);如果需要内建 gRPC、队列、KV 等一整套组件,RoadRunner 更合适;如果要做极致的协程控制与自定义网络协议,Swoole 的灵活性最高。

8.3 什么场景不适合常驻

  • 代码频繁热改的本地开发(每次改都要重启 worker,反而更慢);
  • 单次执行、无复用的批处理脚本;
  • 依赖大量全局状态且难以重置的遗留系统。

这些场景继续用 FPM 更省心。是否迁移的判断标准很简单:请求量大到「bootstrap 成本」成为瓶颈时,常驻才划算。

记忆:常驻运行时适合高 QPS、代码稳定的服务;本地开发、一次性脚本、强全局状态的遗留系统留在 FPM。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. 图像与文档处理
  2. 流封装与文件系统
  3. gRPC 与 Protobuf 服务