こんにちは。PR TIMESでエンジニアをしている田中 (@Romira915)です。
この記事では、認可をアプリケーション側に残したまま、素材ダウンロード用ZIPの配信をApache・PHPから
Fastly・S3へ切り出した方法と、実装時に遭遇したキャッシュ制御の問題を紹介します。
はじめに
PR TIMESでは、企業が投稿したプレスリリースに含まれる画像を、まとめてZIPでダウンロードできる「素材ダウンロード」という機能を提供しています。
ZIPファイルは一度生成されるとリリースが更新されるまでEC2ローカルに保持され、配信のたびにPHPがreadfile()でそのZIPを読み込んでレスポンスとして返す構成になっていました。
Client ↓Fastly ↓ALB ↓Apache / PHP ↓EC2ローカルのZIPをreadfile()で配信
この方式では、ダウンロードが完了するまでPHPとApacheの接続が維持されます。この素材ダウンロード機能がPHP・Apache側の負荷になっていたため、認可はアプリケーション側に残したまま、配信処理をFastly・S3に切り出す構成に変更しました。
前提条件
配信方法を変えるにあたって、外せない制約が2つありました。
認可判定はアプリケーション側に残す
素材ダウンロードは誰でも無条件にアクセスできるわけではなく、プレスリリースの公開状態やダウンロード用トークンなど、複数の条件を確認した上で許可しています。ファイルの配信処理をアプリケーションから切り離すとしても、この認可ロジック自体は今まで通りアプリケーション側に残す必要がありました。
クライアントから見えるURL・ドメインを変えない
S3などの別ドメインへ302リダイレクトする方式は採用しませんでした。PR TIMESのご利用企業には社内ネットワークのセキュリティが厳しい企業も多く、アクセス先ドメインが変わることでダウンロードができなくなってしまう事態を避けたかったためです。そのため、ユーザーから見えるURLとドメインは変えずに、認可が済んだ後のファイル配信部分だけをPHPから切り離す、という前提を置きました。
検討した選択肢
まず、認可後のファイル配信先をPHPからS3へ切り替える構成は、ALB単体では実現できません。ALBはリスナールールに基づいてバックエンドへ振り分けるだけで、バックエンドが返したレスポンスを見て別のオリジンへ内部的にリクエストし直す、という機能を持たないためです。
現実的な選択肢は次の2つでした。
nginxの X-Accel-Redirect / Apacheの mod_xsendfile
PHPが認可判定だけを行い、ファイルパスをヘッダーで返すと、nginxやApacheがそのヘッダーを見てファイル本体を配信する、という仕組みです。PHPプロセスをファイル転送から解放できる点は魅力でしたが、今回は採用しませんでした。mod_xsendfileは実績・情報が乏しく採用しづらいモジュールだったこと、そしてPR TIMESの構成はALB→Apache→PHPで、そもそもnginxが存在しないため、新規導入するとなると影響範囲が広くリスクが高い変更になってしまうことが理由です。
Fastly VCLの restart
FastlyにはX-Accel-Redirectに相当する専用機能はありませんが、restartを使うことで同様のことができます。PHPが認可レスポンスとして内部配信用のヘッダーを返し、Fastlyがそれを検出してリクエストをS3向けに書き換えてrestartする、という方式です。既存のFastlyサービスへVCLとS3 backendを追加することで実現でき、EC2上のApache構成を変更したり、新しいミドルウェアを導入したりする必要がないため、こちらを選択しました。
実装方式:Fastly VCL restart + S3
具体的な実装は次のような流れです。
アプリケーション側
認可判定に成功すると、PHPはreadfile()でZIPを直接返す代わりに、内部配信用のヘッダーを付けたレスポンスを返します。以下のヘッダー名とパスは説明用に簡略化しています。
HTTP/1.1 200 OK
Cache-Control: private, no-store, no-cache
X-Internal-Redirect: /private-archives/{company_id}/{release_id}/{version}/archive.zipFastly側
vcl_deliverで、オリジンからのレスポンスにX-Internal-Redirectが付いているかを見て、リクエストをrestartします。fastly.ff.visits_this_service == 0で囲っているのは、このrestartをクライアント直近のedgeだけに限定するためです(理由は後の「実装時に詰まったポイント」で詳しく触れます)。
sub vcl_deliver {
#FASTLY deliver
if (fastly.ff.visits_this_service == 0) {
if (req.restarts == 0 && resp.http.X-Internal-Redirect ~ "^/private-archives/[0-9]+/[0-9]+/[0-9]+/archive\.zip\z") {
set req.http.X-Internal-Download = "1";
set req.http.X-Internal-Object-Key = resp.http.X-Internal-Redirect;
unset resp.http.X-Internal-Redirect;
restart;
}
unset resp.http.X-Internal-Redirect;
}
}restartすると、Fastlyの処理は再びvcl_recvから始まります。再実行されたvcl_recvでは、まずクライアントが送ってきた同名の内部ヘッダーを削除してから、X-Internal-Downloadを見てbackendとURLをS3向けに書き換えます。クライアントがこれらのヘッダーを偽装して送ってきても、最初の処理で必ず消えるため乗っ取られることはありません。
sub vcl_recv {
#FASTLY recv
if (req.restarts == 0 && fastly.ff.visits_this_service == 0) {
unset req.http.X-Internal-Download;
unset req.http.X-Internal-Object-Key;
}
if (req.http.X-Internal-Download == "1") {
set req.backend = F_private_static;
set req.url = req.http.X-Internal-Object-Key;
unset req.http.Authorization;
set var.fastly_req_do_shield = true;
set req.http.Fastly-Force-Shield = "1";
set req.enable_segmented_caching = true;
return(lookup);
}
...
}実際の通信
1回目のオリジンリクエストはPHPへ送られ、認可とZIPの配信準備を行います。具体的には、ローカルZIPの確認と必要に応じた生成、S3上の存在確認とアップロードまでを行いますが、ZIP本体はクライアントへ転送しません。restart後の2回目のオリジンリクエストはS3へ送られ、ZIP本体を取得します。クライアントから見ると、最初にアクセスしたURLへのレスポンスとしてZIPがそのまま返ってくる形になり、S3のドメインや内部のオブジェクトキーが見えることはありません。
キャッシュ設計
認可処理そのもの(元のダウンロードURL)はキャッシュせず、毎回PHPへ通します。ここをキャッシュしてしまうと、別ユーザーの認可結果が使い回されてしまう危険があるためです。
一方、restart後にS3から取得するZIP本体は、ブラウザにはキャッシュさせず、Fastlyだけで1分間キャッシュします。S3オブジェクトにはCache-Control: private, no-store, no-cacheを設定し、S3メタデータのsurrogate-control: max-age=60をVCLでSurrogate-Controlヘッダーへ昇格させることで、このキャッシュ制御を実現しています。パスにバージョン(更新時刻など)を含めることで、同じパスの内容は変わらない不変オブジェクトとして扱えるようにしています。
クライアントからこの内部パス(/private-archives/...)へ直接アクセスしても、最初のvcl_recvで内部ヘッダーが削除されるため、S3 backendには切り替わりません。しかしFastlyでは、直接アクセスとrestart後の内部アクセスが同じURLをキャッシュキーとして使うため、何もしなければ同じキャッシュ空間を共有する可能性があります。そこでVaryヘッダーにX-Internal-Downloadを追加し、認可済みの内部アクセスと直接アクセスのキャッシュを分離しています。クライアントが送った内部ヘッダーは最初のvcl_recvで削除されるため、直接アクセスは常に「X-Internal-Downloadなし」のバリアントになります。
実装時に詰まったポイント
キャッシュオブジェクトサイズの上限
Fastlyで大きなZIPをキャッシュしようとすると、503 Response object too largeになる場合がありました。req.enable_segmented_caching = trueを設定することで回避できます。この機能を有効にすると、Fastlyはセグメント単位でオリジンへRangeリクエストを送って取得するため、オリジン側がRangeリクエストに対応している必要があります。PHP・Apache側のオリジン(認可判定エンドポイント)はRangeリクエストを受け付けられませんが、S3は標準で対応しているため、大きな静的ファイルであるZIP本体を取得するrestart後のS3へのリクエストに対してのみ有効化しています。
edgeとshieldが異なるPOPでのキャッシュ漏れ
POP(Point of Presence)とは、複数のキャッシュサーバー(node)で構成されるFastlyの配信拠点です。
Beforeのコードには次の問題がありました。
# Before
if (req.restarts == 0 && resp.http.X-Internal-Redirect ~ "^/private-archives/[0-9]+/[0-9]+/[0-9]+/archive\.zip\z") {
...
}条件はreq.restarts == 0しか見ていないため、edge・shieldどちらのPOPで実行されても真になります。そのためshieldでもrestartが実行され、shieldがS3からZIPを取得します。PHPの認可レスポンス自体はキャッシュ対象外ですが、ここで取得したS3レスポンスはSurrogate-Controlによってキャッシュ可能です。このrestartとURLの書き換えはshield内部だけの話で、呼び出し元のedgeには伝わりません。shieldはこのZIPレスポンスを、edgeが送った元のダウンロードURLへのリクエストに対する応答として返すため、edge自身は自分のreq.url(元のダウンロードURL)を使ってこのレスポンスをキャッシュしてしまいます。結果として、後から来た未認可のリクエストが同じedgeで同じURLにアクセスすると、このキャッシュにヒットし、認可判定をバイパスしてZIPを取得できてしまいます。
# After
if (fastly.ff.visits_this_service == 0) {
if (req.restarts == 0 && resp.http.X-Internal-Redirect ~ "^/private-archives/[0-9]+/[0-9]+/[0-9]+/archive\.zip\z") {
...
}
}fastly.ff.visits_this_service == 0を条件に追加し、クライアント直近のedgeでのみrestartするように限定しました。
アクセスログはURL書き換え後の状態で記録される
Fastlyはvcl_deliverより後にアクセスログを書き込むため、restartでURLを書き換えた場合、ログには書き換え後のURL(S3オブジェクトパス)が残ります。元のダウンロードURLでログを検索しても見つからないので、運用時は注意が必要です。必要に応じて、元のダウンロードURLをrestart前に退避し、アクセスログの専用フィールドへ記録することで、元URLでも追跡できます。
restart でクラスタリングが無効化される
Fastlyの1つのPOPは複数台のキャッシュサーバー(ノード)の集まりです。クライアントのリクエストはPOP内のランダムな1台(配信ノード)にまず着地し、オブジェクトのキャッシュキーを担当するノード(フェッチノード)へ転送されます。これがクラスタリングで、同じオブジェクトへのリクエストがPOP内のどのノードに来ても最終的に同じフェッチノードに集約されるため、キャッシュの重複を防ぎつつ、大量の同時リクエストを1本のオリジンリクエストにまとめられます(リクエストコアレッシング)。
restartを実行するとこのクラスタリングが自動的に無効化されます。そのままだとPOP内の別ノードがそれぞれ個別に判断してS3へ取得しに行ってしまい、リクエストコアレッシングの効果が失われ、人気の高いZIPへのアクセスが集中した際にS3への負荷が跳ね上がりかねません。そのためFastly-Force-Shield: 1ヘッダーを設定してクラスタリングを再度有効化する必要があります。名前にshieldとありますが、FastlyのShielding機能とは別物で、クラスタリングが以前”shielding”と呼ばれていた名残りです。加えて、restart後のS3リクエストも設定済みのshield POPを経由させ、各edge POPが個別にS3へアクセスすることを避けるため、var.fastly_req_do_shield = trueを設定しています。
まとめ
素材ダウンロード機能は、PHPが生成したZIPファイルをApache経由でreadfile()により直接配信しており、ダウンロードが完了するまでPHP・Apacheの接続が占有されるため、PHP・Apache側の負荷になっていました。
これに対して、認可判定はアプリケーション側に残したまま、ファイルの実データ配信だけをFastly VCLのrestart機能で切り出し、S3から直接返す構成に変更しました。PHPが内部配信用のヘッダーを返し、Fastlyがそれを検出してrestartし、S3からZIPを取得してクライアントへ返す、という流れです。
この結果、ZIPの転送処理をPHP・Apache・ALB・EC2から切り離すことができました。

