自前の CDN サーバーを構築する:1 回取得して、何千回でも配信

公開日 読了時間 16 分

CDN サーバーは各ファイルをオリジンから 1 回だけ取得し、その後は何千回でも自分で配信します。オリジンサーバーに届くリクエストはごく一部になり、訪問者は短い読み込み時間を得られます。その構築手順を解説します。

訪問者がサイトから読み込むファイルは、1 つずつオリジンサーバーがファイルシステムから取り出し、応答に詰めて送り出しています。1 日の訪問者が 10 人なら、誰も気づきません。同時に 1 万人が訪れると、アプリケーション自体は何もしていないのにサイトが遅くなる原因が、まさにこれです。

CDN サーバーはその前に入ります。各ファイルをオリジンから1 回だけ取得し、その後は必要な回数だけ自分で配信します。これによりオリジンに届くリクエストはごく一部になり、訪問者は配信だけを仕事にしているサーバーからファイルを受け取ります。本記事では、これが実際にどれだけの効果を生むのか、nginx でこうしたサーバーをどう構築するのか、そしてトラフィックと拠点で何が重要になるのかを解説します。

CDN サーバーが実際に行うこと

流れは 3 文で説明できます。

  • 訪問者が CDN サーバーにファイルを要求します。
  • そこにすでにあれば、すぐに送り出されます。これがヒットです。
  • そこにない場合は、CDN サーバーがオリジンから取得し、保存して配信します。これがミスで、有効期間ごとにファイルあたり 1 回だけ起きます。

原理はストリーミングサーバーと同じで、1 回取得して何度でも配信します。違いは、ストリーミングサーバーが流れ続けるデータを配り、CDN サーバーが完成したファイルを配るという点だけです。

計算:キャッシュはどれだけ負荷を下げるか

決め手はヒット率、つまりキャッシュが自分で応答したリクエストの割合です。1 日 100 万リクエストの場合、負荷の下がり方は次のようになります。

ヒット率オリジンへのリクエスト負荷の軽減
50%500,0002 分の 1
80%200,0005 分の 1
95%50,00020 分の 1
99%10,000100 分の 1

静的ファイルでは 95% 以上が通常です。画像、スクリプト、フォントはほとんど変わらないからです。95% から 99% への引き上げは、数字から想像するよりも価値があります。残っている負荷をさらに 5 分の 1 にするからです。

同時にトラフィックの位置が移ります。オリジンはほとんど何も転送しなくなり、代わりに CDN サーバーがその分を転送します。1 日 50 万回の画像呼び出しで 1 枚 400 KB なら、1 日あたり 200 GB、つまり月に約 6 TB で、これは画像だけの数字です。したがって CDN ノードで重要な数字は CPU ではなく、回線と転送量です。

自前の CDN サーバーが見合う場面

商用の CDN 事業者は転送した 1 GB ごとに課金します。これは手軽で、転送量が少なければ安く済みます。ある量を超えると、この関係が逆転します。

状況自前の CDN サーバー1 GB あたりの課金
月に数 GBほとんど見合わない安い
月に数 TB固定の月額量に比例して増える
大きなダウンロード、動画アーカイブはっきり有利すぐ高額になる
ルールとログの完全な制御完全にできる制限がある

さらに、数字にしにくい点があります。自前のノードなら、何をキャッシュするか、どれだけ保持するか、いつ消すかを自分で決められます。他社のルールに縛られず、他社の枠組みに収まらないものも配信できます。

nginx をキャッシュとして構築する

インストールする

apt update
apt install -y nginx

必要なのはこれだけで、キャッシュ機能は nginx に組み込まれています。ファイルを置くディレクトリを作成します。

mkdir -p /var/cache/cdn
chown www-data:www-data /var/cache/cdn

設定する

キャッシュは http ブロックで一度定義し、その後それぞれの location で使います。

proxy_cache_path /var/cache/cdn
                 levels=1:2
                 keys_zone=cdn:100m
                 max_size=200g
                 inactive=30d
                 use_temp_path=off;

