Java トラブルシューティングガイド

こんにちは。ミドルウェア開発チームの青木(@a_o_k_i_n_g)です。将来の夢は藤岡弘の弟子になることです。

Cybozu では多くの Java アプリケーションが稼働しており、トラブルも発生します。僕はトラブル対応をすることが多く、今まで大小様々なトラブルを見てきました。その中で得られた知見を社内ドキュメントとして記していましたが、そちらを手直ししたものを本記事で公開します。Cybozu ではインフラ基盤に Ubuntu を用いているので各種ツールの紹介もすべて Ubuntu を前提にしていることをご承知ください。

すぐやること

各種データはトラブルが発生している状態で運用チームに取得してもらいましょう。鮮度が重要なデータも多いので、常日頃運用チームと手を取り合ってトラブル対応できる組織づくりをしておくべし。

モニタリングツールで該当環境のデータを確認

トラブルの原因は多種多様です。解決はともかくとして、まず何が起きているのかを把握するのがまずいちばん最初にやることです。

まずはじめに、どんな現象なのかを簡単に切り分けましょう。CPU 消費, メモリ 消費, IO 消費, ネットワーク不通, リソースリーク、スタベーション、など。 JVM のモニタリングには jstat を用います。jstat でヒープの New 領域や Old 領域の容量と使用量を確認したり、GC の回数を確認したりします。とは言っても jstat の結果を目で見るのはキツイので、事前に jstat の結果をモニタリングツールに流しておかなければなりません。

スレッドダンプの取得

スレッドダンプは大変役立つ上に簡単に取得でき、ヒープダンプと違って出力後のサイズも小さいので JVM でトラブルが起きたら必ず貰っておきましょう。

スレッドダンプは jstack で取得できます。 注意点として、スレッドダンプの取得は JVM 実行ユーザーと同一でなければいけません。root でも取得できません。

$ sudo -u <USER> jstack $PID > /tmp/jetty.stack.txt

余裕があるときは、数秒おきに何度かスレッドダンプを取ってもらうと尚良いです。なぜならスレッドダンプ1つだけ見た時、あるスレッドが処理が遅くてスレッドダンプに出てきたのか、それともたまたま処理している瞬間に遭遇したものなのか判別しにくいからです。

取得したスレッドダンプの拡張子は .txt にしておきましょう。というのも、会社にいない時のトラブル対応で、スレッドダンプをスマホで見る場合があります。拡張子が .stack のような謎拡張子だとスマホでダウンロードできなかったり開けなかったりすることがあるので、.txt にしておけば安心です。

ヒープダンプの取得

JVM のヒープを取得します。 jmap コマンドで取得可能。

$ sudo -u <USER> jmap -dump:format=b,file=/tmp/app.dump $PID

出力されるヒープダンプは数百 MB から数 GB を超えることもあるので、ダンプする際のディスクの空き容量に注意しましょう。

ヒープダンプはサイズが大きく、かつ、お客様データが含まれるため取り扱いに注意する必要がある等敷居が高く、「すぐやること」とは言えないかもしれません。とはいえトラブル発生時に取得しないと意味がないデータでもあります。まずはスタックトレースや lsof、その他モニタリングツールをざっと流し読みしてわからなかったらヒープダンプを貰う、という流れが良いでしょう。

一旦おさらいです。 jstat でモニタリング、jstack でスレッドダンプ、jmap でヒープダンプ、です。 jstat, jstack, jmap をトラブル解決のための三種の神器と言います[要出典]。

lsof の取得

lsof でリソースリークが無いか確認しましょう。こちらも jstack と同じく、簡単に取得出来てお客様データが混じらないので取り扱いが楽です。

$ lsof -p $PID > /tmp/lsof.txt

プロセスを特定する

CPU 使用率が高い、メモリを大量に使用している、大量にディスク書き込みをしている、などのトラブル時、どのプロセスがリソースを消費しているのか確認しましょう。

CPU を消費している時

top コマンドで CPU カラムでソート。デフォルトで CPU 使用率でソートされているはずですが、そうでない方は f キーを押してソートカラム選択画面に移動、上下キーで %CPU を選択して s キーでセット、q で元の画面に戻ると CPU 使用率でソートされています。

