この記事は「LITALICO Advent Calendar 2024」のシリーズ1の11日目の記事です。
この記事は?
これは、PHPシステムで大量の集計処理をどうしても1リクエスト内で完了させなければならず、さらに既存ユーザーのUXを変えずにタイムアウトせずに実行し切る、という厳しい条件の中で“無理やり並列処理”を実現した奮闘記です。
注意: これは通常怒られてしまう“裏技”的な手法ですので、ぜひ読み物としてお楽しみください。
記事の見出し
- 課せられた課題と現状
- 圧倒的な時間制約と実装条件
- PHPでも並列処理はできるはず!
- プロセスフォークで力技の並列処理
- 謎の子プロセス暴走との戦い
- 最終的にたどり着いた“裏技”での並列処理
課せられた課題と現状
あるWebサービスの開発を任されました。法改正のタイミングで法律によって定められた納期があるため、絶対に遅れられないという制約がありました。さらに、そのシステムには古いレガシーシステムが存在し、誰も手をつけられない状況。レガシー部分には触れず、その上で新しいシステムと結合させる必要がありました。
このWebサービスは既に多くの顧客が利用しており、UXも大きく変えることはできません。しかし、1トランザクションの中でレガシーシステムの重い処理と新システムの連携を同時に実現しなければならず、片方で1分ずつかかる処理を単純に直列に並べると2分以上がかかってしまいます。非同期処理も検討しましたが、UXの観点から同期処理を維持する必要がありました。どうすれば、処理時間を最小限に抑えつつ、既存システムと新システムの連携を実現できるのか──そんな制約の中で、最適解を探すことになりました。
元々のシステム構成
このWebシステムでは、ユーザーがフォームのボタンを押すと重い計算処理が行われ、すぐにその結果が画面に表示される設計になっています。インターフェースの構造上、これを複数のステップに分けることはできず、ボタン1つで完結する一連の流れである必要があります。
新たに実現したいシステム構成
既存の重たい処理に加え、さらに新システムとの接続で追加の処理も行い、複雑なデータ集計が必要です。しかし、これを直列処理するとさらに時間がかかり、ユーザー体験を損なうリスクが出てきました。
圧倒的な時間制約と実装条件
開発環境はPHPとLaravelで、リリース時期の変更はできない厳しいスケジュールです。また、新システムとの結合は巨大プロジェクトで、結合部分の実装に多大な工数がかかりました。初期段階では、レガシーシステムの影響は考慮せず、結合に専念していましたが、リリース直前のパフォーマンステストで問題が明らかに。レガシーシステムと新システムの各処理に時間がかかりすぎ、直列での接続では到底間に合わないことが判明しました。
そのとき思いついたのが「並列処理」でした。UXを維持しつつ、重たい処理を並列化すれば応答速度を改善できるのではと考えました。しかし、他の言語に置き換える余裕はなく、PHP上で何とか工夫する必要がありました。
並列にした場合のシステム構成
こうすればユーザー体験的にはましになるはず。
PHPでも並列処理はできるはず!
PHPで並列処理を実現するにはどうしたらいいか?さまざまな方法を検討しましたが、PHPがNginx上のphp-fpmで動いていることが大きなハードルに。pcntl_fork
などの関数が使えず、手軽なプロセス分岐も難しいことがわかりました。それでも並列処理を諦めるわけにはいかず、他の方法を模索することに。
プロセスフォークで力技の並列処理
最終的に、無理やりではありますが、PHPから popen
を使って外部プロセスを起動することで並列処理を実現しました。具体的には、 artisan command
をOSコマンドとして実行し、別のプロセスとして重たい処理を行わせる形をとりました。子プロセスと親プロセスはパイプで接続し、標準出力を一時ファイルに保存しておき、親プロセス側で出力結果を取得するという強引な構造です。これにより、動作としては並列処理を実現することができました。
しかし、こうした強引な手法の副作用もすぐに現れました。謎のプロセス暴走が頻発し、子プロセスがメモリを大量に消費するなど、予想外の問題が発生。調整が難しい並列処理を、PHPでどのように安定化させていくかという新たな課題が浮き彫りになってきました。
謎の子プロセス暴走との戦い
並列処理の実装が進む中、テスト環境で突然のphp-fpmの暴走に直面しました。エラー発生の直接原因は特定できていませんが、発生するたびにFargateのタスクを再起動しなければ復旧しない状況です。
エラー発生状況の解析
以下はエラー発生時のタイムラインです:
- 21:00: CPU使用率が急激に上昇
- 21:05: リクエスト数が急増
- 21:10: 以下の現象が同時に発生
- 500エラーがユーザーに表示
- php-fpmプロセスが急増
- エラーログにBroken pipeエラーが出力
CPUの急激な負荷増加により、サーバーはリクエストの処理が追いつかず、Webサービスは完全に停止。この状態になると、SSH接続も不可で、外部から強制的にサーバーを再起動するしか方法がありません。
エラーメッセージと原因
発生しているエラーログは以下のとおりです:
70#70: *2161 FastCGI sent in stderr: "PHP message: PHP Fatal error: Uncaught ErrorException: fwrite(): Write of 219 bytes failed with errno=32 Broken pipe in /var/www/xxx
この Broken pipe
エラーは、子プロセスが処理を実行している途中で親プロセスが終了することで発生するようです。親プロセスの終了によりパイプが壊れ、子プロセスが制御不能に陥り、無限に起動と終了を繰り返してしまいます。この現象が発生すると、システムは再起動しない限り正常に戻りません。
対応策としての監視と自動再起動
リリース日は迫っており、納期の変更は許されない状況です。仕方なく、監視ツールで異常検知時に自動再起動する設定を施したうえで、本番リリースに踏み切ることにしました。
最終的にたどり着いた“裏技”での並列処理
リリース後、監視による自動再起動のおかげでシステムはなんとか稼働できていました。しかし、この一時しのぎの対処を続けるわけにはいきません。根本的な解決方法を探し続けた結果、ある「裏技」にたどり着きました。
それは、Guzzleを活用して並列処理を実現するというアイデアです。本来GuzzleはAPIの並列実行を目的としたツールですが、ここで目をつけたのは「APIのように扱えば他の処理も並列にできるのでは?」という点です。
Guzzleを“騙す”並列処理の実装
まず、新システムの処理とレガシー集計の処理を、それぞれ「APIエンドポイント」として見立てました。両方の処理をGuzzleにとって同じAPIであるかのように設計し、パラメータの違いで処理内容を分岐する仕組みを作ります。これにより、Guzzleが両方の処理を並列で実行し、それぞれの結果を待つ形で返却することが可能になりました。
結果として、別々の処理でありながら「同じAPI」として扱わせ、Guzzleでの並列処理を実現できました。この裏技により、無理にシステムを大きく変えることなく、応答速度を大幅に改善できたのです。
これが今回の並列処理の“裏技”の最終形です。シンプルな解決策ではありませんが、時間の制約とシステムの要件を両立させるために辿り着いた一つの答えです。とはいえ、よっぽどの制約がない限り今回のような特殊な手法はおすすめしません!
最後までお読みいただき、ありがとうございました。