Article

Go Profilerでメモリ使用量とCPU時間を最適化

高田晃太郎
Go Profilerでメモリ使用量とCPU時間を最適化

GoのCPUプロファイラは既定で約100Hzのサンプリングを行い、ヒーププロファイルは平均512KBごとの割り当てをサンプリングします¹²。まずはこの前提を正しく押さえ、再現性のある負荷下で取得・分析するだけで、p99レイテンシが改善することがあります。実運用では、最適化の各ステップでCPU時間(関数がCPUを実際に使っていた時間)やメモリフットプリント(プロセスが保持するメモリ量)を定量化して意思決定することが、クラウドコストとSLOの両立につながります。この記事では、Goのpprofを中心に、CPUとメモリの最適化を実装・検証・運用という流れで整理します。導入時の落とし穴を避けつつ、代表的なコード例、簡潔なベンチマークの進め方、設計判断の要点を一つの読み物としてまとめました。

pprof導入の正解を押さえる:安全性とオーバーヘッド

最初に確認したいのは、安全な導入方法と計測自体のコストです。Goのpprofは待機中のオーバーヘッドが小さく設計されており⁵、CPUプロファイルは約100Hz、ヒープはデフォルトで約512KBごとのサンプリングです¹²。計測を有効化しただけで顕著にパフォーマンスが落ちることは一般的には多くありません。ただし、本番のHTTPエンドポイントをそのまま外部に公開することは避けるべきです。管理バインドの分離、アクセス制御、オンデマンド取得のいずれかは必ず併用します⁵。pprofは「標準搭載の診断サーバ」なので、露出のさせ方が要点です。

次の例は、pprofを専用の管理HTTPサーバに安全にマウントし、誤公開を避けつつ障害時に即時取得できる構成です。エラー処理とシャットダウンも含めています。

package main

import (
    "context"
    "log"
    "net/http"
    _ "net/http/pprof"
    "os"
    "os/signal"
    "syscall"
    "time"
)

func main() {
    adminAddr := "127.0.0.1:6060" // 外部公開しない
    if v := os.Getenv("PPROF_ADDR"); v != "" {
        adminAddr = v
    }

    admin := &http.Server{Addr: adminAddr} // DefaultServeMuxにpprofが登録される

    go func() {
        log.Printf("pprof admin listening on %s", adminAddr)
        if err := admin.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("admin server failed: %v", err)
        }
    }()

    srv := &http.Server{Addr: ":8080", Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        _, _ = w.Write([]byte("ok"))
    })}

    go func() {
        log.Println("app listening on :8080")
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("app server failed: %v", err)
        }
    }()

    // Graceful shutdown
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
    <-sigCh

    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    _ = srv.Shutdown(ctx)
    _ = admin.Shutdown(ctx)
}

この構成により、運用ではhttp://127.0.0.1:6060/debug/pprof/に限定公開されます。KubernetesならポートフォワードやServiceAccountの制限で同等の安全性を確保できます。プロファイルは負荷中に取得することが前提です。レイテンシSLOへの影響が無視できないほど大きい場合は、取得時間を短くする、対象トラフィックを部分抽出するなど、影響範囲を狭める工夫を併用します。

CPUプロファイルの正しい取り方と読み方

CPUプロファイルはCPU時間に対する割合を示します。I/O待ちやスリープは基本的に積算されないため、CPUバウンドかどうかの判定に向きます。コンテナのスロットリング下では、OSスケジューラの都合で見かけのCPU時間が変動する点に注意が必要です。取得環境の制約(CPUクォータやcgroup設定)を把握し、できれば同条件で繰り返し取得します。負荷走行と合わせて、pprofのラベル機能でコンテキスト(例: ルート名やテナント)を添えると分析効率が上がります³。

package main

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

func cpuIntensiveWork(n int) int {
    s := 0
    for i := 0; i < n; i++ {
        s += i * i
    }
    return s
}