CPU を消費している場合に、やっておくべき重要なことがあります。 プロセスの特定だけでなく、スレッドの特定も行っておくべし!!

スレッドの特定の仕方。 top -H コマンドか、top コマンドでプロセス表示後、Shift + H キーを押してスレッド表示をオンにする。CPU を消費しているスレッドを特定し、スレッド ID (top コマンドでの表示は PID カラム)をメモしておく。あとでこの top コマンドで得たスレッド ID と、スレッドダンプのネイティブスレッド ID (nid=XXX の値)を突き合わせて原因となるスレッドを特定しましょう。ただしこの技が使えるのは特定のスレッドが CPU を消費し続けている時のみです。

ちなみに top コマンドでのスレッド ID は 10 進数表記、スレッドダンプのスレッド ID は 16 進数表記なので Google 先生に "1234 to hex" とか聞いてさくっと変換できます。逆も当然できて、"0xABCD to decimal" とやると 16 進から 10 進に変換してくれます。"0b11011 to hex", "5324 to binary", "0o1234 to decimal", "0xABCD to octal" とかで 2 進数, 8 進数, 10 進数, 16 進数の相互変換可能。

CPU 使用率が高いからと言って、CPU を大量消費する不具合があると考えるのは早計です。何らかの処理がメモリを大量に消費した結果 GC スレッドがあくせく働いて CPU 使用率が高くなるということもあります。つまり JVM においてはメモリ消費は CPU の消費も促すのです。というわけで、CPU 使用率が高くなった時はモニタリングツールで GC の項目を確認し、GC なのかそれ以外なのかを切り分けする癖をつけましょう。

メモリを消費している時

CPU を消費している時と同様、top コマンドでメモリでソートしてプロセスを特定すべし。top コマンドのカラム的には %MEM を指定すれば大丈夫です。VIRT (仮想メモリ)や SHR (共有メモリ)でソートしないようにしましょう。ほとんどのケースでは注目したいのはプロセスが使ってる実メモリなので、%MEM または RES (実メモリ)でソートすべきです。

JVM は、スレッド単位でのメモリ管理はしていないので、CPU 消費時のようにメモリを消費しているスレッドを特定することはできません。

ディスクに書き込みをしている時

ディスクを消費している時は、どのファイルなのかを特定したい。たいてい /tmp にあるので、ls -lS /tmp などで巨大なファイルが無いか確認しましょう。ファイルが見つかれば大抵プロセスも特定できますが、わからない場合は iotop なんかが使えます。iotop でのソートカラムの選択は top コマンドとは異なり、左右の矢印キーで選択します。

JVM アプリケーションが稼働しているサーバーだからといって犯人が JVM とは限りません。同居している別のプロセスが犯人ということもあるのでプロセスの特定は重要です。Cybozu の過去の例では、ImageMagick という画像変換ツールがディスクを大量消費したケースがありました。

すぐやることの次にやること

必要があればプロセスの再起動

お客様に影響が出ている場合、プロセスを再起動してメモリの状態等一旦クリアすべきですが、逆に、大丈夫な場合はできるだけ再起動せずその状態のまま残しておき、必要な情報を取得できるようにしておきましょう。必要なデータは貰ったと思っても、後から「あのデータが欲しい」と思うことはしばしばあります。

プロセスの起動オプションの取得

これは再起動しても変わらないものが多いのですぐ取得はしなくても大丈夫ですが、トラブル対応時に必要になることもあるので取得しておきましょう。ps aux | grep java するか、jps -m で。jps は JVM 実行ユーザーと同一ユーザーでなければなりません。

再・モニタリングツールで該当環境のデータを確認

Zabbix 等で当該環境のデータを確認しましょう。 ポイントは 2 つ。

  • 現象の確認(CPU 消費、メモリ消費、etc)
  • 障害が発動した時刻の特定

何が起きているのかを良く見極め、それから時刻を特定し、ログから分析していきます。

モニタリングツールで閲覧する際の注意点がいくつかあります。

