Article

HTTP/3対応CDN設定とパフォーマンス計測方法

高田晃太郎
HTTP/3対応CDN設定とパフォーマンス計測方法

Cloudflare Radarの公開データでは、2024年時点で世界のウェブトラフィックの相当割合がHTTP/3で配信されているとされ、主要CDNはすでに標準サポートの段階に入っている[1][3][4][5]。Chromeの実装も成熟し、移動体ネットワークや高レイテンシ環境ではQUIC(UDPベースの新しい輸送層)の利点が出やすい。公開研究では、損失が発生するネットワークでTTFB(最初の1バイトまでの時間)や尾部遅延(99パーセンタイル)の改善が顕著であることが示され、ページ体感の向上に直結しやすい[6]。本稿では、導入可否ではなく、どの順序と計測でリスクを抑えながら効果を最大化するかに軸を置く。新プロトコルの有効化だけでは成果が揺らぐ。CDNの設定、オリジンのTLS、キャッシュ階層、そして計測計画を一体で設計することが、継続的な改善に不可欠である。

HTTP/3の仕組みとCDNで速くなる理由を実務視点で整理

HTTP/3は土台となる輸送層をTCPからUDPベースのQUICへ移し、コネクション確立の往復回数削減、ヘッドオブラインブロッキングの回避、接続の再確立容易性などを得ている[2][3][5]。CDN経由では、エッジに近い地点での暗号終端とキャッシュが効き、0-RTT再開(前回の接続情報を使い往復回数を減らす)やコネクション移動(IP変化時の継続)の恩恵が現れやすい[2]。特にモバイル環境のセル間移動やWi‑Fiからセルラーへの切り替えでは、TCPよりセッション継続性が高く、微小なパケット損失が体感劣化に直結しにくい[2][6]。

一方で、足かせになりやすいのはTLS 1.3の非対応や古い中間装置でのUDP制限、そして誤ったキャッシュキー設計だ。H2/H3混在期の挙動を読み違えると、ベンチマークでは速く見えても実運用ではキャッシュヒット率が下がり、結果として遅くなることがある。鍵になるのは、プロトコルとは別軸でキャッシュレイヤとオリジンの最適化を同時進行すること。画像配信はAVIF/WebPのネゴシエーション、HTMLはServer-TimingやEarly Hints(103応答で先読み指示)の活用、APIは適切なCache-Controlとステール許容で尾部遅延を抑える。新プロトコルは魔法ではないが、これらを支える頼もしい土台になる。なお、利用にはTLS 1.3が前提で、非対応環境ではHTTP/2/1.1へのフォールバックが必要になる[4][5]。

導入の事前チェックリストを文章で描く

実装前に、オリジンがTLS 1.3に対応しているかを確認する。CDN終端でTLS 1.3を使えても、オリジン接続がTLS 1.2のままでは、キャッシュミス時の往復でレイテンシが積み上がる。ファイアウォールや社内ネットワークでUDP/443が制限されている場合も想定し、フォールバックとしてHTTP/2が確実に機能する構成を維持する[4][5]。計測の観点では、切替前後で同一条件のA/B配信を行い、TTFB、FCP(First Contentful Paint)、LCP(Largest Contentful Paint)、CLS(Cumulative Layout Shift)、リクエスト成功率、再送率といった指標を時系列で比較できる設計にしておくことが重要だ。単発のラボ計測ではなく、RUM(実ユーザー計測)での継続観測を前提に、ダッシュボードとロールバックの判断基準を準備する。

主要CDNでのHTTP/3設定ポイントと確認手順

Cloudflare、AWS CloudFront、Fastly、Akamaiなど主要CDNはH3を提供している[1][3][4][5]。共通するのは、エッジで有効化しても、クライアントのネゴシエーションによりHTTP/2やHTTP/1.1へフォールバックすることだ[4][5]。したがって、プロトコル差分に依存しない安全な挙動を維持しつつ、利点を最大化する設定が必要になる。以下では、具体的な設定と検証のコードや呼び出し例を示す。

Cloudflareでの有効化とAPIによる制御

CloudflareではダッシュボードでHTTP/3とQUICを有効化できる。運用ではAPIでの明示的な制御が便利だ。以下はNode.jsでCloudflare APIを用いてHTTP/3とQUICをオンにする例である。トークンスコープはZone Settingsの編集を許可する。エラー時にはHTTPステータスとレスポンス本文を記録し、監査可能にしておく。なお、利用はAlt-Svc(代替サービス)による広告とネゴシエーションで成立し、未対応クライアントはH2/H1へフォールバックする[2]。

