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

Table of Contents
私はMess With DNSというDNSプレイグラウンドを運営しています。楽しいプロジェクトなのですが、しょっちゅうクラッシュしていました。サーバーのRAMは465MBしかなく、IPアドレス検索データベースがそのうち117MBを占めていたのです。これは全メモリの4分の1が消えている計算です。
PowerDNSがさらに100MBを消費し、Mess With DNS自体も200MBを必要としていました。合計すると、サーバーは常に限界ギリギリで稼働している状態でした。一度でも不正なリクエストが来れば、すぐにOOMキルが発生していました。
私がどのように診断し、修正したかを正確に説明します。
メモリ危機
合計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バイトスライス型です。最初にプロファイリングをしていなければ、間違った推測をしていたでしょう。
元のデータ構造
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よりも多い量です。そして、なぜか元の二分探索よりも遅かったのです。問題を改善するどころか、悪化させてしまいました。
早期に学んだこと
時には、エレガントなデータ構造が正しいとは限りません。シンプルな配列と二分探索は、速度とメモリの両方で、より洗練されたアプローチをすでに上回っていました。うまく機能しているものを置き換えるのではなく、無駄を修正しましょう。
この時点で私は立ち止まりました。すでにうまく機能しているものがありました。配列ベースの二分探索です。それは高速で正確でした。唯一の問題はメモリでした。だから、全体を置き換えるのではなく、既存のものを修正してみてはどうだろうか?
実際にうまくいったこと
元の方法に戻り、無駄を探しました。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ルックアップメモリ | 117MB | 46MB | −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の場合、それは信頼できるサービスと、通常の負荷でダウンするサービスとの違いを意味します。
関連投稿
- ターミナルプログラムの暗黙のルール — ターミナルの慣習とパターン
- パイプが詰まる理由:バッファリングの解説 — パイプラインにおけるバッファリングの問題
こちらもおすすめ
Free In-Browser Developer Tools
Clean AI CLI logs, build cron expressions, decode JWTs, and calculate chmod permissions offline.
Related Articles

gRPCとConnectRPC:最新のマイクロサービスとブラウザネイティブなProtobuf
TypeScriptとGoにおけるgRPCとConnectRPCのアーキテクチャを評価し、HTTP/1.1とHTTP/2ストリーミング、Envoyプロキシ不要のブラウザクライアント、p99 RPCレイテンシについて解説します。
Read morePostgreSQLのVACUUMとインデックス肥大化:検知、軽減、そして自動チューニング
PostgreSQLのテーブルとインデックスの肥大化を診断・解消します。自動バキュームのチューニング方法、pg_repackによるゼロダウンタイムでの再構築、MVCCの可視性マップまでを解説します。
Read more
本番環境のSQLite: WALモード、高並行性、そして実践的なPRAGMA設定
高スループットな本番環境でSQLiteをマスターしましょう。先行書き込みログ(WAL)、busy_timeoutのチューニング、読み書きの同時実行性、そして実用的なベンチマークについて解説します。
Read more