file_exists($path) を呼び出し、その $path が Amazon S3 バケットを指している場合、ローカルディスクにアクセスしているのではなく、ネットワークリクエストを行っています。この一行のチェックが S3 へのラウンドトリップとなり、リクエストごとに数十ミリ秒の遅延が加わる可能性があります。
PHP はストリームラッパーを使用してリモートストレージを隠蔽しています。fopen()、file_exists()、is_dir()、unlink()、file_put_contents() といった関数はこれらのラッパーを経由し、呼び出しを S3 API 操作へと変換します。マッピングは以下の通りです。
file_exists()→ HeadObjectis_dir()→ ListObjectsunlink()→ DeleteObjectfile_put_contents()→ PutObject
各呼び出しには、対応する S3 API リクエストと同じレイテンシが発生します。スクリプトが数十個のファイルをチェックする場合、チェックのたびに 1 回のネットワークホップが発生するという、N+1 スタイルのパターンが生じます。
なぜ今、これが重要なのか
通常の開発環境では、ファイルシステムは同じマシン上に存在するため、チェックはほぼ瞬時に完了します。しかし、アセットが S3 にある本番環境では、同じコードがパフォーマンスの急落を引き起こします。AWS SDK は一部の結果をメモリにキャッシュするため、単一の PHP プロセス内での繰り返しのチェックは高速に見えます。しかし、ほとんどの PHP デプロイメントでは、Web リクエストごとに新しいプロセスが生成され、そのたびにキャッシュがクリアされます。その結果、リクエストごとに、すべての異なるパスに対して完全なネットワークラウンドトリップが発生することになります。
ある WordPress サイトでは、ホームページのレンダリングに 10 秒かかったことがありました。データベースクエリは高速でしたが、コンテンツを組み立てるだけで 73 回もの個別の S3 コールがトリガーされていました。各コールがコールドリクエストであったため、一見無害に見える file_exists() が、顕著な遅延へと変わってしまったのです。
緩和策
- リクエスト間でキャッシュを永続化する – プロセス内キャッシュを Redis のような共有ストアに移動します。あるリクエストが S3 上にキーが存在することを確認したら、後続のリクエストは S3 に再度問い合わせる代わりに、キャッシュされた結果を読み取ります。
- ローカルファーストのワークフローを採用する – ファイルをローカルディスクに書き込み、その後非同期で S3 に同期します。メインのリクエストパスはローカルファイルシステムのみに触れるようにし、ネットワークコストはバックグラウンドジョブへと移します。
- コードベースを監査する – リモートラッパーに解決される可能性のあるファイルシステム関数を体系的に検索します。ループ内やリクエストのクリティカルなパス内で使用されているものを特定します。
- APM データを調査する – データベースのメトリクスが安定しているにもかかわらずレスポンスタイムが急増している場合は、ストレージ SDK のタイミングを詳しく調べます。SDK のレイテンシの突然の上昇は、多くの場合、隠れた S3 コールを示しています。
重要な教訓は、マイクロ秒単位のローカルチェックに見える関数が、数十ミリ秒のネットワークレイテンシを隠している可能性があるということです。S3 上の file_exists() は外部呼び出しとして扱い、その結果をキャッシュし、重い処理はリクエストパスから切り離してください。さもなければ、目に見えないネットワークリクエストによって、サイトの速度低下が止まらなくなるでしょう。
