署名付きURLのキャッシュ問題とNo-Vary-Searchヘッダーについて

プライベートなコンテンツのアクセス制限

S3に保存したプライベートなコンテンツを、CloudFrontを経由してダウンロードするには、一般的に以下の方法があります。

  1. アプリケーションサーバーを経由してダウンロードする
  2. 署名付きURLを使う
  3. 署名付きCookieを使う

どれも一長一短がありまして、それぞれ軽く説明しておきます。

アプリケーションサーバーを使う方法

柔軟にアクセス制限を行うことができる一方で、コンテンツダウンロードのアクセスをAppサーバーから逃がしたいという要望には応えることができません。この方法を使う場合は、ファイルのIDと有効期限(またはセッション固有のトークン)から生成したJWTをパラメーターに渡して、Appサーバー側でJWTの検証をすれば、都度アクセス権の評価をしなくていいのでおすすめです。

署名付きURLを使う方法

最も一般的な方法かなと思います。Appサーバーでアクセス権を評価したのち、有効期限が設定された署名付きのURLを生成します。S3から直接アクセスする場合は事前に何も準備する必要はありませんが、CloudFrontを経由する場合は、あらかじめ自前でキーペアを作成して、公開鍵をCloudFrontにアップロードする必要があります。

署名付きCookieを使う方法

署名付きCookieを使うと、複数のファイルを一括でアクセス制限することができます。デメリットとしては、Cookieベースなので、ドメインが制限されることと、ファイル単位のアクセス権には対応できないことが挙げられます。

署名付きURLのHTTPキャッシュ問題

で、なんやかんや検討した結果、署名付きURLを使うケースはやはり多いのではと思います。やっぱりファイルごとにアクセス権を設定したいし、なるべくAppサーバーに負荷をかけたくないですよね。署名付きURLを使う場合、ほとんどのケースでは問題ないのですが、例えば社内ブログなどのサービスで、記事内に画像が大量にあって、それぞれの画像へのアクセスに署名付きURLが設定されているようなケースを考えます。ていうか、つまりこのサービスのことですね。画像にはそれぞれ署名付きURLが与えられてあって、署名には有効期限が設定されてありますから、記事にアクセスするたびに画像のURLが変わります。つまり、ブラウザにキャッシュされないんですね。毎回記事を開くたびにすべての画像をロードし直すことになりますから、パフォーマンスの観点からちょっと気になります。

どうやって解決するか

同じようなことで悩んでいる人たちがいるらしく、この辺でも相談されている方がいますが、これといった回答はないようです。

そんな中、唯一解決策といえそうなのはこんな方法です。

ようするに、URLの署名部分は有効期限をもとにして生成されているので、有効期限を固定したらURLが同じになって、ブラウザのキャッシュも使えるよ、というわけです。とはいえ有効期限を100年後とかに固定したらアクセス制限にならないので、翌日の0:00とかに固定します。

Rubyで実装するとしたらこんな感じになるでしょう。

signer =
Aws::CloudFront::UrlSigner.new(
key_pair_id: KEY_PAIR_ID,
private_key: PRIVATE_KEY
)

padding = 5.minutes
expires_in = 1.day
signer.signed_url(
url,
expires: truncate_time(Time.now.getutc + padding + expires_in, expires_in)
)

def truncate_time(usec, unit)
usec = usec.to_i
usec - (usec % unit.to_i)
end

でも本当はもっと短くしたいよね

有効期限を短くすると、キャッシュのヒット率が悪くなります。かといって長くするとセキュリティの観点から望ましくありません。運用上は1日でも問題になることはほぼないでしょうが、本当は5分とか、もっと短くできればそれに越したことはありません。そこで救世主になりそうなのが、No-Vary-Searchヘッダーです。

No-Vary-Searchヘッダーとは

ここまで説明したように、CloudFrontの署名付きURLにはその名の通り署名を表すクエリーパラメーター「Signature」と、有効期限を表すクエリーパラメーター「Expires」が付きます。これらのパラメーターは変更があっても同じファイルを指しているので、キャッシュがあるかないかを判定するときは、これらのパラメーターを無視してほしいですよね。これを叶えるのがNo-Vary-Searchヘッダーです。

今回の例でいうと、CloudFrontからレスポンスのHTTPヘッダーにこういうのを返せば、うまいことキャッシュしてもらえる、はずです。

No-Vary-Search: params=("Signature" "Expires")

「はず」と書いたのは、現状まだどのブラウザにも実装されていないからで、この仕様を提案しているのはChromeの開発チームですが、Chrome 121で、プリフェッチにおけるNo-Vary-Searchヘッダーの対応が実装されました

HTTPキャッシュについてはこのように書かれています

… HTTP caches
The proposal augments RFC 9111's notion of "Constructing Responses from Caches", providing a mechanism to relax the requirement of "The presented effective request URI [sic] and that of the stored response match" in well-defined ways.
Since everything about the HTTP cache is best-effort, using No-Vary-Search will not guarantee additional cache hits. As such, browsers might implement HTTP cache integration after this feature has already proven its worth on, e.g., preloading caches.

(省略)

「HTTPキャッシュはすべてベストエフォートであるため、No-Vary-Searchは追加のキャッシュヒットを保証しません。そのため、ブラウザはこの機能がすでにプリロードキャッシュなどでその価値が証明された後に、HTTPキャッシュの統合を実装するかもしれません。」

ということなので、HTTPキャッシュに対応したNo-Vary-Searchがそれぞれのブラウザに実装されるのは実現するとしてもかなり先になりそうです。