fluentd + mongodb+ node.js でリアルタイムにグラフを描く

追記 2/22

毎回微妙に追記していますが、今回も追記です。最後にmongodbのinsert性能について80lines/secで厳しくなった、と書いてますが、環境か設定まわりがあやしいので訂正します。もうすこし検証してみようと思います。 → 検証して fluentd側の設定の問題であることが分かりました。詳しくは、http://blog.stanaka.org/entry/2013/02/22/171053

追記ここまで

最近は、fluentd + mongodb でログを蓄積していろいろ便利に使っているわけですが、数分に一回集計スクリプトを周したり、 GrowthForecast の画面をリロードしまくるのではなく、もっとリアルタイムで見たい! という欲求が募ってきたので、 node.js を使って実装してみました。( https://github.com/stanaka/realtime-log-visualization )

mongodb の Tailable cursor

fluentd からのログを(ほぼ)リアルタイムにブラウザ上に出すという実装は、これまでに mongodb + node.js を使って、 polling して最新のデータの取得を取得したり、 mongo-tail で一旦ファイルに書き出したり、という手法が実装されてきたようです。が、それらの実装では効率が良いとは言えず、またもっとリアルタイムにしたいので、より直接的に繋げたいですよね。

ここで mongodb のドキュメントを良く見てみると Capped collection に対して、 Tailable cursor という仕組みで、その collection への追加レコードを push で受け取ることができる機能があったので、それを利用してみます。そもそもこの Tailable cursor は、Unixコマンドの tail -f にインスパイアされたものとのことで、まさに期待する機能です。

今回は proxy のログを受け取って、ステータスコードごとのrequests/secをグラフ化するツールにしてみました。システム構成は、 となります。proxyからmongodbまでは、当然 proxy のログを LTSV形式 で出力して、そのまま mongodb に突っ込むのが基本ですね。

node.js から mongodb に接続するには mongoose を使います。 node.js からブラウザへデータを push するために WebSocketではなく、 SSE (Sever-sent event) を使ってみました。グラフの描画は、データをどんどん追加していけるグラフ描画ライブラリの Rickshaw を使ってみました。

コードを見る

コードは、 https://github.com/stanaka/realtime-log-visualization にありますが、主要部分を見ていきます。まず、SSEでデータのpushを受け取るエンドポイントは "/update" としています。tailable で最新レコードを追い掛けるためにも、まずはその時点での最新のレコードを取得します。

exports.update = function(req, res) {
  req.socket.setTimeout(Infinity);

  var stream;
  Log.findOne().sort({_id:-1}).slaveOk().exec(function(err, item){

そこから tailable を指定して、 stream を起こします。ここで、 gt で最新のレコードを指定しておかないと、頭から全レコードが push されて悲惨なことになるので注意しましょう。

    stream = Log.find().gt('_id', item._id).sort({'$natural': 1}).tailable().stream();

stream からデータを受け取ったら、 SSE でブラウザに push します。

    var messageCount = 0;

    stream.on('data', function(doc){
      messageCount++;
      res.write('id: ' + messageCount + '\n');
      var msg = JSON.stringify({
        status: doc.status,
        reqtime:  doc.reqtime,
        req:    doc.req
      });
      res.write("data: "+msg+"\n\n");
    });
  });  

あとは、SSE コネクションの確立処理と、切断時の対処を書いておきます。

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache',
    'Connection': 'keep-alive'
  });
  res.write('\n');
 
  req.on("close", function() {
    stream.destroy();
  });
});

クライアント側では、EventSource オブジェクトを作って、 SSE で push されるのを待ち受けます。データが push されたら、グラフに描画します。

