Uzabase Tech Blog

SPEEDA, NewsPicks, FORCASなどを開発するユーザベースの技術チームブログです。

Kubernetes で運用する JVM アプリケーションの OutOfMemoryError に備える

こんにちは。SPEEDA 開発チームの old_horizon です。

JVM アプリケーションの運用について回るのが、OutOfMemoryError (以下 OOM) への対処です。
しかし実際に発生した際に、適切なオペレーションを行うのは意外と難しいのではないでしょうか。

特に本番環境では、まず再起動して復旧を急ぐことも多いかと思います。しかし、ただそれを繰り返すばかりでは原因がいつまでも特定できません。

今回は Kubernetes で運用する JVM アプリケーションに対して、ダウンタイムを抑えつつ調査に役立つ情報を自動的に収集する仕組みを構築してみたいと思います。

環境構築

実際に構築してみたサンプルを、こちらのリポジトリに用意しました。

https://github.com/old-horizon/k8s-oom-sample

動作確認は以下の環境で行っています。

  • Ubuntu 18.04
  • MicroK8s 1.18.6

clone して kubectl apply -f kubernetes/ を実行すると oom-sample ネームスペースに各種リソースが作られます。
(おそらく初回は途中で失敗するため、二度実行してみてください)

次章より、このサンプルを使って解説を進めていきます。

OOM 発生時に、自動的にヒープダンプを取得しコンテナを再起動する

ヒープダンプがあれば、Eclipse Memory Analyzer 等で解析してリークしていたオブジェクトを特定できます。

しかし OOM で処理が長時間滞留した場面では、さらに復旧を遅らせてまでダンプを取得するのに抵抗を感じ、再起動を優先させることもあるかと思います。

とはいえ通知を受けて手動で対応している限り、多少なりとも遅延は発生しているわけです。この際すべて自動化して、焦らなくてもユーザーへの影響が最小限に収まるようにしましょう。

java コマンドのオプション指定

Dockerfile (ktor-oom/Dockerfile) で、java コマンドに次のオプションを指定しました。

  • -XX:+ExitOnOutOfMemoryError
    OOM 発生時に JVM が終了する

  • -XX:+HeapDumpOnOutOfMemoryError
    OOM 発生時にヒープダンプを出力する

  • -XX:HeapDumpPath
    -XX:+HeapDumpOnOutOfMemoryError によるヒープダンプの出力先を指定する

JVM が終了すると、Kubernetes の自己修復機能によりコンテナが再起動されます。

補足

  • ヒープダンプは java_pid(プロセス ID).hprof のファイル名で出力されます。
    なお同名のファイルがすでに存在する場合は上書きされません。
    コンテナ環境の場合、プロセス ID が常に一定ゆえファイル重複が起きやすいためご注意ください。

  • -XX:OnOutOfMemoryError で OOM 発生時に任意のコマンドを実行できます。
    上記のオプションと併用可能です。

ヒープダンプ出力先のボリュームをマウント

ヒープダンプは Pod が存在する間のみ存続する emptyDir ボリュームに出力します。

Dockerfile (ktor-oom/Dockerfile) で、ヒープダンプ出力先に /dump を指定します。

-XX:HeapDumpPath=/dump

Deployment (kubernetes/ktor-oom.yml) で /dump にボリュームをマウントします。

        volumeMounts:
        - mountPath: /dump
          name: dump-volume
      volumes:
      - name: dump-volume
        emptyDir: {}

これにより、コンテナ再起動前に出力されたダンプが取得できるようになります。

readinessProbe によるヘルスチェック

コンテナの再起動直後は、リクエストを処理できない場合があります。
readinessProbe で正しく処理できる状態になったことを確認してから、Service のルーティング対象に復帰するようにします。

Deployment (kubernetes/ktor-oom.yml) より抜粋

        readinessProbe:
          httpGet:
            port: 8080
            path: /v1/ping

レプリカ数の確認

基本的なことですが、常に使用可能な Pod が複数存在するようにしましょう。
今回はサンプルなのでレプリカ数は 2 にしています。

Deployment (kubernetes/ktor-oom.yml) より抜粋

spec:
  replicas: 2