時刻のタイムゾーンに注意すべし
基本的にグラフは横軸が時間軸になっていると思います。ただ、この時間が UTC なのか JST なのか、それともそれ以外のタイムゾーンなのかはきちんと把握しておきましょう。これを勘違いすると後述するアクセスログ等での解析時、時間で範囲を絞ったときに痛い目を見ます。

グラフの精度に注意すべし
これは Zabbix の例ですが、1 週間以上古いデータは間引かれて精度が落ちます。また、時間軸のスケールを大きくすると小さな値が正しくプロットされないことがあるので注意です。私はこれで泣きました。

トラブル解決に向けてあらゆる項目を見ましょう。CPU 使用率や JVM のレジデントメモリ使用量、物理メモリの空き容量あたりを見るのは当然として、普段見ないような項目もトラブル対応時くらい見てみましょう。たとえば JVM のメモリ関連の項目を良く見ると、ヒープとしてはまだ空きがあるのに Eden 領域が何故か自動拡張されず、GC が頻発してるというような現象が即座にわかるようになります。ちなみにこれは弊社で実際に起こった事例です。

とにもかくにも、モニタリングツールでの確認はトラブル解決の要なので、穴が空くほどに眺めるべし。眺めていればきっと天啓が舞い降りてくるぞ!

アプリケーションのログを確認

ここに書かずとも当たり前にやることではありますが、ログを確認する。

注意点が1つあります。log4j のログは、時刻順にソートされているわけではありません(きっと logback 等でも同様でしょう)。大体は時刻順ですが、システムの負荷が高い時は数百 ms 以上前後することがあります。

アクセスログで怪しいリクエストを探す

ユーザーからのリクエストが原因と思われる場合、アクセスログから怪しいリクエストを絞りましょう。参考までに、弊社のアクセスログは SQL インターフェースで提供されており、SQL で時刻やサービス、お客様、ステータスコード、処理にかかった時間などなどを元に絞り込めるようになっています。また、アクセスログと JVM が出力するログで、リクエストの紐づけができるようリクエスト ID のようなものを載せておくとよさ気です。

トラブル発生時のアーカイブを確認

当該環境のアーカイブを確認し、どのバージョンでトラブルが発生したのか断定すべし。アーカイブからソースコードへのトレーサビリティが求められます。運用チームのオペレーションログから追ったりリリーススケジュールから追っても良いですが、弊社では JVM アプリケーションがバージョンを返す API を用意しており、それを元に簡単にバージョンを特定できるようになってます。

解析方法

スレッドダンプ

スレッドダンプを取得したら、ひとまず grep tid= jetty.stack.txt | wc -l 等でスレッド数の確認をしましょう。

JVM は何も使っていなくて寝ているだけならスレッド数は 20 程度です。そこにアプリケーションが使うスレッドが追加されます。他の環境や過去のデータと比較してスレッド数が怪しくないか確認すべし。普段からモニタリングツールなどで確認しておき、大体のスレッド数を把握しておくと捗ります。

仮に Jetty を使っている場合、Jetty のスレッド (qtp*** で始まるスレッド名) が入ってきます。詳しく追いたい時は Jetty のスレッドプールは org.eclipse.jetty.util.thread.QueuedThreadPool クラスに実装があるので気になる時はここを見る。Jetty に限らずスレッドプール内の寝てるスレッドは通常 1 分程度で消えることがほとんどですが、すべて消えるのではなく数本のスレッドは残しておく実装になっていることもあります。

次に、遅延系のトラブルの場合、どこかのオブジェクトで wait していないか調べる。grep "waiting to" して同じアドレスを元に複数のスレッドがロックしてないか見るべし。スレッドプールで管理されていて寝てるだけのスレッドも出てくるので混同しないよう注意。

また、寝てるスレッドが多いことがほとんどなので、動いてるスレッドを見るために RUNNABLE で検索して追うと効率的です。スレッドの状態は 6 つほどありますが、RUNNABLE が実行中、BLOCKED は別のスレッドによってブロックされている、WAITING, TIMED_WAITING が寝てるスレッドということさえ覚えておけば大丈夫です。 https://docs.oracle.com/javase/jp/8/api/java/lang/Thread.State.html

jstack にはデッドロックを検出する機構があり、Java レベルのデッドロックを検出して表示してくれます。jstack の表示の一番下部に出るので、一応チェックしましょう。