import Cloudflare from 'cloudflare';

async function enableH3(zoneId: string, token: string) {
  const cf = new Cloudflare({ token });
  try {
    const quic = await cf.zones.settings.edit(zoneId, { id: 'quic', value: 'on' });
    const h3 = await cf.zones.settings.edit(zoneId, { id: 'http3', value: 'on' });
    console.log('QUIC:', quic.result.value, 'HTTP3:', h3.result.value);
  } catch (err: any) {
    console.error('Failed to enable HTTP/3:', err?.response?.status, err?.response?.data || err);
    process.exitCode = 1;
  }
}

enableH3(process.env.CF_ZONE_ID!, process.env.CF_API_TOKEN!);

動作確認はcurlで十分だが、必ずH3で到達しているかを明示的に検証する。サーバがAlt-Svcを返していても、実接続がH2の場合があるため、--http3フラグで強制し、握手と応答が成立していることを確認する。

curl --http3 -I https://example.com/

AWS CloudFrontでのhttp2and3設定と自動テスト

CloudFrontはディストリビューション単位でHTTPバージョンを指定できる。運用ではIaCやSDKで構成を一元管理し、環境間の差分を排除したい。次の例ではAWS SDK for JavaScript v3を使って、既存ディストリビューションのHttpVersionをhttp2and3に更新する。If-MatchにETagを指定する必要があるため、事前にGetDistributionConfigで取得し、バージョンを衝突なく反映する[3]。

import { CloudFrontClient, GetDistributionConfigCommand, UpdateDistributionCommand } from '@aws-sdk/client-cloudfront';

async function enableH3ForDistribution(id: string) {
  const cf = new CloudFrontClient({});
  try {
    const getRes = await cf.send(new GetDistributionConfigCommand({ Id: id }));
    const config = getRes.DistributionConfig!;
    config.HttpVersion = 'http2and3';
    const upd = await cf.send(new UpdateDistributionCommand({
      Id: id,
      IfMatch: getRes.ETag,
      DistributionConfig: config,
    }));
    console.log('Updated to', upd.Distribution?.DistributionConfig?.HttpVersion);
  } catch (e) {
    console.error('CloudFront update failed', e);
    process.exitCode = 1;
  }
}

enableH3ForDistribution(process.env.DIST_ID!);

設定後はヘッダと実プロトコルを併せて検証する。ブラウザではNetworkタブのProtocol列でh3を確認し、ターミナルではs_clientやcurlを併用する。加えて、キャッシュミス時のオリジン到達性とレイテンシをCloudWatchや自前のプローブで可視化し、エッジ—オリジン間のボトルネックを早期に把握する。

FastlyやAkamaiでの考慮点と共通チェック

Fastlyはサービス単位でH3対応を有効化でき、AkamaiはProperty Managerでオンにするフラグを提供する[4][5]。どちらもクライアントとエッジ間でH3が成立していても、エッジ—オリジン間は別バージョンで接続される点を理解しておく[4][5]。したがって、キャッシュヒット率の変動、ステール許容の設定、障害時のGrace期間、圧縮や画像変換のオフロードなど、プロトコル以外の最適化が同じくらい重要になる。特にAPI配信では、Retry-AfterやIdempotency-Key(再試行の安全制御)の運用が尾部遅延の抑制に寄与するため、プロトコル移行と同時にテレメトリーを整備し、問題切り分けを容易にする。

効果を可視化する計測設計:ラボとRUMの両輪

導入効果は、制御されたラボ計測と実環境のRUMの両方で捉える。ラボではネットワーク条件を固定し、HTTP/2とHTTP/3を交互に試験して差分を評価する。RUMではユーザ分布、デバイス、回線品質の多様性をありのままに受け止め、中央値だけでなく尾部の改善を観察する。新プロトコルは特に損失時の強みがあるため、99パーセンタイルTTFBや失敗率の低下に注目しておくのが実践的だ[6]。ここでは自動化しやすい計測コードをいくつか示す。

ブラウザRUM:Navigation TimingとServer-Timingの収集

クライアントからNavigation Timingを送信し、サーバ側はServer-Timingでバックエンドの内部区間を露出する。まずはオリジンでServer-Timingを返す実装例である。Expressを例に、アプリ内部のDB区間を可視化している。

import express from 'express';

const app = express();

app.get('/', async (req, res) => {
  const t0 = Date.now();
  try {
    // Simulated DB call
    const dbStart = Date.now();
    await new Promise((r) => setTimeout(r, 40));
    const dbDur = Date.now() - dbStart;

    res.set('Server-Timing', `db;dur=${dbDur}`);
    res.send('<html><body>Hello</body></html>');
  } catch (e) {
    res.status(500).send('error');
  } finally {
    const total = Date.now() - t0;
    console.log('handled in', total, 'ms');
  }
});