func main() {
    f, err := os.Create("cpu.out")
    if err != nil {
        log.Fatalf("create cpu profile: %v", err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil {
            log.Printf("close cpu profile: %v", cerr)
        }
    }()

    if err := pprof.StartCPUProfile(f); err != nil {
        log.Fatalf("start cpu profile: %v", err)
    }
    // 計測対象のウィンドウを明確にする
    start := time.Now()
    _ = cpuIntensiveWork(50_000_000)
    elapsed := time.Since(start)
    pprof.StopCPUProfile()

    log.Printf("cpu-intensive took %s", elapsed)
}

分析はgo tool pprof cpu.outで行い、top、list、webコマンドを使ってホットスポットとコード行を確認します¹。webビューのグラフ(フレームグラフ)は、重い関数がどの経路で支配的になっているかを可視化します。インライン化やエスケープ解析の影響も、該当関数に当たりを付けてから読むと理解が進みます。

ヒープ/アロケーションの把握とMemProfileRate

メモリ最適化では、**現在生きているオブジェクト(inuse heap)累積アロケーション(alloc_space)**で見える景色が変わります。取得前にruntime.GCを1回挟むと、直近のゴミが片付いた状態のスナップショットを得られます。サンプリング精度を上げたい場合はMemProfileRateを調整しますが、その分オーバーヘッドが増えるため、本番では短時間・低頻度の取得に留めるのが安全です²。

package main

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

func makeGarbage() [][]byte {
    out := make([][]byte, 0, 1_000)
    for i := 0; i < 1_000; i++ {
        b := make([]byte, 64*1024) // 64KB
        out = append(out, b)
    }
    return out
}

func main() {
    // サンプル精度を上げる例(注意: コスト増)
    runtime.MemProfileRate = 128 * 1024 // 128KBごとにサンプル²

    _ = makeGarbage()
    runtime.GC() // ヒープ観察前に整理

    hf, err := os.Create("heap.out")
    if err != nil {
        log.Fatalf("create heap profile: %v", err)
    }
    defer hf.Close()

    // ヒープ(inuse)を出力
    if err := pprof.Lookup("heap").WriteTo(hf, 0); err != nil {
        log.Fatalf("write heap profile: %v", err)
    }

    // 累積アロケーションはpprof側で--alloc_spaceを使う
    log.Println("heap profile written")
}

go tool pprof -http=:0 heap.outで、ヒープの重い型や関数を追跡できます¹。alloc_spaceビューは割り当て頻度の高い場所を顕在化させ、短命オブジェクトの削減に役立ちます¹。inuseでピークを抑え、alloc_spaceで不要な割り当てを掃除する、という二段構えが効果的です。

待ちの可視化:block/mutex/traceでレイテンシを剥ぐ

レイテンシの長いサービスでは、CPUやヒープよりも「待ち」が支配的な場合があります。Goのblock profileはチャネル送受信やselect、システムコール前後のブロック時間を、mutex profileはミューテックスの競合時間を可視化します⁶。いずれも既定では無効なので、レートを有効化して短時間だけ取得します⁴。ここでいう「待ち」とは、ゴルーチンがスケジュールされていない、あるいはロックやI/Oで停止している時間のことです。

package main

import (
    "log"
    "os"
    "runtime"
    "runtime/pprof"
    "sync"
    "time"
)

var mu sync.Mutex

func contended() {
    mu.Lock()
    time.Sleep(2 * time.Millisecond)
    mu.Unlock()
}

func main() {
    // 競合の可視化を有効化
    runtime.SetBlockProfileRate(1)     // すべてのブロックイベントを記録(短時間のみ推奨)⁴
    runtime.SetMutexProfileFraction(5) // 約20%サンプリング(1/n の確率で記録)⁴

    for i := 0; i < 1000; i++ {
        go contended()
    }
    time.Sleep(500 * time.Millisecond)

    bf, err := os.Create("block.out")
    if err != nil { log.Fatalf("block: %v", err) }
    defer bf.Close()
    if err := pprof.Lookup("block").WriteTo(bf, 0); err != nil {
        log.Fatalf("write block: %v", err)
    }

    mf, err := os.Create("mutex.out")
    if err != nil { log.Fatalf("mutex: %v", err) }
    defer mf.Close()
    if err := pprof.Lookup("mutex").WriteTo(mf, 0); err != nil {
        log.Fatalf("write mutex: %v", err)
    }

    log.Println("block/mutex profiles written")
}

これらのプロファイルは、p99レイテンシのボトルネック特定に有効です。グローバルロックの粒度調整、チャネル設計の見直し、I/Oのバッファリングといった修正を短サイクルで試し、再取得して効果を確認します。より包括的な可視化が必要なら、go testやgo tool traceによるイベントトレースでガントチャートを観察し、プリエンプションやGCのSTW(停止時間)の実影響を確認します。

実測で裏付ける:ベンチマークとpprofの往復

最適化は必ず定量で評価します。go test -benchでマイクロベンチマークを用意し、cpuprofileやmemprofileを併用してホットスポットを潰します¹。以下は、短命バッファの削減にsync.Poolを用いる例です。比較のため、素朴な実装とプール活用の実装を同一ファイルで計測できるようにしています。

package optjson

import (
    "bytes"
    "encoding/json"
    "sync"
    "testing"
)

type payload struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

var pool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func encodeNaive(p payload) ([]byte, error) {
    return json.Marshal(p)
}

func encodePooled(p payload) ([]byte, error) {
    buf := pool.Get().(*bytes.Buffer)
    buf.Reset()
    enc := json.NewEncoder(buf)
    if err := enc.Encode(p); err != nil {
        pool.Put(buf)
        return nil, err
    }
    out := append([]byte(nil), buf.Bytes()...) // コピーしてプール返却
    pool.Put(buf)
    return out, nil
}

func BenchmarkNaive(b *testing.B) {
    p := payload{ID: 42, Name: "alice"}
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        if _, err := encodeNaive(p); err != nil { b.Fatal(err) }
    }
}

