•15 min read

MessWithDNSでのIPアドレス検索におけるメモリ使用量の削減

MessWithDNSでのIPアドレス検索におけるメモリ使用量の削減

私はMess With DNSというDNSプレイグラウンドを運営しています。楽しいプロジェクトなのですが、しょっちゅうクラッシュしていました。サーバーのRAMは465MBしかなく、IPアドレス検索データベースがそのうち117MBを占めていたのです。これは全メモリの4分の1が消えている計算です。

PowerDNSがさらに100MBを消費し、Mess With DNS自体も200MBを必要としていました。合計すると、サーバーは常に限界ギリギリで稼働している状態でした。一度でも不正なリクエストが来れば、すぐにOOMキルが発生していました。

私がどのように診断し、修正したかを正確に説明します。

Audio Briefing
0:00 / 0:00
メモリ危機

合計465MBのRAMのうち、IPアドレス検索データベースが117MBを消費していました。PowerDNS(100MB)とMess With DNS(200MB)を合わせると、サーバーは常に限界まで追い込まれており、残りのヘッドルームはわずか約48MBでした。

最初の一歩:実際に測定する

何も手を付ける前に、何がメモリを消費しているのか、なぜ消費しているのかを理解する必要がありました。Goのpprofパッケージを使えば、これは簡単です。

import (
    "runtime"
    "runtime/pprof"
    "os"
)

func dumpHeapProfile(path string) error {
    runtime.GC() // force GC so the profile reflects live allocations
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer f.Close()
    return pprof.WriteHeapProfile(f)
}

次に、それを分析します。

# capture a heap profile from a running process
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap

# or from a saved file
go tool pprof -inuse_space heap.prof

プロファイルはすぐに2つのアロケーションを指摘しました。IPレンジごとに冗長に保存されているASNデータと、古いnet.IPバイトスライス型です。最初にプロファイリングをしていなければ、間違った推測をしていたでしょう。

Advertisement

元のデータ構造

IPルックアップデータベースは、CIDRレンジをその発信元ASN(それらのアドレスを所有するISPまたはネットワークオペレーター)にマッピングします。元の構造は次のようでした。

type IPRange struct {
    Start   net.IP // byte slice: 24 bytes header + 16 bytes data
    End     net.IP
    Country string
    ASN     uint32
    OrgName string // "Cloudflare, Inc." — duplicated for every range
    OrgDesc string // duplicated again
}

var ranges []IPRange // 500,000+ entries

net.IP型はtype IP []byteとして定義されています。Goのスライスはポインタ、長さ、容量の3つのフィールドを持ち、これは24バイトのヘッダーに加えて、別途ヒープに割り当てられたバッキング配列です。各IPRangeはこれらを2つ持っています。

そして、すべてのIPRangeはOrgNameとOrgDescの文字列をインラインで保存しています。もし10,000のレンジがCloudflareに属している場合、「Cloudflare, Inc.」を10,000回保存することになります。

まずは当たり前のことを試す

最初に考えたのはSQLiteでした。データベースをディスクに移動し、本当に速度が必要なものにはRAMを確保する。簡単ですよね?

違いました。

SQLiteは大きな整数を適切にサポートしていません。IPv6アドレスはテキスト文字列として保存する必要がありました。これは間違っていると感じましたが、うまくいくかどうか試すためにそのまま進めました。

本当の問題はパフォーマンスでした。ディスクルックアップは、私がすでに持っていた配列ベースの二分探索よりも500倍遅かったのです。ここで言っているのはマイクロ秒の話ではありません。「目に見えてハングする」レベルです。ハングするDNSプレイグラウンドはプレイグラウンドではありません。それはイライラ製造機です。

というわけで、これは行き止まりでした。

トライでさらに悪化させる

次にトライを試しました。理論的には、トライはプレフィックスマッチングに優れており、これはIPルックアップにまさに必要なものです。これはうまくいくと確信していました。

しかし、うまくいきませんでした。

トライはメモリを800MBにまで膨らませました。これはサーバーが持つRAMよりも多い量です。そして、なぜか元の二分探索よりも遅かったのです。問題を改善するどころか、悪化させてしまいました。

