2012年12月26日水曜日

DataPipelineってなんじゃ?


Data PipelineがUSリージョンで使えるようになっていました。
Data Pipelineはデータの移動と加工を定期的なスケジュールで処理できるためのワークフローを定義できるサービスだそうです。

前の記事では、ApacheのログをS3に流してEMRでマージと集計を行いました。
そのときはEMRのコマンドラインで対象日時をコマンド引数に渡す形にするところまでで終わりました。
これを日次で行うとすると、そのコマンド呼出をどこかの端末のcronに設定するような形になると思います。

Data Pipelineを使うと、そういったcronなどの実行コントロールをEC2やローカル端末などのOS内部ではなく、AWSのサービス上に持つことができます。





それでは前回行ったことをData Pipelineで同じようにやってみます。


設定


AWSコンソールでリージョンを「N.Virginia」にします。
ServicesのDeployment&ManagementにData Pipelineがあるので、クリックします。




Data Pipelineの画面が開き、Create Pipelineというボタンがあるのでクリックします。
するとData Pipelineの初期入力画面が表示されます。
ここではPipelineの名前と、スケジューリングのタイプを選択します。




Select Schedule Typeでは、Time SeriesととCronという2つのスタイルが選べます。
TimeSeriesは設定したスケジュールの終わりに処理が実行され、cronは設定したスケジュールの最初に実行されるようです。

ここではcronスタイルを選択します。
また、DataPipeline自体や、DataPipelineの処理をうけもつEC2などにIAMを紐付けたい場合はRoleで設定します。

「Create New Pipeline」をクリックすると、新しいPipelineが作成され、Pipelineコンソールが開きます。




Pipelineの一覧画面が表示されいて、いま作ったPipelineが一つ登録されています。
細かい項目を設定するため、「View Pipeline」をクリックします。

するとPipelineの設定画面が表示されます。
基本的には左ペインで要素を追加して、右ペインで詳細を設定していき、処理をつなげていく流れです。
基本的には、ヘッダの「Templates」ボタンを押すと、いくつかの典型的なプリセットが用意されているので、それを元に設定していけばいいのですが、今回は1からセットしてみます。




要素には主に以下のような種類があります。

Activity:処理を表す要素です。データコピー、EMR、HIVE、シェルコマンドが選べます。
DataNodes:処理の入力と出力をあらわすデータの要素です。DynamoDB、S3、MySQLが選べます。
Schedule:実行タイミングの要素です。実行間隔と処理期間を設定します。
Resources:処理を実行する環境要素です。EMRとEC2が選べます。
Precondition:処理のを行うかどうかの前提条件の要素です。DynamoDBやS3の存在チェックやシェル、SQLなどで自前のチェックなどが選べます。
Others:上記以外の設定項目を適宜設定できます。上記の要素の設定項目が依存している設定が半自動的に追加されます。


それではまず最初の要素を追加していきます。


DataNodes


ヘッダからactivityかdata nodeを追加できるようになっています。
ここでは、ログの入力元と出力先のdata nodeを追加するので、「Add data node」を3回クリックします。
すると3つのdata nodeが配置され、右側のペインにそれぞれの項目が現れます。




このうちの最初の1つは前回fluentdがログを流し込む入力元のバケットになります。

Nameにlogと入力し、TypeでS3DataNodeを選択します。
Scheduleに「Create new: Schedule」があるので、それを選択します。
するとSchedulesに新規にSchedule要素が追加されます。
また、Directory Pathにfluentdが流しこむ先のS3フォルダパスを記載します。
パスはYYYY-MM-ddで保存されるので、動的になりますが、Data Pipelineでは#{expressions}の形式で変数を割り当てられるようにExpressionという仕組みがあるので、それを利用して、
s3://memorycraft-us-log/logs/#{@scheduledStartTime.format('YYYY-MM-dd')}/ 
と設定します。
#{@scheduledStartTime.format('YYYY-MM-dd')} の部分がExpressionにあたり、処理予定開始日時をYYYY-MM-ddの形にフォーマットした文字列が代入されます。

また、Data Formatを指定します。create new: Data Formatを選択すると、Others用紙に新規データフォーマットの項目が作成されます。

これで1つdata nodeの設定ができました。現在以下のようになっています。

Name:log
Type:S3DataNode
Schedule:DefaultSchedule1
DirectoryPath:s3://memorycraft-us-log/logs/#{@scheduledStartTime.format('YYYY-MM-dd')}/
DataFormat:DefaultDataFormat1


同じように、2つの出力先も以下のように設定します。

Name:log-archive
Type:S3DataNode
Schedule:DefaultSchedule1
DirectoryPath:s3://memorycraft-us-log-archive/archive/#{@scheduledStartTime.format('YYYY-MM-dd')}/
DataFormat:create new: DataFormat

Name:log-stats
Type:S3DataNode
Schedule:DefaultSchedule1
DirectoryPath:s3://memorycraft-us-log-archive/stats/#{@scheduledStartTime.format('YYYY-MM-dd')}/
DataFormat:create new: DataFormat

ここまでできたら、次は先程追加したScheduleとDataFormatを設定します。



Schedule


data nodeの設定で追加されたScheduleを見てみます。Schedulesアコーディオンを開くと、
先ほど追加されたDefaultSchedule1が一つ登録されています。

これを以下のように設定します。

Name:schedule
Type:Schedule
StartDateTime:2012-12-24 00:00:00
Period:1 Days

StartDateTimeは処理期間、Periodは実行間隔です。
cronというよりはanacronのような仕組みのようで、DataPipelineが稼働し始めると、StartDateTimeが過去日付の場合は、現在に追いつくまでPeriod分のデータの処理を繰り返し処理してくれるようです。




DataFormat


Othersアコーディオンには、DefaultDataFormat1、2、3の3つのDataFormatが出来ています。それぞれDataNodesで設定したlog, log-archive, log-statsで新規追加したDataFormatです。これらはS3DataNodeからHiveを通すときに使用される変換用のマッピングです。

まず、log data nodeに割り当てられているDefaultDataFormat1を設定します。
前回の記事同様、fluentのフォーマットは、日時、fluentタグ、json文字列なので、3つデータカラムを登録します。
データカラムはColumnで設定し、カラム名<半角スペース>データ型の形で記載します。
データ型にはhiveで利用できる型の中から選びます。

ここでは、以下のように設定します。

Name:format-log
Type:TSV
Column:dt STRING
Column:tag STRING
Column:json STRING

同様に、2つの出力先も以下のように設定します。

Name:format-archive
Type:TSV
Column:dt STRING
Column:host STRING
Column:user STRING
Column:method STRING
Column:path STRING
Column:code STRING
Column:size BIGINT
Column:referer STRING
Column:agent STRING

Name:format-stats
Type:TSV
Column:path STRING
Column:cnt BIGINT

ここまででDataNodeに関連する設定はひとまず終了です。
おそらくですが、S3DataNodeとDataFormatを設定することで、実行時にHiveのCREATE EXTERNAL TABLEクエリが自動的に発行されるようです。



Activity


次に、処理要素であるActivityを追加します。ヘッダから「Add activity」ボタンをクリックすると、右ペインのActivitiesに新しいactivityが追加されます。
ここで、まず以下のように設定します。

Name:archive
Type:HiveActivity
Schedule:schedule
Input:log
Output:log-archive

Input、Outputを設定することで、左ペインでactivityとdata nodeが矢印で結ばれます。

続いて、Runs Onでcreate new: Emr Clusterを選択します。
すると、ResourcesにDefaultEmrClusterが追加されます。

続いて、Hive Scriptを設定します。ここにはHQLを入れるのですが、クエリ中にもExpressionを使用します。ここでは、以下のように入力します。