func BenchmarkPooled(b *testing.B) {
    p := payload{ID: 42, Name: "alice"}
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        if _, err := encodePooled(p); err != nil { b.Fatal(err) }
    }
}

一般的な環境では、プール活用によりallocs/opが減少し、レイテンシ尾部の短縮につながる傾向が観測されます。効果検証はgo test -bench=. -benchmem -cpuprofile=cpu.prof -memprofile=mem.profのようにプロファイル出力を併用し、変更前後のpprofを比較して判断します¹。数値は環境に大きく依存するため、同一条件での相対比較を重視してください。

ラベルで因果を追う:pprof.Doと運用の再現性

実サービスでは、テナントやエンドポイント、機能フラグごとにホットスポットが異なるのが普通です。runtime/pprofのラベル機能を使うと、プロファイル上で特定のリクエスト群を抽出して分析できます³。A/Bの比較検証や、リージョン依存の挙動差の切り分けにも有効です。

package main

import (
    "context"
    "log"
    "net/http"
    "runtime/pprof"
)

func handler(w http.ResponseWriter, r *http.Request) {
    lbls := pprof.Labels("route", r.URL.Path, "tenant", r.Header.Get("X-Tenant"))
    pprof.Do(r.Context(), lbls, func(ctx context.Context) {
        // 重い処理の例
        s := 0
        for i := 0; i < 20_000_000; i++ { s += i }
        _, _ = w.Write([]byte("ok"))
    })
}

func main() {
    http.HandleFunc("/", handler)
    log.Fatal(http.ListenAndServe(":8080", nil))
}

取得後は、pprofシェルでtagsコマンドやタグフィルタを使って特定のラベルに絞り込みます³。例えば「テナントA」かつ「機能フラグON」のCPU消費だけに限定して比較すると、原因特定のループが短くなります。再現性の高いプロファイリングは、施策のROI算出にも直結します。

メモリ削減のパターンをコードで固める

アロケーションの多いホットパスに対しては、構造体の値受け渡しからポインタへの切り替え、スライスの事前容量確保、mapキーの型選択、I/Oのストリーミング化など、複数の打ち手があります。以下は、スライスの事前容量確保とバッファ再利用の組み合わせでアロケーションを抑制する例です。

package prealloc

import (
    "bufio"
    "bytes"
    "io"
    "sync"
)

type Item struct{ A, B int }

// 事前に容量を見積もって確保し、追加時の再割当てを減らす
func BuildSlice(n int) []Item {
    out := make([]Item, 0, n)
    for i := 0; i < n; i++ {
        out = append(out, Item{A: i, B: i * 2})
    }
    return out
}

// bytes.Bufferの再利用とbufioでI/Oをまとめる
var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }}

func WriteItems(w io.Writer, items []Item) error {
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset()
    bw := bufio.NewWriter(buf)
    for _, it := range items {
        if _, err := bw.WriteString("\n"); err != nil { bufPool.Put(buf); return err }
        if _, err := bw.WriteString("item"); err != nil { bufPool.Put(buf); return err }
    }
    if err := bw.Flush(); err != nil { bufPool.Put(buf); return err }
    if _, err := w.Write(buf.Bytes()); err != nil { bufPool.Put(buf); return err }
    bufPool.Put(buf)
    return nil
}

pprofのalloc_spaceビューでWriteItemsに紐づく割り当てが減ることを確認できます¹。p99のレイテンシ尾部が長いサービスでは、「微小なアロケーションの多数発生」を掃除するだけでも、体感の改善につながる可能性があります。

