HTTPリクエストの旅ークライエントキャッシュ&DNSクエリー編
この記事は 株式会社オプティマンドアドベントカレンダー2024 22日目の記事となります。
前書き
結構以前からこの記事を書こうと思いました。この前に良いアーキテクチャの本(Fenix architecture)を読んで、改めてアーキテクチャ観点から一つのHTTPリクエストが通る各コンポーネントについて改めて勉強になりました。これは、「zenn.devをブラウザーのURL欄に入れて画面が表示されるまで何が起こっていますか」というようなただの面接問題のためではなく、今時のアプリケーションのシステム にどのようなコンポーネントがあるか、どのような働きをするか、どういった場面に必要になるか、をまとめる学習ノートになります。
上記の本の著者が、Transparent Multilevel Diversion Systemとの概念を提唱しています。これはMultilevel-cacheにちなんだ名前ですが、システム全体で見ればもちろんキャッシュの話だけではありません。また、Transparentはコンポーネントの間の通信でその後ろにあるマルチレベルの構造に意識しないから「透明」と説明されています。
この本では、フェニックス(不死鳥)のような何度でも蘇る堅牢なシステムを構築するために、考慮すべき内容を網羅しています。今時かなり普及されているクラウドサービスも含めて、よく見られるアーキテクチャ構成で考えるときに、リクエストがクライエント(この記事では主にブラウザーを指す)からサーバーまでは通常6つほどのレイヤーを通すことが必要です。すなわち、Client Cache -> DNS -> OSI Model -> CDN Server -> Load Balancer -> Server Cacheを経て、ようやくサーバーの方に届きます。
上記の流れで記事を書いている途中でトピックがだいぶ広がってしまいそうに感じたので、数回の記事に分けようと思います。
- 初回:クライエントキャッシュとDNSクエリー、旅の始まりについて書きます。
- 2回目:OSIモデル、肝心になるネットワーク通信のプロトコルについて書きます。
- 3回目:CDNとLB、中間サーバー・プロクシーについて書きます。
- 最終回:サーバーキャッシュ、サーバーに辿りづく最後の一駅について書きます。
この記事では、初回のクライエントキャッシュとDNSクエリーについてまとめます。
クライエントキャッシュ
WHAT
ここのクライエントは、アプリケーションレイヤー(Next.jsで作られたアプリ、tantack routerのキャッシュ機能など)ではなく、ブラウザーを指しています。ブラウザーとサーバーの間ではHTTPベースの通信となり、HTTP Cachingとしてスペックが定義されています。以降は誤解を避けるために、HTTPキャッシュというふうに説明します(ブラウザーには他にも、サービスワーカーによるキャッシング、in-memoryのキャッシングとかがあります)。
An HTTP cache is a local store of response messages and the subsystem that controls storage, retrieval, and deletion of messages in it.
スペックの説明の通り、1) レスポンスがブラウザーに保存される(local store)、2) その保存されたデータを制御するサブシステム、として理解して良いでしょう。
WHY
なぜHTTPキャッシュが必要なのかというと、もちろんキャッシング共通のパフォーマンス向上もありますが、これはHTTPというプロトコルの無状態(stateless)とも関係しています。無状態というのは、一つ一つのHTTPリクエストが独立しており、前に送ったリクエストや後に送ったものと関係なく、それらのステートも保存しないことを指しています。そのために、cookieの運用が誕生されていますし、都度同じリクエストとレスポンスを繰り返さないためには、キャッシングもプロトコルの設計レベルから導入されています。
HOW
HTTPキャッシュには視点次第でいくつかの分類軸がありますが、サーバーへの通信が発生するかどうかの観点から強制キャッシュと問い合わせキャッシュの2つのタイプがあります。
- 前者ではブラウザーのキャッシュを使い、サーバーへのリクエストがない ->
Expirationをベースに - 後者ではサーバーへリクエストを送り、キャッシュが使えるかどうかを問い合わせする ->
ValidationとConditionをベースに
| name | 強制 | 問い合わせ |
|---|---|---|
| 判定ベース | Expiration(有効期間) | Validation(検証)とCondition(条件) |
| 関連ヘッダー |
Cache-Control, Expires
|
Etag&If-None-Match, Last-Modified&If-Modified-Since
|
| サーバーへリクエスト | 発生しない | 発生する |
強制キャッシュ
強制キャッシュの場合だと、Cache-ControlやExpiresとかのヘッダーを利用することで制御できます。
-
ExpiresではHTTP-dateタイムスタンプ(例:Wed, 28 Aug 2024 09:52:35 GMT)の値が入る。名前通り有効期限が切れるまでHTTPキャッシュを使うことになり、サーバーへのリクエストが発生しない(できない)。ただ、HTTP1.0からのヘッダーで、いくつかの問題点がある- クライエントの時間に影響されてしまう問題 → 仮にクライエント側で時間を変更したとすると、キャッシュが最初に意図した時間より短いまたは長い寿命を持つことになる
- キャッシュしない、場面には対応できない問題 → キャッシュしたいリソースとそうしたくないリソースの選別ができなくなる
- パブリックとプライベートの選別ができない → 他の代理中間サーバー(例えばCDN)にキャッシュしたくないものの選別ができなくなる
-
Cache-Controlの方は現在普及されているもので、Expiresの代替案としてHTTP1.1から導入されている。上記の問題点はもちろん解決されているし、より豊富なオプションで細かい制御が可能になっている。-
max-age,s-maxageいずれも秒単位で有効時間指定を指定する、後者はshared cacheの有効時間を指すため、privateのついたリソースには無効。 -
public,private今のブラウザーだけにキャッシュしてよいものと、代理中間サーバーにもキャッシュして良いものをこれで切り分ける。通常はユーザーごとのログイン情報など。指定がない場合デフォルトではpublicとなる。 -
no-cache,no-storeキャッシュを無効化する関連オプション。ただ、結構紛らわしいのは、no-cacheの方は名前通りキャッシュしないわけではなく、キャッシュはするが使う前に再度サーバー側と検証しなければならない。逆に、no-storeの方はキャッシュを保存しないので、キャッシュをしないのが目的であれば、こちらの方が正解になる。 -
no-transformこれも代理サーバーとかと関係があって、CDNで画像またはテキストのコンテンツを圧縮してスループットを向上させるなどのことを禁止する。 -
min-fresh,only-if-cachedこれらはクライエント側のリクエストに使われるヘッダー。min-freshは秒単位でサーバーからmax-ageが指定された秒数より大きいキャッシュリソースがほしい、を表している。only-if-cachedの方は言葉通り、キャッシュ済みのリソースをください、とのこと。キャッシュがなければ直接504になる。 -
must-revalidate,proxy-revalidatemust-revalidateはmax-ageが過ぎたあと必ず再度サーバーからリソースを取得しなければならない、つまりこの時はno-cacheと同じ挙動になる。proxy-revalidateはCDNを含めた中間の代理サーバーのキャッシュ制御に対しての定義となり、使い方はmust-revalidateと同じになる。
-
問い合わせキャッシュ
強制キャッシュは基本TTLに依存するものになりますが、TTL内でリソースが必ず変わらない保証は実は難しい場面がしばしばあります。その時に変化を検知して必要に応じてTTLが切らなくても再度リソースをサーバーから取得することができれば、この問題はだいぶ解決できるのではないかと。その考え方では、問い合わせキャッシュの問題意識になります。つまり、リソースの有効性についてクライエントとサーバーの間に会話を通して、キャッシュの行為を定めることになります。問い合わせキャッシュに使われるヘッダーはいくつかあります。
-
Last-Modified,If-Modified-Sinceこのペアについて、前者がサーバーからレスポンスのヘッダーになり、いわばリクエストされたリソースが最後に変更された時間。このヘッダーがついている場合、クライエントが再度同じリソースをリクエストする場合は、If-Modified-Sinceに前回受け取ったLast-Modifiedを値を入れて、サーバー側に変更があるかどうかを問い合わせする。仮にサーバー側で変更なしの判断にすれば、304/Not Modifiedが返ってくる。逆に変更がありの場合、200で新しいリソースと、新しいLast-Modifiedのヘッダーが返ってくる。 -
Etag,If-None-MatchEtagはサーバー側のヘッダーになり、サーバー側で決められた方法で該当リソースに対して計算されたハッシュ値になっている。If-None-Matchのほうはクライエント側で使うヘッダーで、前回受け取ったEtagの値が入っている。そのため、リソースの内容に変更があると、このハッシュ値が合わなくなり、新しいリソースが返ってくる。逆に変わっていなければ、304が返ってくる。Last-Modifiedの運用方法と比べて、メリットとしてはリソースコンテンツに対する一致性が強く保証される(例えばコンテンツが変わっていないがLast-Modifiedが変わった場合、有効にキャッシュが使えないが、Etagなら変化なしで検知できる。ただしサーバーによって変更時間もハッシュ値計算項目にいれるため必ずこのケースになるわけでもない)。デメリットとして、やはり都度ハッシュ計算しなければならないところにあって、パフォーマンス面の懸念がある。 -
EtagとLast-Modifiedが一緒に使う場合、優先的にEtagの検証がされ、一致した場合はLast-Modifiedの比較を行う。ハッシュ計算で変更時間を入れないケースもあるため、このようなステップになっている。
最後に、強制キャッシュと問い合わせキャッシュは共存するものであって、どれかにする必要がありません。強制キャッシュが優先されるため、max-ageが切れた時、もしくはno-cache/must-revalidateの場合は、問い合わせキャッシュのヘッダーが利用されます。ただ問い合わせキャッシュについて、別軸での運用方法もあり、それはリソースのバージョン管理になります。つまり、ハッシュ値などをファイル名につけることで、ファイル更新時にキャッシュを失効させる手法です。こちらは現在のSPAのバンドラーによく見られています。この手法+Cache-Controlにするとほとんどの場合は問い合わせキャッシュを考慮しなくても良いでしょう。
コンテンツベースのキャッシュ
クライエントキャッシュの話はこれくらいでよしとして、もう一つの問題はキャッシュ内容のバリエーションになります。HTTPの設計上、一つのURLでリソースを特定するのですが、それを複数の形式で表現することが許されています。具体的な表現形式がVaryとのAccept*系のヘッダーで制御しています。いわばコンテンツ交渉 のことです。
例えば、同じdomain.com/home.htmlのリソースが存在するとして、多言語対応のためにAccept-Languageのヘッダーに基づいて対応バージョンのリソースをレスポンスとして返します。このケースだとVary: Accept-Language, Accept-Language: ja-JPを追加すると、同じリソースの日本語形式の物をくださいとのリクエストになります。他によくある、Accept-Encoding, Accept(MIMEタイプ)などが挙げられますが、考え方は同じです。キャッシュを作る時にはコンテンツ交渉にも影響されるので、同じURLだからキャッシュされいるとは限らないことになります。
運用について
ベストプラクティスについてこちらの記事が結構参考になります。上記のキャッシュタイプも考慮して、運用上の考えとして以下にまとめました。
- キャッシュしたいリソースと、したくないリソースを整理して、それぞれの戦略を適応する(こちらの記事にも参考)
- リソースの
max-ageを高く設定し、immutableもつける - ファイル名のバージョンアップ、もしくはハッシュ値をつけたりしてバージョン管理を行うことで、適切にキャッシュをTTLの前失効させる
- 全くキャッシュしたくないものは
no-storeにする
- リソースの
- リソースのバージョン管理できない場合は問い合わせキャッシュのヘッダーを追加する
- バージョン管理は、
<url>?v=1.0のようなクエリーパラメーターの形も有効 - バージョン管理できない、
Etagなどの問い合わせキャッシュを検討する
- バージョン管理は、
- ユーザー個別情報と言った内容は、
privateにして他の共有キャッシュにできないようにする - 複数のコンテンツタイプのリソースは必ず
Accept*系の内容を指定する
DNS
仮にブラウザーブラウザーでキャッシュヒットせず、次のステップとしては、URLのドメイン名を正しいIPアドレスに解析して、そのIPアドレスに向かって旅立つするのですが、ここは行き先判明のプロセスをまとめます。
WHAT
初心者の頃はDNSの解説を色々と探っていて、要するに「ドメイン名とIPアドレスを記録する電話帳」的な物、しか理解がありませんでした。もちろん、機能だけ考えると、ドメイン名とIPアドレスの対応付(正引き逆引きを含め)を管理するシステムとして正しいと思います。今のインターネットの基盤にとって不可欠な部分とはなりますが、その巧みなマルチレイヤーの設計によってインターネット全体のボトルネックになりませんでした。
WHY
存在の意義として、IPv4またはIPv6のアドレスを覚えるよりは、zenn.devとかが一瞬で伝わるし覚えられやすいのです。この意味で言えば人間とコンピュータにとってそれぞれ適切な表現形式を保存して紐付きすることをやっています。また、ドメイン名を覚えれば良いとのことなので、実際にIPアドレスが変わっても関心を持つ必要がありません。
HOW
DNS nameserver
ドメイン名をDNSサーバーへリクストして、正しいIPアドレスを教えてもらうのが根本的な仕組みですが、 DNSサーバーを言っても実はいくつか種類に分けられることができます。一つのDNSLook upクエリーで絡んでいる内容で言えば、以下となります(参考 )。ここは電話帳の例より、図書館の例で考えた方がわかりやすいかもしれません。
- DNS resolver ここは図書館で受付の役割を持っています。顧客(ブラウザーなどのクライエント)からの探したい本の注文(ドメイン対応するIPアドレス)を受けて、必要な情報を他の知っているサーバーへ問い合わせします。
- Root nameserver ルートサーバーは図書館のインデックスシステムとも考えられます。どの階層(TLDサーバー)にどの棚(トップレベルドメイン情報)があるかは知っていますが、本がどの棚(Authoritative nameserver)にあるかはわかりません。
- TLD(Top Level Domain) nameserver 特定の階層(
.com,.org,.dev,.jpなどのトップレベルのドメイン)の情報を持っていて、どの棚(Authoritative nameserver)に本があるかの情報を知っています。 - Authoritative nameserver ここはそれぞれの本棚に相当していて、各自の棚にある本の内容(DNSレコード)の情報を把握しています。つまり最終的にDNSネットワーク上で具体的にIPアドレス情報(DNSレコードタイプによって違いがある)を記録しているのはこちらになります。
DNSクエリーの流れ
DNSクエリー自体は、ここのzenn.devへアクセス例として、仮にキャッシュは各レイヤーにない状態において、流れとしては以下となります。
- ローカル(ブラウザーとOS)のDNSキャッシュをまず確認し、TTL期間内のドメイン名の持っているIPアドレスがあるかを確認する。ここは
図書館の例えで言えば、自宅の本棚に相当します。もちろん、IPアドレスが仮に変わっても、TTLが切れるまで=再度リクエストするまでは「住所が見つからない」サービスダウンの時期が発生します。他のレイヤーも同じくTTL依存します。 - ローカル(自宅の本棚)にはない場合、次にDNS resolver(図書館の受付)にリクエストします。ここは通常ネットワークサービスを提供するプロバイダーになります。キャッシュになければ次にRootサーバー(図書館のインデックスシステム)にリクエストを飛ばします。
- ルートサーバーでは、トップレベルのドメインを管理するTLDサーバー(図書館のN階)のアドレス帳を持っているので、その中から
.devがどのTLDサーバーにあるかを返します。 - DNS resolverはさらにこのTLDサーバーにリクエストして、
zenn.devのIPアドレスを教えてください、と要求します。ここで直接IPアドレスを返すわけではなく、zenn.devのIPアドレスが知っているAuthoritativeサーバー(N階にあるX本棚)のアドレスを返します。 - DNS resolverは今度こそ、Authoritativeサーバーのアドレスにリクエストして、
zenn.devのIPアドレスを教えてください、と要求します。ようやく、正しいIPアドレス(N階にあるX本棚にある本)が取れたら、ブラウザーなどのクライエントへ返します。もちろん、今度のリクエストがスムーズにできるように、IPアドレスをキャッシュします。- ここのAuthoritativeサーバーへの問い合わせは、場合によって複数回行われることがありえます。例えば、
sub.zenn.co.jpの場合、rootサーバーで.jpのTLDを返す ->.jpのTLDでco.jpのAuthoritativeサーバーを返す ->co.jpはzenn.co.jpのAuthoritativeサーバーを返す ->zenn.co.jpはsub.zenn.co.jpのAuthoritativeサーバーを返す ->sub.zenn.co.jpのAuthoritativeサーバーはIPアドレスを返す
- ここのAuthoritativeサーバーへの問い合わせは、場合によって複数回行われることがありえます。例えば、
- クライエント側でそのIPアドレスを持って、
zenn.devへアクセスして、リソースを取得して画面がレンダリングされ始めます。
DNSレコードタイプ
DNS lookupクエリーが基本上記のステップを踏むわけですが、DNSレコードのタイプによって、Authoritative Serverから以降流れに多少違いが生じます。注意したいのは、レコードタイプはどれであれ、これらはAuthoritative Serverに保存されている内容のため、ここに辿りづくプロセスは基本同じです。よく見られるタイプとそのクエリーに対する流れの影響について以下にまとめました。レコードのタイプはこちらの記事を参考にしています。
| レコードタイプ | 内容 | DNS Lookupにおける影響 |
|---|---|---|
| A | ドメインをIPv4アドレスにマッピング | IPv4アドレスを提供 |
| AAAA | ドメインをIPv6アドレスにマッピング | IPv6アドレスを提供 |
| CNAME | あるドメイン名を別のドメイン名にエイリアス設定(e.g. www.zenn.devをzenn.devのエイリアスに指定し、リダイレクトされます) |
別のドメイン名で再度rootサーバーからLookupする |
| NS | ドメインのAuthoritative DNSサーバーを指定しています | この場合はIPアドレスが管理されているNSが指定されるので、rootからの検索は不要になります |
Pros & Cons
このマルチレイヤーの設計には、いくつかのメリットを持っています。
- アクセス集中することを避けて負荷を分散させている
- 各レイヤーの分割によって役割分担をし、ドメインの追加やトップレベルドメインの管理が柔軟になる
- 各レイヤーのキャッシュを通して、クリエリーの効率性が向上する
- 各レイヤーDNSサーバー自体も、分散型システムになっているため、同じデータを持つサーバーが複数に存在し、耐障害性も高い
もちろん、デメリットも存在します。
- 上記のような各レイヤーにキャッシュがない極端なケースになると、TTL無効になったあとの初回リクエストの時間がかかる。ただこれに関しては、DNS Prefetchingのテクニックで解消することが可能。
- Man In the Middle攻撃セキュリティ上の懸念(参考 )、これは主にローカルからプロバイダーのレイヤーに起こりうる(ハッカーではなくても、プロバイダーが別のIPを提供して、それを通して意図的に広告をHTMLファイルに注入することも可能)。この問題は近年のHTTPDNS(DNS over HTTP, 他にDNS over TLSもあるが、HTTPSは他のリクエストと同じポートを使うことによってDNSクエリーのリクエストを偽装することができます)との技術が普及されて、DNSクエリーをHTTPSで暗号化することでミドルマンの懸念を解消でき、主流のブラウザーにもサポートされている[1]。
- メンテナンスの複雑さ、これは自社で設ける場合はありそうだが、現状クラウドサービスに任せることが多いでしょう
終わりに
今回はHTTPリクエストの旅の起点、ブラウザーのキャッシュレイヤーとその後のDNSクエリーについてまとめてみました。
ブラウザーキャッシュの制御に過去ゼロダウンタイムリリースのために色々と苦戦しましたので、改めてその重要性を感じていていました。自分たちのアプリケーションのリソースタイプを分類し、キャッシュ制御のパターンを切り分けするのが良いでしょう。
DNSサーバーやDNSクエリーのプロセスについても、この本を読む前にかなりぼんやりした理解しかありませんでした。そのレイヤー構成と各部分役割を図書館の例で考えてみると、割と理解が進めて、メンタルモデル構築には役だったかと思いました。
旅の目標がわかったので、次回の記事では、ネットワークに旅立ちするための交通機関(プロトコル)について書きたいと思います。
-
関連する技術として、DNSSECも存在します。DNSSECはDNS resolverと、rootサーバーやAuthoritative nameserverのコミュニケーション時のアイデンティティを保証するものになって、DNS cache poinsoning(DNS spoofingとも、キャッシュデータがTTLが切れるまで間違ったもので入れ替える)の攻撃を対処しています。HTTPSやTLSがDNSクエリーの内容を暗号化することに対して、DNSSECは暗号化しません。この機能自体は、resolverが提供するもので、DNS resolverを提供するもののアドレスに設定すると有効になります。HTTPS /TLS暗号化の方は、ブラウザーが提供する機能なので、両者を同時に使うことが可能です。 ↩︎


Discussion