BOOK: WEB+DB Press
TITLE: 常駐型サーバーのデバッグ手法(ドラフト版)
AUTHOR: (株)プリファードインフラストラクチャー 太田一樹


WEB+DB PRESS Vol.48
*注: この文章はWEB+DB PRESS Vol.48に掲載された記事のドラフト版です

はじめに

今回はデバッグ関連特集ということで、常駐型サーバープログラムを作成する際のハマりどころやそれに対する解析方法・解析ツール・対策を、実際の経験を交えながら紹介したいと思います。

筆者は(株)プリファードインフラストラクチャーでインメモリ分散検索エンジン「Sedue (セデュー)」を開発しています。モバイル向け検索エンジン「エフルート」や、2008/11/6にリニューアルされました「はてなブックマーク2」などの検索バックエンドとして使われております。

この検索エンジンはいくつかの常駐型サーバープログラムから構成されており、全てマルチスレッドで動作しています。コアに圧縮サフィックスアレイという検索アルゴリズムを採用し、インデックスを圧縮する事で、元文章と同じサイズの検索用インデックスで検索を行う事ができます。これを全てメモリに載せ、ディスクを使用するものに比べて高速な検索を実現しています。つまり、20Gの文章を検索する場合、メモリ8Gのサーバーが3台程度必要です。

24時間プロセスが動き続けるので、リソースリーク(メモリリーク, ファイルディスクリプタ不足等)に完璧に気をつけなければいけません。ネットワークについては、タイムアウトが無いとスレッドが永遠にブロックしてしまいます。

また、通常のアプリケーションに比べて大規模にメモリを消費するため、メモリアロケーションに対して細かく気を使う必要があります(メモリフラグメンテーション等)。多数のクライアントからの要求を効率的にさばくため、スレッド処理が必要となりますが、これにより各種問題が引き起こされます(デッドロック, Mutex競合等)。

この記事では、Sedueの開発を通して得たこれらの問題に対する解析方法/ツール/対策を紹介し、常駐型サーバーのデバッグ手法をまとめるのが目的です。各章の話は全て筆者が実際に経験した話です。編み出した対策はもしかしたら一般的では無いかもしれませんが(もっと良い方法が有るかもしれません)、最後まで読んでいただけると嬉しいです。

対象とする環境としてはLinuxとします。コマンドについては、特に言及しない場合はLinuxのものとします。筆者は普段C++/STL(Standard Template Library)/pthreadを使用しているため、多少この構成に特化した話も有りますが、C, Java, Perl, PHP, Python, Ruby, Schemeなどを含む他の言語についても有効な話も有ります。

* リソースリーク
24時間動き続けるサーバーにとって、リソースリークは命取りです。何かOSのリソースを獲得し、それの解放を忘れていると、下手をすると数か月後にリソースを確保できなくなり、サーバーが停止してしまう可能性すら有ります。

ここではよく解放を忘れるケースについていくつか列挙してみたいと思います。

** ファイルディスクリプタ不足の調査方法
突然サーバーが止まった!という時に一番最初に疑ってみるのがこの問題です。大体はopen()したものをプログラム中でclose()していないのが原因です。ソケットの場合も有るかもしれません。

通常の環境では、1プロセスが保持できるファイルディスクリプタの数の上限は定められており、これを超えてしまうと新しくopen()出来なくなってしまいます。この上限は"ulimit -n"コマンドで確認でき、通常は1024に設定されています。

サーバーがコネクションを受け付ける際にもディスクリプタが作成されるため、ディスクリプタが新しく作れない状況に陥ると、まったくサーバーが応答を返してくれなくなります。このような状況であるかどうかを確認するには、"strace -f -p [プロセスID]"というコマンドを使用します。open(2)やaccept(2)がEMFILE、もしくはENFILEというエラーで延々とループしているとビンゴです。

mod_perlやFastCGI等、プロセスに常駐する形で各種スクリプト言語のインタプリタを動かす場合にも同じ問題が発生します。スクリプト内部でclose(2)を忘れている場合、しばらくするとWebアプリケーションが停止してしまうので非常に危険です(Apacheの場合はMaxRequestsPerChildで定期的に殺せば良いですが...)。

あるプロセスが開いているファイルディスクリプタを調べるには/proc/[プロセスID]/fdをls -alします。MySQLのサーバープロセスを試しに覗いてみると以下のようになります。

