0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

スケーラビリティは「届かせない」から始まる — キャッシュとCDNの3層構造

0
Posted at

はじめに

Webアプリケーションを作っていると、ふと頭をよぎる不安があります。「ユーザーが10倍に増えたら、このサーバー耐えられるかな?」 —— そんな経験はありませんか?

スケーラビリティを確保する手段は色々あります。サーバーを増やす、データベースを分割する、マイクロサービスに切り出す......。でも、どこから手をつけるべきなのかは意外と語られません。

実は、最もコスパが良い第一歩は「サーバーを強くする」ことではなく、そもそもサーバーにリクエストが届かないようにすることです。

この記事では、その「第一歩」を、なぜ効くのか・どう組み合わせるのかまで含めて解説します。

TL;DR

スケーラビリティの第一歩は「サーバーに届くリクエストを減らす」こと。HTTPキャッシュ → リバースプロキシ → CDN の3層を、コスパが良い順に積み上げていきます。

[ブラウザ]
   │
   ▼
① HTTPキャッシュ     ← ブラウザが覚える(個人用)
   │  ヒット → サーバーに聞きに行かない
   ▼  ミス
② リバースプロキシ   ← サーバーの前の門番(共有)
   │  ヒット → サーバーに届かない
   ▼  ミス
③ CDN               ← 世界中に展開された門番 + ネットワーク最適化
   │
   ▼
[オリジンサーバー]   ← ここに届くリクエストを最小化する

スケーラビリティの全体像 — どこから手をつけるか

本題に入る前に、「スケーラビリティ」という言葉の意味と、この記事がどこに位置するかを簡単に整理しておきます。

スケーラビリティとは何か

スケーラビリティとは、ひと言でいえば「ユーザーが10倍に増えてもサービスが同じ品質で動き続けること」です。

そのための手段は1つではなく、段階があります。『Understanding Distributed Systems』という書籍では、労力が少ない順に手を打つことが推奨されています。

スケーラビリティの4つの段階

┌──────────────────────────────────────────────────────────────┐
│            スケーラビリティの4つの段階                           │
│                                                              │
│  労力:小 ──────────────────────────────────────── 労力:大     │
│                                                              │
│  ┌────────────┐  ┌────────────┐  ┌────────────┐  ┌────────┐  │
│  │ 第1段階     │  │ 第2段階     │  │ 第3段階     │  │ 第4段階│   │
│  │            │  │            │  │            │  │        │  │
│  │ リクエスト   │→│ データの     │→ │ サーバーを   │→ │ アーキ  │  │
│  │ を減らす    │  │ 置き方を    │   │ 水平に      │  │ テクチ  │  │
│  │            │  │ 工夫する    │   │ 増やす     │   │ ャを   │  │
│  │ HTTPキャッ  │  │ パーティ    │   │ ロードバラ  │   │ 変える │  │
│  │ シュ、CDN   │  │ ショニング   │  │ ンシング等   │  │        │ │
│  │【本記事】    │  │            │  │            │  │        │  │
│  └────────────┘  └────────────┘  └────────────┘  └────────┘  │
│                                                              │
│  ※ 後の段階ほど効果は大きいが、導入の労力も大きい                    │
└──────────────────────────────────────────────────────────────┘

この記事は第1段階「リクエストを減らす」に着目します。

この記事のスコープ

具体的には、「サーバーにリクエストが届く前に解決する」ための3つの仕組みを順番に学んでいきます。

  • ブラウザのキャッシュ(クライアント側) — 個々のブラウザがリソースを覚える
  • リバースプロキシのキャッシュ(サーバー側) — 複数クライアントでキャッシュを共有する
  • CDN(グローバル) — リバースプロキシを世界中に展開し、ネットワーク自体を最適化する

HTTPキャッシュ — ブラウザにリソースを覚えさせる

「リクエストを送らないでどうやってデータを取得するの?」と思うかもしれませんが、HTTPにはそのための仕組みが標準で用意されています。

キャッシュとは、一度取りに行ったデータを手元に保存しておき、次回のアクセス時にサーバーへ問い合わせずに済むようにする仕組みです。ブラウザにはこのキャッシュ機能が組み込まれており、HTTPヘッダを適切に設定するだけで活用できます。

