ドリコムを支えるデータ分析基盤

はじめに

これは ドリコムAdventCalendar の4日目です

3日目は、@arihh さんによる 3年くらいお菓子神社運営してきた です

自己紹介

@ka_nipan

ドリコムに新卒で入社し、Android開発、BtoBtoC のwebサービス開発を経て、現在は弊社アプリのログ収集から集計、可視化、その他周辺ツールといった分析基盤の面倒を見ています

本日はそのデータ基盤の話を書きます

データ分析基盤全体図

f:id:ka_nipan:20141128203507p:plain

弊社では Hadoop をオンプレで運用していて、そこにログや分析用のデータを置いています

メリット

運用コストが安い

Treasure Data、Big Query、Amazon Redshift 等の外部サービスを使うよりは安く済みます

自由度が高い

各サービスには容量をはじめ色々と制限があったり、こちらの要求仕様にマッチしない部分が少なからずありますが、自前の場合その辺は融通がききやすいです

分析する人が生データにアクセスしやすい

後述しますが、分析チームの人が R言語 で処理しやすいように、Hadoop にデータを集積しています

なので、基本的に HDFS には gzip 圧縮した tsv ファイル等を置いていて、SequenceFile は置いていません

分析用のサーバーに Fuse で mount しているので通常のファイルシステムのように扱えます

デメリット

メンテナンスが辛い

HDDに障害があったり、運用年数が経ってくると新しいものに入れ替えたり、リカバリ作業をする必要があります

また、HadoopやOSのバージョンアップをしようと思ったら、NameNode や DataNode 全台、その他すべて含めると結構な台数になってくるので精神がガリガリ削られていきます

HDFS全体の容量とかブロックの数とか気にしてあげないといけない点も結構あります

データ取得・保存

ログ送信

ログの送信は fluentd を使用しています

鉄板ですね

図では端折ってますが、web → log collector → Hadoop のように一旦ログを集約してから Hadoop に送信するようにしています

テスト環境のログ排除

テスト環境のログが Hadoop に送られてしまうと集計値がくるってしまうので、ホスト名に test, staging のような文字列が入っている場合 Hadoop に送信しないようにしています

ログ送信成否の確認

webサーバー追加したけど設定ミスでログ送れてませんでしたー!等を突き止めやすくするために、web → collector に送る時にホスト名を送るようにしています

事前処理

送られてきたログに対して、集計に必要な付加情報を加える処理をしています

ここでフォーマットのチェックも行っていて、エラーとなったログは集計対象から除外されます

集計は hive を使用しており、事前処理が終わったらロードされます

スナップショット取得

アプリのDBにSQLを投げて、その結果をファイルとしてHDFSに保存しています

DBのフェイルオーバー対策

DBにクエリを投げる際、SLAVE に投げるわけですが、DBがフェイルオーバーするとSLAVEのIPアドレスも変わってしまいます

そこで、SLAVE のIP情報を管理用のサーバーのDBに入れておき、アプリのDBがフェイルオーバーすると管理用DBにあるIPも書き換わるという仕組みが用意してあります

この仕組みにより常にSLAVEへとクエリを投げることが可能になっています

データの持ち方としてはこんな感じ

