2500億のキャッシュエントリ、1バイトの勝負

CloudflareのDNSサービスであるBig Pineappleは、世界中のデータセンターで2500億以上のDNSキャッシュエントリを保存しています。この規模では、エントリあたり1バイトを削減することは、フリート全体で250GBのメモリを節約することに相当します。この記事では、Cloudflareが5回の構造変更を通じてエントリあたりのメモリ使用量を953バイトから420バイトに削減し、同時にキャッシュパフォーマンスも改善した方法を説明します。

単にメモリを削減しただけでなく、挿入スループットは43%増加し、参照レイテンシは19%減少しました。メモリ最適化がむしろパフォーマンス向上につながったのです。これは単にデータ構造を変更するだけでなく、CPUキャッシュの局所性と割り当てパターンまで考慮した総合的な最適化の結果です。

参考: この記事はCloudflare公式ブログの原文に基づき、日本の開発者の視点から再構成しました。

キャッシュエントリの構造と問題点

Big Pineappleのキャッシュはキーと値のペアで構成されています。キーはクエリされたドメイン情報を、値はDNS応答とメタデータを保存します。

// キャッシュキー構造
pub struct CacheKey {
    qname: Name,           // クエリドメイン名
    qtype: Rtype,          // クエリタイプ (A, AAAA, TXTなど)
    authenticated: bool,   // DNSSEC認証フラグ
    tag: Vec<u8>,          // タグ
}

// キャッシュエントリ構造
pub struct CacheEntry {
    timestamp: UnixTimeStamp,
    pub inception: Instant,
    pub ttl: Ttl,
    pub hits: u32,
    pub answers: Vec<Record>,     // 応答セクション
    pub authority: Vec<Record>,   // 権威セクション
    pub additional: Vec<Record>,  // 追加セクション
    pub errors: Vec<Error>,
    // ...
}

この構造で最も大きな問題はVec<T>です。Vecはヒープにデータを保存し、ポインタ(8バイト)、長さ(8バイト)、容量(8バイト)の3つのフィールドで構成されます。しかし、キャッシュに保存された後はデータが変更されないため、容量フィールドは不要です。また、Vecは事前に容量を確保するため、実際のデータよりも多くのヒープ領域を占有します。

その他にもいくつかの非効率が存在します:

  1. 不要な容量フィールド: VecStringの容量フィールドがメモリを浪費
  2. 重複リスト: 応答、権威、追加セクションがそれぞれ別のリストとして保存
  3. 重複オーナー名: ほとんどのレコードがクエリドメインと同じオーナーを持つが、毎回フルネームを保存
  4. 巨大なEnum: さまざまなDNSレコードタイプを1つのenumで管理するが、最大のバリアントのサイズに合わせてメモリを割り当て

5ステップの最適化: メモリとパフォーマンスを同時に向上

ステップ1: Vec<T>Box<[T]>に置き換え

Box<[T]>は固定サイズの配列で、生成後にサイズを変更できません。したがって、容量フィールドが不要になり、事前に領域を確保する必要もありません。StringBox<str>に置き換えます。

// 変更前
pub struct CacheEntry {
    // ...
    pub answers: Vec<Record>,
    pub authority: Vec<Record>,
    pub additional: Vec<Record>,
}

// 変更後
pub struct CacheEntry {
    // ...
    pub answers: Box<[Record]>,
    pub authority: Box<[Record]>,
    pub additional: Box<[Record]>,
}

この変更だけでエントリあたり64バイトを節約し、ヒープメモリの無駄も排除します。全体では15テラバイト以上のメモリ削減効果があります。

ステップ2: リスト統合とオフセットの使用

応答、権威、追加セクションを1つのリストに統合し、各セクションの開始位置を示すオフセットを保存します。

// 変更前: 3つのリスト (それぞれポインタ8バイト + 長さ8バイト)
pub struct CacheEntry {
    pub answers: Box<[Record]>,
    pub authority: Box<[Record]>,
    pub additional: Box<[Record]>,
}

// 変更後: 1つのリスト + 2つのオフセット
pub struct CacheEntry {
    pub records: Box<[Record]>,
    pub authority_offset: u16,   // 2バイト
    pub additional_offset: u16,  // 2バイト
}

各セクションのレコード数がu16の範囲を超えないため、2バイトのオフセットで十分です。これにより、エントリあたり28バイトを節約できます。

ステップ3: オーナー名の省略

ほとんどのDNSレコードは、クエリしたドメインと同じオーナーを持ちます。この場合、オーナー名を保存せず、参照時にキャッシュキーから復元します。

// 変更前: すべてのレコードがオーナー名を保存
pub struct Record {
    owner: Name,  // 常にフルドメイン名を保存
    class: Class,
    ttl: Ttl,
    rtype: Rtype,
    data: RecordData,
}

// 変更後: オーナーがクエリドメインと同じ場合は省略
pub struct Record {
    owner: Option<Box<Name>>,  // Noneの場合はクエリドメインを使用
    class: Class,
    ttl: Ttl,
    rtype: Rtype,
    data: RecordData,
}

CNAMEなどによりオーナーが異なる場合のみ、ヒープにフルネームを保存します。実際のトラフィックの大部分はオーナーがクエリドメインと同一であるため、ヒープ割り当てを大幅に削減できます。

ステップ4: Enumバリアントのボクシング

DNSレコードタイプを表すenumは、最大のバリアントのサイズに合わせてメモリが割り当てられます。NAPTRレコードが136バイトを占めるため、Aレコード(4バイト)を保存しても144バイトが消費されます。