~$ sudo ls -al /proc/25391/fd/
合計 0
dr-x------ 2 root  root   0 10月 18 18:41 .
dr-xr-xr-x 5 mysql mysql  0 10月 13 13:50 ..
lr-x------ 1 root  root  64 10月 18 18:41 0 -> /dev/null
l-wx------ 1 root  root  64 10月 18 18:41 1 -> /var/log/mysqld.log
lrwx------ 1 root  root  64 10月 18 18:41 10 -> socket:[806574]
lrwx------ 1 root  root  64 10月 18 18:41 100 -> /var/lib/mysql/imanager/integer_extras_1000000144.MYI
...

例えばファイルディスクリプタの1番(通常は標準出力)は/var/log/mysqld.logを開いた結果得られたものだという事を意味しています。socketや、データベースファイル(.MYI)を開いているのも分かります。

このディレクトリに出来ているファイルの個数が数百個を超えると危険信号です。このディレクトリのファイル数が単調増加していると、最終的にサーバーが停止してしまう可能性があります。どのファイルを開いているかが分かれば、プログラムのどの部分でclose()を忘れているかが分かると思いますので、それを元に対策を施せば良いでしょう。次章で出てくるvalgrindを使用する方法もあります。

** メモリリークの調査方法
ファイルディスクリプタの次に多いのがメモリリークの問題です。これも普通の話ですが、malloc()したものをfree()していない問題です。じわじわとサーバーのメモリ使用量が増加、最後にはマシン全体のメモリを食いつくし、サーバーの停止につながります。この問題もmod_perlやFastCGIでWebアプリケーションを動作させる場合には付いてまわる問題です。