静的リソースと動的リソース

キャッシュの話に入る前に、Webで扱うリソースには大きく2種類あることを押さえておきます。

種類 特徴
静的リソース JavaScript、CSS、画像ファイル リクエストが変わっても内容が変わらない
動的リソース ユーザープロフィールのJSON、検索結果 サーバーがその場で生成する

HTTPキャッシュは主に静的リソースに有効です。また、キャッシュが使えるのはサーバーの状態を変更しない「安全な」メソッド(GETやHEAD)に限られます。POSTやDELETEのようにサーバーのデータを変更するリクエストはキャッシュの対象外です。

なぜ「安全な」メソッドだけなのか?
GETは「データを読み取るだけ」のリクエストなので、同じリクエストを何度送っても結果は同じ(はず)です。だからこそ、一度取得した結果を使い回せます。一方、POSTやDELETEはサーバーの状態を変えるので、過去の結果を使い回すわけにはいきません。

初回アクセス — キャッシュが空のとき

まず、あるリソースに初めてアクセスする場面を考えます。

ポイントは、サーバーがレスポンスに2つの重要なHTTPヘッダを付けて返すことです。

  • Cache-Control: キャッシュしてよい期間を指定するヘッダ。max-age=3600なら「3600秒(1時間)は手元に保存していいよ」という意味です。この期間のことをTTL(Time to Live)と呼びます
  • ETag: リソースのバージョン識別子。ハッシュ値のようなもので、リソースが更新されると値が変わります

ローカルキャッシュはこの情報とともにリソースを保存し、クライアントにレスポンスを返します。

再アクセス — フレッシュとストール

しばらく経ってから同じリソースにアクセスした場合、キャッシュはまず「このリソースはまだ使えるか?」を判断します。ここで登場するのがフレッシュストールという2つの状態です。

  • フレッシュ(賞味期限内): TTLがまだ残っている状態
  • ストール(賞味期限切れ): TTLが過ぎた状態

フレッシュの場合 — ネットワーク通信ゼロ

リソースがフレッシュであれば、キャッシュはサーバーに問い合わせることなく、手元のリソースをそのまま返します。ネットワーク通信が一切発生しないので、レスポンスは瞬時です。

ただし、この間にサーバー側でリソースが更新されている可能性はあります。つまり、クライアントが見ているのは「最新」ではなく「TTL内の時点での情報」かもしれません。これは強い整合性(常に最新のデータを返す)ではなく、結果整合性(いずれ最新になる)のトレードオフです。多くのWebアプリケーションにとって、静的リソースのわずかな遅延は許容範囲でしょう。

ストールの場合 — 条件付きリクエスト

リソースがストール状態の場合、キャッシュはサーバーに「確認しに行く」必要があります。ただし、いきなり全データを再取得するわけではありません。

キャッシュはIf-None-Matchヘッダに手持ちのETagを付けて、条件付きGETリクエストを送ります。サーバーはETagを比較して、リソースが変わっていなければ304 Not Modifiedというステータスコードだけを返します。レスポンスボディ(リソース本体)が含まれないので、通信量を大幅に節約できます。

この一連のフロー全体が、実はレプリケーション(データの複製)パターンの実践例の一つになっています。オリジンサーバーのデータをブラウザのキャッシュに「複製」し、TTLとETagで整合性を管理しているわけです。

イミュータブルな静的リソース — 最も効果的なキャッシュ戦略

ここまでの話を踏まえると、一つの疑問が浮かびます。「TTLをどのくらいに設定すればいいのか?」

理想的な答えは、静的リソースを「イミュータブル(不変)」にして、可能な限り長くキャッシュすることです。

イミュータブルとは「一度作ったら変更しない」という考え方です。リソースの内容が絶対に変わらないのであれば、TTLを最大限長く設定しても問題ありません。実務上は1年間(31,536,000秒)が事実上の上限値として広く使われています。

