Kubernetes OOMKilledの根本原因とSpring Bootメモリ最適化(Java 25対応)

本番環境で突如としてPodが再起動し、インフラストラクチャのアラートが鳴り響く。アプリケーションのログを調査しても java.lang.OutOfMemoryError の痕跡はなく、Kubernetesのイベントログには冷酷に Reason: OOMKilled (Exit Code 137) とだけ刻まれている。多くの開発者を悩ませるこの現象は、コードのバグではなく、JVMのメモリ構造とLinuxカーネル(cgroup)の制限境界線に対する「認識のズレ」から生じるアーキテクチャレベルのインシデントだ。

OOMKilledの核心と対策: OOMKilledはコンテナの全体メモリ使用量(RSS)がK8sのLimitを超過した際にOSがプロセスを強制終了する仕組みだ。ヒープ領域(Heap)だけでなく、スレッドやメタスペースなどのNon-Heap領域を考慮した明確なメモリバッファ(Headroom)の設計が不可欠となる。

1. メモリ境界のアーキテクチャと「スーツケースの法則」

💡 概念比喩:KubernetesのコンテナLimitが「容量50リットルのスーツケース」だと仮定する。JVMのヒープメモリ(-Xmx)は「衣服」だ。衣服を50リットル分きっちりパッキングして満足したとしても、実際の旅行(ランタイム環境)では「靴や洗面用具」(Non-Heap領域:Metaspace、スレッドスタック、NIOバッファ等)も詰め込む必要がある。その結果、スーツケースのジッパーは弾け飛び、空港の警備員(Linuxカーネル)によって荷物は没収(OOMKilled)される。

Spring BootアプリケーションをKubernetesにデプロイする際、-Xmx にコンテナのLimitと同等の値を設定するのは古典的なアンチパターンだ。現在主流の最新LTSであるJava 25や、Spring Boot 4.xの環境においても、JVMはヒープ以外に多大なネイティブメモリを要求する,。

Linuxのcgroupは、JVMの内部構造(どのメモリがヒープか非ヒープか)を一切関知しない。監視しているのはコンテナ全体の常駐セットサイズ(RSS: Resident Set Size)のみである。RSSがマニフェストで定義された resources.limits.memory に達した瞬間、OOM Killerが発動し、プロセスにSIGKILL(シグナル9)を送信する。これが終了コード137(128 + 9)の正体だ,。ネイティブイメージ(GraalVM)を利用しない限り、JITコンパイラのコードキャッシュやGCのメタデータに対する余裕(ヘッドルーム)をコンテナレベルの設計に組み込む必要がある,。

2. プロダクションレベルの最適化コードとリソース戦略

実運用でOOMKilledを回避するためには、コンテナのメモリLimitに対してJVMのヒープを意図的に制限し、Non-Heap領域のためのバッファを確保するアプローチをとる。最新のJVMではコンテナサポート(UseContainerSupport)がデフォルトで有効化されているため、パーセンテージベースのヒープ割り当てがクラウドネイティブ環境において最も堅牢な手法となる。


apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-boot-app
spec:
  replicas: 3
  template:
    spec:
      containers:
      - name: spring-boot-container
        image: myrepo/spring-boot-app:4.0.3
        resources:
          requests:
            memory: "1Gi"
            cpu: "500m"
          limits:
            memory: "1Gi" # コンテナの絶対的なメモリ上限
            cpu: "1000m"
        env:
        - name: JAVA_TOOL_OPTIONS
          # Limitの75%をヒープに割り当て、残り25%(< 250MB)をNon-Heap用としてOSレベルで確保する
          value: "-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError -XX:+HeapDumpOnOutOfMemoryError"

⚠️ 注意事項 (Pitfalls):RequestsとLimitsに極端な差を設ける(例:Requests 256Mi、Limits 2Gi)と、ノード自体のメモリが枯渇した際(Node Memory Pressure)、KubernetesのKubeletによってEviction(退役)の対象として最優先で選ばれるリスクが高まる。本番環境ではGuaranteed QoSクラスを適用するため、CPUおよびメモリのRequestsとLimitsを同値に設定することを強く推奨する。

Frequently Asked Questions

Q. JVM OOM (java.lang.OutOfMemoryError) と Kubernetes OOMKilled (Exit Code 137) の違いは何ですか?

A. JVM OOMは「JVMに割り当てられたヒープ領域」が枯渇した際にJavaプロセス内部で発生する例外(Exception)であり、プロセス自体はクラッシュせずに生き残る可能性があります。一方、OOMKilledは「コンテナ全体の使用メモリ(ヒープ+非ヒープ+ネイティブ割り当て)」がKubernetesのLimitを超過した際に、Linuxカーネルのcgroup機構がプロセスをOSレベルで強制終了(SIGKILL)させるインフラストラクチャレベルの現象です,。

Q. ヒープメモリ(-Xmx または MaxRAMPercentage)はコンテナLimitの何パーセントに設定すべきですか?

A. アプリケーションの特性に依存しますが、一般的にはコンテナLimitの「50%〜75%」が推奨値です。残りの25%〜50%は、JVMメタスペース、スレッドスタック(1スレッドあたり約1MBの消費)、NIOのダイレクトバッファ、JITコンパイルキャッシュなどのネイティブメモリ(Non-Heap)領域が使用するバッファとして意図的に空けておく必要があります。

Q. OOMKilled発生時、トラブルシューティングのために最初に確認すべきKubernetesコマンドは何ですか?

A. 第一に kubectl describe pod <pod-name> を実行し、コンテナの `State` や `Last State` セクションで Reason: OOMKilled および Exit Code: 137 を確認します,。続いて kubectl get events を用いてノードレベルのメモリ枯渇(OOM)イベントが発生していないかを検証し、必要に応じて kubectl top pod のメトリクス履歴からメモリ使用量の推移を分析します。

Post a Comment