早期に学んだこと

時には、エレガントなデータ構造が正しいとは限りません。シンプルな配列と二分探索は、速度とメモリの両方で、より洗練されたアプローチをすでに上回っていました。うまく機能しているものを置き換えるのではなく、無駄を修正しましょう。

この時点で私は立ち止まりました。すでにうまく機能しているものがありました。配列ベースの二分探索です。それは高速で正確でした。唯一の問題はメモリでした。だから、全体を置き換えるのではなく、既存のものを修正してみてはどうだろうか?

Advertisement

実際にうまくいったこと

元の方法に戻り、無駄を探しました。pprofの出力からすぐに2つの大きな点が目立ちました。

修正1:ASN情報の重複排除

ASNデータを独自の配列に引き出し、文字列データをインラインで保存する代わりに数値インデックスを保存します。

type ASNRecord struct {
    OrgName string
    OrgDesc string
    Country string
    ASN     uint32
}

type IPRange struct {
    Start   netip.Addr // value type — no heap allocation
    End     netip.Addr
    ASNIdx  uint32 // index into a shared ASNRecord slice
}

var asnRecords []ASNRecord // one entry per unique ASN
var ranges []IPRange       // many entries, tiny per-entry cost

データベースロード中に重複排除されたテーブルを構築します。

func buildIndex(rows []rawRow) ([]IPRange, []ASNRecord) {
    asnMap := make(map[uint32]uint32) // ASN number -> index
    var asnRecords []ASNRecord
    var ranges []IPRange

    for _, row := range rows {
        idx, ok := asnMap[row.ASN]
        if !ok {
            idx = uint32(len(asnRecords))
            asnRecords = append(asnRecords, ASNRecord{
                OrgName: row.OrgName,
                OrgDesc: row.OrgDesc,
                Country: row.Country,
                ASN:     row.ASN,
            })
            asnMap[row.ASN] = idx
        }
        ranges = append(ranges, IPRange{
            Start:  row.Start,
            End:    row.End,
            ASNIdx: idx,
        })
    }
    return ranges, asnRecords
}

これにより50MB節約できました。ASNデータは1か所に集約され、レンジは4バイトのインデックスを持つだけになりました。

修正2:netip.Addrへの切り替え

元のコードではGoの古いnet.IP型を使用していました。これは[]byteです。スライスヘッダー(64ビットシステムで24バイト)とヒープに割り当てられたバッキング配列(IPv6で16バイト)で構成されます。すべてのIPRange内のすべてのIPアドレスがヒープに触れていました。

Go 1.18でnet/netipにnetip.Addrが導入されました。これは値型です。128ビットの値と16ビットのゾーンインデックスで構成され、スタックまたは構造体内にインラインで保存されます。ヒープアロケーションは発生しません。

// Before: heap allocation per address
start := net.ParseIP("2a06:98c0::")  // []byte on heap

// After: pure stack value
start, _ := netip.ParseAddr("2a06:98c0::")  // no heap

二分探索は依然として同じように機能します。netip.Addrは比較演算子をサポートしています。

func lookup(ranges []IPRange, ip netip.Addr) *ASNRecord {
    lo, hi := 0, len(ranges)-1
    for lo <= hi {
        mid := (lo + hi) / 2
        r := ranges[mid]
        if ip.Compare(r.Start) >= 0 && ip.Compare(r.End) <= 0 {
            return &asnRecords[r.ASNIdx]
        }
        if ip.Compare(r.Start) < 0 {
            hi = mid - 1
        } else {
            lo = mid + 1
        }
    }
    return nil
}

型の切り替えにより、さらに20MB節約できました。ルックアップアルゴリズムを変更することなく、二分探索は依然としてO(log n)であり、同じ速度で実行されます。

メモリレイアウト:変更前と変更後

IPRangeエントリあたりのメモリフットプリントの変化は次のとおりです。