Cache-Control: max-age=31536000, immutable
  • max-age=31536000 — 1年間キャッシュしてよい
  • immutable — このリソースは変更されないので、ブラウザはリロード時にも再検証リクエストを送らなくてよい(RFC 8246で定義)

「でも、CSSやJavaScriptを更新したくなったらどうするの?」という疑問は当然です。ここがイミュータブル戦略のポイントで、リソースを書き換えるのではなく、新しいURLで新しいリソースを作るのです。

例えば、ファイル名にバージョンやハッシュ値を含めます。

app-v1.js  →  app-v2.js
style.css  →  style.a1b2c3.css

アトミックな更新という副次的なメリット

イミュータブルな静的リソースには、キャッシュ効率以外にもう一つ大きな利点があります。関連するリソースを「一式まとめて」更新できることです。

仕組みはシンプルです。新しいindex.htmlが新しいJavaScriptやCSSのURLを参照するようにすれば、クライアントは「古いサイト一式」か「新しいサイト一式」のどちらかしか見ません。古いJavaScriptと新しいCSSが混在するような不整合が発生しないのです。

実はフロントエンドのビルドツールがやっていること
ViteやWebpackなどのビルドツールを使ったことがあれば、ビルド後のファイル名にapp.a1b2c3.jsのようなハッシュが付くのを見たことがあるかもしれません。あれはまさに、このイミュータブルキャッシュ戦略を自動的に実現しているのです。

読み取りと書き込みの分離という視点

ここまでHTTPキャッシュの具体的な仕組みを見てきましたが、少し抽象的な視点から整理してみます。

HTTPキャッシュがやっていることを別の角度から見ると、読み取りパス(GET)を書き込みパス(POST、PUT、DELETE)から分離しているともいえます。Webアプリケーションでは、読み取りの回数が書き込みの回数よりも桁違いに多いことがほとんどです。だからこそ、読み取りだけをキャッシュで高速化する戦略が効果的なのです。

この「読み取りと書き込みを別々に扱う」という考え方にはCQRS(Command Query Responsibility Segregation / コマンドクエリ責務分離)という名前が付いています。ここでは名前の紹介だけにとどめますが、分散システムの設計で繰り返し登場する重要なパターンです。

リバースプロキシ — サーバーの前に門番を置く

HTTPキャッシュは強力ですが、一つ大きな制約があります。各ブラウザが独立にキャッシュを持つという点です。

例えば、100人のユーザーが同じCSSファイルに初めてアクセスする場合、100回のリクエストがサーバーに届きます。個々のブラウザがキャッシュを持っていても、「初回アクセス」はクライアントの数だけ発生してしまうのです。

「複数のクライアントが共有できるキャッシュをサーバー側に置けたら?」 —— その発想を実現するのがリバースプロキシです。

リバースプロキシとは何か

リバースプロキシとは、サーバーの前に立ち、クライアントからの全通信を仲介するプロキシ(代理サーバー)のことです。

┌──────────┐       ┌──────────────────┐       ┌──────────────┐
│          │       │                  │       │              │
│クライアン  │──────→│ リバースプロキシ   │──────→│ サーバー       │
│  ト      │←──────│                  │←──────│              │
│          │       │                  │       │              │
└──────────┘       └──────────────────┘       └──────────────┘

           クライアントからは
           サーバーそのものに見える
           (透過的)

クライアントからはサーバーそのものに見えるため、リバースプロキシが間にいることを意識する必要はありません。代表的なソフトウェアとしてはNGINXやHAProxyがよく使われています。

共有キャッシュとしてのリバースプロキシ

リバースプロキシの最も基本的な役割は、静的リソースをサーバーの代わりにキャッシュして返すことです。

個々のブラウザキャッシュとの決定的な違いは、全クライアントでキャッシュが共有される点です。100人のユーザーが同じCSSファイルにアクセスしても、リバースプロキシがキャッシュを持っていれば、サーバーに届くリクエストは最初の1回だけで済みます。

キャッシュ以外のユースケース

リバースプロキシはクライアントとサーバーの間の「中間者」という立場にあるため、キャッシュ以外にも様々な役割を担えます。

