一、为什么要用 Octane,以及先不要解决什么
Laravel Octane 将应用启动一次并常驻内存,后续请求复用已经启动的框架实例。它能减少每次请求重复引导容器、读取配置和注册服务的成本,但也把传统 PHP-FPM 中“请求结束即清空内存”的假设变成长期状态管理问题。
本文以 Laravel + Octane + FrankenPHP 为主线,完成本地基线、容器构建、长驻内存审计、并发安全、反向代理、健康检查、指标、容量测试、滚动发布和回滚。不要在没有性能基线时仅因为“更快”切换运行时。
Browser -> TLS Reverse Proxy -> Octane/FrankenPHP workers
|-- Laravel app booted once
|-- request scoped state
|-- database / cache pools
|-- /healthz and /readyz
`-- logs, metrics, traces
二、建立 PHP-FPM 基线
迁移前记录现有服务在固定数据集下的吞吐、P50/P95/P99、错误率、CPU、RSS、数据库连接数和缓存命中率。测试环境、请求脚本和依赖版本必须冻结,否则不能比较。
php artisan about
php artisan route:list --except-vendor
php artisan config:show app
curl -sS http://app.example.test/healthz
wrk -t4 -c64 -d60s --latency http://app.example.test/api/catalog
创建一组既包含轻接口,也包含数据库、缓存、序列化和异常分支的请求。只压一个返回字符串的路由会夸大 Octane 收益,却无法暴露真实连接池和内存问题。
-- smoke.lua
wrk.method = "GET"
wrk.headers["Accept"] = "application/json"
request = function()
local paths = {"/api/catalog", "/api/profile", "/api/not-found"}
return wrk.format(nil, paths[math.random(#paths)])
end
三、安装 Octane 与选择 FrankenPHP
在项目的 Docker 构建环境中安装并固定依赖版本。官方 Octane 当前支持 FrankenPHP、Open Swoole、Swoole 和 RoadRunner;本文选择 FrankenPHP,是因为它把 PHP 应用服务器和现代 HTTP 服务集成在一起,但仍需根据团队运维能力评估。
composer require laravel/octane
php artisan octane:install --server=frankenphp
composer show laravel/octane
php artisan octane:frankenphp --help
先在预发布启动:
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan octane:frankenphp \
--host=0.0.0.0 \
--port=8000 \
--workers=4 \
--max-requests=500
workers 不是越多越好。先以可用 CPU 为起点,再根据服务等待 I/O 的比例测试。max-requests 用于周期回收 worker,缓解不可控的第三方内存增长,但不能替代修复内存泄漏。
四、构建可重复的生产镜像
采用多阶段构建:依赖阶段安装 Composer 包,资源阶段生成前端产物,运行阶段只保留生产文件。示例只展示关键结构,基础镜像标签应在真实项目中固定到 digest。
# syntax=docker/dockerfile:1.7
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN --mount=type=cache,target=/tmp/cache \
composer install --no-dev --no-interaction --prefer-dist \
--classmap-authoritative --no-scripts
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative
FROM dunglas/frankenphp:php8.4-bookworm AS runtime
RUN install-php-extensions pdo_mysql opcache intl pcntl
WORKDIR /app
COPY --from=vendor /app /app
RUN chown -R www-data:www-data storage bootstrap/cache
USER www-data
EXPOSE 8000
ENTRYPOINT ["php", "artisan", "octane:frankenphp"]
CMD ["--host=0.0.0.0", "--port=8000", "--workers=4", "--max-requests=500"]
docker build --pull --no-cache -t registry.example/app/catalog:2026.09.28 .
docker run --rm registry.example/app/catalog:2026.09.28 php artisan about
docker inspect registry.example/app/catalog:2026.09.28 \
--format '{{json .Config.User}} {{json .Config.Entrypoint}}'
镜像内不要执行数据库迁移作为进程入口。迁移、缓存预热和应用启动应是可独立重试的发布阶段。
五、审计长驻内存:禁止跨请求泄漏
容器生命周期变化
最常见问题是把当前请求、用户、租户、Locale 或可变数组注入 singleton。由于容器只启动一次,这些对象可能保留第一次请求的数据。
// 错误:在单例创建时捕获当前 Request
$this->app->singleton(TenantContext::class, function ($app) {
return new TenantContext($app->make('request')->user()?->tenant_id);
});
// 正确:对象按请求解析,或每次读取当前 request
$this->app->scoped(TenantContext::class, function ($app) {
$request = $app->make('request');
return new TenantContext($request->user()?->tenant_id);
});
静态属性、全局数组、第三方 SDK 缓存和事件监听器也要审计。测试必须连续发送两个不同身份的请求并断言第二个响应不含第一个租户的信息。
it('does not leak tenant context across requests', function () {
$alpha = User::factory()->for(Tenant::factory(['name' => 'alpha']))->create();
$beta = User::factory()->for(Tenant::factory(['name' => 'beta']))->create();
$this->actingAs($alpha)->getJson('/api/context')->assertJsonPath('tenant', 'alpha');
$this->actingAs($beta)->getJson('/api/context')->assertJsonPath('tenant', 'beta');
});
六、重置请求级状态与依赖
Octane 会处理框架核心状态,但应用自己创建的状态必须明确生命周期。避免在服务提供者 boot() 中将请求相关数据写入全局配置。Locale、时区、租户连接名、日志上下文和 feature flag 都应在中间件设置,并在请求结束后清理。
final class ResetRequestContext
{
public function handle($request, Closure $next)
{
try {
Log::withContext(['request_id' => (string) Str::uuid()]);
return $next($request);
} finally {
App::setLocale(config('app.locale'));
date_default_timezone_set(config('app.timezone'));
Log::flushSharedContext();
}
}
}
数据库连接和 Redis 客户端要设置合理的服务端超时,并验证断连后能重建。不要在 worker 内永久保存数据库事务。
DB::transaction(function () use ($order) {
$order->markPaid();
OutboxMessage::record('order.paid', $order->id);
}, attempts: 3);
七、并发任务与副作用安全
并发只缩短可并行的等待
Octane 的并发能力适合互不依赖的 I/O 操作,但闭包必须可序列化、任务必须幂等,且不要共享未加锁的可变状态。先为单任务建立超时和失败语义,再并发。
use Laravel\Octane\Facades\Octane;
[$stock, $price] = Octane::concurrently([
fn () => app(StockService::class)->forSku($sku),
fn () => app(PricingService::class)->quote($sku, auth()->id()),
], timeout: 2_000);
写操作不要简单并发。例如扣库存与创建订单需要事务、幂等键或 Saga,而不是依赖“两个任务都大概率成功”。
八、健康检查、就绪检查和反向代理
存活检查只判断进程能否处理事件循环;就绪检查判断关键依赖是否可用。就绪接口必须快速、有超时、禁止昂贵全表查询。
Route::get('/healthz', fn () => response()->json(['status' => 'ok']));
Route::get('/readyz', function () {
DB::select('select 1');
Cache::store('redis')->put('ready-probe', '1', 5);
return response()->json(['status' => 'ready']);
});
反向代理要传递 Host、真实协议和客户端链路信息,并为长请求设置边界:
upstream catalog_octane {
server catalog-app:8000 max_fails=3 fail_timeout=10s;
keepalive 64;
}
server {
listen 443 ssl http2;
server_name catalog.example.com;
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
proxy_connect_timeout 3s;
proxy_read_timeout 60s;
proxy_pass http://catalog_octane;
}
}
九、可观测性:按 worker 看趋势
至少记录请求总数、状态码、P95/P99、活跃请求、worker 重启次数、worker RSS、PHP 内存、数据库连接池、Redis 延迟和队列积压。日志需要 request_id、trace_id、worker pid 和 release。
Octane::tick('worker-memory-sample', function () {
logger()->info('octane_worker_sample', [
'pid' => getmypid(),
'memory_bytes' => memory_get_usage(true),
'peak_bytes' => memory_get_peak_usage(true),
]);
})->seconds(30)->immediate();
docker stats --no-stream catalog-app
docker logs --since 10m catalog-app | jq -c 'select(.level == "error")'
curl -sS -w 'code=%{http_code} time=%{time_total}\n' -o /dev/null \
https://catalog.example.com/readyz
若 RSS 随每个请求线性增长,先用固定请求序列复现,再二分关闭监听器、SDK 和缓存。不能把降低 max-requests 当作最终修复。
十、容量测试与参数推导
阶梯压测而非瞬时峰值
用真实响应体、数据库和缓存进行阶梯压测:每阶段提高并发,持续时间足够长以观察回收。关注延迟拐点,而不是最高瞬时 RPS。
for c in 16 32 64 128; do
echo "concurrency=$c"
wrk -t4 -c"$c" -d180s --latency -s smoke.lua \
https://catalog.example.com
sleep 30
done
worker 初始值可按 CPU 核数附近测试;I/O 密集服务可能适当增加,但数据库连接上限必须覆盖所有副本的峰值连接。容量表至少写清:副本数、workers/副本、最大连接、稳定 RPS、P99、RSS/worker、错误率。
十一、滚动发布与 reload
Octane reload 让 worker 在处理完当前请求后重启,适合代码或配置更新;容器平台更推荐不可变镜像滚动发布。先迁移,再启动新副本,通过 ready 后逐步移除旧副本。
php artisan migrate --force
php artisan octane:reload
curl --fail https://catalog.example.com/readyz
Kubernetes 示例探针与终止窗口:
readinessProbe:
httpGet: {path: /readyz, port: 8000}
periodSeconds: 5
timeoutSeconds: 2
livenessProbe:
httpGet: {path: /healthz, port: 8000}
periodSeconds: 10
terminationGracePeriodSeconds: 45
发布期间持续比较新旧 release 的错误率和 P99。回滚不仅切回镜像,还要确认数据库迁移是否向后兼容。
十二、验收、故障定位与回滚
验收清单:不同用户连续请求无状态泄漏;worker 周期回收不丢请求;SIGTERM 在窗口内优雅退出;就绪失败会停止流量;依赖断连可恢复;压测 30 分钟 RSS 无持续线性增长;日志可按 request_id/trace_id 定位;旧镜像可在向后兼容数据库上恢复。
php artisan test --parallel
php artisan config:cache
php artisan route:cache
docker kill --signal=TERM catalog-app
docker inspect catalog-app --format '{{.State.ExitCode}} {{.State.FinishedAt}}'
回滚顺序:暂停流量扩张;保留失败实例日志和指标;把流量权重切回旧版本;确认 ready 与核心交易;只有在兼容性已验证时才回退数据库。不要在未取证前删除失败容器。
十三、官方资料
- Laravel Octane 官方文档:服务器选择、依赖注入注意事项、并发、reload、workers 与 max requests。
- FrankenPHP 官方文档:Worker 模式、Caddy 集成、镜像和生产部署。
- Laravel 部署文档:缓存、目录权限、健康路由和优化命令。
- Docker Build 官方文档:多阶段构建、BuildKit cache mount 与镜像可重复性。
上线结论不应是“Octane 压测更快”,而应是:收益可量化、状态隔离已证明、内存趋势受控、依赖容量匹配、发布与回滚经过演练。