社内向けツールのコードネームはもっぱら RPG の登場人物の名前をモチーフにしている沢田です。しれっと Rixia (リーシャ) とか Dubarry (デュバリィ) などと名付けられたツールを一部の社内サーバーに放流しているのですが、今のところ誰にも理解されていないっぽい今日この頃です。ちなみに得意技は半キャラずらしです (古い)。
先日 TokyuRubyKaigi というイベントに行ってきました。いや、「行ってきました」というか主催者の一人なんですがw ご存知でない方向けに補足しておくと、TokyuRubyKaigi というのは 余興でライトニングトークもやっている呑み会 美味しい食べ物&飲み物を頂きつつ Ruby にかかわるライトニングトーク (以下 LT と略します) を聞くイベントです。このイベントで発表された中でもひときわ異彩を放っていたのが、「Rubyと戯れるOSS DTM」と題したこちらのトーク。趣旨としては Ruby を使って音源を鳴らしてみようというものなのですが、面白かったのが、そのままでは単音しか鳴らせないツールを並行かつ順序立てて実行していくために使ったツールが、ワークフローエンジンの rukawa であるという点です。その発想はなかった。
というようなわけで、良い機会なのでワークフローエンジン的なモノについて紹介しようかと思います。なぜ TokyuRubyKaigi で聞いた発表をクラウドワークスのブログで取り上げるのかと言うと、それは弊社のデータ分析の仕組の根幹となる部分がこの「ワークフローエンジン的なモノ」で作られているからです。
rukawa
まずは、前述の発表でも使われていた rukawa を紹介します。こちらは Ruby 方面では著名な joker1007 さん (以下、「joker さん」と表記します) であることもあって、ご存知の方も多いのではないでしょうか。一言でまとめると「複数のタスクを依存関係に沿って順に実行してくれるツール」です。・・・という説明では、馴染みのない方には分かりづらいですね。もう少し詳しく解説してみます。ご存知の方には釈迦に説法かと思いますが、しばしお付き合いください。
たとえば、「前月の総売上高を関係者にレポートする」というジョブを考えてみましょう。このジョブは、
- 前月の総売上高を計算する
- その数値をレポートにまとめる
- レポートを関係者へメールで送る
といったジョブ群に分割できるでしょう。 これらのジョブ群には順序があります。メールを送るためにはレポートが完成している必要があります。レポートを作るには総売上高の計算が完了している必要があります。つまり、
総売上高を計算する→レポートを作る→メールを送る
という順序で片付ける必要があります。 少し言い回しを変えると、
- メール送信はレポート作成に依存する
- レポート作成は総売上高の計算に依存する
と言えます。
rukawa で実現しているのは、この「複数のジョブ同士の依存関係」を定義して、その定義によって決まった順序に沿って実行していく、という動作です。依存関係は上の例のように一本の線で表せるようなシンプルなものもありますが、一般的には、一連のジョブ同士の依存関係はグラフ状のネットワークを構成すると言えます。このようなジョブ同士の依存関係と実行順序をまとめたものを「ジョブネット」と呼んでいます。
冒頭で紹介した LT が面白かったのは、どちらかと言うと業務システムの印象が強いワークフローエンジンを活用して、「業務」というイメージは薄い「楽曲の演奏」を実現したところでした。なかなか出てこない発想ですよね。実際、イベントに居合わせた参加者の方々にも興味深かったようで、投票で選出される最優秀賞である「LT王」に輝いていました。
さて、このワークフローエンジン的なモノですが、使いどころはどの辺りでしょうか。一つの具体例として、クラウドワークスでの利用シーンを紹介します。ここまでさんざん紹介しておきながら rukawa ではないのですがw 同様のコンセプトを持った bricolage という仕組みを活用しています。
bricolage
bricolage は AWS が提供する Redshift での SQL の実行に特化したワークフローエンジンです。ある集計を実行するためには、別の集計が先に完了している必要がある。そんな依存関係があるクエリを順番に実行していくための仕組みです。クラウドワークスでは、この仕組みを用いてサービスのデータベースに蓄積されたデータを集計し、KPI となるような指標を算出しています。
弊社の場合だと、たとえば「総売上高」的なものは複数の源泉からもたらされています。データベース的に言うと、複数のテーブルにまたがってデータが存在していると思ってください。これらのデータを集計して最終的に一つの「総売上高」という数値にまとめ上げるには、どうするのが良いでしょうか。頑張れば単一のクエリで片付けることもできなくはないかもしれませんが、そのようなクエリは巨大でかつ複雑で、保守コストが増大しそうな予感がします。また、計算途中で失敗してしまったら再計算するには全ての集計を一からやり直す破目になってしまいます。たとえば30分ほどかかった集計の最後の最後でコケてしまったりしたら甚大な精神的ダメージを被るであろうことは想像に難くないですね。
それよりは、それぞれのテーブルごとに集計データを作り、さらにそれらの集計済みデータを集計することによってゴールの数値を算出する戦略の方が上手く行きそうに思えます。bricolage はそのような戦略でクエリ群を実行するためのツールです。
bricolage の利用サンプル
具体例があった方が分かりやすいかと思い、以下の場所にごく簡単なサンプルを用意してみました。
https://github.com/cesare/bricolage-examples
ジョブとジョブネットの定義は examples ディレクトリの下にあります。
https://github.com/cesare/bricolage-examples/tree/master/examples
配下にあるファイルの内訳は、
- monthly_sales.* で売上を計算し、
- user_registrations.* でユーザー登録数を計算し、
- report.* で売上とユーザー登録数を同じテーブルにまとめる
という感じになっています。これらはそれぞれジョブの定義、そして3種類あるジョブ同士の依存関係を記述するのが all.jobnet です。
ジョブ定義
まずはジョブの方の中身を見てみましょう。代表で monthly_sales.* で説明します。残りの2つについても、中身はだいたい同じです。 一見して、共通の名前で拡張子だけが異なる3つのファイルが並んでいることにお気づきかと思います。
.ct は、集計結果を格納するためのテーブルを定義しています。
CREATE TABLE ${dest_table} ( month date not null, amount decimal not null ) ;
中身はほぼ create table 文ですね。 ${dest_table} とある箇所は、変数になっています。
.sql は、集計クエリを記述してあります。
INSERT INTO ${dest_table} SELECT date_trunc('month', created_at)::date AS month, sum(amount) AS amount FROM purchases GROUP BY month ;
select した結果を格納用のテーブルに insert するだけの簡単なお仕事です。
.job は、このジョブの詳細を定義しています。
class: rebuild-drop sql-file: monthly_sales.sql table-def: monthly_sales.ct dest-table: examples.monthly_sales
まず、class: rebuild-drop というのは、「ジョブを実行するたびに、格納用テーブルを一度 drop してしまって、改めて create table して、しかる後に集計クエリを実行する」という作戦をとることを指定しています。 sql-file と table-def はそれぞれ .sql と .ct ファイルの名前、dest-table が格納先のテーブル名です。.ct や .sql に出てきた ${dest_table} は、ここで指定された値に置き換えられます。
ここで、これらの定義によってどのような処理が走るかを見てみましょう。以下のようにすると、処理の内容を標準出力に出すだけで実際には何もしない、いわゆる dry run ができます。
$ bundle exec bricolage --dry-run --job examples/monthly_sales.job \timing on \set ON_ERROR_STOP false drop table examples.monthly_sales cascade; \set ON_ERROR_STOP true -- examples/monthly_sales.ct CREATE TABLE examples.monthly_sales ( month date not null, amount decimal not null ) ; -- examples/monthly_sales.sql INSERT INTO examples.monthly_sales SELECT date_trunc('month', created_at)::date AS month, sum(amount) AS amount FROM purchases GROUP BY month ; analyze examples.monthly_sales;
このようにして、意図通りのクエリが発行されているかを確認できる点も bricolage の嬉しいところです。 実行直前に dry run してみて間違いに気づいて救われたことは数知れず。
ちなみに dry run であればデータベースなしでも実行できるので、興味のある方はお手元に clone してきて試してみてください。
(ruby がインストールされている必要があります)
ジョブネット定義
さて、次にジョブネットの方を見てみましょう。こちらは .jobnet という拡張子を持つファイルが一つあるだけです。 中身も非常にシンプルで、
monthly_sales -> report user_registrations -> report
これだけです。記述されている意味も明白ですね。
- report ジョブより前に monthly_sales ジョブを実行する
- report ジョブより前に user_registrations ジョブを実行する
monthly_sales と user_registrations の間には特に何の指定もないので、この2つの順序はどちらでも構わないということが読み取れます。
ジョブネットの方もどのような順序でジョブが実行されるかを確認することができます。
$ bundle exec bricolage-jobnet -l examples/all.jobnet examples/monthly_sales examples/user_registrations examples/report
確かに report ジョブが最後に実行されるようになっています。
ジョブネットで実行するメリット
このような構成にするメリットは何でしょうか。一つには、集計全体をいくつかのクエリに分けることで、一つ一つのクエリが理解・把握しやすいサイズになる点が挙げられます。また、クエリを小分けにしてそれぞれの実行結果がテーブルに保存されるようにしているため、一ステップずつ順を追って検算・デバッグしていくことができます。集計が途中でコケてしまった場合や、出てきた値がおかしいと思われる場合も、どこまでは大丈夫だったのかを調べやすくなりますね。
さらに、この「小分けにする」単位を上手く工夫すれば、あるクエリの実行結果が、後続する複数のクエリで再利用できる効果も期待できます。この恩恵として、似たようなクエリを何度も実行することが避けられるようになります。ある意味で DRY (Don't Repeat Yourself) になると言えるのではないでしょうか。また、こういうった集計途中のテーブルは、定形の集計処理ではないアドホックな集計にも役立つ可能性が出てきます。実際、クラウドワークスではそのような集計途中のテーブルを社内に提供して、複雑なクエリを書かなくてもデータ分析がしやすくなることを目指した施策を進めています。
クラウドワークスでの利用状況と、過去の経緯
ご参考までにクラウドワークスでの現状の bricolage 利用状況を当たり障りのない範囲で紹介すると、
- ジョブの数: 212
- 集計クエリのトータルの行数: 20,542
でした。これが全部単一のクエリだったらと想像すると背筋が寒くなりますね。
bricolage 導入前はどうしていたかと言うと、実は ActiveRecord & Arel を駆使した集計バッチで頑張っていました。scope や Arel の DSL を上手く組み合わせて使えば DRY なクエリが書けて仕事が捗るはず・・・と思いきや、実際には細切れになったクエリが様々なファイルに散らばっていて読みづらくなったり、集計してきたデータをさらに Ruby プログラム上で計算するようになってカオス化が進行したり、実行時間の長時間化に対抗すべくマルチスレッドで集計するようにしてみたりして、運用がかなり辛くなっていました。集計で ActiveRecord 使うのは止めた方が良いです。
bricolage を導入してからは、クエリの可読性や保守・運用の辛みはかなり軽減されました。また、以前は数時間もかかっていた集計が平均10分程度まで短縮できたのですが、こちらは bricolage というよりは集計に使うデータベースを RDS (MySQL) から Redshift に乗り換えたことの方が大きいのではないかと見ています。
Redshift 使ってないんだけど・・・
bricolage は Redshift を前提にしたツールではあるのですが、上で紹介したような「テーブルにクエリを投げて集計を行う」タイプのジョブについては PostgreSQL でも同様に使えるようです。Redshift は使ってないけど PostgreSQL にデータを持っていて、データ集計・分析を行いたいとお考えなら、一度検討してみても良いかもしれません。
bricolage 導入の功労者
さて、ここで最後に来て大事な情報を暴露しておくと、弊社クラウドワークスに bricolage を導入したのは、実は joker さんの功績です。 去年に仕事を手伝ってもらっていたときに、上記の辛くなっていた ActiveRecord な集計をやめて別な仕組にリプレースしたいとお願いしたところ、いろいろ検討してもらった結果 bricolage が採用された次第でした。
そんな joker さんが作った rukawa というツールは、bricolage を一般化して集計以外の用途にも広く使えるようにしたものと言えるでしょう。まさか音楽を鳴らす人が現れるとは予想しませんでしたがw さまざまな仕事を順に片付けていきたい要件がある時には重宝しそうです。
以上、rukawa, bricolage というツールと、クラウドワークスでのデータ集計での使われ方を紹介しました。 ご参考までに、それぞれのツールについて、作者の方が自ら紹介しているエントリーがありますので、合わせてご覧ください。
宣伝
クラウドソーシングのクラウドワークスでは、「がんばらないためにがんばるエンジニア」を募集しています。怠惰で短気で傲慢な美徳を持ち、半キャラずらしができる方を歓迎します。