ノウハウとして、スレッド名にトラブル解決のための何かしらの情報を埋め込んでいく方法があります。たとえば弊社のバックグラウンドで稼働する非同期処理アプリケーションでは、スレッド名に顧客 ID を埋め込むことでどのお客様で障害が発生したのかすぐ特定できるようになっています。情報を埋め込む際、間違ってもお客様の個人情報を埋め込まないようにしましょう(普通はやらないはずですが)。

lsof

これは普通に less で見ましょう。ざっと見て同じような項目がずらーっと並んでなければ大丈夫です。TCP が原因っぽいとわかっている場合は grep TCP して見ても良いですが、やはり生で見るのが一番です。

または、TCP が原因っぽいなら sudo netstat -tnp すると良いでしょう。-p オプションでプロセスを表示してくれます。-p オプション使う時は sudo で。-p オプションを使っていてかつ sudo でない場合でも特にエラーは出ません。

JVM のメモリについて

JVM のメモリの使い方は大別して 4 つ。

  • Java ヒープ
  • パーマネント
  • スタック
  • C ヒープ

※Java8 からはパーマネント領域はなくなります。

これを覚えておくべし。スタックは 1 スレッドにつきたかだか 1MB 程度しか使わないので、異常なスレッド数でない限り問題は出ません。

メモリの状態を詳しく見たい時は前述の通り jstat を使います。とは言っても手動で jstat 実行して数値を眺めるのは目に優しくないので、事前に jstat の項目をモニタリング基盤に流し込んでおく等をして可視化できるようにしておきましょう。手動で実行する場合、表記が若干独特なので慣れておくべし。OU は Old Used, EC は Eden Capacity のように、メモリの用途 + Capacity or Used となっていることが多いです。 http://docs.oracle.com/javase/jp/8/docs/technotes/tools/windows/jstat.html

jstat で手の届かないところは jcmdNativeTrackingMemory で見れます。ただし JVM の起動オプションで指定する必要があるので、再起動が必要になります。 https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr007.html

JVM に限らない話ですが、プロセスのメモリの状態を見るには /proc/$PID/ 下の statusmaps を見るという手もあります。

GC について。 Java 7 から外部から GC 打つことができるようになりました。jcmd $PID GC.run で発行できます。 https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/tooldescr006.html

メモリ系のシビアなケースについて調べている場合、ファイナライザが定義されているオブジェクトの破棄には GC が2回発行される必要があるというのはハマりどころかもしれません。 http://sakuramochi702.hatenablog.com/entry/2013/06/03/125052

ヒープダンプ

Memory Analyzer, JProfiler, Visual VM あたりを使って解析。

調べ方はツールによって異なりますが、Memory Analyer の Leak Suspect で出た部分を追っていけば大丈夫でしょう。ZipFileIndex というクラスのインスタンスが多数あるように見えることがありますが、これらは単にクラスパスの jar を保持しているだけだったりします。Finalizer クラスが多数ある場合でも、それらは GC されれば消えるので無視して大丈夫です。

ヒープダンプ見てもわからない場合、OQL を使いましょう。Object Query Language というもので、SQL ライクにヒープの検索ができます。Memory Analyzer 内で OQL の発行ができるので別途ツールを使う必要はありません。jhat 通して使う OQL と Memory Analyzer 通して使う OQL は異なることに注意。後者のドキュメントはこの辺にあります。でもまぁ、正直あまり出番は無いですね。いざという時のためにお道具箱に忍ばせています。 http://help.eclipse.org/mars/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Freference%2Foqlsyntax.html

メモリダンプ

メモリリークっぽい場合で、かつ C ヒープに原因がありそうな場合はメモリダンプを取得して解析すべし。メモリダンプは gcore コマンドで取得する。当然メモリダンプにはお客様データが入っているので取り扱い注意です。

メモリダンプの解析は strings コマンドでバイナリ内の文字列を抜き出して推測するのが一番簡単です。

$ strings -a memory.dump | sort | uniq -c | sort -nr | less

さらに気合があるなら怪しげな場所を目で見るという手もありますが、バイナリを見たところで原因がわかるかというとかなり困難です。それでも見たい場合には hexdump -C ないしは od -Ax -tx1z あたりで見るべし。