// 変更前: すべてのバリアントがインラインで保存
pub enum RecordData {
    A(Ipv4Addr),      // 4バイト
    Aaaa(Ipv6Addr),   // 16バイト
    Txt(Txt),         // 可変長
    Naptr(Naptr),     // 136バイト (最大サイズ)
    // ...
}

// 変更後: 大きなバリアントのみヒープに保存
pub enum RecordData {
    A(Ipv4Addr),          // インライン保存
    Aaaa(Ipv6Addr),       // インライン保存
    Txt(Box<Txt>),        // ヒープに保存
    Naptr(Box<Naptr>),    // ヒープに保存
    // ...
}

AとAAAAレコードが全体のトラフィックの80%以上を占めるため、この最適化は非常に効果的です。Aレコードの場合、120バイト以上を節約できます。

ステップ5: レコードをワイヤーフォーマットで保存

最後に、パースされたレコードの代わりにDNSワイヤーフォーマットの生バイトを保存します。各レコードを2バイトの長さプレフィックスとともに、連続したバイトバッファに保存します。

// 変更前: パースされたレコードのリスト
pub struct CacheEntry {
    pub records: Box<[Record]>,
}

// 変更後: 生バイトバッファ
pub struct CacheEntry {
    pub records: Box<[u8]>,  // 各レコードは2バイト長 + 生データ
}

この方式の利点は次のとおりです:

  • メモリ節約: 各レコードのenumオーバーヘッドと個別のヒープ割り当てを排除
  • CPUキャッシュ局所性の向上: データが連続して保存され、キャッシュライン効率が向上
  • 応答生成の高速化: A、AAAA、TXT、DNSSECレコードはバイトを直接コピーするだけでよい

CNAME、NS、MX、SOAのようにドメイン名を含むレコードのみパースして名前圧縮を適用します。この最適化だけで参照レイテンシが5%減少しました。

最適化結果: メモリとパフォーマンスの同時改善

ベンチマーク結果、5つの最適化によりエントリあたりのメモリ使用量が953バイトから420バイトへ56%削減されました。本番環境でも、p99基準のメモリ使用量が9.3GBから5.3GBへ43%削減されました。

指標最適化前最適化後変化率
エントリあたりメモリ953バイト420バイト-56%
エントリあたり割り当て1.1 KB461バイト-58%
キャッシュ挿入スループット625,000エントリ/s893,000エントリ/s+43%
キャッシュ参照レイテンシ828 ns670 ns-19%

日本における適用コンテキスト

このCloudflareの事例は、単にDNSキャッシュにのみ適用されるものではなく、大規模なトラフィックを処理するすべてのバックエンドシステムに重要な示唆を与えます。

日本のサービスでは特に、季節性のあるトラフィック変動(年末年始、ゴールデンウィーク、大型セールなど)に備える必要がある場合が多くあります。このような状況でメモリ効率はサービス安定性に直結します。この事例の原則である「データ構造をビジネスロジックに合わせて最適化する」というアプローチは参考になります。

また、日本のエンタープライズ環境では、レガシーシステムを維持しながらパフォーマンスを改善する必要がある場合が多くあります。この事例のような段階的な5ステップの最適化は、大きなリスクを負うことなく適用できる優れた戦略です。

この技術の限界または注意点

この最適化テクニックがすべての状況に適用可能なわけではありません。いくつかの注意点があります。

  1. 読み取り専用データにのみ有効: Box<[T]>に置き換えるとデータを変更できません。キャッシュのように保存後に変更されないデータにのみ適用可能です。
  2. 複雑性の増加: レコードをワイヤーフォーマットで保存すると、参照時にパースが必要です。レコード数が多い場合は、逆にパフォーマンスが低下する可能性があります。
  3. ドメイン特化の最適化: この最適化はDNSキャッシュの特性を深く理解した上で適用されています。他のシステムに適用する際は、データ特性を先に分析する必要があります。
  4. メンテナンスコスト: 構造が複雑になると、コードの理解とメンテナンスが難しくなる可能性があります。十分なドキュメント化とテストが必要です。

まとめ: メモリ最適化はパフォーマンス最適化

Cloudflareの事例は、メモリ最適化が単なる容量節約を超えてパフォーマンス向上につながることを示しています。要点は次のとおりです。

  1. データ構造をビジネスロジックに合わせて設計すること。汎用構造よりも特化した構造の方が効率的です。
  2. 割り当てパターンを最適化すること。ヒープ割り当てを減らし、CPUキャッシュの局所性を高めるとパフォーマンスが向上します。
  3. 小さな変更が大きな効果を生むこと。エントリあたり1バイトの節約が250GBのメモリ節約につながります。

今回の最適化で確保した100TBのメモリは、キャッシュ容量を増やしてヒット率を向上させるために再投資される予定です。これにより、全体的なDNSクエリボリュームが削減され、より高速な応答が提供されます。

メモリ最適化に関するさらに深い内容については、MetaのFFmpegフォーク放棄事例も併せてご覧ください。大規模システムにおける技術負債の管理方法についての洞察が得られます。

関連記事

Cloudflare data center server racks with DNS cache optimization diagram overlay Development Concept Image

本コンテンツは、信頼性の高い情報源をもとにAIツールを活用して作成され、編集者によるレビューを経て公開されています。専門家によるアドバイスの代替となるものではありません。