メモリリークを見つけるのには、valgrind(http://valgrind.org/)が非常に有効です。valgrindはユーザ空間でプログラムの命令実行やメモリアクセスを高速にエミュレートし、

- malloc()で確保されたメモリが開放されていない
- 配列の範囲外アクセスがある
- 初期化されてない値でプログラムの分岐を行った

などの問題を、プログラムの該当箇所と共に指摘してくれます。試しに以下の様なプログラムを用意し、コンパイルします。

static char *s;
void leaking(void) {
    s = (char*)malloc(100);
    s[0] = 'a';
}
int main(void) {
    leaking();
    leaking();
    leaking();
}

これをvalgrindに実行させると、以下のような出力が得られます。

% gcc -g testleak.c
% valgrind --leak-check=full ./a.out
...
==13678== 200 bytes in 2 blocks are definitely lost in loss record 2 of 2
==13678==    at 0x4C20A69: malloc (vg_replace_malloc.c:149)
==13678==    by 0x4004A5: leaking (testleak.c:5)
==13678==    by 0x4004C1: main (testleak.c:11)
...

このように、メモリリークが起こっている場所が一発で特定できます。

C/C++だけではなく、たとえばPHPの拡張ライブラリ等でリークが起こっていないか等のチェックも行えます。C/C++で書かれたライブラリのラッパーを書くのは各スクリプト言語では非常に標準的な事ですが、ここでメモリリークが発生するケースが非常に多い様に感じています。

拡張ライブラリを書いた後、以下のようにしてメモリリークをチェックしたり、そもそもWebアプリケーションとして動作させているスクリプトのチェックをしたりも出来ます。valgrindでは結果をXMLとしても出力出来るので、ユニットテストに追加する事も出来ると思います。

% valgrind --leak-check=yes php my_wrapper_functions.php
% valgrind --leak-check=yes php application.php

--track-fds=yesオプションを付けると、前述のファイルディスクリプタのリークも追う事ができます。

% valgrind --track-fds=yes ./a.out
% valgrind --track-fds=yes python application.py

valgrindが登場する以前のメモリリークチェッカーは非常に動作が遅く、下手をすると実行時間が100倍程度になっていたのですが、valgrindは2倍から10倍程度の実行時間で収まるため、現実的な時間でメモリリークチェック/配列の範囲外アクセスチェックが行えます。システムコールトレーサーstraceと共に、開発者必携のツールと言えると思います。

GCを持つ言語ではvalgrindではチェックできない類のメモリリークが生じるのでさらなる注意が必要です。例えばPerlでは、GCの特性上、循環参照が生じるとオブジェクトを解放できません。mod_perl上で動かした場合にプロセスのメモリ使用率が増えていく場合、この問題が起こっている可能性が高いです(これもMaxRequestsPerChildで定期的に殺せば良いですが...)。

** 言語のサポートで未然に防ぐ
以上、ディスクリプタリークとメモリリークという2つのよくあるケースを取り上げました。気をつけていれば忘れないと思うのですが、エラーが起こった際にclose()を忘れていたりするケースが意外と有ったりします。

これを防ぐためには、言語の機能を使用したプログラム書き方が有効です。

例えばRubyではブロック付きopenを使うと、ブロック内の処理が終了した時にディスクリプタを解放してくれます。

# ブロック内の処理が終了すると、勝手にcloseしてくれる
open("test.txt", "w") { |f| f.puts "aiueo" }

他にも、「ファイルロックのアンロック忘れ」「Mutexのアンロック忘れ」「Mutexのdestroy忘れ」など、様々な解放忘れのケースが考えられます。いくら注意して書いても人間忘れる時には忘れるので、リソースの解放を気にしなくて済むコーディングをするのは非常に重要だと思います。後で痛い目を見た時のコストは非常に高いです。

* ネットワーク周りの問題
サーバーの場合は、ネットワーク周りにも注意を払う必要があります。ネットワーク周りは色々とポイントあるのですが、ここではサーバーを停止させないための要点だけを挙げてみます。

** SIGPIPE
初めてサーバープログラムを書き、負荷をかけてみるとサーバーが落ちてしまう場合は、ほぼこのSIGPIPEによるものと思われます。すでに相手が切断してしまっているのにこちらから何かsendしようとすると、自分のプロセスに対してSIGPIPEシグナルが飛んできます。

このシグナルが飛んで来ると、デフォルトではプロセスが終了してしまうため、以下のようにサーバー起動時にSIGPIPEを無視するようにするのが定石です。

signal(SIGPIPE, SIG_IGN);

** タイムアウト
全てのネットワークのやりとりにはタイムアウトをつける必要があります。悪意のあるクライアントや異常状態になったクライアントがいて、何もしないコネクションを多数貼られると、すべてのスレッドが待ち状態に入りサーバーが停止してしまう羽目になります。

LinuxではSO_RCVTIMEO, SO_SNDTIMEOというソケットオプションで、ソケットに対するタイムアウトを設定できます。何かしらソケットを作成した場合にはこれらの値が必ず設定されるようにコードを書きます。Solarisではこれらのオプションがサポートされないため、ポータビリティを求める際にはタイムアウト付きselect(2)を使用して通信を行う必要があります。

タイムアウトを設定する以外には、全てノンブロッキングソケットで通信を行う方法も有ると思います。大量のコネクションを扱う場合はこちらの方が一般的です。

** acceptしすぎない
サーバーのアーキテクチャーにも依るのですが、accept(2)用にスレッドを用意して、各ワーカースレッドにディスクリプタを渡す場合、ワーカーの処理が追いつかず、acceptしすぎてファイルディスクリプタ制限を突破してしまう可能性が有ります。

多数のクライアントが接続して負荷がかかった場合にも、きちんとディスクリプタ上限を超えないようにプログラムを記述する必要があります。多言語RPCフレームワークThrift(http://developers.facebook.com/thrift/)のTThreadPoolServerにはこの問題が有り、原因解析に苦しんだ経験が有ります。

* メモリ周りの問題
ここではメモリリークを除く、メモリ周りの問題について触れてみたいと思います。主にメモリを使用しすぎる事によって引き起こされる問題について扱ってみたいと思います。

** メモリ使用量とは?
そもそもメモリ使用量とは何でしょうか。これにはOS、アロケーターが非常に密接に関わってきます。

アプリケーションがmalloc()を呼び出すと、アロケーターからメモリを取得します。アロケーターはmalloc()が呼ばれる度にOSにメモリを要求していることもあれば、自分が持っている空きメモリ領域リストから、メモリを割り当てる事もあります。

なので、OSから見たプロセスのメモリ使用量は、大まかに言うと「アロケーターが保持している空きメモリ容量 + アプリケーションが利用しているメモリ容量」になります。なので、通常はアプリケーションがmalloc()した容量以上のメモリが確保される事になります。アプリケーションが取得したメモリ領域には必ずしも物理的にメモリが割り当てられていない場合もあるので、「仮想メモリ使用量」「物理メモリ使用量」という2つのカウント方法も存在します。

このようにメモリシステムは複雑であるため、「メモリ使用量」と一概に言っても、シチュエーションによって色々な意味を持つことになります。

** メモリ使用量を制限する事の必要性
メモリ周りの問題が複雑であることを説明した上で、メモリ使用量を正確に把握する事の重要性を説明したいと思います。

*** スワップアウトの恐怖
実行中プロセスの総物理メモリ使用量が実際の搭載メモリ量を超えると、OSはメモリをディスクに一時的にスワップアウトします。そして、次回そのメモリにアクセスした際にはディスクから読みだすことになります。これが起こるとOS全体の動作が非常に遅くなり、深刻な速度低下を引き起こすため、絶対にこれだけは避けなければいけません。

*** OOM (Out of Memory) Killerの恐怖
OSがスワップ領域も含む深刻なメモリ不足に陥った時、OOM Killerが発動し、適当なプロセスを縦横無尽にkillし始めます。killされるターゲットとなるプロセスはヒューリスティックに選択されるため、もし24時間稼働させたいサーバープロセスが選択されると非常に危険です。

インメモリ検索エンジンの場合、システムのメモリを出来るだけ確保して使いたいのですが、あまり多く確保しすぎると、logrotate/updatedb等他のプロセスが動いてメモリを確保した際にOOM Killerによって殺される可能性があります。午前4時頃にたまにサーバーが落ちてしまうケースが有ったので調べてみると、これが原因でした。

以上のような問題が発生しないように、アプリケーション側でプロセスのメモリ使用量をある程度制限する必要があります。

** 制御できないメモリ使用量
そんなこんなで非常に重要なメモリ使用量なのですが、使用をコントロールできない部分もあります。それがメモリフラグメンテーション、ページキャッシュの問題です。

*** メモリフラグメンテーションとは
メモリフラグメンテーションは、ハードディスクで発生するフラグメンテーションと似ています。使用しているメモリ領域が飛び飛びになってしまい、連続した未使用領域が確保出来なくなる問題です。このような状況になると、アロケーターはOSにさらに余分なメモリ領域を要求し、アプリケーションが使用しているメモリ容量に比べて余分な物理メモリが消費されてしまいます。

この問題は非常に恐ろしく、Sedueでは1〜2ヵ月程度運用していると、じわじわとメモリが増加していく現象が有りました。その当時はメモリリークだと思って必死にvalgrindをかけてチェックしたり、malloc()している箇所を目でチェックしたりしたのですが、free()忘れが全く見つからず、ノイローゼ気味になってしまった事もありました。メモリフラグメンテーションかな、とある時ふと思いついてアロケーションを片っ端から削ったところ、少なくとも数か月のスパンではこの現象は起こらなくなりました。

メモリフラグメンテーションは、Java等VM上で動く言語ではGC時にCompactionを行う(メモリ使用領域を詰める)事で回避できますが、C/C++などメモリ管理を明示的に行う言語では不可避です。ただしプロセスを再起動するという方法で回避できるので、この方法が取れる場合は定期的にプロセスの再起動を楽だと思います。

またアロケーション回数を少なくし、出来るだけ同じサイズのアロケーションを行うことで影響を少なくすることができます。つまり、確保したメモリを出来るだけ使いまわすコードを書くのが望ましいです。

STLを使う場合、暗黙の内にメモリアロケーションが行われる事が多いので注意が必要です。特に注意が必要なのが、map/set/multi_map/multi_mapです。これらはすべて赤黒木(Red-Black Tree)というツリー型のデータ構造を使用しており、大量の要素を入れると、ツリーのノードを確保するために大量のアロケーションが発生します。

この問題をさほど意識していなかった時は平気で1000万個程度の要素をmapに入れたりしていたのですが、今では怖くてそんなことは出来なくなりました。メモリ効率の点でも悪く、入れたデータの約5倍〜10倍のメモリを使用してしまいます。これにより突発的にメモリ使用量が著しく増えてしまう現象が有りました。

対策としては、

- std::map
-- lower_boundなど範囲クエリーが必要なければhash_map(unordered_map)を使用する
-- Berkeley DB, TokyoCabinet等のディスク上のkey-value dbを使用する
- std::set
-- vectorで代用し、包含判定はソート+2分探索(binary_search関数)を用いる

などの工夫が考えられます。

*** ページキャッシュに取られるメモリ
アプリケーション自身が明示的にmalloc()を呼ぶ以外にも、OS内部でメモリが消費される事があります。ページキャッシュはその典型例と言えます。アプリケーションがディスクに書き込みを行うと、通常はディスクに書かれる前に一時的にメモリにキャッシュされ、アプリケーションに制御が戻ります。その後、バックグラウンドでこのメモリ領域がディスクに書き込まれていきます。読み込み時にはディスクから読み込んだ内容がメモリにキャッシュされます。このキャッシュをページキャッシュと呼びます。

ページキャッシュによってread/writeの効率が劇的に上がるので、Linuxは空いているメモリがあればかなりアグレッシブにページキャッシュを確保します。通常のアプリケーションで有ればこの仕組みの恩恵が十分に有ります。

しかし、大規模にメモリを確保するアプリケーションでI/Oを行うと、ページキャッシュにメモリを取られ、malloc()で確保したメモリがスワップアウトしてしまい、アプリケーションの速度低下につながってしまう事が有りました。

これを防ぐために、I/Oを行う際にはページキャッシュの機能を抑制する必要があります。Linuxの場合は、open(2)時にO_DIRECTフラグを付けることでページキャッシュの機能を抑制する事が出来ます。また、posix_fadvise(3)でPOSIX_FADV_DONTNEEDフラグを指定し、カーネルにI/Oのヒントを与えてあげることでも、ページキャッシュの影響を抑制する事が出来るようです。

** メモリ使用量の解析ツール
以上、メモリ使用量についてのハマり所や対策を説明しましたが、これらの動作はすべてgoogle-perftools(http://code.google.com/p/google-perftools/)を使用して解析を行いました。メモリを使用している場所を可視化したり、ある時点とある時点を比較してどの部分でメモリ確保が起こったかを比較する事も出来るので、非常に便利です。

google-perftoolsには、CPUプロファイラー・ヒーププロファイラー(メモリ使用量チェック)・ヒープチェッカー(リークチェッカー)が付属しています。今回はこの内、ヒーププロファイラーとCPUプロファイラーの使い方を解説します。

ヒーププロファイラーを使用するには、google-perftools付属のアロケーターtcmallocをプログラムにリンクする必要があります。もしくは、LD_PRELOAD環境変数にtcmallocを指定します(こちらの方法はあまりお勧めされておらず、SEGVるプログラムも有ります)。

// 方法(1)
$ gcc test.c -ltcmalloc
// 方法(2)
$ export LD_PRELOAD=/usr/local/lib/libtcmalloc.so 

次に、HEAPPROFILE環境変数にプロファイル結果を出力したいパス、HEAP_PROFILE_ALLOCATION_INTERVAL環境変数にプロファイルを出力するアロケーションバイト数を指定し、通常通りプログラムを実行します。以下の例では1Mバイトのアロケーションが起こる度に、/tmp/test.hprof.0001.prof, /tmp/test.hprof.0002.profというように、プロファイル結果が出力されていきます。

$ env HEAPPROFILE=/tmp/test.hprof HEAP_PROFILE_ALLOCATION_INTERVAL=1000000 sort < input.txt > output.txt

プロファイル結果を見るためには、pprofというコマンドを使用します。pprofでは解析結果をテキスト,PostScript,PDF,GIF等の形式で出力する事ができます。

pficore% pprof --text /usr/bin/sort /tmp/test.hprof.0001.heap | head -n 3
    15.4  76.0%  76.0%     16.4  80.9% std::__adjust_heap
     3.9  19.0%  95.0%      3.9  19.0% std::fill_n
     1.0   4.9% 100.0%      1.0   4.9% GetHeapProfile
pficore% pprof --gif /usr/bin/sort /tmp/test.hprof.0001.heap > a.gif

GIFの出力結果は以下のようになります。どの関数でどのぐらいアロケートされたかが視認しやすく、チューニングの際に非常に役立ちます。

また--baseを使用すると、プロファイル間での比較を行う事ができます。これにより、ある時点からどの関数でメモリアロケーションが起こったかが分かるようになります。 pficore% pprof --base=/tmp/test.hprof.0001.heap --text /usr/bin/sort /tmp/test.hprof.0003.heap | head -n 3 0.0 -0.0% -0.0% -0.0 0.0% _dl_runtime_resolve 0.0 -0.0% -0.0% -1.0 5.2% RecordAlloc 0.0 -0.0% -0.0% 1.0 -5.2% __cxa_finalize 次にCPUプロファイラーの使い方です。こちらはlibprofilerをリンクするか、同じようにLD_PRELOADを指定します。 // 方法(1) $ gcc test.c -lprofiler // 方法(2) $ export LD_PRELOAD=/usr/local/lib/libprofiler.so そして実行時にCPUPROFILE環境変数を指定すると、ヒーププロファイラーと同じくpprofで表示できるファイルが出力されます。 $ env CPUPROFILE="/tmp/profile" sort < input.txt > output.txt CPUプロファイラーの出力結果は以下のようになります。こちらも時間がかかっている関数が可視化され、パフォーマンスチューニングに役立ちます。
以上、駆け足ですがgoogle-perftoolsの使用方法を解説しました。google-perftoolsを使うと他にもCPUのプロファイリング等もでき、同じようにpprofで出力できますので興味のある方は試してみてください。 * まとめ 以上、常駐型サーバープログラムをデバッグするための手法を色々と紹介させて頂きました。少しC++依りの話も有りましたが、基本的な考え方や方法は全てのプログラミング言語で通用するはずです。他にもマルチスレッドの問題解決方法なども有りますが、紙面の都合上、今回はここまでとさせて頂きます。

Back To