トラフィック急増時のGoメモリプロファイリング:pprofウォークスルー
Goのメモリプロファイリングはトラフィック急増への対処に役立ちます。pprofのハンズオンでJSON、DBスキャン、ミドルウェアの割り当てホットスポットを見つける手順を解説します。

トラフィック急増がGoサービスのメモリに与える影響
本番で起きる「メモリのスパイク」は、単純に一つの数値が上がるだけではありません。RSS(プロセス全体のメモリ)が急上昇する一方でGoヒープはあまり動かない場合や、GCに応じてヒープが上下するような鋭い波を描くことがあります。同時にレイテンシが悪化することが多く、ランタイムが掃除に時間を割くためです。
よく見るメトリクスのパターン:
- RSSが予想より速く上がり、スパイク後に完全には下がらないことがある
- ヒープのin-useが増えたり、GCに合わせて鋭く落ちたりする
- 割り当て率(秒あたりのバイト数)が跳ね上がる
- GCのポーズ時間やGCに費やすCPU時間が増える(個々のポーズは小さくても)
- リクエストのレイテンシが跳ね上がり、テールが不安定になる
トラフィックの急増は、リクエストあたりの割り当てを拡大します。例えば1リクエストが余分に50KB(一時的なJSONバッファ、行ごとのスキャンオブジェクト、ミドルウェアのコンテキストデータ)を割り当てると、2,000 RPSでは毎秒約100MBをアロケータに与えることになります。Goは多くを処理できますが、GCはそれらの短命オブジェクトを辿って解放しなければなりません。割り当てが掃除を上回るとヒープの目標値が上がり、RSSも追随してコンテナのメモリ上限に達することがあります。
症状はおなじみです:オーケストレーターによるOOM、急なレイテンシ増、GCに費やす時間の増加、CPUが張り付いていなくてもサービスが「忙しい」ように見えること。GCが常に働くことでスループットが落ちる「GCスラッシュ」が起きることもあります。
pprofは素早く答えを出すのに役立ちます:どのコードパスが一番割り当てているか、そしてその割り当てが本当に必要かどうか。ヒーププロファイルは「今」保持されているものを示します。割り当て重視のビュー(alloc_spaceなど)は、作られては捨てられているものを示します。
pprofがすべてのRSSのバイトを説明してくれるわけではない点に注意してください。RSSにはGoヒープ以外のもの(スタック、ランタイムメタデータ、OSマッピング、cgoの割り当て、断片化など)が含まれます。pprofはGoコード内の割り当てホットスポットを指し示すのが得意で、コンテナレベルの総量を正確に証明するためのものではありません。
pprofを安全に導入する(ステップバイステップ)
pprofはHTTPエンドポイントとして使うのが手軽ですが、これらのエンドポイントはサービス内部の多くを露出します。公開APIではなく管理用機能として扱ってください。
1) pprofエンドポイントを追加する
Goでは最も簡単なのはpprofを別の管理用サーバーで動かすことです。これによりプロファイリング用ルートがメインのルーターやミドルウェアから離れます。
package main
import (
"log"
"net/http"
_ "net/http/pprof"
)
func main() {
go func() {
// Admin only: bind to localhost
log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
}()
// Your main server starts here...
// http.ListenAndServe(":8080", appHandler)
select {}
}
もし別ポートを開けない場合はメインサーバーにpprofルートをマウントできますが、誤って公開してしまいやすいので別の管理用ポートが安全なデフォルトです。
2) デプロイ前に締める
まずは設定ミスで問題になりにくいコントロールから始めます。localhostにバインドしておけば、そのポートを誰かが明示的に公開しない限り外部から到達できません。
簡単なチェックリスト:
- pprofをメインのユーザー向けポートではなく管理用ポートで動かす
- 本番では
127.0.0.1(またはプライベートインターフェース)にバインドする - ネットワークのエッジで許可リスト(VPN、踏み台、内部サブネット)を設定する
- エッジで認証を掛けられるなら認証を要求する(ベーシック認証やトークンなど)
- 実際に使うプロファイル(heap, allocs, goroutine)が取得できることを確認する
3) 小さく変えてロールアウトする
変更は小さく保ちます:pprofを追加してデプロイし、期待どおりの場所からのみ到達できるかを確認します。ステージングがあるなら、負荷をシミュレートしてヒープとallocsプロファイルを取得してテストしてください。
本番では段階的に(インスタンス一つまたは少量のトラフィックから)ロールアウトします。pprofが誤設定でも、被害半径が小さいうちに修正できます。
スパイク時に適切なプロファイルを取得する
スパイク時は単一のスナップショットでは不十分なことが多いです。タイムラインを少し取ってください:スパイク前の数分(ベースライン)、スパイク中(影響)、スパイク後(回復)。これで通常のウォームアップと実際の変化を分離しやすくなります。
スパイクを制御負荷で再現できるなら、本番に近いリクエストの組み合わせ、ペイロードサイズ、並列度を合わせてください。小さなリクエストのスパイクと大きなJSONレスポンスのスパイクとでは挙動が大きく異なります。
ヒーププロファイルと割り当て重視のプロファイルは別の問いに答えます:
- ヒープ(inuse)は今保持されているものを示す(リークや長寿命キャッシュを見る)
- 割り当て(alloc_spaceやalloc_objects)は短命でも大量に作られているものを見る(GC負荷やチャーンを追う)
実用的な取得パターン:スパイク中に1つヒープを取り、次に割り当てプロファイルを取り、30〜60秒後に繰り返します。スパイク中に2点取ることで、疑わしいパスが安定しているか加速しているかが分かります。
# examples: adjust host/port and timing to your setup
curl -o heap_during.pprof "http://127.0.0.1:6060/debug/pprof/heap"
curl -o allocs_30s.pprof "http://127.0.0.1:6060/debug/pprof/allocs?seconds=30"
pprofファイルに加えて、同時刻のランタイム統計(ヒープサイズ、GC回数、ポーズ時間など)を記録しておくと状況説明に役立ちます。各キャプチャ時に短いログを残しておくと、「割り当てが増えた」ことと「GCが常に走り始めた」ことを紐づけられます。
インシデント中はメモ:ビルドバージョン(コミット/タグ)、Goのバージョン、重要なフラグ、設定変更、どんなトラフィックがあったか(エンドポイント、テナント、ペイロードサイズ)を残してください。これらは後でプロファイルを比較するときに重要になることが多いです。
ヒープと割り当てプロファイルの読み方
ヒーププロファイルは表示の仕方によって答える質問が変わります。
**Inuse(in-use space)**は、キャプチャ時点でメモリに残っているものを示します。リークや長寿命キャッシュ、オブジェクトが残ってしまうリクエストを探すのに使います。
**Alloc space(総割り当て量)**は、短命であってもどれだけ割り当てられているかを示します。スパイクでGC作業が増え、レイテンシやOOMに繋がる場合はこちらを見ます。
サンプリングには注意してください。Goはすべての割り当てを記録しているわけではなく、runtime.MemProfileRateで制御されるサンプリングを行います。小さく頻繁な割り当ては過小評価されることがありますが、スパイク条件では大きな原因は目立つ傾向にあります。完璧な会計ではなく、傾向とトップ寄与者を探してください。
最も有用なpprofビュー:
- top:inuseやallocで誰が支配しているかの素早い把握(flatと累積の両方を確認)
- list
:ホット関数の行レベルの割り当て源を見る - graph:コールパスを辿ってどうそこに至ったかを説明する
差分比較(diff)が実用的です。ベースラインのプロファイルとスパイク時のプロファイルを比較して変化を浮き彫りにする方が、背景ノイズを追いかけるより効率的です。
発見を確かめるときは大規模リファクタの前に小さな変更で検証してください:
- ホットパスでバッファを再利用する(小さな
sync.Poolを追加するなど) - リクエストごとのオブジェクト生成を減らす(例えばJSONの中間マップを避ける)
- 同じ負荷で再プロファイルして差分が縮むことを確認する
数値が期待どおり動けば、本当の原因を見つけたと判断できます。
JSONエンコーディングの割り当てホットスポットを見つける
スパイク時にJSON関連の処理が大きなメモリ負担になることがよくあります。JSONのホットスポットは、小さな割り当てが大量に発生してGCを強いる形で現れます。
pprofで注意すべき赤旗
ヒープや割り当てビューがencoding/jsonを指している場合は、何を渡しているかをよく見てください。次のパターンが割り当てを膨らませがちです:
- レスポンスに
map[string]any(または[]any)を使って型情報を曖昧にしている - 同じオブジェクトを何度もMarshalしている(ログ用とレスポンス用など)
- 本番で
json.MarshalIndentしている - 一時文字列(
fmt.Sprintfや文字列連結)でJSONを組み立てている - 大きな
[]byteをAPIに合わせるためだけにstringに変換している
json.Marshalは常に出力全体の新しい[]byteを割り当てます。json.NewEncoder(w).Encode(v)はio.Writerに書くことで大きなバッファを避けることが多いですが、vがanyだらけ、マップだらけ、ポインタ多めの構造だと内部で割り当てが発生します。
すぐできる対策と簡単な実験
まずはレスポンスの形を型付きの構造体にして、リフレクションやインターフェースのボクシングを減らしてください。
次に回避できる一時オブジェクトを削ります:bytes.Bufferをsync.Poolで再利用する(注意して)、本番でインデントしない、ログのために再度Marshalしない、などです。
JSONが原因かどうかを確認する小さな実験例:
- ホットなエンドポイントで
map[string]anyを構造体に置き換え、プロファイルを比較する MarshalからEncoderへ切り替えて直接レスポンスに書き出すMarshalIndentやデバッグ用フォーマットを外して同じ負荷で再プロファイルする- 変更のないキャッシュ済みレスポンスではJSONエンコードをスキップし、差を計測する
クエリスキャンでの割り当てホットスポットを見つける
スパイク時にメモリが急増する原因としてデータベース読み取りは意外と多いです。SQLの実行時間に注目しがちですが、行をスキャンするステップでリクエストごとに多く割り当ててしまうことがあります。
よくある犯人:
interface{}やmap[string]anyにスキャンしてドライバに型を任せている- 各フィールドで
[]byteをstringに変換している - 大きなnullableラッパー(
sql.NullStringなど)を大量に使っている - 常に必要ない大きなtext/blob列を引いている
行データを一時変数にスキャンしてから実際の構造体にコピーするパターンは静かにメモリを消費します。可能ならコンクリートなフィールドを持つ構造体に直接スキャンしてください。そうすれば余分な割り当てと型チェックを避けられます。
バッチサイズやページングはメモリ形状を変えます。10,000行をスライスに取るとスライスの成長分と各行分で一度に大きく割り当てます。ハンドラがページだけ必要ならクエリでページングを行い、ページサイズを安定させてください。大量の行を処理するならストリーミングして小さな集約だけ持つようにするのがよいです。
大きなテキスト列は特に注意が必要です。多くのドライバはテキストを[]byteで返し、それをstringにするたびにデータをコピーします。各行で変換すると割り当てが爆発します。値が必要なときまで変換を遅延させるか、そのエンドポイントでは列数を減らしてください。
割り当ての主体がドライバか自分のコードかを確認するにはプロファイルの支配フレームを見ます:
- フレームがマッピングコードを指すなら、スキャン先と変換を見直す
- フレームが
database/sqlやドライバ内部を指すなら、まず行数や列数を減らす、次にドライバ固有のオプションを検討する alloc_spaceとalloc_objectsの両方を見て、小さな割り当てがたくさんある方が問題になることもある
例:list ordersエンドポイントでSELECT *を[]map[string]anyにスキャンしていると、スパイク時に各リクエストが何千もの小さなマップと文字列を作ります。クエリを必要なカラムだけにして、[]Order{ID int64, Status string, TotalCents int64}のような構造体に直接スキャンすると割り当てがすぐに下がることが多いです。AppMasterで生成されたバックエンドでも同様で、ホットスポットはしばしば結果データの形付けやスキャンの仕方にあります。
ミドルウェアの、リクエストごとに静かに割り当てるパターン
ミドルウェアは「ただのラッパー」に見えますが全リクエストで走るため、スパイク時には小さな割り当てが累積します。
ログ用ミドルウェアはよくある原因です:文字列のフォーマット、フィールドのマップ作成、ヘッダーのコピーなど。リクエストIDの生成はIDを作って文字列に変換し、コンテキストに付与する際に割り当てることがあります。context.WithValueも毎回新しいオブジェクトや文字列を保存すると割り当てを発生させます。
圧縮やボディ処理も頻出犯です。ミドルウェアがリクエストボディを丸ごと読み取って「覗き見」や検証をすると、リクエストごとに大きなバッファができます。gzipミドルウェアが毎回新しいリーダー/ライターを作ると大量に割り当てる場合があります。
認証やセッションの層も同様です。各リクエストでトークンを解析し、base64をデコードし、セッションバイナリを新しい構造体に読み込むと、ハンドラが軽くても常にチャーンが発生します。
トレーシングやメトリクスはラベルを動的に作ると予想以上に割り当てます。ルート名やユーザーエージェント、テナントIDを毎回文字列結合すると隠れたコストになります。
「千の小さな切り傷」として現れるパターン:
fmt.Sprintfでログ行を作り、リクエストごとに新しいmap[string]anyを作る- ログや署名のためにヘッダーを新しいマップやスライスにコピーする
- プールしないgzipバッファやリーダー/ライターを毎回作る
- 高カードinalityのメトリックラベル(多くのユニークな文字列)を作る
- 毎リクエスト新しい構造体をコンテキストに格納する
ミドルウェアのコストを切り分けるには、フルチェーン有効時のプロファイルとミドルウェアを無効化した(あるいはno-opにした)プロファイルを比較してください。ヘルスエンドポイントのようなほとんど割り当てをしないはずのものがスパイクで大量に割り当てているなら、ハンドラではなくミドルウェアが問題です。
AppMasterで生成されたGoバックエンドでも同じルールが当てはまります:ログや認証、トレーシングなどの横断的機能は計測可能にして、リクエストごとの割り当てを監査する予算として扱ってください。
効果の出やすい修正
ヒープとallocsのビューがあれば、リクエストごとの割り当てを減らす変更を優先してください。目的はトリッキーな工夫ではなく、ホットパスで短命オブジェクトを少なくすることです。
安全で地味だが効果的な対策から始める
サイズが予測できるなら事前確保してください。例えば通常200件返すならキャパシティ200でスライスを作っておけば何度も再確保してコピーすることを避けられます。
ホットパスで文字列を作らないようにします。fmt.Sprintfは便利ですが割り当てが多いことがあります。ログでは構造化フィールドを使い、使える場面では小さなバッファを再利用してください。
大きなJSONレスポンスを生成するなら、一度に全部作るのではなくストリーミングを検討してください。スパイクの典型は:リクエストを受けて大きなボディを読み、巨大なレスポンスを組み立て、GCが追いつくまでメモリが跳ね上がる、というものです。
典型的にプロファイルの前後で明確に出る簡単な変更:
- サイズ範囲が分かるならスライスやマップを事前確保する
- ハンドリング内での
fmt多用を安価な代替にする - 大きなJSONは直接レスポンスWriterにエンコードしてストリームする
sync.Poolを同形の再利用可能オブジェクト(バッファやエンコーダ)に使い、必ずリセットして戻す- リクエストサイズやページサイズの上限を設定して最悪ケースを抑える
sync.Poolは注意して使う
sync.Poolは同じ形状の繰り返し割り当てに有効(例:リクエストごとのbytes.Buffer)ですが、予測できない大きさのオブジェクトをプールしたり、リセットを忘れると大きなバック配列が生き続けて逆効果になります。
適切に測定してから使ってください:
- スパイクウィンドウでallocsプロファイルを取得する
- 変更は一つずつ適用する
- 同じリクエストミックスで再実行し、allocs/opの合計を比較する
- メモリだけでなくテールレイテンシも監視する
AppMasterで生成されたGoバックエンドにもこれらの修正は当てはまります。ホットな割り当てはハンドラや統合、ミドルウェア周りに隠れていることが多いです。
よくあるpprofのミスと誤警報
間違ったものを最適化するのが最速で時間の無駄になります。サービスが遅いならまずCPUを見てください。OOMで落ちるならヒープから始めてください。サービスは生きているがGCが止まらないなら割り当て率とGC挙動を確認します。
もう一つの罠はtopだけ見て終わることです。topは文脈を隠します。必ずコールスタック(あるいはフレームグラフ)を見て誰がアロケータを呼んでいるかを確認してください。修正は多くの場合、ホットな関数の一つか二つ上にあります。
またinuseとチャーンを混同しないでください。あるリクエストが5MBの短命オブジェクトを割り当ててGCを誘発し、最終的にinuseは200KBしか残らないことがあります。inuseだけ見るとチャーンを見逃しますし、総割り当てだけ見ると常に残らないものを最適化してしまうかもしれません。
コード変更前の簡単なチェック:
- 正しいビューを見ているか確認する:保持はheap inuse、チャーンはalloc_space/alloc_objects
- 関数名だけでなくスタック全体を比較する(
encoding/jsonはよく症状でしかない) - トラフィックを現実的に再現する:同じエンドポイント、ペイロードサイズ、ヘッダ、並列度
- ベースラインとスパイクプロファイルを取り、差分を出す
非現実的な負荷テストは誤警報を生みます。テストが小さなJSONのみを送っているのに本番が200KBを送るなら、間違った経路を最適化してしまいます。テストが1行だけ返すなら、500行で現れるスキャン挙動は見えません。
ノイズを追いかけすぎないでください。ある関数がスパイクプロファイルでのみ現れるなら有力な手がかりです。ベースラインでも同じレベルで出ているなら背景処理の可能性があります。
実際のインシデントの流れ(例)
月曜の朝プロモーションが出てAPIのトラフィックが8倍になりました。最初の症状はクラッシュではありません。RSSが上がり、GCが忙しくなり、p95レイテンシが上がります。最もホットなエンドポイントはGET /api/ordersで、モバイルアプリが画面を開くたびにこれをリフレッシュしていました。
静かな時とスパイク時のスナップショットを取り、同じ種類のヒーププロファイルで比較します。現場で有効だった流れ:
- ベースラインのヒーププロファイルを取得し、現在のRPS、RSS、p95を記録する
- スパイク中にヒーププロファイルと割り当てプロファイルを1〜2分のウィンドウ内で取得する
- 両者のトップ割り当てを比較し、最も増えたものに注目する
- 一番大きい関数から呼び出し元へと辿り、ハンドラパスに至るまで歩く
- 小さな変更を1つ行い、単一インスタンスにデプロイして再プロファイルする
このケースではスパイクプロファイルがJSONエンコード由来の新しい割り当てを示していました。ハンドラはデータベース行をmap[string]anyで作り、それをスライスに入れてjson.Marshalしていました。各リクエストが多数の短命な文字列とインターフェース値を作っていました。
最小限で安全な修正はマップをやめることでした。データベース行を型付き構造体に直接スキャンしてそのスライスをエンコードするようにしただけで、フィールドもレスポンスの形もステータスコードも変えませんでした。1インスタンスでロールアウトしたところ、JSONパスの割り当てが減り、GC時間が下がり、レイテンシが安定しました。
その後、メモリ・GC・エラー率を見ながら段階的にロールアウトしました。AppMasterのようなノーコードプラットフォームでサービスを作る場合も、レスポンスモデルを型付きで一貫させておくことが隠れた割り当てコストを避ける助けになります。
次のスパイクを防ぐためにやること
一度スパイクを落ち着かせたら、次は退屈にします。プロファイリングを反復可能なドリルとして扱ってください。
チームが疲れているときでも従える短いランブックを書いておきましょう。何をいつキャプチャするか、既知の正常状態との比較方法、実行コマンド、プロファイルの保存先、トップアロケータの「正常」は何か、を具体的に示します。
OOMに達する前に割り当て圧を軽く検知するための軽量な監視(ヒープサイズ、秒あたりのGC回数、リクエストごとのバイト割り当てなど)を追加してください。「リクエストあたりの割り当てが週次で30%増えた」を検知する方が、ハードなメモリアラームを待つより有益なことが多いです。
CIで代表的なエンドポイントに対する短い負荷テストを導入して変更を早めに検出するのも有効です。小さなレスポンスの変更で割り当てが倍になることがあり、本番に行く前に見つけた方が楽です。
生成されたGoバックエンドを使っているなら、ソースをエクスポートして同じ手順でプロファイルしてください。生成コードも普通のGoコードなのでpprofは実際の関数と行を指し示します。
要件が頻繁に変わる場合、AppMaster (appmaster.io) のようなツールは進化に合わせてクリーンなGoバックエンドを再生成し、出荷前に実際の負荷でプロファイルできる実用的な方法になり得ます。
よくある質問
スパイクは通常、リクエストあたりの割り当て率が予想以上に上がるために起きます。小さな一時オブジェクトでもRPSに比例して積み上がり、GCが頻繁に走るようになり、結果的にメモリ使用量が急増します。
ヒープ指標はGoランタイムが管理するメモリを示しますが、RSSにはそれ以外のものも含まれます:ゴルーチンのスタック、ランタイムのメタデータ、OSのマッピング、断片化、cgoによる割り当てなど。スパイク時にRSSとヒープが異なる動きをするのは普通なので、RSSの総量を一致させようとするより、pprofで割り当てのホットスポットを探す方が有益です。
保持(何かが生き残っている)を疑うならヒーププロファイルを、チャーン(短命オブジェクトが大量に作られている)を疑うならallocsやalloc_spaceのような割り当て重視のプロファイルを先に見てください。スパイクではチャーンがGCのCPU時間やテールレイテンシを引き起こすことが多いです。
最も簡単で安全な方法は、pprofを管理用の別サーバーとして127.0.0.1にバインドし、内部からのみ到達可能にすることです。pprofはサービス内部の詳細を露出する可能性があるため、公開APIとして扱わないでください。
短いタイムラインでキャプチャしてください:スパイクの数分前(ベースライン)、スパイク中(影響)、スパイク後(回復)のそれぞれ1つ。これにより、通常のウォームアップと本当の変化を分けて見ることができます。
inuseはキャプチャ時点で実際に保持されているものを探すのに使い、alloc_spaceはどれだけ頻繁に割り当てられているかを見るのに使います。短命オブジェクトのチャーンはGCを酷使するので、両方を使い分けるのが重要です。
encoding/jsonが割り当てを占めているなら、多くの場合はデータの形(mapやinterfaceの多用)が原因です。map[string]anyを型付きの構造体に変える、json.MarshalIndentを使わない、一時文字列を作らないなどで即効性のある改善が得られます。
柔軟なターゲット(interface{}やmap[string]any)にスキャンしたり、[]byteを毎回stringに変換したり、必要以上の行やカラムを取得したりすると、リクエストごとの割り当てが膨らみます。必要なカラムだけ選ぶ、ページングする、コンクリートな構造体に直接スキャンする、が高インパクトな対策です。
ミドルウェアは全リクエストで動作するため、小さな割り当てが積み重なります。ログで新しい文字列やマップを作る、リクエストIDを生成して文字列にする、gzipリーダー/ライターを毎回作る、コンテキストに毎回新しいオブジェクトを入れる、などが典型的な原因です。ヘルスチェックのようなほぼ無処理のエンドポイントでも割り当てが多ければミドルウェアが怪しいです。
はい。生成されたコードでもGoのコードとしてpprofは機能します。生成されたバックエンドのソースをエクスポートしてプロファイルを取り、割り当てているコールパスを特定してからモデルやハンドラ、クロスカッティングなロジックを調整して、次のスパイク前にリクエストごとの割り当てを減らしてください。