ユースケース 説明
認証の代行 サーバーの代わりにリクエストの認証を行い、不正なリクエストをサーバーに届く前にブロックする
レスポンス圧縮 クライアントに返す前にデータを圧縮して、転送速度を向上させる
レート制限 特定のIPアドレスやユーザーからのリクエスト数を制限し、サーバーの過負荷を防ぐ
ロードバランシング 複数のサーバー間でリクエストを振り分けて、処理能力を向上させる

これらのユースケースの詳細は、ロードバランシングやAPIゲートウェイといったテーマで改めて扱われることが多いです。

リバースプロキシの先にある選択肢

リバースプロキシを自前で運用するには、サーバーの構築・管理・監視が必要です。しかし現在では、リバースプロキシが担うユースケースの多くがマネージドサービス(自分で管理せずに利用できるサービス)としてコモディティ化しています。

自分でリバースプロキシを構築・運用する代わりに、CDN(コンテンツデリバリネットワーク)を活用するという選択肢があります。CDNはリバースプロキシの機能に加えて、さらに大きなメリットを提供してくれます。

CDN — キャッシュを超えたネットワークの最適化

リバースプロキシを導入すれば、サーバーの負荷は大きく減らせます。しかし、ここでもう一つの問題が残っています。

サーバーが東京にあって、ユーザーがブラジルにいるとします。どれだけサーバーが高速でも、物理的な距離によるレイテンシ(通信の遅延)は往復で250ミリ秒以上になることもあります。これは光速の限界であり、ソフトウェアでは解決できません。

この問題を解決するのがCDN(Content Delivery Network / コンテンツデリバリネットワーク)です。ただし、CDNの価値は「キャッシュを世界中に配置する」ことだけではありません。

CDNの基本的な仕組み

CDNとは、地理的に分散したキャッシングサーバー(リバースプロキシ)のネットワークです。代表的なサービスとしてAWS CloudFront、Akamai、Cloudflareなどがあります。

基本的な動作フローはシンプルです。

  1. クライアントがURLにアクセスすると、DNSがCDNサーバーのIPアドレスを返す
  2. CDNサーバーがリクエストを受け取り、ローカルキャッシュを確認する
  3. キャッシュにあれば(キャッシュヒット)そのまま返す。なければ(キャッシュミス)オリジンサーバーから取得してキャッシュに保存し、返す

ここまでの説明だと「リバースプロキシを世界中にばらまいただけでは?」と思うかもしれません。実はほぼその通りで、キャッシュの仕組み自体はリバースプロキシと同じです。しかしCDNの真の価値は、次に説明するオーバーレイネットワークにあります。

オーバーレイネットワーク — CDNの本当の価値

キャッシュは「データを近くに置いておく」話でした。ここからは「データが通る道そのものを最適化する」話です。CDNは独自のネットワークを持っていて、普通のインターネットより賢い経路選択をします。

BGPの限界

まず前提として、公共のインターネットは何千ものネットワークで構成されています。データが送信元から送信先に届くまでに、いくつものネットワークを経由します。このときの経路を決めているのがBGP(Border Gateway Protocol)というルーティングプロトコル(経路制御の仕組み)です。

しかしBGPには大きな限界があります。BGPは主に「ASパス長」(経由する自律システムの数)で経路を選びます。レイテンシ(遅延時間)や混雑状況は考慮されません。つまり、BGPが選ぶ経路は「経由するネットワークの数が少ない」だけであって「最速」とは限らないのです。

CDNによる解決

CDNは、この公共インターネットの上に構築された独自のネットワーク、すなわちオーバーレイネットワーク(既存のインターネットの上に構築された、もう1つのネットワーク)を持っています。

┌────────────────────────────────────────────────────────┐
│                    公共インターネット                     │
│                                                        │
│  [クライアント]                       [オリジンサーバー]    │
│       │                                     ▲          │
│       │  BGP: ASパス長で経路を決定             │          │
│       │  → レイテンシや混雑を考慮しない          │          │
│       │                                     │          │
│  ┌────┼─────────────────────────────────────┼────┐     │
│  │    ▼       CDNオーバーレイネットワーク            │     │
│  │                                               │     │
│  │  [CDNエッジ]───[CDN中間層]───[CDNエッジ]         │     │
│  │    短い区間      高帯域幅      短い区間           │     │
│  │                                               │     │
│  │  ・独自のルーティングで最速経路を選択               │     │
│  │  ・ネットワーク健全性データに基づく最適化            │     │
│  │  ・レイテンシ・混雑状況をリアルタイムに考慮          │     │
│  └───────────────────────────────────────────────┘     │
└────────────────────────────────────────────────────────┘