Prometheus + Grafana で JVM のメトリクスを可視化する

ライブラリ・フレームワークの設定ミスによるリークは、ネット上にも情報が多いためヒープダンプの解析結果から比較的特定しやすい印象があります。

一方で社内で実装したプロダクションコードでリークが起きている場合、先人の知恵に頼ることはできません。
そのため地道にコードを読む必要はありますが、ヒープ使用量の推移からリークが疑われる事象が発生するタイミングを特定できれば、調査範囲を絞ることができます。

そこで Prometheus + Grafana により JVM のメトリクスを可視化して、その記録をもとに疑うべきポイントに目星がつけられる状況を目指します。

JVM アプリケーションの Java agent に JMX Exporter を指定する

JMX とは

JVM アプリケーションでは、JMX (Java Management Extensions) という監視・管理フレームワークが利用できます。
以下に例を挙げますが、標準でかなり多くの指標を取得することができます。

  • 領域ごとのメモリー使用量
  • ロード済みクラス数
  • スレッド数

JMX から取得できた情報を Prometheus で収集するには、所定のテキストフォーマットに変換して HTTP で配信する必要があります。
今回アプリケーション本体には手を加えず、実行時にこの機能を追加するために Java agent を使います。

Java agent とは

Java agent は J2SE 5.0 で java.lang.instrument パッケージと同時に追加された仕組みです。
Javadoc によると、正式名称は「Javaプログラミング言語エージェント」といったところでしょうか。

-javaagent:(エージェント JAR ファイルへのパス) を指定して java コマンドを実行すると、main に先んじてエージェント JAR ファイル内の premain が呼び出されます。
このタイミングでバイトコード操作が可能になるため、AspectJ の動的ウィービングなどで利用されています。

いわゆる黒魔術を実現するための仕組みですが、応用することでアプリケーションの起動前に任意の処理を差し込むことができます。

Dockerfile の編集

Prometheus が提供する JMX Exporter を Java agent に指定してアプリケーションを起動します。
するとアプリケーションの main 実行前に、指定されたポートで JMX メトリクスを公開する HTTP サーバーが立ち上がります。

まずは JAR ファイルをダウンロードし、設定ファイルである config.yaml を作成します。
今回の用途ではデフォルト設定で問題ないため、中身は空のままです。

ADD https://repo1.maven.org/maven2/io/prometheus/jmx/jmx_prometheus_javaagent/0.13.0/jmx_prometheus_javaagent-0.13.0.jar .
RUN touch config.yaml

最後に java コマンドで Java agent を指定してアプリケーションを起動します。

-javaagent:./jmx_prometheus_javaagent-0.13.0.jar=9100:config.yaml

これで 9100 ポートで JMX メトリクスが公開されました。

詳細は Dockerfile (ktor-oom/Dockerfile) をご覧ください。

Prometheus の Service Discovery で動的に監視対象を指定する

Service Discovery について

Prometheus は、自身が監視対象からデータを収集する Pull 型アーキテクチャを採用しています。
したがって、基本的には監視対象への接続情報をあらかじめ知っておく必要があります。
しかし Kubernetes で運用するアプリケーションの場合、自動的に Pod のスケールや再配置が行われるため接続先を静的に定義しておくことはできません。

このように接続先が動的に変わる場合には、Service Discovery が便利です。
その名の通り、Prometheus 自身が監視対象を検出する機能です。Kubernetes の他にも Azure VM, AWS EC2, GCP GCE といった主要なクラウド仮想マシン等に標準で対応しています。

Kubernetes の設定

まずは、アプリケーションの Service (kubernetes/ktor-oom-svc.yml) に任意のアノテーションを付与します。
サンプルで使用したアノテーションの用途は次のとおりです。

  • prometheus.io/java_scrape
    メトリクスを取得する対象の場合は true
  • prometheus.io/java_port
    メトリクスを公開しているポート番号を指定
  annotations:
    prometheus.io/java_scrape: 'true'
    prometheus.io/java_port: '9100'

アプリケーション本体 の 8080 に加え、メトリクスの 9100 ポートも忘れずに公開します。

  ports:
  - name: app
    protocol: TCP
    port: 8080
  - name: metrics
    protocol: TCP
    port: 9100

