Kubernetes OOMKilledの完全解決:Go pprofとPrometheusによるメモリリーク特定ガイド

本番環境のPodが突如としてExit Code 137 (OOMKilled)で再起動を繰り返し、アラートが鳴り止まない。メモリリミット(limits.memory)を引き上げても、数時間後には再び上限に達してしまう。これは単なるリソース不足ではなく、アプリケーション内部、特にGoランタイムにおけるメモリ管理やGoroutineリークに起因する深刻な問題の兆候です。本記事では、あてずっぽうなリソース調整をやめ、エンジニアリングとして正確に原因を特定し解決する手順を共有します。

なぜOOMKilledが発生するのか:現象と構造的要因

私が担当していた決済マイクロサービス(Go実装)では、トラフィックが少ない夜間帯でも徐々にメモリ使用量が増加し、最終的にOOMが発生していました。KubernetesのOOMKilledは、cgroupがコンテナのメモリ使用量を監視し、設定されたlimitsを超過した瞬間にカーネルがプロセスを強制終了させるメカニズムです。

Critical Error:
State: Terminated
Reason: OOMKilled
Exit Code: 137

ここでの落とし穴は、Goのガベージコレクション(GC)とKubernetesのハードリミットの認識ズレです。Goランタイムはデフォルトではコンテナのメモリ制限を認識せず、OSに利用可能な物理メモリがある限りヒープを拡張しようとします。その結果、GCが走る前にcgroupのリミットに触れてしまい、プロセスがキルされるのです。

特にGo 1.18以前ではこの傾向が顕著でしたが、この問題を解決するには「観測」と「制御」の2つのアプローチが必要です。

Step 1: Prometheusとpprofによる「犯人」の特定

まず、OOMが「急激なスパイク」なのか「緩やかなリーク」なのかをPrometheusで判別します。リークの場合、疑うべきはグローバル変数への追記、閉じていないHTTP Body、あるいは終了しないGoroutineです。

Monitoring Tip: 監視すべき指標は container_memory_usage_bytes ではなく container_memory_working_set_bytes です。OOM判定に使われるのはWorking Setだからです。

次に、Go標準のプロファイリングツールであるpprofをアプリケーションに組み込み、ヒープの割り当て状況を可視化します。以下は、本番環境でも安全にpprofを露出させるための実装パターンです。

// main.go
import (
    "net/http"
    _ "net/http/pprof" // 副作用インポートでルートを登録
    "log"
)

func main() {
    // アプリケーションロジックとは別のポートで管理用サーバーを起動
    go func() {
        log.Println("Starting pprof server on :6060")
        // localhost経由、あるいはInternal LoadBalancer経由でのみアクセス許可することを推奨
        if err := http.ListenAndServe(":6060", nil); err != nil {
            log.Fatalf("pprof failed: %v", err)
        }
    }()

    // メインのサーバー処理...
    runServer()
}

Podへポートフォワードを行い、手元のマシンでヒーププロファイルを解析します。

# Podへのポートフォワード
kubectl port-forward pod/payment-service-xyz 6060:6060

# ブラウザで可視化(Goがインストールされている必要があります)
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap

Flame Graphを確認し、inuse_space(現在使用中のメモリ)が異常に大きい関数を特定します。私のケースでは、ログ出力用のバッファが無限に拡張されている箇所が特定できました。

Step 2: GOMEMLIMITによるランタイム制御

メモリリークを修正した後でも、突発的なトラフィックでOOMが発生するリスクは残ります。これを防ぐ最強の手段が、Go 1.19で導入されたGOMEMLIMITです。

これを設定することで、Goランタイムは「設定値に近づいたら、CPUを使ってでも全力でGCを回す」という挙動に変わります。Kubernetesのマニフェストには以下のように設定します。

# deployment.yaml
env:
  - name: GOMEMLIMIT
    value: "360Mi" # memory.limitsの90%程度を目安に設定
resources:
  limits:
    memory: "400Mi"
  requests:
    memory: "200Mi"
Best Practice: GOMEMLIMITはコンテナのlimits.memoryから10-20%引いた値を設定してください。OSのオーバーヘッドやMetrics Scrape用のバッファを残すためです。

適用結果とパフォーマンス検証

メモリリーク箇所の修正コードをデプロイし、GOMEMLIMITを適用した後の24時間の稼働データ比較です。Podの再起動回数がゼロになっただけでなく、GCの効率化によりCPU使用率も安定しました。

指標 対策前 (Legacy) 対策後 (Optimized) 改善率
Pod Restart Count 12回 / 日 0回 / 日 100% (解決)
Avg Memory Usage 380Mi (Limit付近) 210Mi -45%
P99 Latency 450ms 120ms -73%
注意点: GOMEMLIMITを極端に小さく設定しすぎると、GCが頻発しすぎてCPUスラッシング(GC Death Spiral)を引き起こす可能性があります。CPU使用率の監視もセットで行ってください。

結論

Kubernetes環境でのGoアプリケーションのOOMKilledは、単にメモリを増やすだけでは解決しません。pprofを使って「何がメモリを食っているか」を正確に把握し、GOMEMLIMITを使ってランタイムにコンテナの境界を教えることが、堅牢なプロダクション環境を構築する鍵となります。

Post a Comment