script(src='http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js')
script(src='http://jquery-json.googlecode.com/files/jquery.json-2.2.min.js')
...
script(type='text/javascript')
  var source = new EventSource('/update');
 
  source.addEventListener('message', function(e) {
    var data = JSON.parse(e.data);

    if(s = (data.status.match(/^(\d)/))[0]){
      counter[s] = counter[s] == null ? 0 : counter[s] + 1;
    };
    var now = new Date().getTime() / 1000;
    if(now > last_render + tv){
      var data = {
        "2xx": counter[2],
        "3xx": counter[3],
        "4xx": counter[4],
        "5xx": counter[5]
      };
      counter = new Array();
      last_render = now;

      graph.series.addData(data);
      graph.render();

実行画面はこんな感じです。時間とともに、リアルタイムにグラフが描画されていきます。データは、テスト用データを生成する generate_data.js から mongodbに挿入させています。

もうちょっとちゃんと実装すれば、アプリケーションのデプロイ時や障害発生時など、状況の変化をできるだけ早く把握したい場合に使える技になりそうです。

mongoose の Tailable cursor は、 node.js の stream で実装されているので、 polling に比べて負荷も低く、シンプルで美しい実装ですね。この実装をベースに、 fluentd + mongodb からのリアルタイムビジュアライゼーションをいくつか試してみようと思います。

まとめ

  • fluentd + mongodb + node.js でリアルタイムでグラフを描画させてみました
  • mongodb の tailable を利用した stream による実装でシンプルで効率的です
  • これでサーバーでのLTSVでのログ出力から、手元のブラウザでのグラフ描画まで一気通貫ですね!

このシステムで proxy のログの一部を 800 lines/sec ぐらいの勢いで流したら、 mongodb のところで一瞬で詰 ってしまいました。SSD を搭載したサーバにしておいたのに! 仕方ないので、 fluent-plugin-sampling-filter で流量を10分の1ぐらいにして凌ぎました。やはり mongodb のところがボトルネックになりますね。 ← ここのmongodbのinsert性能上限のところは数字があやしいので、訂正します。詳しくはコメント覧の議論を参照ください。 → 検証して fluentd側の設定の問題であることが分かりました。詳しくは、こちらも参照ください http://blog.stanaka.org/entry/2013/02/22/171053

流量の多いところで使うには、ここのボトルネックの解消が絶対に必要なので、よりパフォーマンスの高い方法を探ろうと思います。

Emacsからオフラインで使えるリファレンスブラウザDashを呼び出す

追記 2/17

現在のmajor-modeに対応して、検索対象のdocsetが絞りこまれるようにしました。以下のようにカスタマイズすることもできます。

(add-to-list 'dash-at-point-mode-alist '(perl-mode . "perl"))

追記ここまで

OSXで開発している時に各言語やライブラリのリファレンスをオフラインで検索したい、とTweetしたところ、

id:naoyaさんにお勧めしてもらったDashを試してみました。各言語、ライブラリのリファレンスが簡単にダウンロードでき、それをオフラインで高速に検索できて、これは良いですね。

Dashのスクリーンショットはこんな感じです。

f:id:stanaka:20130218134501p:plain

Windows時代のころは、chmという複数のhtmlファイルを一つにまとめた形式があって、各種リファレンスをまとめるのに都合が良いものでした。ただ、OSXでは、良さげなクライアントが見つからず、あきらめて、毎回ググってましたが、相当ストレスでしたからね。それが改善されて、捗ります。

この調子で便利に使っていると、いつも使っているエディタ、Emacsと連携させたくなります。幸いDashでは、

open dash://{query}

とするとコマンドラインから検索できる、とのことです。というわけで、久し振りにEmacs Lispを書いてみました。dash-at-point.elというものです。カーソル下のワードをDashで検索できます。これで、コードを書き/読みながら、リファレンスを引きたくなった時にさくっと引けます。

ソースは https://github.com/stanaka/dash-at-point にあります。以下を".emacs"に書くと使えます。

(add-to-list 'load-path "/path/to/dash-at-point")
(autoload 'dash-at-point "dash-at-point"
          "Search the word at point with Dash." t nil)
(global-set-key "\C-cd" 'dash-at-point)

最近は、EmacsもPackage systemに乗るのが流行りのようなので、MELPAにでもpull requestでも送ってみるのがよいですかね。

ログをLTSVやJSONで保存した場合のサイズ比較

追記(2/17)

変換スクリプトを見せてほしい、という要望があったので、 https://gist.github.com/stanaka/4967403 に上げておきました。ltsvを読み込んでオプションで指定したフォーマット(デフォルト JSON)に変更します。

追記ここまで

LTSVの盛り上りも収束してきていますが、サイズに関する懸念があがっていたので、確認してみました。

手近にあったアクセスログ 186万件ほどを対象に、

  • ssv .. Combined Log Formatの拡張で、ラベルなし (レスポンス時間とか10個ぐらいのフィールドを拡張しています)
  • json .. ラベルあり
  • ltsv .. ラベルあり

の3パターンで試してみました。

まずは行数を確認しておきます。

% wc -l access_log.json
1861706 access_log.json

未圧縮だと、

access_log.ssv    818,729,505 (781M)
access_log.json 1,282,452,709 (1.2G)
access_log.ltsv 1,111,017,347 (1.1G)

でした。1G前後でそれなりのサイズのログですね。ssvとltsvの差は300M程度。jsonにするとさらに150Mぐらいでかくなってます。

最初にgz圧縮してみると、

access_log.ssv.gz  136,835,984 (131M)
access_log.json.gz 152,432,366 (146M)
access_log.ltsv.gz 146,072,000 (140M)

131M〜146Mでだいぶ差が縮まりました。1割程度の差を問題視するかどうかはポリシー次第ですが、最近では1割程度は誤差なんじゃないかと思います。

さらにbz2圧縮してみると、

access_log.ssv.bz2   93,621,212 (90M)
access_log.json.bz2 100,358,979 (96M)
access_log.ltsv.bz2  98,269,135 (94M)

と差は5%とさらに縮まりました。スペースを気にしつつ、bz2の圧縮時間に耐えられるなら、これもアリだと思います。

ついでなので、7z圧縮してみました。manの例にも載ってた推奨(?)のオプションでやってみました。

7z a -t7z -m0=lzma -mx=9 -mfb=64 -md=32m -ms=on access_log.ltsv.7z access_log.ltsv

結果は、

access_log.ssv.7z  71,518,534 (69M)
access_log.json.7z 79,827,214 (77M)
access_log.ltsv.7z 77,032,835 (74M)

という感じで、さらに小さくなりました。差はbzip2に比べると、むしろ広がってますね。

まとめ

伝統的なCombine Log Formatの拡張、JSON, LTSVで比べてみましたが、JSON, LTSVともに圧縮すれば1割程度の増加で済むので、JSONにせよLTSVにせよ、気にせずにラベルを付けて、使い勝手重視で行くのがよいんじゃないでしょうか。

Labeled Tab Separated Values (LTSV) ノススメ

追記(2/8 11:30)

追記ここまで

Labeled Tab Separated Values (LTSV) というのは、はてなで使っているログフォーマットのことで、広く使われているTSV(Tab Separated Value)フォーマットにラベルを付けて扱い易くしたものです。はてなでは、もう3年以上、このフォーマットでログを残していて、one-linerからfluentd、Apache Hiveまで幅広く便利に使えています。

ログフォーマットに期待されることは、

  • フォーマットが統一されている → 共通のツールで集計し易い
  • 新しいフィールドの追加が容易 → サービス固有のデータをログに残したいことはよくある。かつ、複数サービスに渡って、別々のエンジニアが触っても混乱しない
  • one linerで操作しやすい → grepした後でsortなり、uniqなりで集計するのはよくあること

の3点だと考えています。

これまでにも、ログフォーマットを扱い易くする取り組みとして、SyslogでもRFC5425で構造化の仕様が入ったり*1していますが、いまいち普及しているとは言えません。Apache標準のCombined Log Formatを独自拡張して頑張る、というのがよくある光景なんじゃないかと思います。ただ、サービスごとの要求に応じて、どんどん独自拡張していると個別にParserの定義を書かないといけなくなったり、fluentdで扱う際にも頑張ってサービスごとの定義を正規表現で書かないといけない羽目になって、どんどん辛くなります。

Labeled Tab Separated Values (LTSV) とは

そこではてなでは、3年ほど前*2からLabeled Tab Separated Values (LTSV) によるログフォーマットを全面的に採用しています。

Labeled Tab Separated Valueは、

time:[28/Feb/2013:12:00:00 +0900]	host:192.168.0.1	req:GET /list HTTP/1.1	status:200	size:5316	referer:- ua:Mozilla/5.0	taken:9789	isrobot:1	dos:-	harddos:-	cache:-

というもので、各フィールドがタブで区切られ、フィールドの頭にラベルを付けています。

ApacheのLogFormat定義は

LogFormat "host:%h\ttime:%t\treq:%r\tstatus:%>s\tsize:%b\treferer:%{Referer}i\tua:%{User-Agent}i\ttaken:%D\tisbot:%{Isbot}e\tdos:%{SuspectDoS}e\tharddos:%{SuspectHardDoS}ecache:%{X-Cache}o" ltsv

となり、nginxでは

log_format tsv "time:$time_local\thost:$remote_addr\treq:$request\tstatus:$status\tsize:$body_bytes_sent\treferer:$http_referer\tua:$http_user_agent\treqtime:$request_time\tcache:$upstream_http_x_cache";

となります。

このLabeled Tab Separeted Valueフォーマットのメリットは、まずログを定義する時に各フィールドの順序を意識しなくてよく、各プロダクトで容易に独自拡張できる、ということにあります。上の例では、X-Cacheヘッダやmod_dosdetectorの判定結果を残したりしています。はてなでの運用では、ラベルをWikiで管理しており、新しいラベルを作る際には記録してもらうようにしています。

Labeled Tab Separated Valueを扱う

LTSVはTSVの拡張ですので、cutコマンドで簡単に切り分けられます。例えば、ステータスコードごとに数えるとすると以下のようになります。

% tail -n 100 /var/log/httpd/access_log.ltsv | cut -f 5 | sort | uniq -c
     97 status:200
      2 status:301
      1 status:404

ただ、これだけだと、ラベルが出て、ちょっと分かり易いぐらいですかね。

では、次にfluentd pluginを見てみます。コードは、https://github.com/stanaka/fluent-plugin-tail-labeled-tsvにあります(近々Gem化します)。fluentdでの定義は、

<source>
  type tail_labeled_tsv
  path /var/log/nginx/access_log.ltsv
  tag access_log
  pos_file /tmp/fluent.log.pos
</source>

のようになります。in_tail pluginのように非標準のフォーマットでも頑張って定義を正規表現で書く必要がありません。Webサーバー側での定義を変更してもfluentd側の設定を触らずにすむのも便利です。

さらにApache HadoopのHive用のSerDe(シリアライザ・デシリアライザ)も用意しています。コードは、 https://github.com/hatena/KeyValuePairsDeserializer にあります。

これにより、Amazon EMR上のHiveで簡単にテーブルを定義することができます。

ADD JAR s3://sample/_lib/hadoop/hive/serde/hatena-serde.jar;

CREATE EXTERNAL TABLE logs (
  time string, host string,
  req string, status string, size string,
  referer string, ua string
) 
PARTITIONED BY (dt string)
ROW FORMAT SERDE 'jp.ne.hatena.hadoop.hive.serde.KeyValuePairsDeserializer';

ALTER TABLE sample ADD PARTITION (dt='2013-01-01') LOCATION 's3://sample/sample_log.ltsv.gz';

その後、Hiveにより、MapReduceでログ解析を行うことができます。例えば、

select count(distinct referer) from logs

のようなHiveQLを流すことで、1年分のログでも、がりっと処理することができます。

まとめ

はてなでは、Labeled Tab Separated Valueによるログフォーマットで統一していて、one-linerからfluentd、Amazon EMR上のHiveまで使えて便利!という話でした。

*1:ところで、Parser書いている人もいますね。 http://blog.kentarok.org/entry/20101217/1292606627

*2:確認すると2009年10月頃からでした