proxy_cache_key "$scheme$request_method$host$request_uri";

各値の意味は次のとおりです。keys_zone=cdn:100m は約 800,000 エントリー分に相当し、max_size はディスク上の容量を制限し、inactive=30d は 30 日間誰も要求しなかったファイルを破棄し、use_temp_path=off はディレクトリ間の不要な移動を避けます。

その下が実際のサーバーです。

server {
    listen 443 ssl;
    http2 on;
    server_name cdn.example.com;

    ssl_certificate     /etc/letsencrypt/live/cdn.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/cdn.example.com/privkey.pem;

    location / {
        proxy_pass https://origin.example.com;
        proxy_set_header Host origin.example.com;
        proxy_http_version 1.1;

        proxy_cache cdn;
        proxy_cache_valid 200 301 302 24h;
        proxy_cache_valid 404 1m;

        # 同時に発生した複数のミスを 1 つのリクエストにまとめる
        proxy_cache_lock on;
        proxy_cache_lock_timeout 10s;

        # オリジンに問題があるときも配信を続ける
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
        proxy_cache_background_update on;

        # セッション Cookie にキャッシュを壊させない
        proxy_ignore_headers Set-Cookie;
        proxy_hide_header Set-Cookie;

        add_header X-Cache-Status $upstream_cache_status always;
    }
}

このなかで特に注目すべき行が 3 つあります。proxy_cache_lock は、新しいファイルに対して 100 人の訪問者が同時にオリジンへ問い合わせるのを防ぎ、問い合わせるのは 1 人だけ、残りは待つようにします。proxy_cache_use_stale は、オリジンが再起動中であったり応答が遅いときでも配信を維持します。そして Set-Cookie を隠すことで、セッション Cookie を含む応答がひとつあるだけでキャッシュ全体が使い物にならなくなるのを防ぎます。

効いているかを確認する

nginx -t && systemctl reload nginx
curl -sI https://cdn.example.com/bild.jpg | grep -i x-cache-status
curl -sI https://cdn.example.com/bild.jpg | grep -i x-cache-status

1 回目の呼び出しでは MISS、2 回目では HIT と表示されます。2 回目も MISS のままなら、原因はほぼ必ず 3 つの定番のどれかです。応答に含まれる Set-Cookie、オリジンからの Cache-Control: no-store、あるいは長さの指定が欠けていることです。

何をキャッシュし、何をしないか

きれいな解き方は、オリジン自身にそれを言わせることです。内容を知っているのはオリジンで、キャッシュではありません。そのために、オリジンが適切なヘッダーを設定します。

内容オリジンでのヘッダー
名前にチェックサムが入ったファイルCache-Control: public, max-age=31536000, immutable
バージョン管理のない画像やダウンロードCache-Control: public, max-age=86400
公開ページの HTMLCache-Control: public, max-age=300
ログイン後の領域、カートCache-Control: private, no-store

キャッシュ側で判断したい場合は、ファイル種別ごとに location を分ければできます。

location ~* \.(jpg|jpeg|png|gif|webp|avif|svg|ico|css|js|woff2|mp4|zip)$ {
    proxy_pass https://origin.example.com;
    proxy_cache cdn;
    proxy_cache_valid 200 7d;
    add_header Cache-Control "public, max-age=604800";
    add_header X-Cache-Status $upstream_cache_status always;
}

内容を狙って消す

最も簡単なのは、そもそも消さずに済ませる方法です。変更した画像ごとに新しいファイル名、たとえば logo.a1b2c3.png を付けていれば、この問題は起きません。古いファイルはいずれ自然にキャッシュから外れます。

それができない場合、nginx では無償のモジュール ngx_cache_purge が役に立ちます。追加モジュールがなくても、nginx はキャッシュキーの MD5 値をファイル名に使うため、パスをキーから計算できます。

KEY="httpsGETcdn.example.com/bild.jpg"
H=$(printf '%s' "$KEY" | md5sum | cut -d' ' -f1)
rm -f "/var/cache/cdn/${H: -1}/${H: -3:2}/$H"