BEFORE (per IPRange, with net.IP):
┌──────────────────────────────────────────┐
│ Start  net.IP  [ptr|len|cap] = 24 bytes  │
│         heap → [16 bytes]                │
│ End    net.IP  [ptr|len|cap] = 24 bytes  │
│         heap → [16 bytes]                │
│ Country string  16 bytes                 │
│ ASN     uint32   4 bytes                 │
│ OrgName string  16 bytes + heap string   │
│ OrgDesc string  16 bytes + heap string   │
└──────────────────────────────────────────┘
~120+ bytes per entry (plus heap strings)

AFTER (per IPRange, with netip.Addr):
┌──────────────────────────────────────────┐
│ Start  netip.Addr  17 bytes (no heap)    │
│ End    netip.Addr  17 bytes (no heap)    │
│ ASNIdx uint32       4 bytes              │
└──────────────────────────────────────────┘
~38 bytes per entry (all inline, no heap)

50万のレンジがある場合、この違いは劇的に増幅されます。

結果

IPルックアップデータベースは117MBから46MBに削減され、61%の削減となりました。二分探索の速度はまったく同じままでした。サーバーはクラッシュしなくなりました。

メトリック変更前変更後変化
IPルックアップメモリ117MB46MB−61%
空きヘッドルーム約48MB約119MB+71MB
ルックアップアルゴリズム二分探索二分探索変更なし
クエリレイテンシ高速高速変更なし
本番環境でのプロファイリング

Goの本番サービスでライブアロケーションを追跡している場合は、net/http/pprofハンドラを使用してください。

import _ "net/http/pprof"
go http.ListenAndServe(":6060", nil)

すると、go tool pprof -inuse_space http://localhost:6060/debug/pprof/heapは現在ライブで存在するものを表示します。-alloc_spaceを使用して、時間の経過に伴う累積アロケーションを確認します。

別のやり方をするとしたら

もしやり直せるなら、トライは完全にスキップするでしょう。私が扱っていたデータサイズには間違ったツールでした。インターネット規模なら機能するかもしれませんが、これはGoogleのBGPテーブルではありません。既知の、境界のあるデータセットを持つ趣味のプロジェクトです。

また、推測するのではなく、まずプロファイリングを行うでしょう。簡単なpprofヒーププロファイルがあれば、SQLiteやトライを試すのに費やした数日ではなく、数分でASNの重複問題が明らかになったはずです。測定は常に仮定に勝ります。

本当の教訓は?常に賢い解決策が必要なわけではありません。時には、すでに持っているものを見て、「どこに無駄があるのか?」と問う必要があるのです。

ここで適用された一般的なGoのメモリ原則

  • 可能な限り値型を使用する。 netip.Addr、[16]byte、固定サイズのフィールドを持つ構造体など、ヒープポインタを避けるものはすべてGCの負荷を軽減し、メモリを節約します。
  • 繰り返されるデータを正規化する。 複数のレコードが同じ文字列やサブレコードを共有している場合、それを一度だけ保存し、インデックスで参照します。これはデータベース正規化の背後にあるのと同じ原則です。
  • 最適化する前にプロファイリングを行う。 10分間のpprofセッションは、何時間もの投機的なリファクタリングに勝ります。構造体では小さく見えるアロケーションも、大規模になるとギガバイト単位になることがあります。
  • 置き換えをベンチマークする。 netip.Addrへの移行をデプロイする前に、ルックアップのホットパスでgo test -bench=. -benchmemを実行して、回帰を導入していないことを確認します。
# Quick benchmark template
func BenchmarkLookup(b *testing.B) {
    ip, _ := netip.ParseAddr("1.1.1.1")
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        lookup(ranges, ip)
    }
}

それ以来、サーバーは安定しています。71MBの追加ヘッドルームはそれほど多くないように聞こえるかもしれませんが、合計465MBの場合、それは信頼できるサービスと、通常の負荷でダウンするサービスとの違いを意味します。

関連投稿

こちらもおすすめ

Share this article:

Stay Updated

Get the latest posts delivered straight to your inbox.

Free Developer Utilities

Free In-Browser Developer Tools

Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.

Explore Tools
Advertisement