Chonaso's Commentary

InternetやIT技術などについて知ったこと、試したこと、考えたことを書いていきます。

実録 緊急JVMチューニング


発端

Amazon EC2Tomcat/Java8を運用していたとあるサービス。

サービス終了は告知済みでUUも減ってきたのでインスタンスサイズを小さくして運用費下げたいとの相談。

どのようなサイズダウンかと聞いたところ、メモリが現在31GBから9GBになります、とのこと。

この時点でEC2慣れしてる方ならピンと来るでしょう。

当時EC2のスペックなどよく知らなかった私は(ずいぶんリッチな環境で運用してたんだなぁ、最初から9GBでも十分じゃないの?てかなんで奇数?と思いつつ)とりあえず承諾。

そして当日「サイズダウンと動作確認終わりました」のお知らせを受けたのでこの件は終了、と思っていたら「一部サーバが応答しないようだ」との報告が上がってきました。

どう考えてもダウンサイズが原因でしょ、と思い色々調べてもらうと

「OutOfMemoryError出てますね😇」

まじかー、雑な運用しちまったなぁ、このサービス舐めてたわーと反省しつつ、まずはメモリ消費量の確認を依頼。

ところが調べてもらうとどうもソロバンが合いません。 メモリが枯渇するほどは使用されていませんでした。

ここで答え合わせ。 メモリサイズ(GB)として聞いていた値はECU1でした。 というわけで9GBと聞いていたメモリは本当は3.5GBでした。

さすがにこれはマズいです。

サイズ戻せば一応解決ではあるのですが、JVMチューニングで凌ぐ余地がありそうだったのでそちらで解決することにしました。


メモリ設定の確認

まず今のメモリ設定とメモリ状況をチェックします。

psコマンドでjavaコマンドラインパラメータを見れば現在のパラメータがわかります。

→ 特に指定なし

デフォルトですね(にがわら)。

$ ps auxww | grep tomcat | grep -v grep
tomcat    1348  9.0 36.9 3691336 1376316 ?     Sl   Xxx22 106:39 /usr/bin/java -Djava.util.logging.config.file=/usr/local/tomcat/tomcat8/conf/logging.properties -Djava.util.logging.manager=org.apache.juli.ClassLoaderLogManager -Djdk.tls.ephemeralDHKeySize=2048 -Djava.protocol.handler.pkgs=org.apache.catalina.webresources -Djava.library.path=/usr/local/apr/lib -Dignore.endorsed.dirs= -classpath /usr/local/tomcat/tomcat8/bin/bootstrap.jar:/usr/local/tomcat/tomcat8/bin/tomcat-juli.jar -Dcatalina.base=/usr/local/tomcat/tomcat8 -Dcatalina.home=/usr/local/tomcat/tomcat8 -Djava.io.tmpdir=/usr/local/tomcat/tomcat8/temp org.apache.catalina.startup.Bootstrap start

メモリ使用状況の確認

次にメモリ使用状況。

私はjstatコマンドが好きなのでこちらを使いました。

$ sudo jstat -gc 1348
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
54272.0 54784.0  0.0    0.0   200704.0 196670.3  622592.0   622576.1  173312.0 141172.7 20736.0 16110.2     92    3.362 1767  2886.327 2889.688

主要な項目は以下の通りです。

  • OC Old領域の容量(キロバイト)
  • OC Old領域の使用量(キロバイト)
  • YGC Young領域GC(Scavenge GC)の回数
  • YGCT Young領域GCにかかった総時間(秒)
  • FGC フルGCの回数
  • FGCT フルGCにかかった総時間(秒)

ああこれはひどいですね...

YGC(YoungGC)が92回しかないのにFGC(FullGC)は4桁超えています。 明らかにメモリ不足です。

これでもある程度サービスが動いていたのだから大したものです。
(むしろ起動直後に即死してくれた方がよかった)

必要メモリを決める

ここから最善のメモリ設定を割り出していきます。

まずJavaアプリケーションが動作するために確保すべきメモリは以下の通りです。 (Linuxを想定しています)

  • OS
    • カーネルや最低限起動が必要なデーモンなど
    • Buffer/Cache
  • Java
    • heap領域サイズ
    • native領域サイズ(Metaspace, CompressedClassSpace, Cヒープなど)

OSについてはこの状況下ではBuffer/Cacheはほぼ見捨てていいかと思います。もしメモリが余っていれば優先度が低いなりにOSがうまく使ってくれるはず。 その他有象無象でどのくらいメモリが必要かはpsやtopコマンドなどから割り出してください。

