はじめに
これは ドリコムAdventCalendar の4日目です
3日目は、@arihh さんによる 3年くらいお菓子神社運営してきた です
自己紹介
ドリコムに新卒で入社し、Android開発、BtoBtoC のwebサービス開発を経て、現在は弊社アプリのログ収集から集計、可視化、その他周辺ツールといった分析基盤の面倒を見ています
本日はそのデータ基盤の話を書きます
データ分析基盤全体図
弊社では 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 を実行してその結果を見ることができるように手を加えています
クエリのシンタックスエラーのチェックもできて地味に便利
バックアップ
ログ、スナップショットファイルは Hadoop に取り込まれると同時にバックアップサーバにコピーされます
集計
前述のとおり集計はみんな大好き hive を使っています
presto はテスト的に使ってみたりしてます
集計したデータは可視化のため MySQL へ
集計単位
- hourly
- dayily
- monthly
ジョブ数
集計以外の全ジョブ含め約600個
可視化
有料BIツールを使っていますが、パフォーマンス、アカウント制限等々の理由で結局BIツール自作しました
黒背景にするとなんかカッコよく見えますね!
分析チーム
弊社にはアプリの分析を行う分析専門のチームがいて、
ハイスペックな分析用サーバ数台に Hadoop を Fuse マウントして自由に Hadoop 上のファイルを使えるような環境を用意しています
基本的に R で分析を行い、彼らの定期ジョブは分析サーバーで Jenkis で実行しています
少し前まで cron を使っていましたが、さすがに管理が辛くなってきました
闇スクレイピング
ログの集計値の正当性を担保するために、プラットフォームの管理画面で見ることができる数字(売上等)を取ってこなければいけません
(ちなみにあまりにも誤差があると、ログと管理画面からDLできる課金明細をつき合せていくことになります...)
しかし、プラットフォームによってサイトの作りがまちまちです
単純にスクレイピング用ライブラリではすべてを解決できませんでした (そのへんのAPIは提供されていない)
そこで色々もがいた結果、以下のような環境が出来上がりました
使用しているもの
- Xwindow
- firefox
- Grease moneky
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 さんも落ちます気を付けてください
まとめ
次は id:GUSSAN さんです。