app.listen(3000, () => console.log('listening on :3000'));

次に、RUMビーコンでNavigation Timingを送信するコードである。送信先はCDNのエッジログに取り込むか、自前の収集エンドポイントを用意する。エラーは握りつぶさず、送信失敗率も別途可視化対象にする。

async function sendRum() {
  try {
    const [nav] = performance.getEntriesByType('navigation');
    const payload = {
      ttfb: nav.responseStart - nav.requestStart,
      fcp: performance.getEntriesByName('first-contentful-paint')[0]?.startTime,
      lcp: performance.getEntriesByType('largest-contentful-paint').slice(-1)[0]?.startTime,
      protocol: (performance).navigation?.nextHopProtocol,
      ts: Date.now(),
    };
    await fetch('/rum', { method: 'POST', keepalive: true, body: JSON.stringify(payload), headers: { 'content-type': 'application/json' } });
  } catch (e) {
    console.error('RUM send failed', e);
  }
}
window.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') sendRum();
});

Cloudflare Workersでのヘッダ追加と軽量計測

エッジでの微小な追加計測はWorkersの責務に向いている。以下ではオリジン応答にServer-Timingを追加し、H3で応答しているかどうかのヒントをX-Protoで返す例を示す。実運用ではセキュリティ上、拡散しすぎないよう適切にスコープする。

export default {
  async fetch(req, env, ctx) {
    const t0 = Date.now();
    try {
      const res = await fetch(req);
      const h = new Headers(res.headers);
      const dur = Date.now() - t0;
      h.append('Server-Timing', `edge;dur=${dur}`);
      h.set('X-Proto', req.cf?.httpProtocol || 'unknown');
      return new Response(res.body, { status: res.status, headers: h });
    } catch (e) {
      return new Response('edge error', { status: 502 });
    }
  }
};

ラボ計測:Puppeteerでの自動収集と比較

ブラウザ自動化で、HTTP/2とHTTP/3をサブドメインで出し分け、同一条件で複数回測定する。以下はPuppeteerで主要メトリクスを収集する例である。ネットワーク劣化の再現にはChromeのネットワークエミュレーションを併用する。失敗時にはスクリーンショットを残し、後からの再解析に備える。

import puppeteer from 'puppeteer';

async function measure(url) {
  const browser = await puppeteer.launch({ headless: 'new' });
  try {
    const page = await browser.newPage();
    await page.goto(url, { waitUntil: 'load', timeout: 60000 });
    const metrics = await page.evaluate(() => {
      const [nav] = performance.getEntriesByType('navigation');
      const lcp = performance.getEntriesByType('largest-contentful-paint').slice(-1)[0]?.startTime;
      const fcp = performance.getEntriesByName('first-contentful-paint')[0]?.startTime;
      return {
        protocol: (performance).navigation?.nextHopProtocol,
        ttfb: nav.responseStart - nav.requestStart,
        fcp, lcp,
      };
    });
    console.log(url, metrics);
  } catch (e) {
    console.error('measure failed', e);
    await (await (await browser.pages())[0]).screenshot({ path: 'error.png' });
  } finally {
    await browser.close();
  }
}

await measure('https://h2.example.com');
await measure('https://h3.example.com');

GoによるHTTP/3直叩きとTTFB分解

サーバ側やCIから直接H3で到達確認したい場合、Goとquic-go/http3を使うと低オーバーヘッドで実行できる。以下はTTFB相当の区間を大まかに測る例である。タイムアウトやDNS失敗など、ネットワークエラーは分類してログに残す。

package main

import (
    "context"
    "crypto/tls"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"

    qhttp3 "github.com/quic-go/http3"
)

func main() {
    if len(os.Args) < 2 {
        fmt.Println("usage: h3get <url>")
        os.Exit(2)
    }
    url := os.Args[1]
    dialer := &qhttp3.RoundTripper{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}}
    defer dialer.Close()

    client := &http.Client{Transport: dialer, Timeout: 30 * time.Second}

    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    t0 := time.Now()
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    resp, err := client.Do(req)
    if err != nil {
        fmt.Println("request failed:", err)
        os.Exit(1)
    }
    defer resp.Body.Close()

    tHeaders := time.Since(t0)
    _, _ = io.Copy(io.Discard, resp.Body)
    tTotal := time.Since(t0)

    fmt.Printf("proto=%s status=%d headers=%v total=%v\n", resp.Proto, resp.StatusCode, tHeaders, tTotal)
}

