はじめに こんにちは。リテールハブ開発部小売アプリチームの池です。 業務で Laravel Octane のメモリが残る挙動について調査する機会がありました。 Laravel Octane は、長時間稼働するプロセス上で Laravel アプリケーションを動かして高速化するツールです。便利な一方で、プロセスが長く生きるためメモリが残り続け、書き方次第ではリクエスト間で状態が引き継がれてしまうという、従来の Nginx + PHP-FPM 構成の Laravel では発生しにくい特性を持っています。この特性を理解せずに使うと予期しない事故につながる可能性があると感じました。 そこで本記事では、Octane + Swoole の仕組みを整理した上で、サンプルプログラムで挙動を検証し、Worker プロセスが常駐することに起因して気をつけるべきポイントについて整理したいと思います。 Laravel および Octane について多少の知識がある方を前提に書いており、Laravel 本体の解説等には触れません。 なお、本記事の内容は一次情報から確認するように努めていますが、私の理解違いや Octane / Swoole のバージョン差による挙動の違いが含まれている可能性があります。誤って実装すると事故につながり得る領域でもあるため、最終的にはご自身でソースコードや公式ドキュメントをご確認の上で適用ください。 仕組みの整理 この章では、Octane + Swoole で前のリクエストの情報が次のリクエストに残る仕組みを、次の 4 つの観点から整理します。 Octane + Swoole では Worker プロセスが常駐するため、PHP プロセス内のメモリがリクエストごとに初期化されない Octane はリクエストごとに $this->app を clone して $sandbox 上で処理することで、ベースのアプリインスタンスを直接書き換えないようにしている ただし PHP の clone はシャローコピーなので、共有されたオブジェクトの内部状態はリクエスト間で残り得る Octane は RequestReceived イベントに紐づくリスナー群でフレームワーク側の状態をリセットしている 本記事に登場するアーキテクチャ図やライフサイクル図は必要な要素に絞って簡略化しています。 1. Worker プロセスが常駐するため、メモリは初期化されない Octane + Swoole は、長時間生きる PHP プロセスを立ち上げる仕組みです。 Swoole のプロセスアーキテクチャ Worker は OS プロセスです。Manager から複数生成され、一定数のリクエストを処理するか停止シグナルを受信するまで走り続けます。 Worker のライフサイクル 長時間生き続ける Worker プロセスは次のように動きます。 注目したいのは、1 つの Worker プロセスが生き続けたまま複数のリクエストを順に処理し続ける構造です。Worker 起動時に 1 回だけ Laravel アプリケーションを組み立てて $this->app に保持し、その後はリクエストのたびにこの $this->app を clone して $sandbox として使い回します。この「同じ $this->app が複数リクエストにまたがって使われる」点が、後で見る「前のリクエストの情報が次のリクエストに残ってしまう」仕組みの一部になっています。 Worker 起動時に Laravel アプリケーションを組み立てている処理 Worker::boot() の実装は以下の通りです。 <?php // vendor/laravel/octane/src/Worker.php public function boot ( array $ initialInstances = []) : void { // ベースとなる Laravel アプリインスタンスを1つ生成 // 以降、リクエストのたびにここから clone する $ this -> app = $ app = $ this -> appFactory -> createApplication ( ... ) ; $ this -> dispatchEvent ( $ app , new WorkerStarting ( $ app )) ; } なぜメモリが残るのか Swoole + Octane では以下の仕組みでメモリが残ります。 Worker プロセスが生き続けるため、リクエスト終了時に変数・オブジェクトに割り当てられたメモリが解放されない 結果として、 $this->app を含む変数・オブジェクトがリクエストをまたいでメモリに残り続ける ここから先は、この「メモリに残り続ける $this->app をリクエストごとにどう扱うか」ということに焦点を当てます。 2. Octane はリクエストごとに $this->app を clone する Worker が常駐すれば $this->app も残ります。ただ、リクエスト処理の中で $this->app を直接書き換えてしまうと、その変更が次のリクエストにそのまま残ります。Octane はこれを避けるために、リクエストのたびに $this->app を clone して $sandbox を作り、その上でリクエスト処理を回す設計になっています。 <?php // vendor/laravel/octane/src/Worker.php public function handle ( Request $ request , RequestContext $ context ) : void { // アプリインスタンスを clone してリクエスト用の sandbox を作る CurrentApplication :: set ( $ sandbox = clone $ this -> app ) ; $ gateway = new ApplicationGateway ( $ this -> app, $ sandbox ) ; try { $ response = $ gateway -> handle ( $ request ) ; // ... レスポンス返却 ... } finally { $ sandbox -> flush () ; // sandbox 側の bindings クリア unset ( $ gateway , $ sandbox , ... ) ; CurrentApplication :: set ( $ this -> app ) ; // 元のアプリインスタンスに戻す } } ここでの clone の役割は、リクエスト処理を $sandbox 側に隔離して、ベースの $this->app を直接書き換えないようにすることです。 $this->app 自体は Worker 寿命までずっと生き続けますが、毎リクエストの処理が $this->app を直接書き換えなければ、結果としてベースを変更せずに使い回せる、という設計になっています。 3. clone はシャローコピーなので、内部状態はリクエスト間で残り得る ただし、PHP の clone はシャローコピーであるため、前節の「ベースを書き換えない」が成り立つ範囲には限界があります。 Application オブジェクト自体は新規(リクエストごとの器) 配列プロパティ( bindings / instances など)はコピーされる(sandbox 側で書き換えても元には反映されない) 配列の中身(実際のオブジェクト)は元のアプリ $this->app と $sandbox で共有される これにより、以下のような挙動になります。 例えばリクエスト中に app()->instance('request', $req) のように差し替えても、 $sandbox 側にのみ反映され、ベースの $this->app には反映されない 一方で、ベースの $this->app に登録された解決済み singleton インスタンスは両者で共有されたまま 「singleton にリクエスト固有データを入れると次のリクエストにも残る」という現象は、この「clone してもオブジェクト自体は共有される」ことが要因と考えられます。 clone はコンテナの配列レベルでの隔離は提供するものの、配列の中に入っているオブジェクトの内部状態までは守ってくれない、というのがポイントです。 4. Octane の RequestReceived リスナーが一部の状態をリセットする clone だけでは配列の中に入っているオブジェクトのプロパティ書き換えを防げないため、Octane はそこを、リクエストごとに発火するイベントとそれに紐づくリスナーで、明示的に状態をリセットすることで補っています。 Worker のメインループ Worker のメインループの中では RequestReceived イベントが発火し、デフォルトで 8 個のリスナーを順に実行してから HTTP Kernel に処理を渡します。 Worker 起動 ↓ WorkerStarting イベント ↓ ┌── メインループ ─────────────────────────────────────────┐ │ RequestReceived イベント ─→ [8 listeners] │ │ ├─ FlushLocaleState │ │ ├─ FlushQueuedCookies │ │ ├─ FlushSessionState │ │ ├─ FlushAuthenticationState │ │ ├─ EnforceRequestScheme │ │ ├─ EnsureRequestServerPortMatchesScheme │ │ ├─ GiveNewRequestInstanceToApplication │ │ └─ GiveNewRequestInstanceToPaginator │ │ ↓ │ │ HTTP Kernel (Middleware → Controller) │ │ ↓ │ │ リクエスト終了 │ └─────────────────────────────────────────────────────────┘ ↓ max_requests に達したら Worker 再起動 これら 8 個のリスナーは、Locale / Cookie / Session / Auth といったフレームワーク状態のリセットや、 app('request') の差し替え、HTTPS スキームやポートの整合性チェックなどを担います。 実装の中でも、特に挙動を把握しておきたい 2 つを確認します。 まず、認証ガードを毎リクエスト破棄する FlushAuthenticationState を見てみます。 Laravel の AuthManager は内部で Guard インスタンスを $guards 配列にキャッシュし、各 Guard はさらに認証済みユーザーをプロパティに保持します。Octane でこのインスタンスをクリアしないと、 singleton で起きる現象と同じ構造で、前のリクエストの認証ユーザーが次のリクエストに引き継がれてしまいます。 FlushAuthenticationState は、このキャッシュを毎リクエスト破棄することで、前のリクエストの情報が次のリクエストに残らないようにしています。 実装は以下の通りです。 <?php // vendor/laravel/octane/src/Listeners/FlushAuthenticationState.php class FlushAuthenticationState { public function handle ( $ event ) : void { if ( $ event -> sandbox -> resolved ( 'auth.driver' )) { $ event -> sandbox -> forgetInstance ( 'auth.driver' ) ; } if ( $ event -> sandbox -> resolved ( 'auth' )) { with ( $ event -> sandbox -> make ( 'auth' ) , function ( $ auth ) use ( $ event ) { $ auth -> setApplication ( $ event -> sandbox ) ; $ auth -> forgetGuards () ; }) ; } } } auth がコンテナで解決済みの場合に forgetGuards() を呼びだし、Guards のキャッシュをクリアしていることがわかります。 次に、Request インスタンスを差し替える GiveNewRequestInstanceToApplication の実装は以下の通りです。 <?php // vendor/laravel/octane/src/Listeners/GiveNewRequestInstanceToApplication.php class GiveNewRequestInstanceToApplication { public function handle ( $ event ) : void { $ event -> app -> instance ( 'request' , $ event -> request ) ; $ event -> sandbox -> instance ( 'request' , $ event -> request ) ; } } app('request') を新しい Request インスタンスに差し替えます。 app('request') を呼ぶコードが常に最新の Request を見られるのは、このリスナーの働きによるものと理解できます。 何が残って、何が消えるのか 2 つのレイヤーに分けて整理します レイヤー 何が常駐するか リセット手段 Worker プロセス全体 Worker プロセス自体 + 関数テーブル / クラステーブル / static 変数 / グローバル変数 octane:reload / max_request 到達による Worker 再起動 Laravel Application ( $this->app ) bindings、singleton インスタンス、boot 済み ServiceProvider RequestReceived リスナー(部分的) なお、各 Worker は独立した OS プロセスであるため、Worker 間のメモリは分離されています。コルーチン無効時には同じ Worker 内のリクエストも順次処理されるため、気にすべきは「同じ Worker の中で、前のリクエストのデータが次のリクエストに引き継がれてしまわないか」という点に絞られると考えられます。 そのうえで、Laravel アプリコード上でよく使う状態保持の方法ごとに、同じ Worker・別リクエストでどう振る舞うかを整理すると次のようになります。 状態保持の方法 同 Worker・別リクエスト static 変数 残る グローバル変数 / $GLOBALS 残る Worker boot 時点などで解決済みの singleton インスタンスのプロパティ 残る 通常 bind (毎回新規) 毎回新規 scoped バインディング 次のライフサイクル開始時に flush $request->attributes Request 自体が新規生成 「残る」となっている static / グローバル変数 / singleton プロパティは、リクエスト固有のデータや、リクエストごとに増え続けるデータを置くと事故につながり得る点に注意が必要です。アプリ起動時に 1 度だけ初期化されるような不変なデータや、Worker 内で意図的に共有したいデータを置く分には問題ないと考えています。 ここまでは仕様上こうなっているはず、という整理でした。次は簡易的なプログラムで動作を検証します。 検証 仕組みの整理で示した観点を、実機で順に確認していきます。 static 変数 / グローバル変数がリクエスト間で残ること $this->app の内部状態(singleton インスタンスのプロパティ)がリクエスト間で残ること RequestReceived リスナーが一部の状態を実際にリセットすること 加えて、これらの検証が成立する前提として「同 Worker 内ではリクエストが順次処理される」ことを最初に確認します。 検証環境 検証は macOS 上のローカル Docker コンテナで、以下のバージョン構成で行います。 ソフトウェア バージョン Laravel 12.59.0 Octane 2.17.3 Swoole 6.2.1 Octane は次のコマンドで起動します。 php artisan octane:start --server=swoole --workers=10 --max-requests=500 このとき内部で適用される主な Swoole オプションは次の通りです。 設定 値 由来 enable_coroutine false Octane デフォルト(Laravel 本体がコルーチンセーフでないため意図的に無効化) worker_num 10 --workers=10 で指定 max_request 500 --max-requests=500 で指定(メモリリーク対策) 特別な設定はしておらず、Octane / Swoole のデフォルトのままです。 検証用ルートは routes/web.php に用意し、curl で叩いて結果を観察します。 前提の確認: 同 Worker 内でリクエストが順次処理されること まず大前提として、1 Worker 内では複数リクエストが並行処理されず、1 つずつ順番に処理されることを確認します。これ以降の検証はすべて「同じ Worker に来た複数リクエストが順番に処理される」という前提で議論を組み立てるため、確認します。 確認用コード リクエストを受け取ったら 2 秒スリープして PID とコルーチン ID を返すだけの単純なルートを用意します。 <?php Route :: get ( '/test-coroutine' , function () { $ pid = getmypid () ; $ cid = \Swoole\Coroutine :: getCid () ; sleep ( 2 ) ; return [ 'pid' => $ pid , 'cid' => $ cid , 'is_coroutine' => $ cid !== - 1 , ] ; }) ; 実行 Worker 数を 1 に絞った状態( --workers=1 )で、3 リクエストを並列で投げます。もし並行処理されるなら合計 2 秒前後で完了するはずです。 start= $( date +%s ) curl -s http://localhost:8001/test-coroutine > /tmp/r1 & curl -s http://localhost:8001/test-coroutine > /tmp/r2 & curl -s http://localhost:8001/test-coroutine > /tmp/r3 & wait echo " Total: $(( $ ( date +%s ) - start )) s " 結果 { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } Total : 6s 3 リクエストの PID が一致(=14)し、 cid = -1 でコルーチン外、合計時間が 2 秒×3 = 6 秒となっていることから、1 Worker 内ではリクエストが並行ではなく順次処理されることが確認できました。 検証 1: static 変数 / グローバル変数がリクエスト間で残ること 仕組みの整理で「static 変数は同 Worker 内では持続する」と書きました。これを実際に確かめます。 確認用コード <?php Route :: get ( '/test-static' , function () { static $ counter = 0 ; $ counter ++ ; $ GLOBALS [ 'global_counter' ] = ( $ GLOBALS [ 'global_counter' ] ?? 0 ) + 1 ; return [ 'pid' => getmypid () , 'static_counter' => $ counter , 'global_counter' => $ GLOBALS [ 'global_counter' ] , ] ; }) ; static 変数とグローバル変数の両方をインクリメントするシンプルなコードです。 実行 まず、同 Worker 内挙動を見るため Worker 数 1( --workers=1 )で順次 10 リクエストを投げます。 for i in { 1 .. 10 } ; do curl -s http://localhost:8001/test-static echo done 次に、Worker 数 10( --workers=10 )で並列 20 リクエストを投げ、Worker 間の独立性を確認します。 for i in { 1 .. 20 } ; do curl -s http://localhost:8001/test-static > /tmp/s_ $i & done wait for i in { 1 .. 20 } ; do cat /tmp/s_ $i ; echo; done | sort 結果 順次 10 リクエスト( --workers=1 ): { " pid ": 14 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 14 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 14 ," static_counter ": 3 ," global_counter ": 3 } ... { " pid ": 14 ," static_counter ": 10 ," global_counter ": 10 } 並列 20 リクエスト( --workers=10 ): { " pid ": 22 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 22 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 23 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 23 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 24 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 24 ," static_counter ": 2 ," global_counter ": 2 } ... { " pid ": 31 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 31 ," static_counter ": 2 ," global_counter ": 2 } 順次 10 リクエストでは全て同じ PID(=14)で、 static_counter と global_counter が 1 から 10 まで連続して増加しています。同 Worker 内では static / グローバル変数が持続していることが確認できます 並列 20 リクエストでは 10 個の異なる PID(22 〜 31)にリクエストが分散し、それぞれの Worker でカウンタが独立に 1 から始まっています。Worker 間でメモリが分離されていることが分かります 以上から、 static 変数とグローバル変数は同 Worker 内のリクエスト間で持続し、Worker 間では分離されることが確認できました。 これは、1 リクエスト目の値が 2 リクエスト目に意図せず見えてしまう可能性を意味します。前のリクエストの情報が次のリクエストに残るパターンと考えられるため、利用には注意が必要そうです。 検証 2: $this->app の内部状態がリクエスト間で残ること singleton の中にリクエスト固有のデータを保持して、リクエスト間で値が残ることを確認します。 確認用コード <?php // app/Services/UserContextSingletonService.php class UserContextSingletonService { private ? string $ currentUserName = null ; public function setCurrentUser ( string $ name ) : void { $ this -> currentUserName = $ name ; } public function getCurrentUser () : ? string { return $ this -> currentUserName; } } <?php // app/Providers/AppServiceProvider.php public function register () : void { $ this -> app -> singleton ( UserContextSingletonService :: class ) ; } public function boot () : void { // ベースの $this->app->instances にインスタンスを格納するため、boot 時点で resolve する $ this -> app -> make ( UserContextSingletonService :: class ) ; } 通常の singleton() だけでは、初回 app(...) 解決時にインスタンスが sandbox 側に入り、リクエスト終了時の $sandbox->flush() で破棄されます。リクエスト間で持続する状態を再現するため、サンプルでは boot() で make() を呼んでベースの $this->app->instances にインスタンスを積んでいます。 <?php use App\Services\UserContextSingletonService; Route :: get ( '/test-singleton-set/{name}' , function ( string $ name ) { app ( UserContextSingletonService :: class ) -> setCurrentUser ( $ name ) ; return [ 'pid' => getmypid () , 'action' => 'SET' , 'value' => app ( UserContextSingletonService :: class ) -> getCurrentUser () , ] ; }) ; Route :: get ( '/test-singleton-get' , function () { return [ 'pid' => getmypid () , 'action' => 'GET' , 'value' => app ( UserContextSingletonService :: class ) -> getCurrentUser () , ] ; }) ; 実行 curl http://localhost:8001/test-singleton-set/Alice curl http://localhost:8001/test-singleton-get curl http://localhost:8001/test-singleton-get 結果 { " pid ": 14 ," action ":" SET "," value ":" Alice " } { " pid ": 14 ," action ":" GET "," value ":" Alice " } ← 別リクエストなのに残っている { " pid ": 14 ," action ":" GET "," value ":" Alice " } ← まだ残っている 別のリクエストにもかかわらず、Alice という値がそのまま見えています。 singleton バインディングのインスタンスプロパティに格納した値が残ることが確認できました。 検証 3: RequestReceived リスナーが一部の状態をリセットすること FlushAuthenticationState リスナーが Guard キャッシュを破棄していることを確かめます。 確認用コード <?php use App\Models\User; use Illuminate\Support\Facades\Auth; Route :: get ( '/test-auth-set/{name}' , function ( string $ name ) { $ user = new User ([ 'name' => $ name ]) ; Auth :: setUser ( $ user ) ; return [ 'pid' => getmypid () , 'action' => 'SET' , 'user_name' => Auth :: user () ?-> name , ] ; }) ; Route :: get ( '/test-auth-get' , function () { return [ 'pid' => getmypid () , 'action' => 'GET' , 'user_name' => Auth :: user () ?-> name , ] ; }) ; Auth::setUser() を利用してデフォルトの Guardの $user プロパティに直接 User インスタンスを入れ、Guard キャッシュの状態を作って検証します。 実行 (1) デフォルト構成( FlushAuthenticationState 有効) curl http://localhost:8001/test-auth-set/Alice curl http://localhost:8001/test-auth-get 結果は以下の通りです。 { " pid ": 14 ," action ":" SET "," user_name ":" Alice " } { " pid ": 14 ," action ":" GET "," user_name ": null } ← flush で消えている 実行 (2) FlushAuthenticationState を外した場合 検証のため、 RequestReceived リスナーから FlushAuthenticationState を除外します。 <?php // config/octane.php (listeners 部分のみ抜粋) use Laravel\Octane\Events\RequestReceived; use Laravel\Octane\Listeners\FlushAuthenticationState; use Laravel\Octane\Octane; return [ // ... 'listeners' => [ // ... RequestReceived :: class => array_values ( array_filter ( Octane :: prepareApplicationForNextRequest () , fn ( $ listener ) => $ listener !== FlushAuthenticationState :: class , )) , // ... ] , ] ; 同じ curl を実行します。 { " pid ": 15 ," action ":" SET "," user_name ":" Alice " } { " pid ": 15 ," action ":" GET "," user_name ":" Alice " } ← 前のリクエストの情報が残っている リクエストをまたいで Alice という値が残っています。 検証結果から、 FlushAuthenticationState を外すと認証状態がリクエスト間で残り、有効な場合は Guard キャッシュが毎リクエスト破棄されることが確認できました。フレームワーク標準の Auth が Octane でも安全に使えるのは、このリスナーが裏で動いているからこそだと言えます。 おわりに 本記事では、Octane + Swoole で前のリクエストの情報が次のリクエストに残る仕組みを整理し、サンプルコードで動作を検証しました。その結果、 clone とリスナーによって Auth などのフレームワーク側の状態はリクエストごとにクリアされる一方、 static 変数 / グローバル変数や、Worker boot 時に解決済みの singleton インスタンスのプロパティは自動ではクリアされないことが確認できました。 持続させてはいけない場所に状態を置かないこと、また、もし独自のグローバル状態を持たせる場合には RequestReceived などのイベントに自前のリスナーを追加してクリアすることを意識できればと思います。 最後まで読んでいただきありがとうございました。 参考 Deep Dive into Laravel Octane(Albert Chen)