設計判断とビジネス価値:どこまでやるかを数値で決める

最適化は手段であり、目的はSLO達成とコスト削減です。pprofでホットスポットを特定したら、改善の優先順位はユーザー影響の大きさ改善コストで決めます。例えば、CPU時間の大きな割合を占める関数で割り当て削減に成功すれば、GC時間の短縮とキャッシュ効率の向上が連鎖し、p95/99のレイテンシが改善することがあります。クラウド環境では、同一スループットに必要なvCPU数やメモリ予約が下がるため、インフラコストの削減につながり得ます。施策前後で1コアあたりのRPSやGiBあたりのRPSを比較し、削減できたノード数を金額換算すれば、ROIは明確になります。

コンテナ化環境では、CPUクォータのスロットリングやメモリ制限によるOOMキルが計測に影響します。pprofのCPU時間はスロットル中に伸びにくいため、pod単位の使用率やスロットリング率のメトリクスを併読し、必要に応じて検証環境でクォータを緩めて再取得します。ヒープのピークはトラフィックのバーストと相関するため、ロードジェネレータで現実的な分布を再現し、GCターゲットや並列度を変更した影響を横断的に観察します。GoのGCやエスケープ解析の基礎は、合わせて入門解説を参照すると理解が早まります。CTOの視点では、技術的改善を「SLOとコストのKPI」にどう翻訳するかまでをワンセットで設計するのが要諦です。

計測から改善までの最短距離:実務フロー

現場では、負荷を再現してCPU/ヒープを取得し、上位のホットスポットに対して小さな変更を加え、ベンチと本番メトリクスで差分を確認する、という往復を短サイクルで回します。pprofの取得は短時間(例えば30秒程度)にとどめ、変更1件ごとにコミットに紐づくプロファイルを保存しておくと、回帰時の切り戻し判断が容易です¹。pprof HTTPエンドポイントは社内ネットワークに限定し、必要に応じてmTLSやIPフィルタで保護します⁵。プロファイルファイルはS3やGCSに日付とGit SHAで保管し、週次で傾向を振り返ると良いでしょう。環境差異の影響を小さくするため、ランタイムや依存のバージョン、カーネル、cgroup設定は記録に残します。これらの運用ディテールが、最適化成果の再現性とチーム知見の蓄積を支えます。

まとめ:pprofを軸に、測って直す文化を育てる

pprofは、Goに標準で備わる強力な顕微鏡です。安全に導入し、CPU時間とメモリ使用量を事実として掴めば、改善の当たり所は自ずと見えてきます。この記事で示したように、CPU/ヒープ/ブロック/ミューテックスの各プロファイルを短時間で繰り返し取得し、ベンチと本番メトリクスで裏取りするだけで、p99の改善やコスト削減に直結する打ち手を見つけやすくなります。さらに、ラベリングで因果を切り出し、sync.Poolや事前容量確保といった素朴だが効く施策をホットパスに適用すれば、GC圧力は着実に下げられます。

測って、直して、また測る。この単純なループをチームの標準にできるかどうかが、継続的なパフォーマンス最適化とビジネス成果を両立させる鍵です。次のスプリントで、まずは本番に安全なpprofエンドポイントを用意し、代表的なトラフィック下でCPUとヒープのスナップショットを取得してみませんか。ホットスポットが見えた瞬間から、改善のロードマップは具体的になります。

参考文献

  1. Ajmani S. Profiling Go programs. go.dev. https://go.dev/blog/profiling-go-programs
  2. Ajmani S. Profiling Go programs — Adjusting heap profiles (MemProfileRate). go.dev. https://go.dev/blog/profiling-go-programs#:~:text=Adjusting%20heap%20profiles%20for%201,4%20MB
  3. Büyüktür A. Profiler labels in Go. rakyll.org. https://rakyll.org/profiler-labels/
  4. Go performance wiki — profiling rates (block/mutex). GitHub. https://github.com/golang/go/wiki/Performance/9f85935dc7ed841717325a542e5934260cba9b50#:~:text=,set%20the%20rate%20to%201
  5. Tviisari T. Continuous profiling and Go. Medium. https://medium.com/@tvii/continuous-profiling-and-go-6c0ab4d2504b
  6. kLab Tech Blog. Goのプロファイラ(pprof)を使ってみる(Block/Mutexプロファイルの解説を含む). https://www.klab.com/jp/blog/tech/2015/1047753599.html