同様に、Deployment (kubernetes/ktor-oom.yml) でも両方のポートを公開します。

        ports:
        - containerPort: 8080
        - containerPort: 9100

Prometheus の設定

先ほど付与したアノテーションを持つ Service 配下の Pod を監視対象として設定します。
今回は Kubernetes クラスタ内に Prometheus を立てましたが、認証情報を設定すればクラスタ外からも監視できます。

デフォルト設定の場合、Prometheus は /etc/prometheus/prometheus.yml を設定ファイルとして参照します。
サンプルでは ConfigMap の形式で Pod にマウントしているため、内容は (kubernetes/prometheus-config.yml) に記載されています。
以下、その一部を抜粋しながら解説していきます。

role が監視対象を検出する際のルールです。
endpoints を設定したことで Service のルーティング先であるバックエンドが対象になりました。

    kubernetes_sd_configs:
    - role: endpoints

続いて relabel_configs のブロックに入ります。

role: endpoints により、Prometheus は Kubernetes クラスタ上のすべての Service に紐づくバックエンドを検出します
しかし実際に監視したい対象は一部のため、条件を指定して絞り込んでいく必要があります。

Prometheus は検出した候補から取得できるメタデータを、ラベルとして扱うことができます。
このラベルを元にしてフィルタしたり、値を設定したりすることで必要なものだけが抽出されるようにします。

    - source_labels: [__meta_kubernetes_service_annotation_prometheus_io_java_scrape]
      action: keep
      regex: true

アノテーション prometheus.io/java_scrape の値が、正規表現 true に一致した場合は候補に残ります。
一致しなければ、この段階で除外されます。

    - source_labels: [__address__, __meta_kubernetes_service_annotation_prometheus_io_java_port]
      action: replace
      target_label: __address__
      regex: ([^:]+)(?::\d+)?;(\d+)
      replacement: $1:$2

まずメトリクス取得先ホストが入った __address__ と、アノテーション prometheus.io/java_port の値を区切り文字 ; で連結します。
対象の Service は 2 つのポートを公開しているため、監視対象は Pod ごとに 8080 と 9100 の 2 つがヒットします。
したがって、ここまでの出力は (Pod の IP):(8080 または 9100);9100 になります。

次に正規表現 ([^:]+)(?::\d+)?;(\d+) でマッチさせ、$1$2 で置換した値を __address__ に設定します。
よって __address__(Pod の IP):9100 に上書きされ、正しく監視先 URL が組み立てられるようになりました。
このルールは __address__ に含まれるポートが 8080 でも 9100 でも同じ値を返すため、その結果 9100 だけが監視対象に残ります。

Grafana にダッシュボードを作成する

JMX Exporter の出力に対応する JVM dashboard を使用しました。

サンプルでは grafana-init ジョブにより、自動的にダッシュボードが追加されるようにしています。
簡単なシェルスクリプトで実現していますので、もし興味があれば kubernetes/grafana-init-config.yml をご覧ください。

動作確認

それでは、サンプルの動作確認を進めていきましょう。
なお MicroK8s 前提での手順になりますので、異なる環境をお使いの方は適宜読み替えてください。

まずは Kubernetes クラスタ内のアプリケーションにアクセスするための InternalIP を取得します。

$ kubectl get nodes -o jsonpath='{.items[*].status.addresses[?(@.type=="InternalIP")].address}'
192.168.0.11

各アプリケーションの Service は NodePort であり、ランダムに割り当てられたポートでクラスタ外に公開しています。
以下は prometheus-svc のポートを取得した例です。

$ kubectl -n oom-sample get svc prometheus-svc -o jsonpath='{.spec.ports}'
[map[nodePort:31122 port:9090 protocol:TCP targetPort:9090]]

上記より、Prometheus にアクセスするための URL は http://192.168.0.11:31122 であることが確認できました。
他のアプリケーションに接続する場合も、この要領で URL を取得してください。

JMX メトリクスの取得

Prometheus にアクセスして、画面上部メニューの Status > Targets を選択します。
以下のように、2 台の Pod それぞれが監視対象として認識されています。