メモリダンプは巨大になることがあります。なぜならメモリが怪しい時はたいてい大量消費している時なので。そんなメモリダンプをバイナリエディタで見るのは大変酷なので、split コマンドで小分けにしましょう。

$ split -b 10485760 jetty.gcore split.jetty

で、10MB ごとに小分けにされるので、途中のやつから hexdump -C する。

メモリダンプ + strings のお話は以前僕が遭遇したケースで、下記記事に記してあるので参考になるかもしれません。 blog.cybozu.io

普段からやっておくこと

モニタリングシステムの整備

今の時代、モニタリングの重要性は今更説くまでもないですね。これが無ければトラブル解決できるものもできません。必ず整備しておきましょう。

弊社の監視・モニタリングの仕組みについては下記記事で一部公開されています。良かったらご参考に。 blog.cybozu.io

ログの整備

トラブル対応をした後に振り返ると、「ログに○○の情報を書いていれば早期解決できた」というようなことがしばしばあると思われます。どのようなログを出すべきか再度検討し、改善しましょう。不要なものは削り、必要なものを入れる。ログの鉄則です。

ツールに慣れ親しんでおく

ここまで記載したトラブルシューティングガイドですが、ここにある方法だけではトラブル解決は難しいです。ケースバイケースで原因の切り分けをしなければならないからです。その際には各種ツールに慣れ親しんでおくと捗るでしょう。日常生活やログ解析時には cat, grep, sort, uniq, tail, awk, ...。挙げたらキリが無いですね。jstack, jps, jmap あたりの Java コマンド系もローカルでひと通り触っておきましょう。パケット解析の tcpdump, プロセスの挙動を監視する strace, バイナリを眺める hexdump あたりの学習もトラブルシューティング要員は必須項目です。いざトラブルの時に使い方がわからないとダウンタイムが長くなってしまうので、平和な時に学習しておくべし。

それでもわからない時は

トラブル対応していない人に現象を説明すると「ここ調べた?」と言ってくれて意外とそこは盲点だったりすることがしばしばあります。

1人で原因を特定できたらカッコいいですが、サーバーの向こうに困っているお客さんがいることを考えると悠長なことはしていられません。誰かに助けを求めることができる能力というのもトラブル対応をする人が持つべき資質だと思っています。

終わりに

トラブル対応に正解の道はありません。システムごとに仕組みは異なり、原因も多種多様にあるからです。トラブルは基本的に再発防止と共にあるので、一度改修したら次出会うのはまた別のトラブルです。今まで多種多様なトラブルを見てきましたが、振り返ってみればいずれもケースバイケースで調査していました。それでも、Java アプリケーションに限って言えば、ある程度のトラブルシューティングのパターンが出てきます。本記事がトラブル対策時の 1 つの助けになればと思います。

トラブルというものは起きてはならないものなので、それと向き合う時はつらい気持ちになったり申し訳ない気持ちになったりします。でも、落ち着いて調査しましょう。申し訳ない気持ちになったところで障害は直らないですし、人間が書くコードなのでバグが無いというのもありえないのです。

解決できなかったトラブルもあります。そういうものには結構苛まされて、夢に出てきたこともありました。でも一方で、複雑怪奇なトラブルを解明した時の喜びもまた強烈なものがあります。様々な知識を動員して原因を特定していく様は推理小説で犯人を追い詰めるかの如くであり、また、宝探しの如くでもあります。原因を特定できたらヒーローになれることもあります。トラブル対応はやりたがる人があまり多くないかもしれませんが、毎回未知の問題に立ち向かうというのは、結構、チャレンジングで面白いものですよ。

そして何より、お客様のためにつながります。サーバーの向こうで困ったユーザーを助けられるのは僕達プログラマをおいて他にいません。たとえ第三者ライブラリが原因であろうと、外部ネットワーク上の問題であろうと、僕達が作ったサービスにトラブルが起きたらそれを解決するのは僕達であり、解決を諦めてはなりません。何か異常が起きるたびにアプリケーションを再起動するのも立派な 1 つの手段ではありますが、さらなる安定稼働のために、より深く探求してみませんか。