日常の運用なら、この数行をパスの一覧に対して実行する小さなスクリプトで足りることが多いです。

大きなファイルと動画

数百 MB を超えるファイルにはブロックへの分割が必要です。これがないと、動画内で再生位置を動かすたびにキャッシュがファイル全体を取り直します。

location /video/ {
    proxy_pass https://origin.example.com;
    proxy_cache cdn;
    proxy_cache_valid 200 206 7d;

    slice 1m;
    proxy_set_header Range $slice_range;
    proxy_cache_key "$uri$slice_range";
}

これにより nginx は、実際に要求されたブロックだけを取得します。視聴者が映画の中ほどへ飛んでも、回線を流れるのは数 GB ではなく数 MB です。

TLS、HTTP/2、HTTP/3

CDN ノードは多数の接続に多数の小さなファイルを配信します。新しいプロトコルが効くのはまさにこの場面です。HTTP/2 は http2 on; だけで済みます。HTTP/3 が特に効くのはモバイル回線で、網が切り替わっても接続が途切れないからです。

listen 443 quic reuseport;
listen 443 ssl;
http2 on;
add_header Alt-Svc 'h3=":443"; ma=86400';

このときファイアウォールで UDP 443 を開けるのを忘れないでください。HTTP/3 を設定したのに誰も使っていない原因として、これが最も多いものです。

複数の拠点

ノードが 1 つあるだけで、利益の大部分、つまりオリジンの負荷軽減は得られます。2 つ目の利益、訪問者までの距離の短さは、複数の拠点を持ってから出てきます。

振り分けは DNS で行います。同じ名前が、訪問者の所在地に応じて別のノードを指します。そのためには地理的な振り分けに対応した DNS 事業者が必要です。すべてのノードは同じ設定と同じオリジンを持ち、互いのことを何も知りません。この構成が頑丈なのはまさにそのためで、1 つのノードが落ちたら DNS から外せば、残りが引き受けます。

KernelHost はドイツ、米国西海岸、米国東海岸、シンガポール、韓国に拠点を用意しています。遅延を含む一覧はサーバー拠点でご確認いただけます。目安としては、訪問者の相当な割合がいる大陸ごとにノードを 1 つです。

推測せずに測る

キャッシュの状態をログに書けば、ヒット率をいつでも数えられます。

log_format cdn '$remote_addr $status $body_bytes_sent '
               '$upstream_cache_status "$request"';
access_log /var/log/nginx/cdn.log cdn;

集計はワンライナーでできます。

awk '{print $4}' /var/log/nginx/cdn.log | sort | uniq -c | sort -rn

静的ファイルで HIT の割合が 90% を下回る場合、原因を探す価値はほぼ必ずあります。多いのは、応答に含まれる Cookie、短すぎる有効期間、あるいはアドレスの末尾のパラメーターが毎回変わり、キャッシュにはそのたびに別のファイルに見えている場合です。

どのサーバーが向いているか

CDN ノードは配信のための機械です。そのため重要な性能の順序は、アプリケーションサーバーとは違ってきます。

  1. トラフィックと回線。負荷はすべてここに生まれます。1 つのノードは運用中、月に数 TB を定常的に転送します。
  2. ディスク。キャッシュは小さなランダムアクセスの集まりなので、NVMe が明確に有利です。容量は、持っているデータ全体の大きさではなく、そのうちどれだけが頻繁に要求されるかで決めます。
  3. RAM。OS は空きメモリーを、ディスクの手前の二次キャッシュとして使います。ここでは RAM が多いほど、そのまま速さにつながります。
  4. CPU。最も影響が小さい部分です。使われるのは TLS だけで、最近のプロセッサーは片手間に処理します。