CDNのオーバーレイネットワークは、常に更新されるネットワーク健全性データに基づいて、レイテンシや混雑が少ない経路を選択します。BGPが「経由するネットワーク数が少ない道」を選ぶのに対し、CDNは「実際に速い道」を選ぶのです。

クライアントを最寄りのサーバーに導く仕組み

ここまでは「データがどの道を通るか」(経路の品質)の話でした。ここからはレイヤーが変わって「データがまずどこに向かうか」(接続先の選択)の話です。CDNはまず近い入口にクライアントを案内し、そこから先はオーバーレイネットワークで速い道を通す、という2段構えになっています。

CDNがクライアントの近くにサーバーを配置していても、クライアントがそのサーバーに接続できなければ意味がありません。ここで2つの仕組みが連携します。

CDNが「近い入口」にクライアントを案内する流れ

① DNSに問い合わせ
[クライアント] ──「example.comのIPアドレスは?」──→ [CDNのDNS]
                                                      │
                                                      │ クライアントの位置、
                                                      │ サーバーの混雑状況を
                                                      │ 考慮して判断
                                                      ▼
[クライアント] ←──「東京のサーバー(203.0.113.1)へどうぞ」──

② 最寄りのCDNサーバーに接続
[クライアント] ──→ [東京のCDNサーバー] ~~オーバーレイNW~~→ [オリジン]
                    ↑ 近い!速い!

グローバルDNSロードバランシング

通常のDNSはドメイン名に対して常に同じIPアドレスを返します。しかしCDNのDNSは、問い合わせてきたクライアントごとに最適なサーバーのIPアドレスを返します。これをグローバルDNSロードバランシングと呼びます。

具体的には、以下の情報を総合して接続先を決定します。

  • クライアントのIPアドレスから地理的な位置を推測する
  • 各サーバーの混雑状況や稼働状態を確認する
  • これらを総合して、最も近くて空いているサーバーのIPアドレスを返す

IXP(インターネットエクスチェンジポイント)への配置

CDNのサーバーは、IXP(Internet Exchange Point) に配置されることが多いです。

IXPを理解するために、まずISP(インターネットサービスプロバイダー)について触れておきます。ISPとは、NTTやau、ソフトバンクなど、私たちがインターネットに接続するために契約する通信事業者のことです。自宅やオフィスの端末は、まずISPのネットワークに接続し、そのISPが他のISPと接続することで世界中と通信できるようになっています。

IXPは、このISP同士のネットワークが相互に接続する場所です。多くのISPのネットワークが集まるこの場所にCDNサーバーを置くことで、どのISPのユーザーからも少ないネットワーク経由でCDNに到達でき、通信が短い区間で済むようになります。

トランスポートレベルの最適化

CDNは経路の選択だけでなく、通信そのもののレベル(トランスポート層) でも最適化を行っています。

通常、2台のコンピューターがTCP通信を始めるときにはTCPハンドシェイク(接続確立のための3往復のやり取り)が必要で、これは新しい通信のたびに発生します。CDNではこのコストを2つの方法で削減しています。

  • 永続的な接続プール: CDNサーバー間の接続を常に維持しておくことで、毎回ハンドシェイクをやり直すオーバーヘッドを省く
  • 最適なTCPウィンドウサイズ: 一度に送信するデータ量を回線の状況に合わせて調整し、有効帯域幅を最大限に活用する

これらの最適化により、クライアントから見たラウンドトリップ時間(データの往復にかかる時間)が短縮され、同時にオリジンサーバーへの負荷も軽減されます。