INSERT OVERWRITE TABLE ${output1} SELECT dt,host,user,method,path,code,size,referer,agent FROM ${input1} LATERAL VIEW json_tuple(${input1}.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j AS host,user,method, path, code, size, referer, agent WHERE substr(dt, 0, 10) = '#{@scheduledStartTime.format('YYYY-MM-dd')}' ORDER BY dt;

${output1}はactivityにアタッチされたOutput要素の1番目、${input1}はactivityにアタッチされたInput要素の1番目のテーブルがそれぞれ割り当てられるようです。また、#{@scheduledStartTime.format('YYYY-MM-dd')}では、日時が2012-12-24などが動的に入ります。

また、OnSuccess,OnFailなどで設定追加したAction要素にSNSArnを設定しておくと、処理の結果をメールで受け取ることもできるようですが、ここでは割愛します。

同じように、集計用のactivityも追加して、以下のように設定します。

Name:stats
Type:HiveActivity
Schedule:schedule
Input:log
Output:log-stats
HiveScript:INSERT OVERWRITE TABLE ${output1} SELECT path,COUNT(dt) FROM ${input1} LATERAL VIEW json_tuple(${input1}.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j AS host,user,method, path, code, size, referer, agent WHERE substr(dt, 0, 10) = '#{@scheduledStartTime.format('YYYY-MM-dd')}' GROUP BY path ORDER BY path;
RunsOn:DefaultEmrCluster1
LogUrl:s3://memorycraft-us-hive-log/pipeline/stats/




Resources


これは、処理を実際に行うリソースです。
HiveActivityを選んだ場合は、リソースはEMRになります。
ここでは、さきほどのactivityで追加されたDefaultEmrCluster1を設定します。

Name:emr-log
Type:EmrCluster
TerminateAfter:2 hours
EmrLogUrl:s3://memorycraft-us-hive-log/emr/
KeyPair:myfirstcloud-use-key
MasterInstanceType:m1.small
EnableDebugging:true
LogUri:s3://memorycraft-us-hive-log/task/
Schedule:schedule
CoreInstanceType:m1.small
CoreInstanceCount:2
InstallHive:latest
ActionOnTaskFailure:continue
ActionOnResourceFailure:retryAll

多くは、実際にEMRのジョブフローを設定するときに見かける項目ですが、
ここでのポイントはEnableDebuggingとLogUri,EmrLogUriです。
これを設定しておくと、EMRの画面でどんなエラーが起きているかがわかるので、慣れないうちは設定しておくと便利です。


これですべての設定が完了しました。

内容をまとめると以下のようになります。

【Activities】

 Name:archive
 Type:HiveActivity
 Schedule:schedule
 Input:log
 Output:log-archive
 HiveScript:INSERT OVERWRITE TABLE ${output1} SELECT dt,host,user,method,path,code,size,referer,agent FROM ${input1} LATERAL VIEW json_tuple(${input1}.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j AS host,user,method, path, code, size, referer, agent WHERE substr(dt, 0, 10) = '#{@scheduledStartTime.format('YYYY-MM-dd')}' ORDER BY dt;
 RunsOn:emr-archive
 LogUrl:s3://memorycraft-us-hive-log/pipeline/archive/


 Name:stats
 Type:HiveActivity
 Schedule:schedule
 Input:log
 Output:log-stats
 HiveScript:INSERT OVERWRITE TABLE ${output1} SELECT path,COUNT(dt) FROM ${input1} LATERAL VIEW json_tuple(${input1}.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j AS host,user,method, path, code, size, referer, agent WHERE substr(dt, 0, 10) = '#{@scheduledStartTime.format('YYYY-MM-dd')}' GROUP BY path ORDER BY path;
 RunsOn:emr-archive
 LogUrl:s3://memorycraft-us-hive-log/pipeline/stats/


【DataNodes】

 Name:log
 Type:S3DataNode
 Schedule:schedule
 DirectoryPath:s3://memorycraft-us-log/logs/#{@scheduledStartTime.format('YYYY-MM-dd')}/
 DataFormat:format-log
 Name:log-archive
 Type:S3DataNode
 Schedule:schedule
 DirectoryPath:s3://memorycraft-us-log-archive/archive/#{@scheduledStartTime.format('YYYY-MM-dd')}/
 DataFormat:format-archive
 Name:log-stats
 Type:S3DataNode
 Schedule:schedule
 DirectoryPath:s3://memorycraft-us-log-archive/stats/#{@scheduledStartTime.format('YYYY-MM-dd')}/
 DataFormat:format-stats


【Schedules】
 Name:schedule
 Type:Schedule
 StartDateTime:2012-12-24 00:00:00
 Period:1 Days


【Resources】
 Name:emr-log
 Type:EmrCluster
 TerminateAfter:2
 EmrLogUrl:s3://memorycraft-us-hive-log/emr/
 KeyPair:myfirstcloud-use-key
 MasterInstanceType:m1.small
 EnableDebugging:true
 LogUri:s3://memorycraft-us-hive-log/task/
 Schedule:schedule
 CoreInstanceType:m1.small
 CoreInstanceCount:2
 InstallHive:latest
 ActionOnTaskFailure:continue
 ActionOnResourceFailure:retryAll


【Others】
 Name:format-log
 Type:TSV
 Column:st STRING
 Column:tag STRING
 Column:json STRING


 Name:format-archive
 Type:TSV
 Column:dt STRING
 Column:host STRING
 Column:user STRING
 Column:method STRING
 Column:path STRING
 Column:code STRING
 Column:size BIGINT
 Column:referer STRING
 Column:agent STRING

 Name:format-stats
 Type:TSV
 Column:path STRING
 Column:cnt BIGINT

 ここまでの設定を保存します。ヘッダーの「Save pipeline」をクリックします。エラーがあるとこの時点で、右ペインのErrorsにエラーが表示されるのでそれにしたがって修正します。
エラーがなく、保存に成功すると以下のようなSuccessダイアログが出ます。




ここで、右上の「Back to List of Pipeline」のリンクをクリックします。
一覧ではPipelineが「PENDING」の状態になっています。
まだ設定を保存しただけで、このままでは実行されません。

次にアクティベートという処理を行います。
設定した内容のほとんどをFixし、実行もしくは実行待機状態にするための作業です。


もう一度「View pipeline」をクリックして、詳細画面に入ります。
ヘッダーに「Activate」ボタンがあるので、クリックします。
するとアクティベートに成功した旨のダイアログが現れます。




再度一覧に戻ると、ステータスが「SCHEDULED」になっています。
もし設定したScheduleが過去日付であれば、この時点で処理が開始されます。
未来日時であれば該当日時になると処理が開始されます。


処理の観察



この時点で、「View pipeline」を見ると、処理の一覧が表示されます。data nodeを用意したり、EMRフローを実行したりなどが表示されます。




個人的にはEMRのジョブフローのログを見たほうがわかりやすいので、EMRの方を見てみます。
すると、過去日付を設定したので、即座にジョブフローが立ち上がっているのがわかります。
また、ジョブフロー中のInstall TaskRunnerの内容で、何日のスケジュールを実行しているのかが判断できそうです。




そして、ジョブフローを選択し、上部の「Debug」ボタンをクリックします。
ジョブフローのデバッグダイアログでは、処理が完了し、出力の終わったログを見ることができます。







結果



処理が終わったようなので、S3を見てみます。




おお!ファイルが出力されているようです。
内容を見てみると、、、、


"2012-12-24T14:16:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:16:22+00:00""10.118.137.132""-""GET""/assets/img/logo.png""404""329""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:16:23+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:25+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:26+00:00""10.118.137.132""-""GET""/assets/img/logo.png""404""329""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:27+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:28+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:32+00:00""10.118.137.132""-""GET""/img/logo.png""404""322""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:32+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:34+00:00""10.118.137.132""-""GET""/assets/img/loading.gif""404""332""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:34+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:34+00:00""10.118.137.132""-""GET""/img/logo.png""404""322""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:35+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:36+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:37+00:00""10.118.137.132""-""GET""/img/loading.gif""404""325""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:37+00:00""10.118.137.132""-""GET""/favicon.ico""404""321""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
"2012-12-24T14:16:38+00:00""10.118.137.132""-""GET""/assets/css/bootstrap.css""404""334""-""Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"
~~ 略 ~~
"2012-12-24T14:46:53+00:00""::1""-""OPTIONS""*""200""-""-""Apache/2.2.23 (Amazon) (internal dummy connection)"
"2012-12-24T14:47:01+00:00""::1""-""OPTIONS""*""200""-""-""Apache/2.2.23 (Amazon) (internal dummy connection)"
"2012-12-24T14:47:03+00:00""::1""-""OPTIONS""*""200""-""-""Apache/2.2.23 (Amazon) (internal dummy connection)"
"2012-12-24T14:47:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:47:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:47:34+00:00""::1""-""OPTIONS""*""200""-""-""Apache/2.2.23 (Amazon) (internal dummy connection)"
"2012-12-24T14:47:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:47:45+00:00""::1""-""OPTIONS""*""200""-""-""Apache/2.2.23 (Amazon) (internal dummy connection)"
"2012-12-24T14:47:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:48:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:48:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:48:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:48:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:49:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:49:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:49:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:49:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:50:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:50:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:50:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:50:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:51:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:51:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:51:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:51:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:52:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:52:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:52:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:52:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:53:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:53:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:53:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:53:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:54:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:54:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:54:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:54:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:55:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:55:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:55:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:55:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:56:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:56:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:56:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:56:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:57:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:57:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:57:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:57:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:58:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:58:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:58:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:58:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:59:13+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:59:22+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:59:43+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"
"2012-12-24T14:59:52+00:00""10.118.137.132""-""GET""/health.txt""200""5""-""ELB-HealthChecker/1.0"


"*""16"
"/""19"
"/assets/css/bootstrap.css""5"
"/assets/img/loading.gif""3"
"/assets/img/logo.png""10"
"/assets/js/bootstrap.js""4"
"/assets/js/client.js""3"
"/assets/js/jquery.min.js""5"
"/favicon.ico""149"
"/health.txt""165"
"/icons/apache_pb2.gif""19"
"/img/loading.gif""1"
"/img/log.png""2"
"/img/logo.png""3"
"/signin""6"
"/signup""6"

おお、うまく出力されているようです!
ここまでで、ログの移行と処理がすべてAWS上で行えるようになりました。
このログにはELBからのヘルスチェックも含まれていますが、HiveQLのWHERE句一発で簡単に除外できますね。

今回Scheduleの調査が中途半端でした。
たとえばStartDateTime が 2012-12-25 15:30:00で、Periodが1dayの場合、次回が2012-12-26 15:30:00になるのか、2012-12-26 00:00:00になるのか、StartDateTimeが毎回の実行日時に影響するのかは追って調査したいと思います。
どちらにしてもTokyoリージョンに来た場合にはJSTに対応してほしいですね。。

今回はHiveActivityだったため、ShellCommandやEC2リソースなどもあるので、いろいろ試せそうです。
結局、DataPipelineというのは、データ同士や処理プロセスを疎結合にすることで、データを抽象的に扱うためのツールなのかもしれません。

それにしてもAWSは留まることを知りませんねー。
以上です。

2012年12月21日金曜日

AWS SDK for Node.js ってなんじゃ?

遅ればせながら、AWS SDK for Node.js が出ました。

簡単に触ってみます。

インストール

npm install aws-sdk


S3にアクセス

test.js
/*
AWSオブジェクトを初期化します。
*/
var AWS = require('aws-sdk');

/*
アカウント情報やリージョンなどを設定します。
*/
AWS.config.update({accessKeyId: 'xxxxxxxxxxxxx',
                   secretAccessKey: 'yyyyyyyyyyyyyyyyyyyyyyy',
                   region: 'ap-northeast-1'});

/*
 このようにしてもOKです。
 AWS.config.loadFromPath('./credentials.json');
*/


/*
S3オブジェクトを作ります。
*/
var s3 = new AWS.S3();

/*
バケットのオブジェクト一覧を取得するには以下のようにします。
*/
s3.client.listObjects({Bucket:"memorycraft-log"}, function(err,data){
  if(err){
    console.log(err);
  }
  else{
    for(var i=0;i<data.Contents.length;i++){
      console.log(data.Contents[i].Key);
    }
  }
});


基本的には、呼び出し→コールバックという一般的なnode.jsの作法ですね。
結果は以下のように正常に取得できます。

# node test.js
logs/
logs/20121221%3F--num-instances_$folder$
logs/20121221/
logs/20121221/ip-10-128-12-239-04_0.gz
logs/20121221/ip-10-128-12-239-04_1.gz
logs/20121221/ip-10-128-12-239-04_10.gz
logs/20121221/ip-10-128-12-239-04_2.gz
logs/20121221/ip-10-128-12-239-04_3.gz
logs/20121221/ip-10-128-12-239-04_4.gz
logs/20121221/ip-10-128-12-239-04_5.gz
logs/20121221/ip-10-128-12-239-04_6.gz
logs/20121221/ip-10-128-12-239-04_7.gz
logs/20121221/ip-10-128-12-239-04_8.gz
logs/20121221/ip-10-128-12-239-04_9.gz
logs/20121221/ip-10-128-12-239-04_98.gz
logs/20121221/ip-10-128-12-239-04_99.gz
logs/20121221/ip-10-157-2-63-04_0.gz
logs/20121221/ip-10-157-2-63-04_1.gz
logs/20121221/ip-10-157-2-63-04_10.gz
logs/20121221/ip-10-157-2-63-04_100.gz
logs/20121221/ip-10-157-2-63-04_2.gz
logs/20121221/ip-10-157-2-63-04_3.gz
logs/20121221/ip-10-157-2-63-04_4.gz
logs/20121221/ip-10-157-2-63-04_5.gz
logs/20121221/ip-10-157-2-63-04_6.gz
logs/20121221/ip-10-157-2-63-04_7.gz
logs/20121221/ip-10-157-2-63-04_8.gz
logs/20121221/ip-10-157-2-63-04_9.gz

Dynamoにアクセス
/*
AWSオブジェクトを初期化します。
*/
var AWS = require('aws-sdk');

/*
DynamoDBオブジェクトをつくります。
*/
var ddb = new AWS.DynamoDB();

/*
投入するデータは以下のようにします。
*/
var data = {
    id:{S:'msg_'+(new Date()).getTime()},
    msg:{S:'hoge'},
    datetime:{S:(new Date())}
};

/*
投入します
*/
ddb.client.putItem({
    TableName:'nodetest',
        Item:data
    },
    function(err, res) {
        if(err){
            console.log("Error [" + err.code + "] id=" + data.id.S);
        }
        else{
            console.log("Done id=" + data.id.S);
        }
});


結果は以下のようになります。
# node test.js 
Done id=msg_1356091073919


AWSコンソールでみると


正常に登録されたことがわかります。

他にもEC2とSWFにアクセスできますが、他のサービスはまだまだこれからのようです。
今後に期待ですね。

以上です。


EMRってなんじゃ?(ログ、ゆりかごから墓場まで)

AWS Advent Calendar 2012 に参加させてもらいました。21日担当です。

AWSでWEBサイトをホストするときのログのライフサイクルについて、まとめてみました。

WEBサーバーの一般的なログの扱いは以下のような感じだと思います。

  1. 各インスタンスのアクセスログを1箇所に集める
  2. 複数のログファイルを1ファイルにまとめてソートする
  3. 集計をする
  4. 古いログのバックアップをとり、削除する


これをAWSで行なってみると例えば以下のようになります。

  1. fluentdを使って各インスタンスのログを1つのバケットAに送る
  2. EMRで1つにまとめてソート。別のバケットBに保存
  3. EMRで集計もして、別のバケットBに保存
  4. バケットAの期限が過ぎたものをGlacierに送る


図にすると以下のようなイメージです。



それでは1つずつやってみます。

1.各インスタンスのアクセスログを1箇所に集める


これはfluentdでtail→s3で行います。
WEBサーバーのインスタンスで、以下の用にfluentdをインストールします。
# cat /etc/yum.repos.d/td.repo
[treasuredata]
name=TreasureData
baseurl=http://packages.treasure-data.com/redhat/$basearch
gpgcheck=0

# yum install td-agent -y


次にtd-agent.confを設定しますが、ログファイル名があとで重複しないように、
td-agent.conf.tmplを使用します。以下のようにtime_slice_formatに${hostname}というプレイスホルダを入れます。
また、yyyymmddのフォーマットでフォルダを切って、そこに保存するようにします。
# cat /etc/td-agent/td-agent.conf.tmpl

<source>
  type tail
  format apache
  path /var/log/httpd/access_log
  tag apache.access
</source>

<match apache.access>
  type s3
  aws_key_id xxxxxxxxxxxxxxxxxxxx
  aws_sec_key yyyyyyyyyyyyyyyyyyyyyyyyyyy
  s3_bucket memorycraft-log
  path logs/
  buffer_path /var/log/fluent/s3
  time_slice_format %Y%m%d/${hostname}-%H
  time_slice_wait 10m
</match>


次に、/etc/init.d/td-agent の最初の方に、以下のようにtmplをホスト名で書き換える処理を入れます。
#!/bin/bash
#
# /etc/rc.d/init.d/td-agent
#
# chkconfig: - 80 20
# description: td-agent
# processname: td-agent
# pidfile: /var/run/td-agent/td-agent.pid
#
### BEGIN INIT INFO
# Provides:          td-agent
# Default-Stop:      0 1 6
# Required-Start:    $local_fs
# Required-Stop:     $local_fs
# Short-Description: td-agent's init script
# Description:       td-agent is a data collector
### END INIT INFO


# Source function library.
. /etc/init.d/functions

sed -e "s/\${hostname}/`hostname`/g" /etc/td-agent/td-agent.conf.tmpl > /etc/td-agent/td-agent.conf


…


そして、td-agentとhttpdを起動時ONにします。
# chkconfig httpd on
# chkconfig td-agent on


ここまでやったら、/var/www/html に適当なヘルスチェックファイルを置いて、AMIにかためます。
その後、何台か起動し、ELBにぶら下げます。

いくつかのURLでアクセスをして、ログの実績を作っておきます。

しばらくすると、S3のmemorycraft-logバケットに各ホストのログが溜まってきます。




データは以下のようなフォーマットになります。

日付<タブ文字>fluentタグ<タブ文字>access_logをJSON文字列化したもの

2012-12-21T04:22:02+09:00     apache.access     {"host":"10.158.169.28","user":"-","method":"GET","path":"/favicon.ico","code":"404","size":"321","referer":"-","agent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11"}

2.複数のログファイルを1ファイルにまとめてソートする


EMRをつかいます。
あとで集計も行うので、ここでは手軽にSQLライクな集計ができるHiveを利用してみます。
Hiveスクリプトは以下の流れです。

入力データを指定

アクセスログの溜まったmemorycraft-logバケットをテーブルとしてアクセスできるようにします。
実行時に日付指定をするために変数DATEを渡すので、それを使ってfluentdが保存するyyyymmddのフォルダを指定します。
CREATE EXTERNAL TABLE IF NOT EXISTS fluentLog (dt string, tag string, json string)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n'
LOCATION 's3://memorycraft-log/logs/${DATE}';


出力データを設定

1ファイルにまとめてソートしたデータの出力先をmemorycraft-log-archiveバケットのarchives/yyyymmddというフォルダで出力するように設定します。このときカラムはfluentdが送る日付部分(dt)と、データのJSON部分の内容(host~agent)にします。また、fluentdのS3出力フォーマットに合わせて、フィールドと改行をそれぞれタブ区切り、改行コードで指定します。
CREATE EXTERNAL TABLE IF NOT EXISTS archiveLog (dt string, host string, user string, method string, path string, code string, size bigint, referer string, agent string)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n'
LOCATION 's3://memorycraft-log-archive/archives/${DATE}';


まとめてソート

INSERT ~ SELECT ~
を利用すると、入力ソースから整形して出力先へ出力することができます。
整形するときは LATERAL VIEW json_tuple関数で、JSONをパース分割すると通常のカラムとしてSELECTできます。
また、fluentの日付部分(dt)はJSON外なのでそのままカラムとして扱えます。

そして、日付部分dtが今回の対象日時に限定するように念のため絞っておきます。
アクセスログのフォーマットはyyyy-mm-ddなので、yyyymmddの形にしてから比較します。
${DATE}をyyyy-mm-ddに整形したほうがパフォーマンスはいいと思います。またフォルダ名など全体的にフォーマットをyyyy-mm-ddに統一すればこのような変換は必要ありません。今回は気にせず、yyyymmddで進めます。
INSERT OVERWRITE TABLE archiveLog
SELECT
 dt,host,user,method,path,code,size,referer,agent
FROM
 fluentLog LATERAL VIEW json_tuple(fluentLog.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j
 AS host,user,method, path, code, size, referer, agent
WHERE
 regexp_replace(substr(dt, 0, 10), '-', '') = '${DATE}'
ORDER BY dt;

このクエリを実行すると's3://memorycraft-log-archive/archives/yyyymmdd/に日付でソートされたデータが1つのファイルにまとまって出力されることになります。


3.集計をする



出力データを設定

もう一つ、それぞれのURLに何回アクセスがあったかを集計したデータの出力先をmemorycraft-log-archiveバケットのstats/yyyymmddというフォルダに設定します。
CREATE EXTERNAL TABLE IF NOT EXISTS statLog (path string, cnt bigint)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n'
LOCATION 's3://memorycraft-log-archive/stats/${DATE}';


集計

ここではpathでGROUP BYすることで、pathごとのアクセス回数をSELECTすることができます。
INSERT OVERWRITE TABLE statLog
SELECT
 path,COUNT(dt)
FROM
 fluentLog LATERAL VIEW json_tuple(fluentLog.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j
 AS host,user,method, path, code, size, referer, agent
WHERE
 regexp_replace(substr(dt, 0, 10), '-', '') = '${DATE}'
GROUP BY path
ORDER BY path;


このクエリを実行すると's3://memorycraft-log-archive/stats/yyyymmdd/にpathとそのpathへのアクセス回数のデータが1つのファイルにまとまって出力されることになります。

ここまでのクエリをすべて1つのファイルにまとめて、whatislog.qという名前で、s3://memorycraft-hive/にアップロードします。hiveはこのスクリプトをつかってジョブを実行します。

whatislog.q
CREATE EXTERNAL TABLE IF NOT EXISTS fluentLog (dt string, tag string, json string)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n'
LOCATION 's3://memorycraft-log/logs/${DATE}';

CREATE EXTERNAL TABLE IF NOT EXISTS archiveLog (dt string, host string, user string, method string, path string, code string, size bigint, referer string, agent string)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n'
LOCATION 's3://memorycraft-log-archive/archives/${DATE}';

INSERT OVERWRITE TABLE archiveLog
SELECT
 dt,host,user,method,path,code,size,referer,agent
FROM
 fluentLog LATERAL VIEW json_tuple(fluentLog.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j
 AS host,user,method, path, code, size, referer, agent
WHERE
 regexp_replace(substr(dt, 0, 10), '-', '') = '${DATE}'
ORDER BY dt;


CREATE EXTERNAL TABLE IF NOT EXISTS statLog (path string, cnt bigint)
ROW FORMAT DELIMITED FIELDS TERMINATED BY '\t' LINES TERMINATED BY '\n'
LOCATION 's3://memorycraft-log-archive/stats/${DATE}';

INSERT OVERWRITE TABLE statLog
SELECT
 path,COUNT(dt)
FROM
 fluentLog LATERAL VIEW json_tuple(fluentLog.json, 'host', 'user', 'method', 'path', 'code', 'size', 'referer', 'agent') j
 AS host,user,method, path, code, size, referer, agent
WHERE
 regexp_replace(substr(dt, 0, 10), '-', '') = '${DATE}'
GROUP BY path
ORDER BY path;



次に、ローカルの操作端末にelastic map reduceのコマンドラインインターフェースをインストールします。
# wget http://elasticmapreduce.s3.amazonaws.com/elastic-mapreduce-ruby.zip
# unzip elastic-mapreduce-ruby.zip
# yum install ruby


設定情報をcredentials.jsonに記載して保存します。
# cat credential.json
{
"access_id":"xxxxxxxxxxxxxxxxxx",
"private_key":"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyy",
"keypair":"memorycraft",
"key-pair-file":"./memorycraft.pem",
"log_uri":"s3://memorycraft-hive-log/",
"region":"ap-northeast-1"
}



そして、以下のようにhiveにDATE引数を渡してコマンドを実行し、ジョブを開始します。
# SCRIPT=s3://memorycraft-hive
# ./elastic-mapreduce --create --name "What is Log" --num-instances 2 --master-instance-type m1.small --slave-instance-type m1.small  --hive-script --arg $SCRIPT/whatislog.q --args -d,DATE='20121221'
Created job flow j-1WQTYOTXBCNXC


するとAWSコンソールのElasticMapReduceにジョブフローが追加され、Debugダイアログを見ると実行中である旨の表示を確認できます。




処理が終わるとフローが完了表示になります。




S3の出力先ディレクトリをみると、ファイルが出力されています。




まとめてソートの出力ファイルをダウンロードしてみると、ちゃんとまとめてソートされていることがわかります。
2012-12-21T04:18:38+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:19:08+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:19:38+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:19:38+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:20:08+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:20:08+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:20:22+09:00     10.158.169.28     -     GET     /     304     \N     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:22+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:25+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:25+09:00     10.158.169.28     -     GET     /assets/img/log.png     404     328     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:38+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:20:38+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:38+09:00     10.158.169.28     -     GET     /assets/img/title.png     404     330     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:38+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:20:41+09:00     10.158.169.28     -     GET     /assets/img/title1.png     404     331     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:41+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:43+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:43+09:00     10.158.169.28     -     GET     /assets/img/title2.png     404     331     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:49+09:00     10.158.169.28     -     GET     /assets/img/loading.gif     404     332     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:49+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:53+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:53+09:00     10.158.169.28     -     GET     /assets/js/client.js     404     329     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:57+09:00     10.158.169.28     -     GET     /assets/js/jquery.min.js     404     333     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:20:57+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:01+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:01+09:00     10.158.169.28     -     GET     /assets/css/bootstrap.min.css     404     338     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:03+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:08+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:21:08+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:21:09+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:12+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:12+09:00     10.158.169.28     -     GET     /test     404     314     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:15+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:15+09:00     10.158.169.28     -     GET     /singin     404     316     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:22+09:00     10.158.169.28     -     GET     /signup     404     316     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:22+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:26+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:37+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:37+09:00     10.158.169.28     -     GET     /download/test.zip     404     327     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:38+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:21:38+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:21:40+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:58+09:00     10.158.169.28     -     GET     /assets/img/logo.png     404     329     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:21:58+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:22:02+09:00     10.158.169.28     -     GET     /favicon.ico     404     321     -     Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_2) AppleWebKit/537.11 (KHTML, like Gecko) Chrome/23.0.1271.101 Safari/537.11
2012-12-21T04:22:08+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0
2012-12-21T04:22:08+09:00     10.158.169.28     -     GET     /index.html     200     5     -     ELB-HealthChecker/1.0


また、集計の出力ファイルをダウンロードしてみると、以下のようにURLと回数が表示されていることがわかります。
/     1
/assets/css/bootstrap.min.css     1
/assets/img/loading.gif     1
/assets/img/log.png     1
/assets/img/logo.png     1
/assets/img/title.png     1
/assets/img/title1.png     1
/assets/img/title2.png     1
/assets/js/client.js     1
/assets/js/jquery.min.js     1
/download/test.zip     1
/favicon.ico     19
/index.html     14
/signup     1
/singin     1
/test     1



マージ、集計ともうまくいったようです。

また、Hiveはスクリプトをアップするのではなく、EMRのマスタノードからHiveコンソールに入ってクエリを実行することもできるので、お客さんからイレギュラーな集計処理を頼まれた時も手軽に集計ができます。

4.古いログのバックアップをとり、削除する


次はfluentから送られてきて溜まったログをS3からGlaceirに送る設定をします。
Glaceirは入出力にかなり時間がかかるものの、S3よりも更に安価に大容量のデータを保存することができます。
削除したくないけど、使用はしばらくしないようなログの保存先に向いています。

今回の場合はmemorycraft-logのデータで、集計が終わって期限が1週間をすぎたものをGlaceirにアーカイブするように設定してみます。
AWSコンソールのS3バケットプロパティで、LifeCycleセクションから「Add Rule」ボタンを押します。





ダイアログが表示されるので、ルールに名前をつけて、「Apply to Entire Bucket」にチェックを入れます。
もしこのバケットが他のデータも含んでいたり、アーカイブに条件をつけたい場合は、チェックを入れずに対象としたいファイル名のプリフィクスを入力します。




下部の「Add Transition」をクリックし、アーカイブされるまでの日数を入力します。ここでは7を入れます。
ファイルの作成日時から7日たつとアーカイブされる設定です。
もし、日付を指定したいのであれば、「Time Period Format」をDateのほうにチェックを入れて日付を入力します。

OKを押して設定完了です。

これで、不要なファイルはS3からGlaceirに送られるようになります。
といっても、このブログを書きながらやったので、まだGlacierに送られないので結果は追って観察してみたいと思います。

こんな感じで、AWS内でログのライフサイクルをひと通り眺めてみました。
もちろんS3やCloudfrontのログにも応用できますし、EC2内のログのローテーションや自動削除、EMRによる更に複雑な集計など、いろいろなバリエーションが考えられます。


EC2インスタンスの外側でこれだけのことができるのであればとても気が楽ですね。便利だー。

2012年12月11日火曜日

CDPってなんじゃ?(通知に関するオレオレパターンでCloudwatchからサイレンをならす)


CDP Advent Calendar 2012へ参加しました。

CDPのパターンリストを見ていると純粋に勉強になったり、知らずに実践していたパターンなどがあったり、見ていて楽しいです。

何を書くか迷いましたが、オレオレパターンを定義してみました。

普段運用などでCloudWatchからのアラートがよく飛び交っていますが、うっかり気づかなかったりすると困ることがあります。
SNSを使用した通知はインフラ内で使用されることが多いような気がしますが、HTTP Notificationを使用してアプリケーションサーバーからクライアントまで含めたプッシュ通知の可能性を探ってみたいと思います。

名前をつけるとしたらDeep Notificationでしょうか。
構成は以下の様なイメージです。



今回は例として、CloudWatchでアラートが発生したら、管理画面にプッシュしてWebAudioでサイレンを鳴らしてみたいと思います。

 まず、SNSに"sound"というトピックを立てます。




次にEC2インスタンス内で、HTTP通知先になるWEBアプリをつくります。
今回はFuelPHPで、/monitor/alert のアクションを通知先とします。

SNSのメール通知では購読確認はメールのリンクを踏めばOKですが、HTTP通知では登録後通知先のURLにSNSがアクセスし、その際のJSONパラメータに含まれる情報を元に、SNSの認証APIをリクエストするという方式のようです。

/monitor/alert のアクションで、以下のようにJSONリクエストからTopicArnとTokenを抽出し、SNSに購読確認を行うようにしておきます。

fuel/app/class/controller/monitor.php
     public function action_alert()
     {

          $sns = new AmazonSNS(array('key'=>'xxxxxxxxxxxxxxxxxx',                                               
                                      'secret'=>'xxxxxxxxxxxxxxxxxxxx'));
          $sns->set_region(AmazonSNS::REGION_APAC_NE1);
          $input = file_get_contents('php://input');
          $input =json_decode($input, true);

          if ($input["Type"] === 'SubscriptionConfirmation') {
               $response = $sns->confirm_subscription(
                                $input["TopicArn"], 
                                $input["Token"]);
          }
     }


次に、SNSの"sound"トピックで、購読者の登録を行います。
プロトコルはHTTP、エンドポイントは/monitor/alert アクションのURLとします。



すると、すぐにSNSがエンドポイントにアクセスし、認証が完了すると、以下のようにSubscriptin IDにsnsのarn値がセットされます。
これで認証が完了しました。

すでに認証が完了しているので、このコードは不要なのでコメントアウトし、実際に通知があったときの処理を書きます。
SNSから送信されたJSONデータをredisにPublishするようにします。

fuel/app/class/controller/monitor.php
     public function action_alert()
     {

/*
          $sns = new AmazonSNS(array('key'=>'xxxxxxxxxxxxxxxxxx',
                                     'secret'=>'xxxxxxxxxxxxxxxxxxxx'));
          $sns->set_region(AmazonSNS::REGION_APAC_NE1);
          $input = file_get_contents('php://input');
          $input =json_decode($input, true);

          if ($input["Type"] === 'SubscriptionConfirmation') {
               $response = $sns->confirm_subscription(
                              $input["TopicArn"], 
                              $input["Token"]);
          }
*/

          $input = file_get_contents('php://input');

          $redis = Redis::instance('default');
          $redis->publish('alert', $input);

          $this->template->title = 'Monitor » Alert';
          $this->template->content = View::forge('monitor/alert');
     }


次に、node.jsで、redis経由でSubscribeしたJSONデータをユーザーにプッシュするようにします。

node/server.js
var server = require('http').createServer(function(req, res){
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.end('server connected');
});
server.listen(3000);

var io = require('socket.io').listen(server);
var opts = {host:'127.0.0.1', port:6379};

var redis = require('redis');
var sub = redis.createClient(opts.port, opts.host);
sub.subscribe('alert');
sub.on("message", function(channel, message){
  io.sockets.emit('alert', JSON.parse(message));
});


また、アラート通知画面を別途用意し、alertイベントを受信したら情報を表示してWebAudioを鳴らすようにします。

/fuel/app/views/monitor/index.php
<script src="http://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js" type="text/javascript"></script>
<script src="/assets/js/client.js" type="text/javascript"></script>

<div class="container">
<div class="hero-unit">
<span id="timestamp" style="color: red;"></span>
<h3 id="subject" style="color: red;"></h3>
<dl class="dl-horizontal" id="message"></dl>
<button class="btn" id="stop" type="button">Stop</button>
  </div>
</div>


/public/assets/js/client.js
$(function(){

  var context = new webkitAudioContext();
  var band = 24000;
  var buf = context.createBuffer(1, band, band);
  var data = buf.getChannelData(0);
  for (var i = 0;i < data.length;i++) {
        data[i] = i > band*0.5 ? 0 : ((i % 100) < 50 ? 1 : 0);
  }
  var src = context.createBufferSource();
  src.buffer = buf;

  $.getScript("http://"+location.hostname+":3000/socket.io/socket.io.js", function(){
    var socket = io.connect('http://'+location.hostname+':3000/');
    socket.on('connect', function() {
    });

    socket.on('alert', function(data){
      $("#timestamp").text(data.Timestamp);
      $("#subject").text(data.Subject);
      $("#message").empty();
      var msg = JSON.parse(data.Message);
     for(var p in msg){
        $("#message").append("<dt>"+p+"<dt><dd>"+msg[p]+"</dd>");
     }
      src.connect(context.destination);
      src.loop = true;
      src.noteOn(0);
    });

  });

  $("#stop").click(function(){
    src.disconnect();
  });
});



そして、試しにCloudWatchで監視対象のインスタンスの10%のCPUでアラートがでるようにしておきます。



それでは監視対象のインスタンスで無限ループを動かして、様子を見てみます。


10%を超えたラインでしばらくするとプッシュ通知され、サイレンが鳴りました!!

運用だけでなくバッチの完了通知やGrowlへの通知など、HTTP通知を使ってSNSをアプリケーション・サーバーのほうに一歩踏み込ませることで、通知に関する自由度が一気に高まり、運用効率などに効果があるのではないかと思います。

以上です。

2012年12月2日日曜日

Node.jsってなんじゃ?(knox:S3にアクセス)


AWS界隈でもAdvent Calendarが流行っているようで、CloudpackでもAdvent Calendarが始まりました。

cloudpack Advent Calendar 2012

社内でやると、ただのブログの催促のような気もしますが、1日目のsuz-labさんに続いて、2日目を担当します。

最近node.jsを実案件で利用することがいくつかあり、AWSサービスをnodeから利用したいケースが結構あります。そこで今回はnodeからS3へファイルをアップロードしてみたいと思います。

nodeでS3アクセスするには、knoxというモジュールがあります。
他にもs3-clientというknoxをもとにした簡易機能のモジュールがありますが、簡単さと引換にヘッダーを付けられないなどの制限があるので、いろいろやりたい場合はknoxがよいかと思います。

今回は、redisからのpublishをトリガーにしてメッセージ内容をS3へJSONファイルとしてアップロードしてみたいと思います。

まず、S3にはアップロード用のバケットを用意しておきます。
ここではmemocra-jsonというバケットにしました。
内容を確認しやすいように、webサイト設定をしておきます。




そしてEC2側では、既にnode, redis本体はインストールされている前提で、
knox, redisのnodeモジュールを追加でインストールします。
# npm install -g knox
# npm install -g redis


次にコードを書きます。
knox.createClientでアクセスキー、シークレットキーと対象バケットを指定し、クライアントオブジェクトを作成します。
var knox = require('knox');var s3Client = knox.createClient({
    key:'xxxxxxxxxxxxxx',
    secret:'yyyyyyyyyyyyyyyyyyyyyyyyyyyy',
    bucket:'memocra-json'
});


また、redisサーバーのjson_createというチャンネルをsubscribeするようにします。
var redis = require('redis');
var sub = redis.createClient({host:'10.0.0.200', port:6379});
sub.subscribe('json_create');


受信時には、受信内容と日時をjson化して、その文字列をknoxクライアントのputメソッドでS3へアップロードします。
この際、ヘッダーにpublic-readをつけることで、WEBアクセスが可能になります。
sub.on('message', function(channel, message){
    var json = JSON.stringify({msg:message, datetime:(new Date())});
    var s3_path = 'json/'+message+'.json';
    var headers = {'Content-Length': json.length,
                    'Content-Type': 'application/json',
                    'x-amz-acl': 'public-read'};
    var req = s3Client.put(s3_path, headers);
    req.on('response', function(res){
        if(res.statusCode != 200){
            console.log("Error! code="+res.statusCode);
        }
        else{
            console.log("Done!");
        }
    });
    req.on('error', function(err){
        console.log('Error='+err);
    });
    req.end(json);
});


ここまで書けたら、起動します。
$ node sample.js


次にredis-cliを起動し、json_createチャンネルでpublishします。
# redis-cli
redis 127.0.0.1:6379> publish json_create hello
(integer) 1
redis 127.0.0.1:6379> publish json_create konnichiwa
(integer) 1


すると、node側では完了メッセージが2つ表示され、2件の処理が終わったことがわかります。
$ node sample.js
Done!
Done!


バケットを見ると、ちゃんと2つファイルができています。





ブラウザで中身を見てみます。




内容も正しいようです。


もちろんこれ以外にも、ファイルをそのままアップロード、ダウンロード、削除する機能などもあり、
nodeでもS3のファイルを操作することができます。

以上です。

2012年11月14日水曜日

Node.jsってなんじゃ?(DynamoDBにアクセス)

いままでチャットプログラムをサンプルとして使用していましたが、やりとりしているメッセージは基本的に揮発性のものです。サーバーが止まればなくなってしまう、もしくは参照できなくなるデータです。

今回は、AmazonDynamoDBをメッセージの保存場所としてみます。
node.js でDynamoDBを利用するには、dynodeというモジュールを使用します。

dynodeはDynamoDBへのAPIアクセスをラップしてnodeライクに使うことができます。

では早速触ってみたいと思います。
まずdynodeをインストールします。
# npm install -g dynode

ロードと設定は以下のように行います。
var dynode = require('dynode');
dynode.auth({region:"ap-northeast-1",
                accessKeyId:"xxxxxxxxxxxxxxxxxx",
                secretAccessKey:"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"});


また、各メソッドは以下のようにコールバック式で行います。
dynode.putItem("chat", {id:id, msg:msg, date:(new Date()).toString()}, function(err, res){
  console.log(err);
});


ここでは、メッセージの保存先として、idというプライマリキーと、dateをレンジキーをもつchatというテーブルを作成します。


サンプルとして以前のチャットプログラムを使用します。

server.js

connectionハンドラで、接続が確率したときにchatテーブルから過去のメッセージをスキャンしてarchiveイベントに乗せてクライアントにemitします。
また、msgハンドラでは、いままで単純にemitしていたところを、dynode.putItemで保存してからemitするようにしました。
var server = require('http').createServer(function(req, res){
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.end('server connected');
});
server.listen(3001);

var dynode = require('dynode');
dynode.auth({region:"ap-northeast-1",
                accessKeyId:"xxxxxxxxxxxxxxxxxx",
                secretAccessKey:"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"});

var io = require('socket.io').listen(server);
var RedisStore = require('socket.io/lib/stores/redis');
opts = {host:'10.0.0.200', port:6379};
io.set('store', new RedisStore({redisPub:opts, redisSub:opts, redisClient:opts}));

io.sockets.on('connection', function (socket) {

  var id = socket.id;
 //メッセージ履歴をscanします
  dynode.scan("chat", function(err, res){
  //dateでソートします
  res.sort(function(a,b){
     if(a.date < b.date)return -1;
      if(a.date > b.date)return 1;
      return 0;
    });
    //archiveイベントに送信します。
    io.sockets.emit('archive', { id:id, archive: res });
    io.sockets.emit('info', { id:id, msg: 'welcome '+id });
  });

  socket.on('msg', function (msg) {
    io.sockets.emit('msg', {id:id, msg: msg});<
              //保存します。
    dynode.putItem("chat", {id:id, msg:msg, date:(new Date()).toString()}, function(err, res){
        if(err){
            console.log(err);
            console.log(res);
        }
    });
  });
  socket.on('disconnect', function(){
    io.sockets.emit('info', {id:id, msg: 'bye '+id});
  })
});


client.js

クライアント側では、新たにarchiveハンドラを作成し、メッセージをまとめて展開できるようにしておきます。

$(function(){
    var socket = io.connect('http://'+hostname+':3001/');
    socket.on('connect', function() {
      $("#log").html($("#log").html() + "<br />" + 'connected');
      socket.on('info', function (data) {
        $("#log").html($("#log").html() + "<br />" + data.msg);
      });
      socket.on('archive', function(data){
                     //メッセージ履歴を表示します。
      for(var i=0;i<data.archive.length;i++){
      $("#log").html($("#log").html() + "<br />" + "<b>" +       data.archive[i].id + ":</b>" + data.archive[i].msg);
      }
      });

      socket.on('msg', function(data){
        $("#log").html($("#log").html() + "<br />" + "<b>" + data.id + ":</b>" + data.msg);
      });

      $("#send").click(function(){
        var msg = $("#msg").val();
        if(!msg){
          alert("input your message");
          return;
        }
        socket.emit('msg', msg);
      });
    });
  });


これで完了です。それでは動かして見ましょう。
以下のように、メッセージを投稿すると、



以下のようにDynamoに登録されているのがわかります。


ひとしきりメッセージを送ったあと、別のブラウザで接続すると、過去のメッセージが表示されるようになりました。


このように、nodeでもAWSに接続できるモジュールもいくつかあるので、幅が広がりますね。
以上です。

2012年11月12日月曜日

redisってなんじゃ?(FuelPHPの管理画面からSocket.IOで全員にPush)

node.js、とりわけSocket.IOでは、ユーザー同士のリアルタイム通信が簡単にに行えることがわかってきました。
ですが、時には管理画面や、バッチの処理によってユーザーに何か通知をしたいことがあるかもしれません。
今度はredisを利用して、システム管理者とユーザーの間でリアルタイム通信を行なってみます。

以前紹介したFuelPHPもまた、キャッシュストアや、クライアントインターフェースとしてRedisをサポートしています。
そこで、FuelPHPで管理画面をつくり、ボタンを押すとチャット中のユーザーに管理者からのメッセージを表示させたいと思います。

チャットプログラムや構成は前々回の構成のまま、node1, node2という2つのサーバーがredisサーバーにつながってセッション共有されている状態とします。
今回は、以下のように、さらにadminサーバーを追加し、管理画面を置きます。

    +----------+       +---------+
    | admin    |       | redis   |
    |----------| pub   |---------|
    | fuelphp  +------->         |
    |          | sub   | redis   |
    | admin.js <-------+         |
    +-----+----+       +---------+
          |
          +-----------------+
          |      emit       |
    +-----v----+       +----v----+
    | node1    |       | node2   |
    |----------|       |---------|
    | chat.js  |       | chat.js |
    +----------+       +---------+

上図のように、fuelphpからredisにpublishしたものを同じadminサーバー上のsocket.ioでsubscribeし、チャットサーバーへブロードキャストするイメージです。
チャットサーバーが直接subscribeすることも可能ですが、そうすると、チャットサーバーの数だけsubscribe→emitが発生して、メッセージが重複してしまいます。subscribe→emit役は1つである必要があるので、adminサーバーに兼任させます。
なので、既存のチャットサーバーには手を加える必要がなく、adminサーバーだけ用意して実装すればいいわけです。

それでは実際に用意してみます。


まず必要なユーザーやライブラリを用意します。
# useradd appadmin
# passwd appadmin
# yum install -y vim git wget php  gcc gcc-c++ make
# curl get.fuelphp.com/oil | sh


FuelPHPの最新版(v1.4)では、タイムゾーンの指定が必至になったのでphp.iniで設定します。
# vim /etc/php.ini
---
date.timezone = Asia/Tokyo
--


次にアプリユーザーのホームディレクトリをApacheからアクセス可能にします。
# chmod 755 /home/appadmin


アプリを作成します。
publishボタンを配置する画面(index)とpublishボタンの押下(publish)の2つのアクションを用意します。
# su - appadmin
$ oil create app
$ cd app
$ oil g controller greeting index publish


次に、DocumentRootをFuelPHPのpublicディレクトリに向けます。
$ exit
# cd /var/www/ 
# mv html html.org
# ln -s /home/appadmin/app/public html


そして、Apacheの設定をシンボリックリンクをたどり、.htaccessを許可するように変更し、起動します。
# vim /etc/httpd/conf/httpd.conf
---

<Directory "/var/www/html">
~略~
    Options FollowSymLinks
~略~
    AllowOverride All
~略~
</Directory>

---
# /etc/init.d/httpd start 


次にFuelPHPのconfig/db.phpおよび、viewとcontrollerを以下のように実装します。

fuel/app/config/db.php
redisの接続先を指定します。チャットサーバーが参照しているのと同じサーバーに接続させるようにします。
$ vim fuel/app/config/db.php
<?php

/**
 * Use this file to override global defaults.
 *
 * See the individual environment DB configs for specific config information.
 */

return array(

    'redis' => array(
        'default' => array(
            'hostname' => '10.0.0.200',
            'port'     => 6379
        )
    ),

);


fuel/app/views/greeting/index.php
Publishボタンを配置します。
$ vim fuel/app/views/greeting/index.php
---
<p>Index</p>

<?php echo Form::open('greeting/publish'); ?>
<?php echo Form::submit('publish', 'publish'); ?>
<?php echo Form::close(); ?>
---


fuel/app/classes/controller/greeting.php
publishアクション内で、Redisクラスをインスタンス化しています。
ここでの引数defaultは、db.phpで設定したラベルを指定します。
そして、publishメソッドを呼び出すことにより、redisサーバーに「greeting」というチャンネルでpublishを行います。
$ vim fuel/app/classes/controller/greeting.php
---
<?php

class Controller_Greeting extends Controller_Template
{

    public function action_index()
    {
        $this->template->title = 'Greeting &raquo; Index';
        $this->template->content = View::forge('greeting/index');
    

    public function action_publish()
    {

        $redis = Redis::instance('default');
        $redis->publish('greeting', 'おはよう諸君!!');

        $this->template->title = 'Greeting &raquo; Publish';
        $this->template->content = View::forge('greeting/publish');
    }
}
---


ここまでで、管理画面自体は完成です。
以下のようにPublishボタンがあるだけのシンプルな画面になっています。




つづいて、同じadminサーバーに、node.jsを入れて動かします。
モジュールはforever, socket.io, redisを入れます。
socket.ioにもredisが含まれているのですが、requireのしやすさやバージョンが新しさなどのため、別途いれておきます。
# cd /usr/local/src
# wget http://nodejs.org/dist/v0.8.14/node-v0.8.14.tar.gz
# tar xzvf node-v0.8.14.tar.gz
# cd node-v0.8.14
# ./configure  
# make
# make install
# curl https://npmjs.org/install.sh | sh
# npm install -g socket.io
# npm install -g forever
# npm install -g redis


これでnodeのインストールができました。
それでは、subscribe→emit用にnodeのスクリプトを実装します。
# su - appadmin
$ mkdir -p /home/appadmin/admin/node
$ cd /home/appadmin/admin/node
$ vim admin.js
---

var server = require('http').createServer(function(req, res){
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.end('server connected');
});
server.listen(3001);

var io = require('socket.io').listen(server);
var RedisStore = require('socket.io/lib/stores/redis');
var opts = {host:'10.0.0.200', port:6379};
io.set('store', new RedisStore({redisPub:opts, redisSub:opts, redisClient:opts}));

var redis = require('redis');
var sub = redis.createClient(opts.port, opts.host);
sub.subscribe('greeting');
sub.on("message", function(channel, message){
  io.sockets.emit('msg', {msg:message});
});

---

ポイントは、チャット用のスクリプトと同じようにredisサーバーをRedisStoreとしてstore登録することと、
それとは別にsubscribe用のredisクライアントを作り、「greeting」チャンネルをsubscribeし、受信したメッセージをチャット参加者全員にemitするようにするところです。

これを起動します。
$ forever start admin.js

それでは、実際に動かしてみましょう。
まず、前回と同様、node1とnode2のチャットウィンドウを開いて、適当にチャットしてみます。


それでは、ここで先程の管理画面でPublishボタンを押してみます。



そして、チャット画面をみてみます。


おお!メッセージが表示されました。
これで管理画面からのコントロールも可能になりました。

以上です。

2012年11月11日日曜日

redisってなんじゃ?(pub/sub編)


redisにはpub/subという機能があります。

これはpublish/subscribeパターンという仕組みの実装で、発行者と購読者という役割でメッセージの配信を行うものです。

では、redisのコンソールを使って試してみたいと思います。

redis1とredis2という2つのホストを使用します。
redis1ではローカルホストでredis-cliに接続します。
redis2ではredis1のredis-clに接続します。

redis1
# redis-cliredis 127.0.0.1:6379>

redis2
# redis-cli -h 10.0.0.200redis 10.0.0.200:6379>

メッセージの購読はチャンネル名を指定します。
redis2でgreetingというチャンネル名でsubscribeコマンドを実行します。

redis2
redis 10.0.0.200:6379> subscribe greeting
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "greeting"
3) (integer) 1

こうすることによって、redis2はredis1のgreetingというチャンネルのメッセージの待ち受けが始まります。

続いて、redis1でメッセージを発行します。
greetingというチャンネルで「Hello World!」という内容のメッセージを発行します。

redis1
redis 127.0.0.1:6379> publish greeting "Hello World!"
(integer) 1
redis 127.0.0.1:6379>

すると即座にredis2のクライアントにHello Worldというメッセージが受信されたことがわかります。

redis2
redis 10.0.0.200:6379> subscribe greeting
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "greeting"
3) (integer) 1
1) "message"
2) "greeting"
3) "Hello World!"

このように、redisでは他のサーバーのredisクライアントとメッセージのやりとりが可能です。
クライアントコンソールでは効能がわかりにくいので、次回はアプリケーションレベルで試してみたいと思います。

以上です。

2012年11月10日土曜日

Node.jsってなんじゃ?(redisでSocket.IOをスケール)


前回は、redisをインストールして生でつかってみました。
今回はnode.jsでredisを利用してみたいと思います。

マルチユーザーのサーバーでのプッシュ配信はSocket.IOが定番ですが、
サーバーが増えた時にある問題が生じます。
例えばサーバーを2つに増やして、サーバーAでブロードキャストしても
サーバーBのクライアントでは受信できないのです。

以前の記事で作成したチャットプログラムを例にしてみます。

サーバー側のjs
$ cat /home/appadmin/chat/node/chat.js
var server = require('http').createServer(function(req, res){
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.end('server connected');
});
server.listen(3001);

var io = require('socket.io').listen(server);
io.sockets.on('connection', function (socket) {
  socket.emit('info', { msg: 'welcome' });
  socket.on('msg', function (msg) {
    io.sockets.emit('msg', {msg: msg});
  });
  socket.on('disconnect', function(){
    socket.emit('info', {msg: 'bye'});
  });
});


クライアント側のjs

$ cat /home/appadmin/chat/public/assets/js/client.js
$(function(){
    var socket = io.connect('http://'+location.hostname+':3001/');
    socket.on('connect', function() {
      $("#log").html($("#log").html() + "<br />" + 'connected');
      socket.on('info', function (data) {
        $("#log").html($("#log").html() + "<br />" + data.msg);
      });
      socket.on('msg', function(data){
        $("#log").html($("#log").html() + "<br />" + "<b>" + data.msg + "</b>");
      });
      $("#send").click(function(){
        var msg = $("#msg").val();
        if(!msg){
          alert("input your message");
          return;
        }
        socket.emit('msg', msg);
      });
    });
  });


画面
$ cat /home/appadmin/chat/public/index.html
<!DOCTYPE html>
<html>
<head>
     <meta charset="UTF-8">
     <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.4.2/jquery.min.js" type="text/javascript" charset="utf-8"></script>
     <script type="text/javascript">
     $(function(){
         function load(){
             $.getScript("assets/js/client.js");
         }
         $.getScript("http://" + location.hostname + ":3001/socket.io/socket.io.js", function(){
             load();
         });
     });
     </script>
     <title>Node A</title>
</head>
<body>
     <input id="msg" type="text" style="width:400px;"></input>
     <input id="send" type="button" value="send" /></br >
     <div id="log" style="width:400px;height:400px;overflow:auto;border:1px solid #000000;"></div>
</html>


サーバー側のjsをforeverで起動します。
$ forever start chat.js

これをサーバーAとします。
同じ内容を別のサーバーBに配置し、同じようにnodeを起動します。

2つのサーバーにアクセスすると、以下のように同じ画面が表示されます。




サーバーAで「a」と入力します。
サーバー側では接続した全ユーザーに投稿内容をブロードキャストし、ユーザーの画面に「a」が表示されます。
しかし、サーバーBには何も表示されません。
おなじようにサーバーBで、「b」と投稿してもサーバーAに接続した画面にはなにも表示されません。
接続がサーバーAとサーバーBで共有されていないためです。

ここで登場するのがredisです。
Socket.IOではデフォルトで接続情報をローカルメモリに保存しています。
これをMemoryStoreと呼びますが、Socket.IOにはRedisStoreというredisに接続情報を保存するオプションも存在します。
このオプションを選択することで、分散されたnodeサーバーが同じredisサーバーを参照し
各nodeの接続情報を共有することができます。


それではサーバー側のjsを修正してRedisStoreを使ってみます。
接続するredisは前回設定したredisサーバーにします。

var server = require('http').createServer(function(req, res){
  res.writeHead(200, {'Content-Type': 'text/html'});
  res.end('server connected');
});
server.listen(3001);

var io = require('socket.io').listen(server);

//RedisStoreを読み込みます
var RedisStore = require('socket.io/lib/stores/redis');
//redisサーバーの接続先情報を定義します
opts = {host:'10.0.0.200', port:6379};
//storeをRedisStoreにし、redisPub, redisSub, redisClientをredisサーバーに向けます
io.set('store', new RedisStore({redisPub:opts, redisSub:opts, redisClient:opts}));

io.sockets.on('connection', function (socket) {
  socket.emit('info', { msg: 'welcome' });
  socket.on('msg', function (msg) {
    io.sockets.emit('msg', {msg: msg});
  });

  socket.on('disconnect', function(){
    socket.emit('info', {msg: 'bye'});
  });
});


これで再起動します。
$ forever restart chat.js


再度2つの画面をリロードして、サーバーAの画面に「a」サーバーBの画面に「b」と入力してみます。




おお、両方の画面に「a」「b」が表示されました!
これで台数が増えてもすべてのユーザーが同じ空間でコミュニケーションすることができます。

以上です。