まさにこの用途のために作ったのが、当社の無制限トラフィックのルートサーバーです。1 台ごとに絞りのない実測 2 × 1 Gbit/s、固定の月額で無制限の転送量、RAID 構成のデータセンター向け NVMe、そして 3.2 Tbps のフラッド対策を標準で備えています。用意にかかる時間は 30 秒未満で、完全な root 権限、IPv4、/64 の IPv6 ネットワークが付きます。料金はプリペイド方式で月単位の解約ができるので、ノード 1 つから始めて、訪問者が各地に広がったらいつでも 2 つ目の拠点を追加できます。

よくあるご質問

CDN サーバーは具体的に何をしますか?
訪問者とオリジンサーバーの間に入ります。あるファイルへの最初のリクエストはオリジンへ転送し、その応答を手元に保存します。同じファイルへのそれ以降のリクエストは、オリジンに触れることなく自分で返します。
実際にどれくらい負荷が下がりますか?
ヒット率で決まります。95% なら、オリジンに届くのは 20 件に 1 件だけで、負荷は 20 分の 1 になります。80% でもまだ 5 分の 1 です。画像、スクリプト、フォント、ダウンロードといった静的ファイルなら、95% 以上は普通の水準です。
そのために商用の事業者を使う必要がありますか?
必ずしも必要ではありません。自前の CDN サーバーが特に見合うのは、大きなファイルと大量の転送がある場合です。商用の事業者は転送した 1 GB ごとに課金するからです。無制限トラフィックのサーバーなら、100 TB 目の料金は 1 TB 目と同じで、追加の費用はかかりません。
どのファイルをキャッシュし、どれをキャッシュすべきでないですか?
すべての訪問者に同じ見え方をするものはすべてです。画像、動画、CSS、JavaScript、フォント、PDF、インストーラーなどです。キャッシュすべきでないのは、個人向けに生成されるページ、カート、ログイン領域、そしてセッション Cookie を伴うものすべてです。この判断は Cache-Control ヘッダーを通じてオリジンに任せるのが最善です。
キャッシュはファイルをどれくらい保持すべきですか?
名前にバージョン番号やチェックサムが入っているファイルなら 1 年でも問題ありません。内容が変わることはないからです。バージョン管理のない画像やダウンロードなら、数時間から数日が一般的です。HTML をそもそもキャッシュするなら数分です。重要なのは正確な数値ではなく、個々のファイルを狙って削除する手段を持っていることです。
キャッシュから特定のファイルを削除する方法は?
最も簡単なのはファイル名を使う方法です。変更のたびに新しい名前を付けていれば、削除はそもそも不要です。それができない場合、nginx では商用の Purge モジュールか、無償の ngx_cache_purge が役に立ちます。最後の手段として、ファイルシステム上のキャッシュファイルを直接削除することもできます。パスはキャッシュキーから計算できます。
オリジンサーバーが停止したらどうなりますか?
正しく設定していれば、エントリーの有効期限が切れていてもキャッシュが配信を続けます。これを担うのが proxy_cache_use_stale の error と timeout、そして proxy_cache_background_update です。これにより、オリジンを保守している間や再起動している間も、サイトは到達できるままです。
大きな動画ファイルでも機能しますか?
はい、ただし slice の設定が必要です。これがないと、動画内で再生位置を動かすたびにキャッシュがファイル全体をオリジンから取り直します。slice を使うと、nginx はファイルをたとえば 1 MB のブロックに分け、実際に要求された部分だけを保存します。
拠点はいくつ必要ですか?
まずは 1 つから始めてください。置く場所は、訪問者が最も多い地域です。2 つ目の拠点が見合うのは、訪問者の相当な割合が別の大陸にいる場合だけです。振り分けはその際 DNS で行い、各訪問者は最も近いノードにたどり着きます。
キャッシュが効いているかはどう分かりますか?
変数 upstream_cache_status をヘッダーとして出力してください。同じファイルを 2 回目に呼び出したときには HIT と表示されるはずです。同じ値をログにも書いておけば、ヒット率をいつでも数えられます。静的ファイルで 90% を下回る場合、たいていヘッダーか Cookie に原因があります。

CDN Caching nginx Reverse Proxy Performance HTTP Unlimited Traffic