HTTP/3(QUIC)の登場
近年、主要CDN(Cloudflare、Akamai、AWS CloudFrontなど)はHTTP/3をサポートしています。HTTP/3はTCPの代わりにQUIC(UDP上に構築されたプロトコル)を使用し、TCPが抱えていたHead-of-Line Blocking(先頭のデータが詰まると後続も全部止まる問題)を回避します。特にモバイル回線や高レイテンシ環境で効果が大きいとされていますが、ここでは存在の紹介にとどめます。

動的コンテンツの高速化とDDoS防御

「CDNはキャッシュできる静的リソースにしか効果がないのでは?」と思うかもしれません。実はそうではありません。

オーバーレイネットワークの経路最適化は、キャッシュできない動的リソースの配信も高速化します。BGPの非効率な経路を回避するだけで、動的なAPIレスポンスも速くなるのです。これがCDNの価値を「キャッシュだけ」と理解していると見落としてしまうポイントです。

さらに、CDNはアプリケーションの「フロントエンド」(最前面)として機能するため、DDoS攻撃(大量のリクエストでサービスを停止させようとする攻撃)からの盾にもなります。

  • オリジンサーバーのIPアドレスを直接公開しないので、攻撃者がサーバーを直接狙いにくくなる
  • 大量のトラフィックをエッジ(世界各地に配置されたCDNサーバー)で吸収できる

CDNのキャッシング階層とトレードオフ

最後に、CDN内部のキャッシュ構造についても触れておきます。CDNは単一のキャッシュ層ではなく、複数のレイヤーを持っています。

┌─────────────────────────────────────────────────────────┐
│                CDNのキャッシング階層                       │
│                                                         │
│   [クライアント]  [クライアント]    [クライアント]            │
│        │               │              │                 │
│        ▼               ▼              ▼                 │
│   ┌─────────┐    ┌─────────┐    ┌─────────┐             │
│   │エッジ    │    │エッジ    │    │エッジ    │  ← 多数      │
│   │クラスタ  │    │クラスタ   │    │クラスタ  │    世界各地  │
│   └────┬────┘    └────┬────┘    └────┬────┘             │
│        │              │              │                  │
│        └──────────┬───┘──────────────┘                  │
│                   ▼                                     │
│          ┌──────────────┐                               │
│          │中間キャッシン   │  ← 少数                       │
│          └──────┬───────┘                               │
│                 │                                       │
│                 ▼                                       │
│          [オリジンサーバー]                                │
└─────────────────────────────────────────────────────────┘
  • エッジクラスター: 世界各地に多数展開され、クライアントに最も近い位置にある
  • 中間キャッシングクラスター: エッジよりも少ない拠点数で配置され、エッジでキャッシュミスしたときの次の砦

ここに興味深いトレードオフがあります。

エッジクラスターが多い場合 エッジクラスターが少ない場合
広範囲のクライアントをカバーできる カバー範囲は狭い
個々のクラスターへのリクエストが分散する 個々のクラスターにリクエストが集中する
キャッシュヒット率が低下しやすい キャッシュヒット率が高くなりやすい
オリジンサーバーへの負荷が増大 オリジンサーバーへの負荷は抑えられる

エッジクラスターを増やすとカバー範囲は広がりますが、各クラスターに来るリクエストが分散するため、キャッシュヒット率(キャッシュ内で目的のリソースが見つかる確率)が下がります。中間キャッシングクラスターは、このトレードオフを緩和する仕組みです。エッジでミスしても、中間層でヒットすればオリジンサーバーへの問い合わせを防げます。

CDN内部のデータ分割
CDNクラスター内では、1台のサーバーで全データを保持することは不可能です。コンテンツは複数のサーバーにパーティショニング(分割配置)されています。このパーティショニングは、スケーラビリティにおける中核的なパターンです。

まとめ — 3つの層で「届くリクエストを減らす」

この3つの層は排他的ではなく、組み合わせて使うものです。HTTPキャッシュでブラウザの再リクエストを防ぎ、リバースプロキシ(またはCDN)で複数クライアントの初回アクセスを吸収し、CDNのオーバーレイネットワークで物理的な距離の問題も解消する。この積み上げが、スケーラビリティ設計の出発点になります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?