f:id:old_horizon:20200816170455p:plain

次に Grafana を開きます。ユーザー名およびパスワードは admin です。
画面左メニューから Dashboards > Manage を選択すると、表示された一覧に JVM dashboard が存在しています。

f:id:old_horizon:20200816170522p:plain

実際に表示してみると、グラフ形式で値の推移が確認できることがわかります。

f:id:old_horizon:20200816170534p:plain

OOM を発生させてヒープダンプを取得してみる

サンプルアプリケーション (ktor-oom) は、/v1/oom にアクセスすると OOM が発生する実装になっています。
ここにリクエストして、構築した仕組みが動作することを確かめます。

最初に現在の Pod の状態を見てみます。
まだコンテナは一度も再起動していないため RESTARTS は 0 のはずです。

$ kubectl -n oom-sample get po -l app=ktor-oom -o wide
NAME                        READY   STATUS    RESTARTS   AGE   IP            NODE    NOMINATED NODE   READINESS GATES
ktor-oom-646544cc67-4x9bd   1/1     Running   0          42m   10.1.52.222   t470s   <none>           <none>
ktor-oom-646544cc67-ljgpt   1/1     Running   0          42m   10.1.52.223   t470s   <none>           <none>

どちらの Pod も /dump にファイルは存在しません。

$ kubectl -n oom-sample exec ktor-oom-646544cc67-4x9bd -- ls /dump
$ kubectl -n oom-sample exec ktor-oom-646544cc67-ljgpt -- ls /dump

それでは OOM を発生させてみましょう。

以下のコマンドで /v1/oom/v1/ping に連続してアクセスします。
/v1/ping のリクエストは正しく処理されたため、OOM が発生した Pod はルーティング対象から外されたことがわかります。

$ curl -v http://192.168.0.11:31072/v1/{oom,ping}
*   Trying 192.168.0.11...
* TCP_NODELAY set
* Connected to 192.168.0.11 (192.168.0.11) port 31072 (#0)
> GET /v1/oom HTTP/1.1
> Host: 192.168.0.11:31072
> User-Agent: curl/7.58.0
> Accept: */*
> 
* Empty reply from server
* Connection #0 to host 192.168.0.11 left intact
curl: (52) Empty reply from server
* Connection 0 seems to be dead!
* Closing connection 0
* Hostname 192.168.0.11 was found in DNS cache
*   Trying 192.168.0.11...
* TCP_NODELAY set
* Connected to 192.168.0.11 (192.168.0.11) port 31072 (#1)
> GET /v1/ping HTTP/1.1
> Host: 192.168.0.11:31072
> User-Agent: curl/7.58.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Length: 4
< Content-Type: text/plain; charset=UTF-8
< 
* Connection #1 to host 192.168.0.11 left intact
pong

再度 Pod の状態を確認すると、片方の Pod の RESTARTS が 1 になっていました。
したがって、この Pod で OOM が発生したものと考えられます。

$ kubectl -n oom-sample get po -l app=ktor-oom -o wide
NAME                        READY   STATUS    RESTARTS   AGE   IP            NODE    NOMINATED NODE   READINESS GATES
ktor-oom-646544cc67-4x9bd   1/1     Running   0          73m   10.1.52.222   t470s   <none>           <none>
ktor-oom-646544cc67-ljgpt   1/1     Running   1          73m   10.1.52.223   t470s   <none>           <none>

実際にヒープダンプが出力されていることを確認できました。

$ kubectl -n oom-sample exec ktor-oom-646544cc67-ljgpt -- ls /dump
java_pid1.hprof

出力されたヒープダンプの回収には kubectl cp が利用できます。

$ kubectl -n oom-sample cp ktor-oom-646544cc67-ljgpt:/dump/java_pid1.hprof ./java_pid1.hprof

おわりに

OOM 発生時に十分な情報が取得できず、再現待ちとなってしまうケースを過去に見てきました。
しかし各種ダンプ・メトリクスがあるだけで調査は大いに捗るため、その取得の自動化は費用対効果が高いと思います。
まさに備えあれば憂いなしです。機会があればぜひお試しください。