CREATE TABLE IF NOT EXISTS `slavedb` (
  `app_key`               varchar(64)    NOT NULL ,
  `host_name`             varchar(64)    NOT NULL ,
  `slave_ipaddress`       varchar(64)    NOT NULL ,
  `db_name`               varchar(64)    NOT NULL ,
  `updated_at`            timestamp      NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`app_key`,`host_name`,`db_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8

帯域への配慮

結構な数のクエリを投げたり、結果がでかいものがあったりで何も考えずに一気に流すとネットワークの帯域を食いつぶす可能性があった (実際やばかった) ので、

一度に流すクエリの量を抑える仕組みを作って安全に配慮してます

クエリの管理

アプリのDBへ投げるクエリは ActiveAdmin で管理しています

また、ここで管理されているクエリは管理画面から Explain を実行してその結果を見ることができるように手を加えています

クエリのシンタックスエラーのチェックもできて地味に便利

f:id:ka_nipan:20141203155910p:plain

バックアップ

ログ、スナップショットファイルは Hadoop に取り込まれると同時にバックアップサーバにコピーされます

集計

前述のとおり集計はみんな大好き hive を使っています

presto はテスト的に使ってみたりしてます

集計したデータは可視化のため MySQL

集計単位

  • hourly
  • dayily
  • monthly

ジョブ数

集計以外の全ジョブ含め約600個

可視化

有料BIツールを使っていますが、パフォーマンス、アカウント制限等々の理由で結局BIツール自作しました

f:id:ka_nipan:20141128203959p:plain

黒背景にするとなんかカッコよく見えますね!

分析チーム

弊社にはアプリの分析を行う分析専門のチームがいて、

ハイスペックな分析用サーバ数台に HadoopFuse マウントして自由に Hadoop 上のファイルを使えるような環境を用意しています

基本的に R で分析を行い、彼らの定期ジョブは分析サーバーで Jenkis で実行しています

少し前まで cron を使っていましたが、さすがに管理が辛くなってきました

スクレイピング

ログの集計値の正当性を担保するために、プラットフォームの管理画面で見ることができる数字(売上等)を取ってこなければいけません

(ちなみにあまりにも誤差があると、ログと管理画面からDLできる課金明細をつき合せていくことになります...)

しかし、プラットフォームによってサイトの作りがまちまちです

単純にスクレイピング用ライブラリではすべてを解決できませんでした (そのへんのAPIは提供されていない)

そこで色々もがいた結果、以下のような環境が出来上がりました

使用しているもの

f:id:ka_nipan:20141128205157p:plain

Grease monkey とは firefoxプラグインのひとつで、特定のURLに対して任意のコード (javascript) を実行できます

仕組み

  • Linux に作った GUI 環境から指定のURLに firefox でアクセスする
  • グリモンで目的のページまでたどり着いたら、そのページをテキストファイルとしてダウンロードします
  • そのテキストファイルから正規表現で必要な部分を抜いてDBに入れます

もはやスクレイピングでもなんでもないですね

最高に力技ですが、この方法でURLの末尾にランダムなハッシュがつくサイトも非同期処理をするサイトでもなんでも抜いてこれるようになりました!

サンプルコード (雰囲気だけ感じてもらえれば...)

// ==UserScript==
// @name           kani_scraper
// @namespace      imperial_cross
// @include        https://developer.hogee.jp/
// @include        https://developer.hogee.jp/*
// ==/UserScript==


(function()
{


if(location.href == "https://developer.hogee.jp/"){
    document.getElementsByName("login_id")[0].value = login_id;
    document.getElementsByName("password")[0].value = password;
    document.getElementsByName("form")[0].submit();
}else if(location.href == "https://developer.hogee.jp/home"){

// http://d.hatena.ne.jp/a_bicky/20110718/1311027391 参照
writeHtmlFile =  function () {
    function writeToLocal (filename, content){
    var ua = navigator.userAgent.toLowerCase();

    try {
        if (ua.indexOf('firefox') != -1) {  // Firefox
                //filename = (ua.indexOf('windows') != -1 ? 'C:\\tmp\\' : '/tmp/') + filename;
        if (ua.indexOf('windows') != -1){
            filename = "C:\\tmp\\" + filename;
        }
        else {
                    filename = path + filename;
        }

        unsafeWindow.netscape.security.PrivilegeManager.enablePrivilege('UniversalXPConnect');
        // ファイルコンポーネントの取得+ローカルファイル操作用のインターフェイスの取得;
        var file = Components.classes['@mozilla.org/file/local;1'].createInstance(Components.interfaces.nsILocalFile);
        file.initWithPath(filename);

        var fileStream = Components
            .classes['@mozilla.org/network/file-output-stream;1']
            .createInstance(Components.interfaces.nsIFileOutputStream);
        // ファイルが存在しない場合は664の権限で新規作成して書き込み権限で開く
        // cf. https://developer.mozilla.org/en/NsIFileOutputStream
        //     http://www.oxymoronical.com/experiments/apidocs/interface/nsIFileOutputStream;
        fileStream.init(file,
                0x02 | 0x08,  // 0x01: 読み取り専用, 0x02: 書き込み, 0x03: 読み書き, 0x08: 新規作成, 0x10: 追記
                0664,         // mode
                0             // 第4引数は現在サポートしていないとか
                   );

        // cf. http://www.oxymoronical.com/experiments/apidocs/interface/nsIConverterOutputStream
        var converterStream = Components
            .classes['@mozilla.org/intl/converter-output-stream;1']
            .createInstance(Components.interfaces.nsIConverterOutputStream);
        converterStream.init(fileStream, 'UTF-8', content.length,
                     Components.interfaces.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER);
        converterStream.writeString(content);

        converterStream.close();
        fileStream.close();
        //alert('書き込みが完了しました!');
        } else if (ua.indexOf('chrome') != -1) {  // Google Chrome
               // 起動オプションに --unlimited-quota-for-files --allow-file-access-from-files をつける必要あり
        function errorCallback(e) {
            alert('Error: ' + e.code);
        }

        function fsCallback(fs) {
            fs.root.getFile(filename, {create: true}, function(fileEntry) {
            fileEntry.createWriter(function(fileWriter) {
                fileWriter.onwriteend = function(e) {
                alert('書き込みが完了しました!');
                };

                fileWriter.onerror = function(e) {
                alert('Failed: ' + e);
                };

                var bb = new WebKitBlobBuilder();
                bb.append(content);
                var output = bb.getBlob('text/plain');
                fileWriter.write(output);
            }, errorCallback);
            }, errorCallback);
        }
        // 現時点ではたぶん第1引数はPERSISTENTもTEMPORARYディレクトリ名が異なるだけだし、
        // 第2引数は極端な話0でもOK
        webkitRequestFileSystem(PERSISTENT, 1024, fsCallback, errorCallback);
        } else if (ua.indexOf('msie')) {  // MS IE
        filename = 'C:\\tmp\\' + filename;
        // インターネットオプションで「スクリプトを実行しても安全だとマークされていない
        // ActiveX コントロールの初期化とスクリプトの実行(セキュリティで保護されていない)」
        // を有効にする必要あり
        var fso = new ActiveXObject('Scripting.FileSystemObject');

        // ファイルを新規作成して書き込みモードで開く (文字コードはUTF-16)
        // cf. http://msdn.microsoft.com/ja-jp/library/cc428044.aspx
        //     http://msdn.microsoft.com/ja-jp/library/cc428042.aspx
        var file = fso.OpenTextFile(filename,
                        2,     // 1: 読み取り専用, 2: 書き込み, 8: 追記
                        true,  // ファイルが存在しなければ新規作成するかどうか
                        -1     // -2: OSのデフォルト文字コード, -1: UTF-16, 0: ASCII
                       );
        file.Write(content);

        file.Close();
        alert('書き込みが完了しました!');

        /*
         * ADODB.Stream を使う場合(レジストリをいじっても何故か書き込めない・・・)
         */
        // var adodbStream = new ActiveXObject('ADODB.Stream');
        // adodbStream.type = 2;  // テキストファイル(バイナリは1)
        // adodbStream.charset = 'UTF-8';
        // adodbStream.open(filename);
        // adodbStream.writeText(content);
        // adodbStream.saveToFile(filename, 2);  // 上書き保存(1だと新規作成のみが対象)
        // adodbStream.close();
        } else {
        alert('エラー: ローカルファイルへの書き込み方がわかりません・・・');
        }
    } catch (e) {
        alert('Error: ' + e);
    }
   }    


    writeToLocal('test1.txt', "hogehoge");


}
}
setTimeout( writeHtmlFile, 20000);//20秒後にページを閉じる
})();

闇っぽい雰囲気だけ感じてください

Hadoop まわりの運用

Hadoop、hive での運用で気を使っている点や以前困っていた点を紹介します

細かいファイルは集約する

HDFS のデフォルトブロックサイズは 64MB と大きく、小さいファイルをたくさん置くのはあまり効率が良くありません

サイズの小さいものは1日単位などに集約すると結構ブロック数を減らせます

簡単に再集計できる仕組み

再集計をかけるタイミングが必ず存在します。しかも意外に高い頻度で

ジョブスケジューラで簡単にできればそれがベストですが、

なんらかの事情やそんな機能がなかった場合は、再集計するコードを書いておくと幸せになります

hive の json カラムには気をつけろ

hive には json 型があり、スキーマレスっぽい感覚で使えます

しかし使えるからと言って何でもつっこんで、1行なのに画面いっぱいの json で埋まるみたいなことをされるとさすがに hive さんも落ちます気を付けてください

まとめ

  • fluentd 最高
  • Hadoop の自前運用には割と手間暇かかる
  • 分析チームの人がデータをRで好きにいじれる環境を作った
  • プラットフォーム管理画面のスクレイピングは業が深い

次は id:GUSSAN さんです。

-- 追記 --

HDFS 上のファイル集約のくだりですが、ご指摘を頂いたのでほそくすると、

容量を節約したいからではなく、NameNode のメモリが節約できるということです

あと、mapタスクの数を減り mapの起動コストも減ることになります (こっちは失念してました!)