運用で効かせるリリース戦略とSLO:安全に速くする

有効化は、全量切替よりも段階的なロールアウトが運用に優しい。まずは特定のリージョンやプレフィックス、またはクッキーで対象ユーザを限定し、ラボ計測の仮説どおりにRUMが改善しているかを確認する。改善の見込みが薄い場合でも、尾部遅延の改善がビジネスに与える影響は小さくない。Eコマースやメディアでは、わずかな応答時間短縮が直帰率やコンバージョンに波及しやすいため、中央値よりも99パーセンタイルの健全性をSLOに落とし込むと意思決定が明瞭になる。

ロールバックの条件は定量化しておく。たとえば、H3群の失敗率がH2群より一定以上高い状態が継続した場合に自動的に無効化するなど、制御をオーケストレーション側に寄せる。CDN APIでの切替をCI/CDに組み込み、メトリクスの閾値越えをトリガーに設定変更を流すと、夜間帯の障害でも被害を限定できる。以下は、CIからH3有効化の可否を検知・切替するための一環として、実到達をcurlで検証し、失敗時にステップを停止する最小の例だ。

set -euo pipefail
if ! curl --http3 -Is https://example.com/ >/dev/null; then
  echo "HTTP/3 unreachable; aborting rollout" >&2
  exit 1
fi

最後に、外形監視や第三者計測を用いて、社内の計測バイアスを減らすことも重要だ。WebPageTestのAPIやPageSpeed Insightsのデータを継続取得し、HTTP/2対照群とHTTP/3群を同一ダッシュボードで比較する。以下はNode.jsでWebPageTestを叩き、結果JSONの主要値だけを抽出する例である。テストキューの混み具合に左右されるため、結果取得は指数バックオフでリトライする。

import fetch from 'node-fetch';

async function runWpt(apiKey, url) {
  const start = await fetch(`https://www.webpagetest.org/runtest.php?url=${encodeURIComponent(url)}&f=json&k=${apiKey}`);
  const job = await start.json();
  const testId = job.data.testId;
  for (let i = 0; i < 20; i++) {
    const r = await fetch(`https://www.webpagetest.org/jsonResult.php?test=${testId}`);
    const j = await r.json();
    if (j.statusCode === 200) {
      const first = j.data.runs["1"].firstView;
      console.log({ TTFB: first.TTFB, FCP: first.FirstContentfulPaint, LCP: first.LargestContentfulPaint });
      return;
    }
    await new Promise((res) => setTimeout(res, 15000));
  }
  throw new Error('WPT result timeout');
}

runWpt(process.env.WPT_API_KEY, 'https://h3.example.com');

実務の落とし穴を避けるための検証観点

UDP制限のある企業ネットワークではH3接続が成立しないケースがある[5]。そのため、B2B向けの管理画面やダッシュボードはHTTP/2へのフォールバック性能を高めておき、急激なプロトコル依存を避ける。画像最適化やEarly Hintsのようなプロトコル非依存のチューニングも合わせて進めると、効果がさらに乗る。さらに、キャッシュポリシーがH2/H3で一致しない罠を避けるため、VaryやAccept-Encoding、CDNのキャッシュキー定義をレビューし、A/B期間中にヒット率が対照群と同等であることを確認しておくとよい。

まとめ:安全に、確実に、HTTP/3の恩恵を自社のKPIへ

HTTP/3は単体のスイッチではなく、キャッシュ、TLS、画像最適化、そして計測の総合格闘技に近い。まずはCDNでのHTTP/3とQUICの有効化を最小スコープで始め、curlやブラウザで実接続を確かめ、RUMとラボの両輪でTTFBやLCPの差分を継続観測する。改善が確認できた領域から段階的に対象を広げ、SLOに尾部遅延の指標を組み込みながら、万一に備えた自動ロールバックを仕掛ける。これらのプロセスが回り始めれば、安定運用のままKPIに効く施策として定着するはずだ。次のスプリントで、まずは一つのパスや地域から小さく始めてみてはどうだろうか。

参考文献

  1. Cloudflare Radar 2023 Year in Review
  2. Cloudflare Developers: About HTTP/3 (QUIC) and performance characteristics
  3. AWS Blog: New — HTTP/3 support for Amazon CloudFront
  4. Fastly Documentation: Enabling HTTP/3 for Fastly services
  5. Akamai TechDocs: HTTP/3 support in Property Manager
  6. arXiv: QUIC/HTTP/3 performance in lossy/high-latency networks (2023-10-16)