Native領域

Javaについてはできる限りHeapにメモリを回したいのでNativeの見極めが重要です。

一番いい材料はJVMの各メモリ領域のサイズや使用量の変化履歴になりますが、そんなのがあるなら普通監視してますよね。

次点としては十分な期間起動していたJVMのメモリ状況があれば判断できると思います。

それもなければjmeter等で負荷かけまくった後のJVMメモリ状況が使えるのではないでしょうか。

今回は落とす前のjstatの結果がありますのでこれを使います。 Metaspaceは140MB程使われていました。余裕をもたせて256Mとします。

CompressedClassSpace(CCS)は16MBちょっとでした。アバウトで申し訳ないんですが64Mにしています。

CCSの必要サイズはヒープ内に存在するオブジェクトの数で決まります。

つまりアプリの作り方次第となってしまうため見極めが難しい部分ではあります。 可能であればオブジェクト統計情報を取得して設定値を検討したいところです。

またCompressedClassSpaceのサイズはデフォルトで1GBですが、このサイズはよほどオブジェクトが多くなければ消費できるサイズではないので設定することをお勧めします。 特に搭載メモリの少ない環境では必須2です。

他にもNative領域として確保される値はあるのですが、 これも適当で申し訳ないんですがCヒープはJNIを多用している・スタックは大量のスレッドを使用しているなどの状況でなければ設定自体は無視できるのではないかと思います。

Heap領域

jstat結果で確認したOld容量約608MBでは足りていないのは明らかです。

極力Old領域に振り分けることにしました。これでダメならメモリを増やします。

最終には以下の設定になりました。

-Xmx2560m -Xms2560m -XX:NewRatio=3 -XX:CompressedClassSpaceSize=64m -XX:MaxMetaspaceSize=256m

物理メモリサイズは3.5GB、スワップは極力させない、という前提です。

-Xmx2560m-Xms2560m ですが、Xmxはヒープの最大サイズ、Xmsはヒープの初期サイズです。 初期サイズを最大サイズにすることでJVM実行中の自動拡張のオーバーヘッドを無くします。

-XX:NewRatio=3 はYoung領域とOld領域の割合を1:3にする、という設定です。 つまりOld領域は1,920MBとなります。

ヒープが足りているかの判断

ベストな確認方法は負荷試験をすることです。 負荷試験は処理性能のネックだけではなくサーバリソースの限界を見極めるためにも重要な試験といえます。

短時間で見極めるにはヒープの消費トレンドを観察します。

Full GC頻度/時間

まずはFullGCの頻度を見ます。

「一定時間内に何回あったらダメ」といった絶対的な指標はありませんが回数が少ないに越したことはないです。

それに加えてFullGCに掛かった時間を見ます。1回あたりのFullGCが大きいとそれ自体がシステムへの負荷が大きく、アプリケーションの応答時間に影響してきます。

1回のFullGC時間が極端に大きければはメモリ不足にならない程度にOld領域を小さくする対応も視野に入れる必要があります。

Old使用量の増加トレンド

Full GCはOld領域が不足した時に発生します。 Old領域はFull GC以外では使用量は減少しません。

つまりYoung領域からOld領域へ移動するオブジェクトが多いかどうかでOld使用量の増加ペースが決まります。

オブジェクトがOld領域への移動するメカニズムはここでは割愛しますが、JVM設定で対応できるのはYoung領域のサイズとオブジェクトがOldに移動するGC回数(-XX:MaxTenuringThreshold)となります。

ちなみに筆者は -XX:MaxTenuringThreshold を設定したことはあまりないです。 そこまでシビアな運用をしたことがない、アップデートなどでコードが変わればトレンドが変わるなどが理由です。

メモリリークしていないか

手前味噌ですが、ここにまとまっています。 Javaアプリケーションサーバの監視 - Chonaso's Commentary

メモリリークしているかどうかはほぼコードの問題ですが、メモリサイズが大きかった場合はそれが顕在化していなかった可能性もあります。

Javaアプリはメモリ設定が変われば挙動が変わる(かもしれない)という覚悟をしながら運用していかないとならないですね。


その後

このサービスはチューニング以降何事もなくサービス終了を迎えたのでした。

めでたしめでたし(合掌)

追記

今回のケースの直接の原因はEC2サービスに対する誤解や検証不足ですが、メモリ(ヒープ)使用量やCPU使用率、レスポンスタイム、FullGC回数などの監視を適切に行なっていればサービス影響が発生する前に問題の検出ができた可能性が高く、この辺は猛省せねばならないところですね。