エッジにおける TLS セッション再開と HTTP/3
セッション再開により、再訪問者は TLS のフルハンドシェイクを省略できます。再起動後もチケット鍵を保持する必要がある理由、安全なローテーションの方法、エッジの HTTP/3 が既存のリクエスト処理パイプラインを再利用する仕組みを解説します。
CDN では、バイト列の転送よりも接続の確立に大きなコストがかかることがよくあります。TLS のフルハンドシェイクには非対称暗号の処理と追加のネットワーク往復が必要です。遠方からの訪問者にとっては、その数十〜数百ミリ秒が最初のバイトを受信するまでの時間に直接影響します。この記事では、Rust 製エッジで取り組んだ、ハンドシェイクに関係する2つのテーマ、TLS セッション再開と HTTP/3 をご紹介します。
セッション再開とは
ブラウザが初めてサイトに接続すると、フルハンドシェイクを実行し、セッション鍵を合意します。その直後に、新しいタブを開く、接続を張り直す、モバイルネットワークを切り替えるといった理由で別の接続が必要になっても、コストの高い鍵交換を繰り返す必要はありません。TLS には、まさにこのための再開の仕組みがあります。
- TLS 1.2 には、セッション ID とセッションチケットの2つの方式があります。セッション ID ではサーバーがセッション状態をメモリに保持します。セッションチケットでは、サーバーがセッション状態を暗号化してクライアントに渡し、クライアントが次の接続時にそれを提示します。
- TLS 1.3 では、これらを PSK(事前共有鍵)による再開に統一しています。ハンドシェイクの後にサーバーが
NewSessionTicketを送信し、クライアントが次の接続時にそれを提示します。
いずれの場合も、チケットはサーバー側のチケット鍵で暗号化されます。クライアントが再び提示したチケットをサーバーが復号できる限り、フルハンドシェイクを省略できます。訪問者にとってはレイテンシの低減、エッジにとっては暗号処理に使う CPU の削減につながります。
見落としやすい問題:再起動のたびに全チケットが無効化
Rust 製エッジの初期バージョンで、不思議な現象に気づきました。セッション再開の成功率が予想を大きく下回り、デプロイのたびにさらに低下していたのです。原因は単純でしたが、見落としやすいものでした。TLS 層にチケット鍵を設定していなかったのです。
明示的な設定がない場合、BoringSSL と OpenSSL はプロセスの起動時にランダムなチケット鍵を生成します。単一のプロセスが長期間稼働するなら問題ありませんが、エッジのプロセスはデプロイやアップグレードで再起動します。再起動のたびに新しいプロセスには新しい鍵が与えられ、クライアントが保持するチケットがすべて一斉に使えなくなり、フルハンドシェイクに戻っていました。コードベースにはキーリングの実装があったものの、TLS アクセプターには接続されていませんでした。
対策:永続化、ローテーション、上限設定
現在は、チケット鍵をプロセスの使い捨ての副産物ではなく、慎重に管理すべき状態として扱っています。
- ディスクへの永続化。 起動時に、エッジはノードの証明書ディレクトリからキーリングを読み込みます。ファイルが存在しなければ、CSPRNG で新しい鍵を生成します。一時ファイルに書き込んでからアトミックにリネームし、ファイルは最初から
0600の権限で作成します。そのため、権限のないユーザーが読み取れる時間帯は生じません。 - 実際に機能するコールバックの接続。 リポジトリに取り込んだ TLS バインディングに、チケット鍵のコールバック(BoringSSL の
SSL_CTX_set_tlsext_ticket_key_cb)を追加しました。新しいチケットは現在の鍵で暗号化し、復号時はチケットに埋め込まれた鍵名を使ってキーリング内の鍵を検索します。 - 定期的なローテーション。 デフォルトでは 6 hours ごとに鍵をローテーションします。キーリングには現在の鍵と過去の鍵をいくつか保持し、過去の鍵は復号にのみ使用し、発行には使いません。最近発行されたチケットを引き続き利用できる一方、各鍵の露出期間を限定することで、鍵が漏えいした場合の前方秘匿性への影響を抑えます。
- フェイルオープン。 鍵ファイルを読み込めない場合、エッジは一時的な鍵を生成して配信を継続します。その稼働中に発行されたチケットは、次の再起動後には利用できなくなります。セッション再開は最適化であり、配信を停止する理由にすべきではありません。
これとは別に、TLS 1.2 のセッション ID に使うサーバー側のセッションキャッシュには、上限付き LRU を採用しています。デフォルトの上限は20,480件です。上限に達すると最も古いセッションを削除するため、メモリ使用量が際限なく増えることはありません。
検証方法もご紹介します。テストでは、同じ鍵ファイルから新しい SSL_CTX を構築して再起動を模擬し、古いコンテキストが発行したチケットで接続して、session_reused == true を確認します。実際のノードでは、チケットを取得してからエッジサービスを再起動し、古いチケットで再接続して、セッションを再開できることを確認します。注意点は、実際にリクエストを送信し、少し待つことです。TLS 1.3 では NewSessionTicket がハンドシェイクの後に届くため、切断が早すぎるとチケットを受け取れず、すべての接続が新規接続に見えてしまいます。
適用範囲について:現在、チケット鍵はノードごとに管理しています。訪問者の次の接続が別のノードに到達した場合は、フルハンドシェイクが行われます。これは意図的なトレードオフです。ノード間で鍵を配布すると、鍵の露出範囲が広がってしまいます。
調査中に修正した問題:正常な再起動
調査中、エッジが正常に再起動していないこともわかりました。Pingora では SIGTERM 後のグレースフルシャットダウンの猶予期間がデフォルトで長く、systemd の停止タイムアウトはそれよりはるかに短いため、再起動のたびに SIGKILL で終了していました。猶予期間とシャットダウンのタイムアウトを数秒に短縮したことで、現在は数秒以内に正常な再起動が完了します。チケット鍵の永続化と合わせて、訪問者はデプロイをほとんど意識せずに済むようになりました。既存の接続は円滑に処理を終え、再訪問者のチケットも有効なまま維持されます。
HTTP/3:同じパイプライン、異なるトランスポート
HTTP/3 は QUIC 上で動作します。QUIC は UDP 上で動作し、TLS 1.3 を内蔵しています。主に不安定なネットワークで効果を発揮します。TCP レベルのヘッドオブラインブロッキングがなく、接続確立の往復回数が少なく、ネットワークが変わっても接続を移行できます。
Pingora 自体の HTTP/3 対応は、まだアップストリームにマージされていません。データプレーンのロードマップをアップストリームのリリースサイクルに左右されたくなかったため、Cloudflare の tokio-quiche を使って QUIC と HTTP/3 の終端処理を実装しました。tokio-quiche は quiche を基盤としています。
設計上の重要な判断は、HTTP/3 はトランスポートのみを担当し、リクエストのロジックはすべて既存のパイプラインを再利用することです。具体的には、次のように構成しています。
- tokio-quiche が UDP 443 で QUIC と HTTP/3 を終端し、ハンドシェイク中に SNI に基づいて証明書を選択します。証明書は TCP 443 とまったく同じストアから取得するため、別の証明書管理システムはありません。
- デコードした HTTP/3 リクエストは、ローカルのループバック接続経由で同じプロセス内の Pingora に渡します。これにより、ルーティング、キャッシュ、WAF、ルール、オリジンからの取得処理をすべて再利用できます。HTTP/3 のために機能モジュールを書き直す必要はありませんでした。
- ループバック経由のリクエストには、プロセスメモリ内にのみ存在するランダムなシークレットを付与し、実際にループバックから届いた場合に限って信頼します。この仕組みで、実際のクライアント IP とプロトコルを復元します。同じ名前のヘッダーが外部から送られてきた場合は、必ず除去します。
- H1/H2 と H3 は1つのプロセス内で単一の BoringSSL を共有しており、QUIC ハンドシェイク中にクライアントのフィンガープリントを計算することもできます。
配信できる場合だけ通知する:Alt-Svc の送信条件
ブラウザは、レスポンスヘッダー Alt-Svc: h3=":443"; ma=86400 からサイトが HTTP/3 に対応していることを認識します。UDP で待ち受けていないサーバーがこのヘッダーを送信すると、ブラウザは接続を一度無駄に試みてから HTTP/2 にフォールバックします。そのため、Alt-Svc の通知にはノードとドメインの両方のレベルで条件を設けています。ノードが実際に QUIC リスナーを稼働させており、かつドメインで HTTP/3 が有効な場合にのみ送信します。また、オリジンが返した Alt-Svc ヘッダーも除去し、オリジン独自の通知によってクライアントが誤った接続先を使わないようにしています。
現在、0-RTT は有効にしていません。さらに1往復を省略できますが、早期データはリプレイされる可能性があり、安全に有効化するには、どのリクエストが冪等かを判断する必要があります。その点に確信が持てた段階で、改めて検討します。
まとめ
セッション再開も HTTP/3 も、単にスイッチを入れれば済む機能ではありません。セッション再開にはチケット鍵のライフサイクル管理が不可欠です。HTTP/3 には別のトランスポートスタックが必要ですが、リクエストのロジックを分岐させるべきではありません。私たちは、鍵を永続化とローテーションが必要な状態として扱い、HTTP/3 をトランスポート専用のフロントエンドと位置づけ、各機能の実装を1つに集約しています。
Yeap Cloud で開発を始める
新規アカウントで DNS と CDN の無料プランをご利用いただけます。
続きを読む
OpenAI SDK で始める Yeap LLM API
Yeap LLM API は OpenAI 互換です。SDK はそのまま、base_url を https://llm.yeap.cloud/v1 に変更して利用できます。キーの作成、Python と Node.js の例、ストリーミング、埋め込みベクトル、キーごとのクォータ・モデル制限・IP 制限を解説します。

PHP2Shell(CVE-2026-109618/109619):エッジでの仮想パッチ対応
2 件の重大な PHP アドバイザリにより、OOM bailout と古いグローバル状態を通じて認証なしの RCE が可能になります。脆弱性の仕組み、Yeap WAF が初日からエッジでブロックしているもの、そしてオリジン側で引き続き修正が必要なことを解説します。
DNS がゾーンのネームサーバーを選ぶ仕組み
Yeap DNS でホストする各ゾーンには、2つのネームサーバーが割り当てられます。その組み合わせには、安定性、分散、実際に応答できることが求められます。ハッシュベースの割り当ての仕組みと、「ステータスは意図、ハートビートは事実」である理由を解説します。