日曜の朝、冷蔵庫を開けたら牛乳の賞味期限が2日過ぎていました。
キャップを開けて匂いを嗅いで、大丈夫そうなのでコーヒーに入れました。たぶん、多くの人が似たような判断を毎週のようにしていると思います。
で、コーヒーを飲みながら気づいたんです。今の一連の動作、HTTP キャッシュの再検証とほぼ同じことをやっているなと。
ラベルに期限が書いてあり、期限を過ぎたら中身を確かめ、問題なければそのまま使う。期限が「おいしく食べられる目安」なのか「安全に食べられる限界」なのかで、判断が変わる。DNS の TTL も、HTTP の Cache-Control も、TLS 証明書の有効期限も、構造はこれと同じです。
今日は、冷蔵庫の話をしながらキャッシュの話をします。最後まで読むと、サーバー移行の当日に冷や汗をかく確率が少し下がるはずです。自分は一度、盛大にかきました。その話も途中で書きます。
賞味期限と消費期限は、別のもの
まずは冷蔵庫側の整理からです。消費者庁の説明をざっくりまとめると、こうなります。
- 賞味期限:おいしく食べられる期限。過ぎても、すぐに食べられなくなるわけではない
- 消費期限:安全に食べられる期限。過ぎたら食べないほうがいい
牛乳やスナック菓子は賞味期限、お弁当やお刺身は消費期限、というのが典型的です。前者は「過ぎたら確かめてから使う」、後者は「過ぎたら捨てる」。
コンピューターサイエンスで最も難しいことは2つしかない、という有名なジョークがあります。
There are only two hard things in Computer Science: cache invalidation and naming things.
(コンピューターサイエンスで難しいことは2つだけ。キャッシュの無効化と、名前を付けることだ)
Netscape のエンジニアだったフィル・カールトンの言葉として知られています。キャッシュの無効化が難しいのは、つまり「この情報はいつまで信じていいのか」を決めるのが難しいからです。冷蔵庫の前で牛乳を手に悩むのと、本質的には同じ問題なんですね。
HTTP の Cache-Control は、ほぼ冷蔵庫のラベル
HTTP のキャッシュ指示を冷蔵庫に置き換えると、驚くほどきれいに対応します。
| 冷蔵庫 | HTTP | 意味 |
|---|---|---|
| 期限のラベル | max-age=600 |
600秒間は確かめずに使っていい |
| 匂いを嗅ぐ | 条件付きリクエスト(If-None-Match → 304) |
中身が変わっていないかサーバーに確認する |
| 賞味期限 | stale-while-revalidate=86400 |
期限切れでもひとまず出す。裏で新しいものを取りに行く |
| 消費期限 | must-revalidate |
期限切れのものは、確認が取れるまで絶対に出さない |
| 停電中なら、少し古くても食べる | stale-if-error=259200 |
サーバーが落ちているときは、古いものを出してでも何かを返す |
| 食べる前に必ず匂いを嗅げ | no-cache |
保存はしていい。ただし毎回確認してから使う |
| 冷蔵庫に入れるな | no-store |
そもそも保存しない |
この表で一番伝えたいのは、下の2行です。
no-cache は「キャッシュするな」ではありません。**「キャッシュしていいけど、使う前に毎回確認しろ」**です。「キャッシュするな」は no-store のほうです。名前が紛らわしすぎて、自分も新人の頃に1回やらかしました。個人情報が載ったページに no-cache を付けて安心していたら、先輩に「それ、保存はされるよ」と言われて青くなったのを覚えています。
それから stale-if-error には、「腐っても鯛」という言葉がぴったりです。多少古くても、何も表示されないよりはずっといい。ニュースサイトやブログ、商品一覧のようなページなら、なおさらです。
nginx で書くと、たとえばこんな感じになります。
# ファイル名にハッシュが入る静的ファイル:中身が変われば URL ごと変わるので、1年置いておける
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
# 普通のページ:1分は新鮮、10分までは裏で更新しながら出す、障害時は1日まで古くても出す
location / {
add_header Cache-Control "public, max-age=60, stale-while-revalidate=600, stale-if-error=86400";
proxy_pass http://app;
}
ひとつ、地味に事故りやすいポイントを書いておきます。nginx の add_header は、その階層で1つでも add_header を書くと、親の階層の add_header が全部引き継がれなくなります。server ブロックにセキュリティ系のヘッダーをまとめて書いていても、location で Cache-Control を1行足した瞬間、その location からは X-Content-Type-Options などが静かに消えます。キャッシュの設定を変えたらセキュリティヘッダーも一緒に確認する。これは習慣にしておいて損はないです。
なお stale-while-revalidate や stale-if-error に対応しているかどうかは、CDN やブラウザによって差があります。使う前に、手元の CDN のドキュメントで一度確認してください。
DNS の TTL と、「浸透」という言葉
HTTP の次は DNS です。
DNS のレコードには TTL(Time To Live)が付いています。リゾルバ(いわゆる DNS キャッシュサーバー)は、一度引いた答えを TTL の秒数だけ冷蔵庫にしまっておき、その間は権威サーバーに聞きに行きません。
ここで大事なのは、冷蔵庫は世界中にたくさんあるということです。
実際に見てみましょう。権威サーバーと、主要なパブリックリゾルバ3つに同じ質問をして TTL を並べる、小さなスクリプトを書きました。
#!/usr/bin/env bash
# 権威サーバーと主要なパブリックリゾルバに同じ質問をして、TTL と答えを並べる
# 使い方: ./ttl-check.sh www.example.com [A]
set -euo pipefail
name=${1:?ドメイン名を指定してください}
type=${2:-A}
# NS レコードが見つかるまでラベルを1つずつ削って、ゾーンの頂点を探す
zone=$name
until ns=$(dig +noall +answer NS "$zone" | awk '$4 == "NS" {print $5; exit}'); [[ -n $ns ]]; do
[[ $zone == *.* ]] || { echo "NS が見つかりません: $name" >&2; exit 1; }
zone=${zone#*.}
done
show() {
dig +noall +answer "@$1" "$name" "$type" |
awk -v who="$2" '{printf "%-32s %6s %-6s %s\n", who, $2, $4, $5}'
}
printf '%-32s %6s %-6s %s\n' "SERVER" "TTL" "TYPE" "ANSWER"
show "$ns" "auth:${ns%.}"
for r in 1.1.1.1 8.8.8.8 9.9.9.9; do
show "$r" "$r"
done
今朝、www.github.com に対して実行した結果がこれです。
SERVER TTL TYPE ANSWER
auth:ns-1283.awsdns-32.org 3600 CNAME github.com.
auth:ns-1283.awsdns-32.org 60 A 20.27.177.113
1.1.1.1 3119 CNAME github.com.
1.1.1.1 36 A 20.27.177.113
8.8.8.8 3088 CNAME github.com.
8.8.8.8 29 A 20.27.177.113
9.9.9.9 1800 CNAME github.com.
9.9.9.9 33 A 20.27.177.113
権威サーバーは CNAME の TTL として 3600 秒を返しています。一方、リゾルバ側の数字は 3119、3088、1800 とバラバラです。これは、それぞれの冷蔵庫が別々のタイミングで牛乳を買ってきて、それぞれの残り時間をカウントダウンしているからです。リゾルバに問い合わせたときに見える TTL は、元の TTL ではなく「その冷蔵庫での残り時間」なんですね。
ちなみに、Google Public DNS(8.8.8.8)のように、裏で複数のキャッシュが動いているリゾルバもあります。同じ 8.8.8.8 に2回聞いて違う残り時間が返ってきても、壊れているわけではありません。
日本ではよく「DNS の浸透待ち」と言いますが、自分はこの言葉をなるべく使わないようにしています。何かが染み込んでいくわけではなく、世界中の冷蔵庫で古い牛乳が、それぞれの期限で順番に捨てられていくだけだからです。言葉を変えると、待つべき時間が「なんとなく1日くらい」から「古い TTL の秒数ぶん」という具体的な数字に変わります。
自分が冷や汗をかいた夜の話
ここからが、ちょっと恥ずかしい話です。
数年前、あるサイトを古い VPS から AWS に移行しました。データベースもファイルも事前に同期して、手順書も作って、リハーサルもしました。段取りには自信がありました。
切り替えは夜の22時。DNS の A レコードを新しいサーバーの IP に書き換えて、手元で新サーバーが表示されるのを確認して、ひと息つきました。
23時過ぎに、クライアントさんから連絡が来ました。
「さっきテストで問い合わせフォームを送ったんですけど、届いてないみたいです」
調べると、その問い合わせは旧サーバーに届いていました。旧サーバーのデータベースは、移行のために凍結してあったものです。
原因はすぐに分かりました。A レコードの TTL が **86400 秒(24時間)**のままだったんです。旧 DNS サービスのデフォルト値でした。クライアントさんの手元のリゾルバには、まだ旧サーバーの IP が入っていて、そこから最大24時間は旧サーバーを見続けることになります。
一番痛かったのはここからです。焦って TTL を 300 に下げたんですが、これには何の意味もありませんでした。TTL を下げるという変更そのものも、各地の冷蔵庫に入っている古い牛乳が期限切れになるまでは届かないからです。牛乳パックの期限ラベルを工場で書き換えても、すでに家庭の冷蔵庫に入っている牛乳のラベルは変わりません。
その夜のリカバリー:旧サーバーを「橋」にする
結局その夜は、旧サーバーの nginx を、新サーバーへのリバースプロキシに書き換えてしのぎました。旧サーバーに来たリクエストを、そのまま新サーバーに流すわけです。
server {
listen 443 ssl;
server_name example.com www.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
location / {
proxy_pass https://203.0.113.10; # 新サーバーの IP
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_ssl_server_name on;
proxy_ssl_name $host;
}
}
proxy_ssl_server_name on と proxy_ssl_name $host を忘れると、新サーバー側で SNI が正しく渡らず、別のサイトの証明書が返ってきたりします。それから X-Forwarded-For を渡しておかないと、新サーバーのアクセスログが旧サーバーの IP で埋まります。問い合わせフォームのスパム対策で IP を見ている場合は、新サーバー側でこのヘッダーを信頼する設定も必要です。
これで、どちらの冷蔵庫を開けた人も、最終的には新サーバーにたどり着くようになりました。旧サーバーのアクセスログを tail -f で眺めながら、リクエストが少しずつ減っていくのを見守った夜のことは、たぶん一生忘れません。
本当はこうすべきだった
あの夜の反省を、今はこういう段取りにしています。
| タイミング | やること |
|---|---|
| 切り替えの「現在の TTL + 余裕」以上前 | TTL を 300 秒に下げる。86400 なら、少なくとも2日前 |
| 切り替えの1時間前 |
ttl-check.sh で、権威サーバーとリゾルバの TTL が 300 以下になっていることを確認 |
| 切り替え | レコードを書き換える。同時に、旧サーバーをプロキシにするか、書き込みを止める |
| 切り替え後 | 旧サーバーのアクセスログが止まるまで、旧サーバーは消さない |
| 安定したら | TTL を 3600 など普段の値に戻す |
ポイントは1行目です。TTL を下げる作業は、切り替え作業の一部ではなく、事前準備です。 備えあれば憂いなし、と言いますが、DNS の場合は「備え」に物理的な待ち時間がかかります。気合いでは縮められません。
それから、普段の TTL をずっと 300 にしておけばいいかというと、そうでもありません。リゾルバへの問い合わせが増えますし、権威サーバー側で障害が起きたときに、キャッシュという「冷蔵庫の備蓄」がすぐ尽きてしまいます。短い TTL は、停電に弱い冷蔵庫でもあるわけです。
「ない」もキャッシュされる
もうひとつ、地味にハマりやすいのがネガティブキャッシュです。
新しいサブドメインを作る前に、誰かが(たいていは作業中の自分が)その名前を引いてしまうと、リゾルバは「そんな名前は存在しない」という答えをキャッシュします。冷蔵庫に牛乳がないことを確認して、「うちには牛乳はない」と覚えてしまう状態です。そのあと牛乳を買ってきても、しばらくは「ない」と言い張られます。
この「ない」を覚えておく時間は、ゾーンの SOA レコードで決まります。RFC 2308 では、SOA レコード自身の TTL と、SOA の最後のフィールド(MINIMUM)の、小さいほうを使うことになっています。
dig +noall +answer SOA github.com
最後の数字がそれです。新しいサブドメインを用意するときは、レコードを作る前にその名前を引かないこと。テストしたい気持ちをぐっとこらえるのが、意外と一番難しいです。
証明書の消費期限が、どんどん短くなっている
最後に、もうひとつの期限の話です。TLS 証明書の有効期限です。
こちらは賞味期限ではなく、完全に消費期限です。1秒でも過ぎたら、ブラウザは容赦なく警告画面を出します。「多少古くてもいい」は通用しません。
そして、この消費期限が今どんどん短くなっています。CA/Browser Forum の決定で、公的に信頼される TLS 証明書の最長有効期間は、2026年3月から 200 日に短縮されました。今後も段階的に短くなる予定です。Let's Encrypt はさらに先を行っていて、2026年5月13日から、オプトインの tlsserver プロファイルで 45日 の証明書を発行し始めています。デフォルトの classic プロファイルも、2027年2月10日に64日、2028年2月16日に45日へと短くなる予定です。
ここで危ないのが、cron に「60日ごとに更新」と手で書いてある古い環境です。90日証明書の時代なら問題なかった設定が、64日や45日の証明書では普通に期限切れを起こします。Let's Encrypt は、ACME の更新情報(ARI)に対応したクライアントを使うこと、それができなければ有効期間の3分の2が過ぎたあたりで更新することを勧めています。固定の日数ではなく、比率で考えるのがポイントです。
冷蔵庫でいえば、「毎週日曜に牛乳を買い足す」というルールで回していたら、ある日から牛乳の消費期限が5日になった、という状況です。ルールの側を変えないと、確実にどこかで腐ります。
手元のサーバーの証明書の残り日数は、これでさっと確認できます。
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null |
openssl x509 -noout -enddate
まとめの代わりに、この記事の賞味期限について
今日の話を一行にすると、こうなります。
期限には「おいしく使える目安」と「絶対に越えてはいけない限界」の2種類があって、どちらの期限なのかを意識するだけで、キャッシュの設計はかなり楽になる。
サーバー移行は仕事で何度もやっていますが、今でも切り替えの前日は少し緊張します。それでも、あの夜以来、TTL の確認を忘れたことは一度もありません。もし移行の段取りで迷っている方がいたら、気軽にコメントで聞いてください。TTL の確認くらいなら、喜んで一緒に悩みます。
最後に、この記事自体の期限を書いておきます。
この記事の賞味期限
- Let's Encrypt と CA/Browser Forum の日付・日数は、2026年10月18日時点の情報です。
-
classicプロファイルが64日になる 2027年2月10日 を過ぎたら、一部の記述が古くなります。そのときに見直して更新します。 -
ttl-check.shの出力例は、2026年10月18日の朝に実際に取得したものです。TTL の数字は、実行するたびに変わります。
賞味期限なので、過ぎてもすぐに読めなくなるわけではありません。ただ、念のため匂いを嗅いでから使ってください。
