図解 MapR のメモリ管理

MapR Hadoopディストリビューションにおいて、メモリがどのように割り当てられているかは一見わかりにくいので、図を使いながら詳細を解説していきましょう。なお、説明はMapR 5.0のYARN構成がベースになっています。YARNアプリケーションのメモリ割り当ての説明以降は、どのHadoopディストリビューションでも同じなので広く参考になると思います。

MapRのサービスのメモリ割り当て

MapRでは各ノードで管理や処理を担うプロセスを「サービス」として定義しています。一般的なHadoopにも存在するYARNのResourceManagerやNodeManagerといったサービスもあれば、HDFSの代わりにファイルシステムの機能を提供するMapR-FSやCLDBのようなサービスや、NFSサーバ機能を提供するNFSサービス、Web UI機能を提供するMapR Control Systemサービスなどが存在します。

その中で、すべてのサービスのライフサイクルを管理する役割を提供するWardenというサービスがあります。いわばサービスをまとめて管理するサービスです。Wardenサービスの重要な役割の一つに、各サービスにどれだけメモリリソースを割り当てるかを管理する機能があります。

Wardenはクラスタ起動時に物理メモリ容量を取得し、設定ファイルをもとに各サービスが利用可能なメモリ容量を計算し、サービスごとに割り当てていきます。設定ファイルの一つ/opt/mapr/conf/warden.confを見ながら、例としてZooKeeperサービスに割り当てられるメモリがどのように計算されるかを見ていきます。

service.command.zk.heapsize.percent=1
service.command.zk.heapsize.max=1500
service.command.zk.heapsize.min=256

上記はZooKeeperのメモリ割り当てを記述している部分ですが、原則としてノードの物理メモリに対するpercentの割合がそのサービスに割り当てられます。ただし、上限としてmax、下限としてminの値が決められていますので、その範囲を外れる場合は上限または下限に値が調整されます。

例として、物理メモリがそれぞれ192GB、64GB、16GBの3つのパターンについて見てみましょう。

f:id:nagixx:20150811181052p:plain

物理メモリが64GBのノードの場合は64GB×1%=655MBがZooKeeperに割り当てられます。一方、192GBのノードの場合は192GB×1%=1966MBになりますが、これは上限の1500MBを超えていますので、割り当て値は1500MBになります。同様に、16GBのノードの場合は16GB×1%=164MBで下限を下回るため、割り当て値は256MBになります。

上記の計算がすべてのサービスに対して適用されます。ノードによってインストールされるサービスが異なるため、各ノードでのメモリ割り当ても異なる結果になります。

MapR 特有のコアサービスに関しての設定は/opt/mapr/conf/warden.confファイルに記述されています。この中に、OSに割り当てるメモリの設定があります。この項目は実際にはサービスではありませんが、OSの稼働に必要なメモリ容量は計算上確保しておく必要があるため存在しています。また、MapR-FSサービスの設定には割合と下限の項目はありますが、上限はありません。

以下に、パラメータとデフォルト値の一覧を示します。

/opt/mapr/conf/warden.conf

パラメータデフォルト値説明
service.command.cldb.heapsize.percent 8 CLDBに割り当てる物理メモリの割合(%)
service.command.cldb.heapsize.max 4000 CLDBが利用可能な最大メモリ容量(MB)
service.command.cldb.heapsize.min 256 CLDBが最低限確保するメモリ容量(MB)
service.command.mfs.heapsize.percent 35 MapR-FSに割り当てる物理メモリの割合(%)
service.command.mfs.heapsize.maxpercent 85 余剰メモリ容量がある場合にMapR-FSが利用可能な最大の物理メモリの割合(%)。YARN環境では使われない
service.command.mfs.heapsize.max 512 MapR-FSが最低限確保するメモリ容量(MB)
service.command.webserver.heapsize.percent 3 MapR Control Systemに割り当てる物理メモリの割合(%)
service.command.webserver.heapsize.max 750 MapR Control Systemが利用可能な最大メモリ容量(MB)
service.command.webserver.heapsize.min 512 MapR Control Systemが最低限確保するメモリ容量(MB)
service.command.nfs.heapsize.percent 3 NFSに割り当てる物理メモリの割合(%)
service.command.nfs.heapsize.max 1000 NFSが利用可能な最大メモリ容量(MB)
service.command.nfs.heapsize.min 64 NFSが最低限確保するメモリ容量(MB)
service.command.os.heapsize.percent 10 OSに割り当てる物理メモリの割合(%)
service.command.os.heapsize.max 4000 OSが利用可能な最大メモリ容量(MB)
service.command.os.heapsize.min 256 OSが最低限確保するメモリ容量(MB)
service.command.warden.heapsize.percent 1 Wardenに割り当てる物理メモリの割合(%)
service.command.warden.heapsize.max 750 Wardenが利用可能な最大メモリ容量(MB)
service.command.warden.heapsize.min 64 Wardenが最低限確保するメモリ容量(MB)
service.command.zk.heapsize.percent 1 ZooKeeperに割り当てる物理メモリの割合(%)
service.command.zk.heapsize.max 1500 ZooKeeperが利用可能な最大メモリ容量(MB)
service.command.zk.heapsize.min 256 ZooKeeperが最低限確保するメモリ容量(MB)

YARNコンポーネントのサービスやHadoopエコシステムのサービスに関しては、/opt/mapr/conf/conf.d/ディレクトリ以下のwarden.<サービス名>.confという名前の、サービスごとの個別ファイルに設定があります。MapRコアサービスとは異なり、メモリ割り当てのデフォルト値は記載されていません。ノード上のメモリ割り当てを厳密に管理したい場合には、明示的に下記のパラメータを指定する必要があります。

/opt/mapr/conf/conf.d/warden.<サービス名>.conf

パラメータ説明
service.heapsize.percent サービスに割り当てる物理メモリの割合(%)
service.heapsize.max サービスが利用可能な最大メモリ容量(MB)
service.heapsize.min サービスが最低限確保するメモリ容量(MB)

サービス名と対応するパッケージ名は下表を参照してください。

サービス名説明パッケージ名
drill-bits Drillサービス mapr-drill
gateway MapR Gatewayサービス mapr-gateway
hue Hueサービス mapr-hue
httpfs HttpFSサービス mapr-httpfs
hbasethrift HBase Thriftサービス mapr-hbasethrift
hbase-rest HBase REST Gatewayサービス mapr-hbase-rest
historyserver YARN Job History Serverサービス mapr-historyserver
hivemeta Hive Metastoreサービス mapr-hivemetastore
hs2 HiveServer2サービス mapr-hiveserver2
nodemanager YARN Node Managerサービス mapr-nodemanager
oozie Oozieサービス mapr-oozie
resourcemanager YARN Resource Managerサービス mapr-resourcemanager
spark-master Spark Masterサービス mapr-spark-master

MapRサービス用メモリの積み上げ

このように各サービスで個別に計算した割り当てメモリサイズを積み上げて、サービス用割り当て容量の合計サイズが求められます。

ここで、CLDBおよびZooKeeperサービスが稼働するノードのみに適用する例外があります。もしどちらかのサービスがそのノードに構成されていると、1500MB(ただしサービス用割り当て容量の合計が6GB未満であれば、その25%)がマージンとして確保されます。これは、CLDBまたはZooKeeperはクラスタの稼働のための重要なプロセスであり、万が一にもメモリ不足による停止もしくは応答の中断が発生することを避け、余裕を持たせるためです。

さて、物理メモリ容量から、サービス用割り当て容量とCLDB/ZooKeeper用のマージンを差し引いた残りは、YARNアプリケーションに提供されることになります。一般的なHadoopでは、YARNアプリケーション用のメモリサイズはパラメータyarn.nodemanager.resource.memory-mb (yarn-site.xml)にてあらかじめ定義しておく必要がありますが、MapRの場合は上記の通りWardenが計算した結果が、yarn.nodemanager.resource.memory-mbパラメータとしてYARN NodeManagerに自動的に引き渡されます。

f:id:nagixx:20150811185740p:plain

具体的な計算例を見ていきましょう。64GB (=65536MB)の物理メモリを搭載したマシンがある場合、各サービスに割り当てられるメモリは次の通りになります。

サービス名percentminmax計算結果
cldb 8 256 4000 4000
mfs 35 512 - 22937
webserver 3 512 750 750
nfs 3 64 1000 1000
os 10 256 4000 4000
warden 1 64 750 655
zk 1 256 1500 655
サービス合計 33997
cldb/zkマージン 1500
YARNアプリケーション用メモリ 30039

この場合、30039MBがYARNアプリケーションに割り当てられることになります。

MapRサービスで実際に使われるメモリ容量

ところで、Wardenで計算されたサービスのメモリ割り当ては、実際に使用されるメモリ容量に反映されるのでしょうか。実は、Wardenの設定がサービス起動時にプロセスに反映されるサービスと、反映されないサービスの2通りがあります。

例えばMapR-FSサービスは、Warden設定から計算された割り当て容量がMapR-FSプロセスに渡され、実際にOSのコマンドで確認すると設定されたサイズでメモリが確保されていることがわかります。一方、NodeManagerサービスは、Warden設定が計算上利用されますが、JVM起動時の最大ヒープサイズの指定にはyarn-env.shの環境変数YARN_NODEMANAGER_HEAPSIZEが使用されます。

ということで、実際に使われるメモリ容量が指定されているプロパティを表としてまとめました。ちなみにJVMで動作するサービスの場合、指定されたメモリ容量がJVMの最大ヒープサイズとして設定されますが、JVMはメモリが必要になったときに初めて物理メモリがマップされるため、常にすべての容量が確保されるわけではありません。念のため。

サービス名プロパティデフォルト値ファイル名
cldb Warden設定
mfs Warden設定
webserver Warden設定
nfs Warden設定
os 環境による
warden JVMのデフォルト値(物理メモリの1/4)
zk JVMのデフォルト値(物理メモリの1/4)
historyserver HADOOP_JOB_HISTORYSERVER_HEAPSIZE 1000 mapred-env.sh
nodemanager YARN_NODEMANAGER_HEAPSIZE 1000 yarn-env.sh
resourcemanager YARN_RESOURCEMANAGER_HEAPSIZE 1000 yarn-env.sh
drill DRILL_MAX_DIRECT_MEMORY 8G drill-env.sh
DRILL_HEAP 4G

YARNアプリケーションのメモリ割り当て

YARNアプリケーションに対するメモリ割り当てのしくみも結構込み入っているため、正確に把握している人は意外に少ないような気がします。YARNフレームワークは汎用的なリソース管理のしくみを持っていますが、YARN上で動作する代表的なアプリケーションの1つであるMapReduce (MRv2)を例として、メモリが割り当てられる流れを見ていきます。

関連するパラメータは以下の通りです。

/opt/mapr/hadoop/hadoop-2.7.0/etc/hadoop/yarn-site.xml

パラメータデフォルト値説明
yarn.nodemanager.resource.memory-mb Wardenによる計算 YARNコンテナに割り当てることのできるメモリ容量(MB)
yarn.scheduler.minimum-allocation-mb 1024 メモリ要求に対しYARNスケジューラが割り当てる最小メモリサイズ(MB)。CapacitySchedulerの場合、割り当てるメモリサイズの単位にもなる
yarn.scheduler.increment-allocation-mb 1024 (FairSchedulerのみ)メモリ要求に対しYARNスケジューラが割り当てるメモリサイズの単位(MB)
yarn.scheduler.maximum-allocation-mb 8192 メモリ要求に対しYARNスケジューラが割り当てる最大メモリサイズ(MB)

/opt/mapr/hadoop/hadoop-2.7.0/etc/hadoop/mapred-site.xml

パラメータデフォルト値説明
yarn.app.mapreduce.am.resource.mb 1536 MRv2のApplicationMasterのコンテナのメモリサイズ(MB)
mapreduce.map.memory.mb 1024 MRv2のMap Taskのコンテナのメモリサイズ(MB)
mapreduce.reduce.memory.mb 3072 MRv2のReduce Taskのコンテナのメモリサイズ(MB)

NodeManagerが起動する際には、まずパラメータyarn.nodemanager.resource.memory-mbが利用できるメモリ容量として読み込まれます。前述の通り、MapRの場合はWardenが自動計算したサイズが使われます。

MRv2アプリケーションの実行時には、アプリケーションの処理を行うYARNコンテナに必要なメモリサイズが、ResourceManagerのYARNスケジューラに対して要求されます。MRv2のApplicationMasterを実行するコンテナのメモリサイズはyarn.app.mapreduce.am.resource.mb、Map Taskを実行するコンテナのメモリサイズはmapreduce.map.memory.mb、Reduce Taskを実行するコンテナのメモリサイズはmapreduce.reduce.memory.mbで指定された値が読み込まれ、要求が行われます。

f:id:nagixx:20150812094448p:plain

割り当てるメモリサイズはFairSchedulerの場合yarn.scheduler.increment-allocation-mb、CapacitySchedulerの場合yarn.scheduler.minimum-allocation-mbの倍数である必要があり、要求されたメモリサイズがこれらのパラメータのちょうど倍数になっていない場合には、要求を受けたYARNスケジューラは、最も近い倍数に繰り上げたサイズを割り当てます。また、メモリサイズが下限のyarn.scheduler.minimum-allocation-mbおよび上限のyarn.scheduler.maximum-allocation-mbで指定される範囲に収まっているかを確認し、範囲を外れる場合には上限または下限に値を調整します。これらは「リソースの正規化」と呼ばれます。

見方を変えると、yarn.app.mapreduce.am.resource.mb、mapreduce.map.memory.mb、mapreduce.reduce.memory.mbの値がyarn.scheduler.increment-allocation-mbやyarn.scheduler.minimum-allocation-mbの倍数になっていない場合には必要以上の容量がコンテナに割り当てられることになるため、メモリ利用効率を最大にするためにはこれらの倍数にあわせることが重要になります。

YARNスケジューラは、各ノードのNodeManagerが管理する利用可能なリソースの中から、上記の通り計算されたリソースをコンテナに割り当て、処理が実行されます。

YARNコンテナで実際に使われるメモリ容量

MapReduceアプリケーション用に設定するメモリ関連の項目には、他にも次のようなパラメータが存在します。これらはJVMで利用する最大ヒープサイズを指定していますが、なぜコンテナ用のメモリサイズとJVMの最大ヒープサイズの2種類の設定項目があるのでしょうか。 

/opt/mapr/hadoop/hadoop-2.7.0/etc/hadoop/mapred-site.xml

パラメータデフォルト値説明
yarn.app.mapreduce.am.command-opts -Xmx1024m MRv2のApplicationMasterのJVM最大ヒープサイズ
mapreduce.map.java.opts -Xmx900m MRv2のMap TaskのJVM最大ヒープサイズ
mapreduce.reduce.java.opts -Xmx2560m MRv2のReduce TaskのJVM最大ヒープサイズ

その理由は、JVMのヒープサイズはJVMの世代別GCで扱われる領域のうち、Eden、Survivor、Old領域のみを対象としており、それ以外に使われるメモリ容量は別に管理する必要があるためです。

それ以外に使用される可能性があるメモリとしては、Permanent領域(クラス/メソッド/static変数)、Cヒープ(JVM自体の内部使用メモリ)、スレッドスタック(メソッドの呼び出し階層情報/変数)、JNI呼び出しのネイティブコード、Forkされた子プロセスのメモリ、などがあります。これらが知らないうちにノード内のメモリを使い尽くしてしまうことを防ぐため、各ノードに存在するcontainer-executorというプロセスがYARNコンテナで利用されるメモリを管理・監視しています。JVM自体もcontainer-executorがForkする子プロセスであるため、container-executorはプロセスツリーをもとに使用メモリ容量を監視して、各コンテナに割り当てられたメモリサイズに収まっているかを確認し、超過していればJVMプロセスおよびその子プロセスを強制的に終了させます。

f:id:nagixx:20150812095125p:plain

では、コンテナ用のメモリサイズに対してJVMの最大ヒープサイズをどれくらいに設定しておけばよいか、ということですが、アプリケーションコードにも依存しますが、だいたいコンテナ用のメモリサイズの75〜80%程度にしておけば問題ないケースが多いです。

以上、YARNアプリケーションとしてMapReduceの例で説明をしましたが、TezやSparkなどでは異なるパラメータの設定があります。ただ、YARNスケジューラの部分の考え方は変わりませんので、参考にしていただければと思います。

政府統計情報 e-Stat を Apache Drill で分析してみる (3)

前回の記事からの続きです。

ビューを作成

前回までに、平成22年の国勢調査における人口増減に関する統計データを取得するところまでいきました。今回はSQLを使って統計データの集計をしていきます。

まずデータをSQLで扱いやすくするために、使用するデータはビューとして定義しておくことにします。 「CREATE VIEW」句を使用しますが、ビューの名前は「dfs.tmp.`ビュー名`」のようになっています。dfs.tmpはスキーマの名前で、ファイルシステムのテンポラリディレクトリにビューの定義ファイルが保存されることを意味しています。ここでは地域名を取り出すためのビューと、統計データを取り出すためのビューを定義します。

0: jdbc:drill:zk=local> CREATE VIEW dfs.tmp.`area_info` AS SELECT
. . . . . . . . . . . >   t.data.`@code` code,
. . . . . . . . . . . >   t.data.`@name` name,
. . . . . . . . . . . >   t.data.`@level` level,
. . . . . . . . . . . >   t.data.`@parentCode` parent
. . . . . . . . . . . > FROM (
. . . . . . . . . . . >   SELECT
. . . . . . . . . . . >     FLATTEN(f.GET_STATS_DATA.STATISTICAL_DATA.CLASS_INF.CLASS_OBJ[2].CLASS) data
. . . . . . . . . . . >   FROM
. . . . . . . . . . . >     dfs.`/tmp/stats_data_modified.json` f
. . . . . . . . . . . > ) t;
+-------+------------------------------------------------------------+
|  ok   |                          summary                           |
+-------+------------------------------------------------------------+
| true  | View 'area_info' created successfully in 'dfs.tmp' schema  |
+-------+------------------------------------------------------------+
1 row selected (0.103 seconds)
0: jdbc:drill:zk=local> CREATE VIEW dfs.tmp.`stats_data` AS SELECT
. . . . . . . . . . . >   t.data.`@tab` tab,
. . . . . . . . . . . >   t.data.`@cat01` did,
. . . . . . . . . . . >   t.data.`@area` area,
. . . . . . . . . . . >   t.data.$ val
. . . . . . . . . . . > FROM (
. . . . . . . . . . . >   SELECT
. . . . . . . . . . . >     FLATTEN(f.GET_STATS_DATA.STATISTICAL_DATA.DATA_INF.`VALUE`) data
. . . . . . . . . . . >   FROM
. . . . . . . . . . . >     dfs.`/tmp/stats_data_modified.json` f
. . . . . . . . . . . > ) t;
+-------+-------------------------------------------------------------+
|  ok   |                           summary                           |
+-------+-------------------------------------------------------------+
| true  | View 'stats_data' created successfully in 'dfs.tmp' schema  |
+-------+-------------------------------------------------------------+
1 row selected (0.155 seconds)

ビューの定義ファイルは/tmpに「.view.drill」という拡張子のついたJSONファイルとして保存されています。

$ ls /tmp
area_info.view.drill
stats_data_modified.view.drill

ビューの内容を確認してみます。

0: jdbc:drill:zk=local> SELECT * FROM dfs.tmp.`area_info` LIMIT 10;
+--------+--------+--------+---------+
|  code  |  name  | level  | parent  |
+--------+--------+--------+---------+
| 00000  | 全国     | 1      | null    |
| 00001  | 全国市部   | 1      | null    |
| 00002  | 全国郡部   | 1      | null    |
| 01000  | 北海道    | 2      | 00000   |
| 01001  | 北海道市部  | 3      | 01000   |
| 01002  | 北海道郡部  | 3      | 01000   |
| 01100  | 札幌市    | 3      | 01000   |
| 01101  | 中央区    | 4      | 01100   |
| 01102  | 北区     | 4      | 01100   |
| 01103  | 東区     | 4      | 01100   |
+--------+--------+--------+---------+
10 rows selected (11.902 seconds)
0: jdbc:drill:zk=local> SELECT * FROM dfs.tmp.`stats_data` LIMIT 10;
+------+--------+--------+------------+
| tab  |  did   |  area  |    val     |
+------+--------+--------+------------+
| 020  | 00710  | 00000  | 128057352  |
| 020  | 00710  | 00001  | 116156631  |
| 020  | 00710  | 00002  | 11900721   |
| 020  | 00710  | 01000  | 5506419    |
| 020  | 00710  | 01001  | 4449360    |
| 020  | 00710  | 01002  | 1057059    |
| 020  | 00710  | 01100  | 1913545    |
| 020  | 00710  | 01101  | 220189     |
| 020  | 00710  | 01102  | 278781     |
| 020  | 00710  | 01103  | 255873     |
+------+--------+--------+------------+
10 rows selected (87.308 seconds)

検索・集計する

さて、ここまで来たら、あとはもう普通のSQLです。まずは市区町村の人口増加率トップ10を出してみましょう。

下記のクエリの条件の部分では、表章項目コードが102(=人口)、人口集中地区区分を表すDIDコードが00710(=全域)、値が「-」(=無効)ではないレコードを取り出しています。また、ソートをする際には値をDOUBLE型にキャストして、正負を適切に比較できるようにしています。

0: jdbc:drill:zk=local> SELECT a.name 地域, b.val 増減率
. . . . . . . . . . . > FROM dfs.tmp.`area_info` a
. . . . . . . . . . . > JOIN dfs.tmp.`stats_data` b
. . . . . . . . . . . > ON a.code = b.area AND b.tab = '102' AND
. . . . . . . . . . . >    b.did = '00710' AND b.val <> '-'
. . . . . . . . . . . > ORDER BY CAST(b.val AS DOUBLE) DESC LIMIT 10;
+-----------------+-----------+
|       地域        |    増減率    |
+-----------------+-----------+
|   (旧 421 山古志村)  | 11710     |
|   (旧 463 旭町)    | 64.137    |
| 朝日町             | 35.31066  |
| 中央区             | 24.7594   |
|   (旧 483 谷和原村)  | 22.32891  |
| 御蔵島村            | 19.17808  |
| 中央区             | 17.76318  |
|   (旧 362 木津町)   | 17.5982   |
|   (旧 407 三雲町)   | 16.44737  |
| 守谷市             | 16.35382  |
+-----------------+-----------+
10 rows selected (363.228 seconds)

増減率トップの11710%増というのは何かの間違いか?と思いましたが、山古志村というのはあの平成16年の新潟県中越地震が直撃したところでしたよね。この統計データは平成17年から平成22年の間の増減率なので、平成17年時点では全村民に避難指示が出ていたものと思われます。ちなみに第2位の島根県の旧旭町の人口の急増は、平成22年に開所した刑務所の影響のようです。

次に、人口密度が1,000人/平方キロ以上で人口減少率トップ10を出してみます。人口増減率と人口密度のデータは同じstats_dataビューに入っているため、条件を変えてセルフジョインしています。

0: jdbc:drill:zk=local> SELECT a.name 地域, b.val 増減率, c.val 人口密度
. . . . . . . . . . . > FROM dfs.tmp.`area_info` a
. . . . . . . . . . . > JOIN dfs.tmp.`stats_data` b
. . . . . . . . . . . > ON a.code = b.area AND b.tab = '102' AND
. . . . . . . . . . . >    b.did = '00710' AND b.val <> '-'
. . . . . . . . . . . > JOIN dfs.tmp.`stats_data` c
. . . . . . . . . . . > ON a.code = c.area AND c.tab = '104' AND
. . . . . . . . . . . >    c.did = '00710' AND CAST(c.val AS DOUBLE) > 1000.0
. . . . . . . . . . . > ORDER BY CAST(b.val AS DOUBLE) LIMIT 10;
+----------------+-----------+----------+
|       地域       |    増減率    |   人口密度   |
+----------------+-----------+----------+
| 西成区            | -8.13079  | 16594.8  |
|   (旧 564 鵜殿村)  | -7.77341  | 1549.0   |
|   (旧 422 稲築町)  | -7.35908  | 1023.6   |
| 琴平町            | -7.25784  | 1178.1   |
| 糸田町            | -5.86335  | 1196.1   |
| 真鶴町            | -5.76084  | 1169.8   |
| 大牟田市           | -5.68464  | 1516.1   |
| 川西町            | -5.67909  | 1456.7   |
| 芦屋町            | -5.40407  | 1345.8   |
|   (旧 321 赤岡町)  | -5.32491  | 1918.9   |
+----------------+-----------+----------+
10 rows selected (674.226 seconds)

減少率の高い大阪府西成区では雇用減少と高齢化、三重県の旧鵜殿村は「日本一人口密度が高い村」として有名だったみたいですが人口減少の影響は大きく受けているようですね。

最後に東京都23区の人口と人口増減率を並べてみましょう。23区だけを絞り込むために、まず「千代田区」の上位地域コードを取り出し、同じ上位地域コード「13100」を持つ地域を抜き出すように条件を指定します。

0: jdbc:drill:zk=local> SELECT * FROM dfs.tmp.`area_info` a WHERE a.name = _UTF16'千代田区';
+--------+-------+--------+---------+
|  code  | name  | level  | parent  |
+--------+-------+--------+---------+
| 13101  | 千代田区  | 4      | 13100   |
+--------+-------+--------+---------+
1 row selected (15.284 seconds)
0: jdbc:drill:zk=local> SELECT a.name 地域, b.val 人口, c.val 増減率
. . . . . . . . . . . > FROM (SELECT * FROM dfs.tmp.`area_info` t WHERE t.parent = '13100') a
. . . . . . . . . . . > JOIN dfs.tmp.`stats_data` b
. . . . . . . . . . . > ON a.code = b.area AND b.tab = '020' AND
. . . . . . . . . . . >    b.did = '00710'
. . . . . . . . . . . > JOIN dfs.tmp.`stats_data` c
. . . . . . . . . . . > ON a.code = c.area AND c.tab = '102' AND
. . . . . . . . . . . >    c.did = '00710' AND c.val <> '-'
. . . . . . . . . . . > ORDER BY CAST(c.val AS DOUBLE) DESC;
+-------+---------+-----------+
|  地域   |   人口    |    増減率    |
+-------+---------+-----------+
| 中央区   | 122762  | 24.7594   |
| 豊島区   | 284678  | 13.60536  |
| 千代田区  | 47115   | 12.77467  |
| 港区    | 205131  | 10.36796  |
| 江東区   | 460819  | 9.49851   |
| 足立区   | 683426  | 9.38194   |
| 文京区   | 206626  | 8.96157   |
| 墨田区   | 247606  | 7.10853   |
| 新宿区   | 326309  | 6.73599   |
| 台東区   | 175928  | 6.50297   |
| 荒川区   | 203296  | 6.32247   |
| 品川区   | 365302  | 5.46979   |
| 世田谷区  | 877138  | 4.27657   |
| 葛飾区   | 442586  | 4.16778   |
| 大田区   | 693373  | 4.16105   |
| 杉並区   | 549569  | 3.96945   |
| 江戸川区  | 678967  | 3.82647   |
| 練馬区   | 716124  | 3.43546   |
| 板橋区   | 535824  | 2.43575   |
| 目黒区   | 268330  | 1.61552   |
| 北区    | 335544  | 1.55321   |
| 中野区   | 314750  | 1.32732   |
| 渋谷区   | 204492  | 0.56951   |
+-------+---------+-----------+
23 rows selected (878.589 seconds)

東京都下では中央区の人口の伸びがダントツです。特に日本橋エリアのマンション開発が進んだ影響のようですね。豊島区の伸びは池袋近辺の中国人人口の増加によるものが大きいとのことです。

いかがでしたでしょうか。Apache Drillの強みは、どんな形式のデータが格納されているかわからない状況で、スキーマが事前に整っていなくても比較的柔軟にユーザーが思い通りにデータの探索をできることです。ぜひ手元の環境で試してみてください。

政府統計情報 e-Stat を Apache Drill で分析してみる (2)

前回の記事の続きからです。

メタ情報の取得

前回は、平成22年度の国勢調査の人口増減のデータを含む統計表ID「0003038586」を探し出すところまでいきました。

次は、この表がどのような構造をしているか調べるために、表のメタ情報を次のAPIを使用して取得します。必要なパラメータは自分のアプリケーションIDと統計表ID「0003038586」です。

$ curl -o /tmp/meta_info.json "http://api.e-stat.go.jp/rest/2.0/app/json/getMetaInfo?appId=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&statsDataId=0003038586"

実際のところ、ここで取得したデータは一部のデータ型に問題があるので、そのままDrillのクエリを実行するとエラーになってしまいます。問題についての詳細と対処方法についてはこの記事でも書きましたが、JSON加工ツール「jq」で次のようにデータを整形して使用することにします。

$ jq '(.. | objects | .CLASS | objects) |= [.]' /tmp/meta_info.json > /tmp/meta_info_modified.json

それでは、APIの出力データの仕様を見つつデータを眺めてみます。メタ情報JSONファイルの最上位のオブジェクトのキーは「GET_META_INFO」となっていますので、前回同様KVGEN()関数とFLATTEN()関数を使って中の要素を表示してみましょう。

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_META_INFO)) FROM dfs.`/tmp/meta_info_modified.json` t;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"key":"RESULT","value":{"STATUS":0,"ERROR_MSG":"正常に終了しました。","DATE":"2015-06 |
| {"key":"PARAMETER","value":{"LANG":"J","STATS_DATA_ID":"0003038586","DATA_FO |
| {"key":"METADATA_INF","value":{"TABLE_INF":{"@id":"0003038586","STAT_NAME":{ |
+------------------------------------------------------------------------------+
3 rows selected (0.367 seconds)

メタ情報本体である「METADATA_INF」を見てみます。

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_META_INFO.METADATA_INF)) FROM dfs.`/tmp/meta_info_modified.json` t;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"key":"TABLE_INF","value":{"@id":"0003038586","STAT_NAME":{"@code":"0020052 |
| {"key":"CLASS_INF","value":{"STAT_NAME":{},"GOV_ORG":{},"TITLE":{},"MAIN_CAT |
+------------------------------------------------------------------------------+
2 rows selected (0.237 seconds)

「TABLE_INF」は指定した統計表の情報、「CLASS_INF」はデータのメタ情報です。「CLASS_INF」のほうを見てみましょう。

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_META_INFO.METADATA_INF.CLASS_INF)) FROM dfs.`/tmp/meta_info_modified.json` t;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"key":"CLASS_OBJ","value":[{"@id":"tab","@name":"表章項目","CLASS":[{"@code":"0 |
+------------------------------------------------------------------------------+
1 row selected (5.208 seconds)

「CLASS_OBJ」はデータのメタ情報のオブジェクトですが、複数のオブジェクトが配列として格納されているので、次はFLATTEN()関数のみで展開します。

0: jdbc:drill:zk=local> SELECT FLATTEN(t.GET_META_INFO.METADATA_INF.CLASS_INF.CLASS_OBJ) FROM dfs.`/tmp/meta_info_modified.json` t;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"@id":"tab","@name":"表章項目","CLASS":[{"@code":"020","@name":"人口","@level":"" |
| {"@id":"cat01","@name":"全域・人口集中地区2010","CLASS":[{"@code":"00710","@name":"全域 |
| {"@id":"area","@name":"地域(2010)","CLASS":[{"@code":"00000","@name":"全国","@le |
| {"@id":"time","@name":"時間軸(年次)","CLASS":[{"@code":"2010000000","@name":"2010 |
+------------------------------------------------------------------------------+
4 row selected (0.148 seconds)

メタ情報としては、「表章項目(=テーブルのカラム)」「全域・人口集中地区2010(=カテゴリ分け)」「地域(2010)(=地域コード)」「時間軸(年次)(=時間)」の4種類があることがわかります。それぞれの要素は「CLASS」に配列として格納されていますので、これも見てみることにします。「CLASS_OBJ」は配列ですので、それぞれインデックスを指定する必要があります。

まずは「表章項目」

0: jdbc:drill:zk=local> SELECT FLATTEN(t.GET_META_INFO.METADATA_INF.CLASS_INF.CLASS_OBJ[0].CLASS) FROM dfs.`/tmp/meta_info_modified.json` t;
+--------------------------------------------------------------------+
|                               EXPR$0                               |
+--------------------------------------------------------------------+
| {"@code":"020","@name":"人口","@level":"","@unit":"人"}               |
| {"@code":"100","@name":"組替人口(平成17年)","@level":"","@unit":"人"}      |
| {"@code":"101","@name":"平成17年~22年の人口増減数","@level":"","@unit":"人"}  |
| {"@code":"102","@name":"平成17年~22年の人口増減率","@level":"","@unit":"%"}  |
| {"@code":"103","@name":"面積","@level":"","@unit":"平方km"}            |
| {"@code":"104","@name":"人口密度","@level":""}                         |
+--------------------------------------------------------------------+
6 rows selected (0.166 seconds)

続いて「全域・人口集中地区2010」。人口集中地区というのは国勢調査特有の用語で、人口集中地区 - Wikipediaによれば「市区町村の区域内で人口密度が4,000人/km²以上の基本単位区が互いに隣接して人口が5,000人以上となる地区」とのことです。

0: jdbc:drill:zk=local> SELECT FLATTEN(t.GET_META_INFO.METADATA_INF.CLASS_INF.CLASS_OBJ[1].CLASS) FROM dfs.`/tmp/meta_info_modified.json` t;
+----------------------------------------------------+
|                       EXPR$0                       |
+----------------------------------------------------+
| {"@code":"00710","@name":"全域","@level":"1"}        |
| {"@code":"00711","@name":"人口集中地区","@level":"1"}    |
| {"@code":"00712","@name":"人口集中地区01","@level":"1"}  |
| {"@code":"00713","@name":"人口集中地区02","@level":"1"}  |
| {"@code":"00714","@name":"人口集中地区03","@level":"1"}  |
| {"@code":"00715","@name":"人口集中地区04","@level":"1"}  |
| {"@code":"00716","@name":"人口集中地区05","@level":"1"}  |
| {"@code":"00717","@name":"人口集中地区06","@level":"1"}  |
| {"@code":"00718","@name":"人口集中地区07","@level":"1"}  |
| {"@code":"00719","@name":"人口集中地区08","@level":"1"}  |
| {"@code":"00720","@name":"人口集中地区09","@level":"1"}  |
+--------------------------------------------------------------------+
11 rows selected (0.184 seconds)

次に、最も要素の多い「地域(2010)」。

0: jdbc:drill:zk=local> SELECT FLATTEN(t.GET_META_INFO.METADATA_INF.CLASS_INF.CLASS_OBJ[2].CLASS) FROM dfs.`/tmp/meta_info_modified.json` t;
+-----------------------------------------------------------------------+
|                                EXPR$0                                 |
+-----------------------------------------------------------------------+
| {"@code":"00000","@name":"全国","@level":"1"}                           |
| {"@code":"00001","@name":"全国市部","@level":"1"}                         |
| {"@code":"00002","@name":"全国郡部","@level":"1"}                         |
| {"@code":"01000","@name":"北海道","@level":"2","@parentCode":"00000"}    |
| {"@code":"01001","@name":"北海道市部","@level":"3","@parentCode":"01000"}  |
| {"@code":"01002","@name":"北海道郡部","@level":"3","@parentCode":"01000"}  |
| {"@code":"01100","@name":"札幌市","@level":"3","@parentCode":"01000"}    |
| {"@code":"01101","@name":"中央区","@level":"4","@parentCode":"01100"}    |
| {"@code":"01102","@name":"北区","@level":"4","@parentCode":"01100"}     |
| {"@code":"01103","@name":"東区","@level":"4","@parentCode":"01100"}     |
| ...                                                                   |
+-----------------------------------------------------------------------+
4,499 rows selected (19.901 seconds)

最後に「時間軸(年次)」。もともと2010年のデータしか入っていないので、要素数は1です。

0: jdbc:drill:zk=local> SELECT FLATTEN(t.GET_META_INFO.METADATA_INF.CLASS_INF.CLASS_OBJ[3].CLASS) FROM dfs.`/tmp/meta_info_modified.json` t;
+------------------------------------------------------+
|                        EXPR$0                        |
+------------------------------------------------------+
| {"@code":"2010000000","@name":"2010年","@level":"1"}  |
+------------------------------------------------------+
1 row selected (0.167 seconds)

個々の統計データには上記の4つの属性がそれぞれ含まれることになります。

統計データの取得

では、いよいよ統計表ID「0003038586」の統計データを取得しましょう。ここでは「人口」「人口増減率」「人口密度」だけを対象としたいので、パラメータとして表章項目コード「020,102,104」を指定しています。また、統計データには前述のメタデータも含まれるので、同じようにjqでデータを整形します。

$ curl -o stats_data.json "http://api.e-stat.go.jp/rest/2.0/app/json/getStatsData?appId=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx&statsDataId=0003038586&cdTab=020,102,104"
$ jq '(.. | objects | .CLASS | objects) |= [.]' /tmp/stats_data.json > /tmp/stats_data_modified.json

APIの出力データの仕様によると、統計データJSONファイルの最上位のオブジェクトのキーは「GET_STATS_DATA」です。

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_STATS_DATA)) FROM dfs.`/tmp/stats_data_modified.json` t;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"key":"RESULT","value":{"STATUS":0,"ERROR_MSG":"正常に終了しました。","DATE":"2015-06 |
| {"key":"PARAMETER","value":{"LANG":"J","STATS_DATA_ID":"0003038586","NARROWI |
| {"key":"STATISTICAL_DATA","value":{"NARROWING_COND":{},"RESULT_INF":{"TOTAL_ |
+------------------------------------------------------------------------------+
3 rows selected (0.495 seconds)

統計データ本体である「STATISTICAL_DATA」を見てみます。

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_STATS_DATA.STATISTICAL_DATA)) FROM dfs.`/tmp/stats_data_modified.json` t;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"key":"RESULT_INF","value":{"TOTAL_NUMBER":19356,"FROM_NUMBER":1,"TO_NUMBER |
| {"key":"TABLE_INF","value":{"@id":"0003038586","STAT_NAME":{"@code":"0020052 |
| {"key":"CLASS_INF","value":{"STAT_NAME":{},"GOV_ORG":{},"TITLE":{},"MAIN_CAT |
| {"key":"DATA_INF","value":{"STAT_NAME":{},"GOV_ORG":{},"TITLE":{},"MAIN_CATE |
+------------------------------------------------------------------------------+
4 rows selected (0.429 seconds)

「RESULT_INF」は取得結果の情報、「TABLE_INF」は指定した統計表の情報、「CLASS_INF」はデータのメタ情報、「DATA_INF」はデータ本体です。メタ情報の部分は前のステップで取得したものと共通です。「DATA_INF」の中を見てみましょう。

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_STATS_DATA.STATISTICAL_DATA.DATA_INF)) FROM dfs.`/tmp/stats_data_modified.json` t;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"key":"NOTE","value":[{"@char":"***","$":"当該数値がないもの"},{"@char":"-","$":"当該数 |
| {"key":"VALUE","value":[{"$":"128057352","@tab":"020","@cat01":"00710","@are |
+------------------------------------------------------------------------------+
2 rows selected (199.208 seconds)

「NOTE」は備考の文字列を列挙している配列、「VALUE」はデータの配列です。「VALUE」をFLATTEN()関数で展開してみます。「VALUE」はDrillでは予約語なので、バッククォートで囲う必要があります。

0: jdbc:drill:zk=local> SELECT FLATTEN(t.GET_STATS_DATA.STATISTICAL_DATA.DATA_INF.`VALUE`) FROM dfs.`/tmp/stats_data_modified.json` t;
+---------------------------------------------------------------------------------------------------+
|                                              EXPR$0                                               |
+---------------------------------------------------------------------------------------------------+
| {"@tab":"020","@cat01":"00710","@area":"00000","@time":"2010000000","@unit":"人","$":"128057352"}  |
| {"@tab":"020","@cat01":"00710","@area":"00001","@time":"2010000000","@unit":"人","$":"116156631"}  |
| {"@tab":"020","@cat01":"00710","@area":"00002","@time":"2010000000","@unit":"人","$":"11900721"}   |
| {"@tab":"020","@cat01":"00710","@area":"01000","@time":"2010000000","@unit":"人","$":"5506419"}    |
| {"@tab":"020","@cat01":"00710","@area":"01001","@time":"2010000000","@unit":"人","$":"4449360"}    |
| {"@tab":"020","@cat01":"00710","@area":"01002","@time":"2010000000","@unit":"人","$":"1057059"}    |
| {"@tab":"020","@cat01":"00710","@area":"01100","@time":"2010000000","@unit":"人","$":"1913545"}    |
| {"@tab":"020","@cat01":"00710","@area":"01101","@time":"2010000000","@unit":"人","$":"220189"}     |
| {"@tab":"020","@cat01":"00710","@area":"01102","@time":"2010000000","@unit":"人","$":"278781"}     |
| {"@tab":"020","@cat01":"00710","@area":"01103","@time":"2010000000","@unit":"人","$":"255873"}     |
| ...                                                                                               |
+---------------------------------------------------------------------------------------------------+
19,356 rows selected (303.648 seconds)

各要素には「表章項目」「全域・人口集中地区」「地域」「時間軸」の属性のほか、「$unit(=単位)」と「$(=値)」が含まれていることがわかります。

これでデータが揃いました。次の記事で、分析をしてみることにします。

マナーの悪い JSON データを jq で整形する

JSONデータの問題

色々なJSONデータを扱っていると、たまにマナーの悪いデータを目にします。例えば、政府統計情報e-Statでダウンロードできる次のようなJSONデータ(一部を切り出しています)。

{"CLASS_OBJ":[
  {"@id":"tab","@name":"表章項目","CLASS":[{...},{...},...]}, ←このオブジェクト内の"CLASS"の値は配列
  {"@id":"cat01","@name":"全域・人口集中地区2010","CLASS":[{...},{...},...]}, ←同じく配列
  {"@id":"area","@name":"地域(2010)","CLASS":[{...},{...},...]}, ←同じく配列
  {"@id":"time","@name":"時間軸(年次)","CLASS":{...}} ←このオブジェクト内の"CLASS"の値は配列ではないオブジェクト!
]}

おそらく、このAPIサービスを提供しているプログラムの内部では、「CLASS」のデータはどれも配列として値を持っているはずです。ところが、これをJSONデータとして出力する際に、要素が2つ以上あればJSONデータとしても配列([a, b, ...])で表されますが、要素が1つしかない場合はなぜかオブジェクト({...})として出てきてしまっている感じです。こちらとしては要素が一つでも配列として出てきてほしいのですが。

これの影響はデータをパースするところで出てきます。私が最近使っているApache Drillの例では、このデータに対してSQLクエリを実行しようとすると次のようなエラーが出ます。

0: jdbc:drill:zk=local> SELECT * FROM dfs.`/tmp/stats.json`;
Error: DATA_READ ERROR: You tried to write a VarChar type when you are using a ValueWriter of type SingleListWriter.

File  /tmp/stats.json
Record  1
Line  1
Column  430917
Field  @code
Fragment 0:0

[Error Id: 647d9c6f-8d53-4996-8367-8ba8a03edd9e on 192.168.0.10:31010] (state=,code=0)

これは本来リスト型が期待されるところに可変長文字列型が使われたので、データ読み込みエラーになったというメッセージです。

少し調べてみると、このような情報もありました。

JAX-RSのリファレンス実装であるJerseyの古いバージョンでは、JSONバインディングの実装に起因して冒頭のような問題が起きるようです。想像ですが、e-StatのAPIを提供しているプログラムではこのような実装が使われているのでしょう。ちなみに、JSONバインディングにJacksonを使ったり、比較的新しいJersey 2.xを使用すると問題ないとのこと。

データの問題のある部分を特定する

とはいえ、すでにこのように出力されてしまったデータは仕方ないので、個別に整形して使いやすくしなければなりません。そこで、「JSONsed」とも言われる、コマンドラインJSONプロセッサ「jq」で整形することにします。

jq - A lightweight and flexible command-line JSON processor
http://stedolan.github.io/jq/

f:id:nagixx:20150603122438p:plain

jqは軽量かつとても柔軟にJSONデータを加工できるので、JSONデータを扱う人であれば知っていて損はないと思います。導入は、コマンドのバイナリファイルを上記のサイトからダウンロードしてくるのが最も簡単です。

それでは実際にやってみましょう。まず、上記のDrillのエラーの例では、「Column 430917」という表示が出ていることから、先頭から430,917文字目の直前あたりに手がかりがありそうです。

$ cut -c 430800-430917 /tmp/stats.json
A","@level":"4","@parentCode":"47000"}]},{"@id":"time","@name":"\u6642\u9593\u8EF8(\u5E74\u6B21)","CLASS":{"@code":"20

上のようにデータを見てみると、直前にCLASSというキーがあるため、CLASSの値の型が問題ではないかと推測できます。ここでjqを使って、この推測が正しいことを確認します。

$ jq '.. | objects | .CLASS | select(type != "null") | type' /tmp/stats.json
"array"
"array"
"array"
"object"

jqは引数に様々なフィルタをパイプ(|)でつなげた形式で処理を指定します。

jq [オプション] 'フィルタ1 | フィルタ2 | フィルタ3 | ...' [入力ファイル]

上のコマンドで指定している各フィルタは次のような処理を行うことを意味しています。

フィルタ説明
.. 入力JSONデータのすべてのレベルを再帰的にたどり、処理の対象とする
objects object型のデータのみを選択する
.CLASS CLASSをキーとする値を出力する。ない場合はnullを出力する
select(type != "null") データ型がnullではないデータを選択する
type データ型を文字列で出力する。null、boolean、number、string、array、objectのいずれか

つまり、ここで行っている処理は入力データのすべての階層をたどってキーが「CLASS」である値のデータ型を出力するというものです。すると、先頭から3つはarray型であるにも関わらず、最後の値はobject型になっていることが分かります。これがエラーの原因ということが裏付けられました。

データを整形する

では、これをエラーにならないように整形してみましょう。jqを使うとかなり簡潔に処理を書くことができます。

$ jq '(.. | objects | .CLASS | objects) |= [.]' /tmp/stats.json > /tmp/stats-out.json

一見分かりにくいのですが、前半の括弧の中は先ほどのフィルタの処理とほぼ同じで、キーが「CLASS」で値がobject型となるフィールドを選択しています。後半の表記は、次のような意味を持っています。

表記説明
|= 左辺の値を右辺の値で更新する。右辺の入力は左辺のフィルタの出力結果であることがポイント
[.] .は入力データそのものを表すが、[ ]で囲むことで入力データを要素とするJSON配列を生成する

つまり、演算子「|=」の右辺には左辺の結果のオブジェクトが入力されるのですが、これを[ ]で囲んで1つのオブジェクトを要素として持つ配列にして、左辺の値を更新するという処理になります。

ちゃんと置換されたかどうかdiffで確認してみます。

$ diff stats.json stats-out.json
27148,27152c27148,27154
<             "CLASS": {
<               "@code": "2010000000",
<               "@name": "2010年",
<               "@level": "1"
<             }
---
>             "CLASS": [
>               {
>                 "@code": "2010000000",
>                 "@name": "2010年",
>                 "@level": "1"
>               }
>             ]

大丈夫そうですね。Drillでもエラーにならないことを確認しました。

0: jdbc:drill:zk=local> SELECT * FROM dfs.`/tmp/stats-out.json`;
+------------------------------------------------------------------------------+
|                                                                              |
+------------------------------------------------------------------------------+
| {"RESULT":{"STATUS":0,"ERROR_MSG":"正常に終了しました。","DATE":"2015-06-02T20:19:08.1 |
+------------------------------------------------------------------------------+
1 row selected (6.797 seconds)

jqのコマンドの詳細はこちらにありますので、色々試してマスターしてみましょう。

jq Manual
http://stedolan.github.io/jq/manual/

Apache Drill で日本語を扱うときの注意

言語の設定がUTF-8になっている環境であれば、データに日本語が含まれていても基本的に問題はありません。

$ echo $LANG
ja_JP.UTF-8

次のようなCSVファイルを

$ cat /tmp/test.csv
1,くまモン,熊本
2,ふなっしー,船橋
3,せんとくん,奈良

次のSQLで取得すると、カラム名、データともきちんと表示されます。まあ、マルチバイト文字が入っていると水平の表示位置がずれるのですが。

$ apache-drill-1.0.0/bin/drill-embedded
0: jdbc:drill:zk=local> SELECT
. . . . . . . . . . . >   COLUMNS[0] 番号,
. . . . . . . . . . . >   COLUMNS[1] 名前,
. . . . . . . . . . . >   COLUMNS[2] 住所
. . . . . . . . . . . > FROM dfs.`/tmp/test.csv`;
+-----+--------+-----+
| 番号  |   名前   | 住所  |
+-----+--------+-----+
| 1   | くまモン   | 熊本  |
| 2   | ふなっしー  | 船橋  |
| 3   | せんとくん  | 奈良  |
+-----+--------+-----+
3 rows selected (2.711 seconds)

ところがSQLの中で文字列リテラルとして日本語を記述すると、次のようなエラーが出ます。

0: jdbc:drill:zk=local> SELECT
. . . . . . . . . . . >   COLUMNS[0] 番号,
. . . . . . . . . . . >   COLUMNS[1] 名前,
. . . . . . . . . . . >   COLUMNS[2] 住所
. . . . . . . . . . . > FROM dfs.`/tmp/test.csv`
. . . . . . . . . . . > WHERE COLUMNS[2] = '熊本';
5 29, 2015 2:13:32 午後 org.apache.calcite.runtime.CalciteException <init>
重大: org.apache.calcite.runtime.CalciteException: Failed to encode '熊本' in character set 'ISO-8859-1'
Error: SYSTEM ERROR: org.apache.calcite.runtime.CalciteException: Failed to encode '熊本' in character set 'ISO-8859-1'


[Error Id: 0a2a7793-19e2-4792-9820-7d5af813b934 on mbp:31010] (state=,code=0)

これは「熊本」という文字列をISO-8859-1文字セットで扱おうとして失敗したという内容です。DrillはSQLパーサの部分にApache Calciteを利用しているため、 Calciteのドキュメントを見てみましょう。

Apache Calcite - SQL Language
http://calcite.incubator.apache.org/docs/reference.html

データ型のCHAR(n)の説明の部分にこんな記載があります。

データ型説明範囲と例
CHAR(n), CHARACTER(n) 固定長文字列 'Hello', '' (空文字列), _latin1'Hello', n'Hello', _UTF16'Hello', 'Hello' 'there' (複数パートに分割されたリテラル)

これを見ると、どうも「_UTF16'文字列'」という書き方をするとうまくいきそうな予感がします。確かに文字列のJavaにおける内部表現はUTF-16です。

文字列の部分を書き換えて、もう一度先ほどのクエリを実行してみます。

0: jdbc:drill:zk=local> SELECT
. . . . . . . . . . . >   COLUMNS[0] 番号,
. . . . . . . . . . . >   COLUMNS[1] 名前,
. . . . . . . . . . . >   COLUMNS[2] 住所
. . . . . . . . . . . > FROM dfs.`/tmp/test.csv`
. . . . . . . . . . . > WHERE COLUMNS[2] = _UTF16'熊本';
+-----+--------+-----+
| 番号  |   名前   | 住所  |
+-----+--------+-----+
| 1   | くまモン   | 熊本  |
+-----+--------+-----+
1 rows selected (1.543 seconds)

こんどはうまくいきました。

ただ、毎回文字列が現れるところで「_UTF16」と入れるのは面倒ですね。これをデフォルトにできないものでしょうか。と思い、Calciteのソースコードを見ていくと

  /**
   * The string property "saffron.default.charset" is the name of the default
   * character set. The default is "ISO-8859-1". It is used in
   * {@link org.apache.calcite.sql.validate.SqlValidator}.
   */
  public final StringProperty defaultCharset =
      new StringProperty(this, "saffron.default.charset", "ISO-8859-1");

「saffron.default.charset」というプロパティにUTF-16を指定すればよさそうな感じです。これを記載するのは、設定ファイルdrill-env.shの中の環境変数DRILL_SHELL_JAVA_OPTSのところにします。文字セット名は、いろいろ試したところ、「UTF-16LE」である必要があるようです。

$ vi apache-drill-1.0.0/conf/drill-env.sh
export DRILL_SHELL_JAVA_OPTS="-Dsaffron.default.charset=UTF-16LE"

これで、個別に「_UTF16」の指定をしなくても、日本語が扱えるようになりました。

政府統計情報 e-Stat を Apache Drill で分析してみる (1)

スキーマフリーSQLクエリエンジンApache Drillがついにバージョン1.0になりました。

Apache Drill - Schema-free SQL for Hadoop, NoSQL and Cloud Storage

これを機会に、Drillの得意とするJSON形式の結構複雑なデータの分析を、いろいろなオープンデータを使ってやってみたいと思います。本来Hadoopのようなビッグデータ分散処理基盤で威力を発揮するDrillですが、ラップトップ環境でスタンドアローンで動かしても全く同じ使い勝手を体験できます。

準備

最初にこちらの政府統計情報 e-Statのサイトの利用登録を行います。

登録後にログインをして、ユーザー毎に割り当てられるアプリケーションIDを取得することで統計データのダウンロードができるようになります。

Apache Drillの実行にはJDK 7が必要です。LinuxMac OSであれば次の3つのコマンドで、インストールしてDrillのフロントエンドであるSQLLineが起動するところまで行きます。Windowsの場合はこちらのページをご参考に。

$ wget http://getdrill.org/drill/download/apache-drill-1.0.0.tar.gz
$ tar -xvzf apache-drill-1.0.0.tar.gz
$ apache-drill-1.0.0/bin/drill-embedded
0: jdbc:drill:zk=local>

統計表情報の取得

本記事では、平成22年度の国勢調査の人口統計をいろいろ集計してみたいと思います。まずはデータとしてどのようなものがあるのかがわからないので、「統計表情報取得」APIを使用して「統計表」のリストを取得しましょう。必要なパラメータは自分のアプリケーションID、調査年「2010」、政府統計コードです。

提供データ | 政府統計の総合窓口(e-Stat)−API機能

によれば国勢調査の政府統計コードは「00200521」ですね。次のコマンドで統計表情報JSONファイルをダウンロードします。

$ curl -o stats_list.json "http://api.e-stat.go.jp/rest/2.0/app/json/getStatsList?appId=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&surveyYears=2010&statsCode=00200521"

早速データの中身を見てみましょう。まずは全部をSELECT。FROM句のところには「dfs.`<ファイルの絶対パス>`」を指定します。

0: jdbc:drill:zk=local> SELECT * FROM dfs.`/tmp/stats_list.json` t;
+--------------------------------------------------------------------------------------------------------------------------------------------------------------+
|                                                                                                                                                              |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------+
| {"RESULT":{"STATUS":0,"ERROR_MSG":"正常に終了しました。","DATE":"2015-05-21T19:01:17.540+09:00"},"PARAMETER":{"LANG":"J","SURVEY_YEARS":2010,"STATS_CODE":"0 |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row selected (1.257 seconds)

すると、巨大な1つのフィールドにすべてのデータが入っているのがわかります(あまりにも大きいのでカットしています)。これは、JSONデータの最上位のオブジェクトのキー「GET_STATS_LIST」と値に対応しているためです。実際のJSONファイルも見てみましょう。

{
  "GET_STATS_LIST":{
    "RESULT":{ ← 取得結果のステータス情報
      "STATUS":0,
      "ERROR_MSG":"\u6B63\u5E38\u306B\u7D42\u4E86\u3057\u307E\u3057\u305F\u3002",
      "DATE":"2015-05-21T19:01:17.540+09:00"
    },
    "PARAMETER":{ ← 取得時に指定したパラメータ
      "LANG":"J",
      "SURVEY_YEARS":2010,
      "STATS_CODE":"00200521",
      "DATA_FORMAT":"J"
    },
    "DATALIST_INF":{ ← 統計表のリストデータ本体
      "NUMBER":356,
      "RESULT_INF":{
        "FROM_NUMBER":1,
        "TO_NUMBER":356
      },
      "TABLE_INF":[ ← 統計表のリスト
        {"@id":"0003033021",...},
        {"@id":"0003033022",...},
        ...
      ]
    }
  }
}

データの詳細仕様は次のページが参考になります。

政府統計の総合窓口(e-Stat)のAPI 仕様 | 政府統計の総合窓口(e-Stat)−API機能

KVGEN()関数とFLATTEN()関数によるデータの展開

さて、では一つ下の階層にどんなキーがあるかわかりにくいので、Drill特有のKVGEN()関数とFLATTEN()関数を使って分解していきます。KVGEN()関数は、対象のオブジェクト内の

<キー>:<値>,
<キー>:<値>,
...

の並びを、

[
  {"key":<キー>,"value":<値>},
  {"key":<キー>,"value":<値>},
  ...
]

というオブジェクトの配列に変換します。FLATTEN()関数は配列をレコードに展開します。ここで「GET_STATS_LIST」に対してKVGEN()関数とFLATTEN()関数を使うことで、一つ下の階層のキーと値の組がレコードとして展開されます。

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_STATS_LIST)) FROM dfs.`/tmp/stats_list.json` t;
+---------------------------------------------------------------------------------------------------------------------------------------+
|                                                                EXPR$0                                                                 |
+---------------------------------------------------------------------------------------------------------------------------------------+
| {"key":"RESULT","value":{"STATUS":0,"ERROR_MSG":"正常に終了しました。","DATE":"2015-05-21T19:01:17.540+09:00","RESULT_INF":{},"TA |
| {"key":"PARAMETER","value":{"LANG":"J","SURVEY_YEARS":2010,"STATS_CODE":"00200521","DATA_FORMAT":"J","RESULT_INF":{},"TABLE_INF":[]}} |
| {"key":"DATALIST_INF","value":{"NUMBER":356,"RESULT_INF":{"FROM_NUMBER":1,"TO_NUMBER":356},"TABLE_INF":[{"@id":"0003033021","STAT_ |
+---------------------------------------------------------------------------------------------------------------------------------------+
3 rows selected (0.452 seconds)

これで、「RESULT」「PARAMETER」「DATALIST_INF」という3つのキーがあることがわかりますね。では、さらに階層を降りていきましょう。DATALIST_INFというのがデータ本体ですので、これを展開しようとすると

0: jdbc:drill:zk=local> SELECT FLATTEN(KVGEN(t.GET_STATS_LIST.DATALIST_INF)) FROM dfs.`/tmp/stats_list.json` t;
Error: SYSTEM ERROR: org.apache.drill.common.exceptions.DrillRuntimeException: Mappify/kvgen does not support heterogeneous value types. All values in the input map must be of the same type. The field [`unknown`] has a differing type [minor_type: LATE
mode: OPTIONAL
].

Fragment 0:0

[Error Id: e5f8f642-d03d-4d95-b2d9-e2b47289dee6 on 192.168.111.11:31010] (state=,code=0)

今度はエラーです。これは、次の階層のキー「NUMBER」「RESULT_INF」「TABLE_INF」がそれぞれ整数、オブジェクト、配列、という別々のデータ型になっているのが原因です。今のところKVGEN()関数ではこれを変換できないので、やむを得ず直接要素を指定することで次の階層に降りていくことにしましょう。

配列の要素へのアクセス

次に見たいのは「TABLE_INF」です。これは配列で、「統計表」のリストになっていると思われます。試しに配列の先頭の要素を見るには「TABLE_INF[0]」と指定します。

0: jdbc:drill:zk=local> SELECT t.GET_STATS_LIST.DATALIST_INF.TABLE_INF[0] FROM dfs.`/tmp/stats_list.json` t;
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
|                                                                                                                                                                                                                        EXPR$0                                                                                                                                                                                                                        |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| {"@id":"0003033021","STAT_NAME":{"@code":"00200521","$":"国勢調査"},"GOV_ORG":{"@code":"00200","$":"総務省"},"STATISTICS_NAME":"平成22年国勢調査 速報集計 抽出速報集計","TITLE":{"@no":"00110","$":"年齢(各歳),男女,国籍(総数及び日本人)別人口,平均年齢及び年齢中位数 全国,全国市部,全国郡部"},"CYCLE":"-","SURVEY_DATE":201010,"OPEN_DATE":"2011-06-29","SMALL_AREA":0,"MAIN_CATEGORY":{"@code":"02","$":"人口・世帯"},"SUB_CATEGORY":{"@code":"01","$":"人口"},"OVERALL_TOTAL_NUMBER":1980,"UPDATED_DATE":"2011-08-02"} |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row selected (0.187 seconds)

これで統計表に関するデータの要素が分かります。

サブクエリとフィルタリング

次に、統計表ID「@id」、統計表の表題「TITLE.$」を取り出して一覧にしてみましょう。TABLE_INFは配列なのでFLATTEN()でレコードに展開して別名「table_inf」を付けておきます。また、内側をサブクエリにしてテーブルに「d」という別名を付けた上で、外側で「d.table_inf」を使って下位の要素にアクセスします。ちなみに「@」という文字は特殊文字なのでバッククォートで囲っています。

0: jdbc:drill:zk=local> SELECT
. . . . . . . . . . . >   d.table_inf.`@id`,
. . . . . . . . . . . >   d.table_inf.TITLE.$
. . . . . . . . . . . > FROM (
. . . . . . . . . . . >   SELECT
. . . . . . . . . . . >     FLATTEN(t.GET_STATS_LIST.DATALIST_INF.TABLE_INF) table_inf
. . . . . . . . . . . >   FROM dfs.`/tmp/stats_list.json` t
. . . . . . . . . . . > ) d;
+-------------+-----------------------------------------------------------------------------------------------------------------------------------+
|   EXPR$0    |                                                              EXPR$1                                                               |
+-------------+-----------------------------------------------------------------------------------------------------------------------------------+
| 0003033021 | 年齢(各歳),男女,国籍(総数及び日本人)別人口,平均年齢及び年齢中位数 全国,全国市部,全国郡部 |
| 0003033022 | 年齢(5歳階級),男女,国籍(総数及び日本人)別人口,平均年齢及び年齢中位数 全国,全国市部,全国郡部,都道府県,都道府県市部,人口20万以上の市 |
| 0003033023 | 配偶関係(4区分),年齢(各歳),男女,国籍(総数及び日本人)別15歳以上人口 全国,全国市部,全国郡部 |
...
+------------+-----------------------------------------------------------------------------------------+--+
356 rows selected (0.608 seconds)

さて、最終的には分析の対象となるデータの「統計表ID」を知らないといけないのですが、表題に「人口増減」という言葉が入っている統計表を探して、その統計表IDを取得しましょう。条件はWHERE句のLIKEで指定しますが、日本語をそのまま指定するとエラーになるので、「_UTF16'%人口増減%'」というように文字列を指定しなければいけないのが少しトリッキーなところです(詳細はこちら)。

0: jdbc:drill:zk=local> SELECT
. . . . . . . . . . . >   d.table_inf.`@id`,
. . . . . . . . . . . >   d.table_inf.TITLE.$
. . . . . . . . . . . > FROM (
. . . . . . . . . . . >   SELECT
. . . . . . . . . . . >     FLATTEN(t.GET_STATS_LIST.DATALIST_INF.TABLE_INF) table_inf
. . . . . . . . . . . >   FROM dfs.`/tmp/stats_list.json` t
. . . . . . . . . . . > ) d
. . . . . . . . . . . > WHERE d.TABLE_INF.TITLE.$ LIKE _UTF16'%人口増減%';
+-------------+-----------------------------------------------------------------+
|   EXPR$0    |                             EXPR$1                              |
+-------------+-----------------------------------------------------------------+
| 0003038586  | 人口,人口増減,面積及び人口密度 全国,市部・郡部,都道府県,市部・郡部,支庁,郡計,市区町村・旧市町村,全域・人口集中地区  |
+-------------+-----------------------------------------------------------------+
1 row selected (0.72 seconds)

はい、全国の人口増減のデータを含む統計表の統計表ID「0003038586」が取得できました。ではいよいよ、国勢調査のデータそのものをダウンロードしてみましょう、といきたいところですが、長くなってきたので次の記事で!

ポータブルHadoopクラスタ

(*English translation is here)

アルミアタッシュケースに入れて持ち運びができる、3ノードのHadoopクラスタを作りました。インテルのNUCと呼ばれるタイプのマシンを使用して、かなりコンパクトにまとめました。大きさは、29 x 41.5 x 10 cm。

f:id:nagixx:20140609161347j:plainf:id:nagixx:20140609162049j:plain

主な仕様はこちら。

  • Intel NUC core i3 + 16GBメモリ + 128GB SSDのマシン3台
  • ギガビットEthernet + IEEE 802.11na Wifiルータ
  • 13.3インチ HDMI接続 LCDモニタ
  • 2.4GHz 無線接続キーボード + タッチパッド
  • 電源はコンセントを1つ差し込むだけ
  • CentOS 6.6 + MapR M7 4.0.1 + CDH 5.3

メモリを16GB積んでいるので、大抵のアプリケーションの稼働には問題ありません。私はお客さん先に行って、これを使ってよくデモをするのですが、コンセント一つだけあれば起動して1〜2分でデモを開始できるので非常〜に便利です。Wifi接続もできるので、プロジェクタに接続したラップトップPCからこのクラスタにアクセスして、広い画面でデモを見せることもできます。

これはHadoop用に作りましたが、クラスタとして動作するソフトウェアなら、どんなものでもよいでしょう。例えば、MongoDB、Cassandra、Redis、Riak、Solr、Elasticsearch、Spark、Storm、等々。実際、MapRはNFS経由の連携で他のネイティブアプリケーションとの相性がいいので、このポータブルクラスタではHP VerticaやElasticsearchなどを共存させて動かしています。

完全なパーツリストと、構築方法を公開しますので、ぜひ皆さんも自作にチャレンジを。

パーツリスト

次のパーツリストは価格を含めちょっと古い2014年6月10日現在の情報です。このページの説明はこのリストのパーツを使って構築したものに基づいて書いています。

No.パーツ参考価格数量
01 Intel DC3217IYE (NUC ベアボーンキット) 38,454円 3
02 PLEXTOR PX-128M5M (128MB mSATA SSD) 14,319円 3
03 CFD W3N1600Q-8G (8GB DDR3-1600 SO-DIMM x 2) 17,244円 3
04 ELECOM LD-GPS/BK03 (0.3m CAT6 Gigabit LAN ケーブル) 211円 3
05 Miyoshi MBA-2P3 (電源ケーブル 2ピン/3ピン変換アダプタ) 402円 3
06 FILCO FCC3M-04 (3分岐電源ケーブル) 988円 1
07 Logitec LAN-W300N/IGRB (Gigabit/11na Wifi ルータ) 5,500円 1
08 SANWA SUPPLY KB-DM2L (0.2m 電源ケーブル) 600円 1
09 GeChic On-LAP 1302/J (13.3 インチ LCD) 18,471円 1
10 ELECOM T-FLC01-2220BK (2m 電源タップ) 639円 1
11 EAPPLY EBO-013 (2.4GHz ワイヤレスキーボード) 2,654円 1
12 IRIS OYAMA AM-10 (アルミニウムアタッシュケース) 2,234円 1
13 Inoac A8-101BK (発泡ポリエチレンシート 10x1000x1000) 2,018円 1
14 Kuraray 15RN黒 (マジックテープ粘着用 25mm x 15cm) 278円 2
15 セメダイン AX-038 スーパーXクリア (超多用途接着剤 20ml) 343円 1

が、それから半年が経った2014年12月29日現在、すでに市場に出回っていないパーツもあるので、新しいリストを作りました。今だと合計で約23万円で構築できるかと思います。ただ多少、部品の配置や手順が異なるところがあるかもしれません。特にNUC本体が後継機種のD34010WYKになって、メモリが1.35V低電圧対応の物が必要になったり、HDMIではなくmini HDMI/mini DisplayPortポートになっていたりするため、メモリやLCDのモデルも入れ替えています。

No. 11のワイヤレスキーボードはたまたま手元にあったものを使いましたが、現在は入手が難しいと思われるので、Rii mini X1iClever IC-RF01が代わりに使えると思います。なぜBluetooth接続ではなく、2.4GHz無線接続なのかというと、これらはUSBレガシーデバイスとして認識されるため、マシン起動直後のBIOS設定でも使えるからです。

No.パーツ参考価格数量
01 Intel D34010WYK (NUC ベアボーンキット) 35,633円 3
02 Transcend TS128GMSA370 (128MB mSATA SSD) 7,980円 3
03 CFD W3N1600PS-L8G (8GB DDR3-1600 SO-DIMM x 2) 18,153円 3
04 ELECOM LD-GPS/BK03 (0.3m CAT6 Gigabit LAN ケーブル) 406円 3
05 Diatec YL-3114 (電源ケーブル 2ピン/3ピン変換アダプタ) 494円 3
06 FILCO FCC3M-04 (3分岐電源ケーブル) 1,008円 1
07 Logitec LAN-W300N/IGRB (Gigabit/11na Wifi ルータ) 6,000円 1
08 SANWA SUPPLY KB-DM2L (0.2m 電源ケーブル) 429円 1
09 GeChic On-LAP 1302 for Mac/J (13.3 インチ LCD) 20,153円 1
10 ELECOM T-FLC01-2220BK (2m 電源タップ) 994円 1
11 Rii mini X1 (2.4GHz ワイヤレスキーボード) 2,860円 1
12 IRIS OYAMA AM-10 (アルミニウムアタッシュケース) 2,271円 1
13 Inoac A8-101BK (発泡ポリエチレンシート 10x1000x1000) 2,018円 1
14 Kuraray 15RN黒 (マジックテープ粘着用 25mm x 15cm) 270円 2
15 セメダイン AX-038 スーパーXクリア (超多用途接着剤 20ml) 343円 1

ベアボーンキット組み立て

 NUCベアボーンキットの組み立てはとても簡単です。メモリとSSDを取り付けるだけで、マシンが完成します。

まず底面の四隅のネジを外して中を開けると、SO-DIMMスロット2列(写真左側)とmini PCI Expressスロットが2列(写真右側)が見えます。

f:id:nagixx:20140421091249j:plain

SO-DIMMスロットに2枚組のメモリを1枚ずつ差して、「カチッ」というまで下に押し込んで固定します。2列あるmini PCI Expressスロットのうち、下側のスロットがハーフサイズの無線LANモジュール用、上側がフルサイズのmSATA対応のスロットです。今回は無線LANは使用しないので、上側のスロットにmSATA SSDを差し込み、1箇所ネジで固定します。

f:id:nagixx:20140421092516j:plain

これで底面を元に戻してネジを締めれば作業完了です。

アルミアタッシュケースのクッション加工

さて、この小さなケースの中に3台のマシンとACアダプタ、Wifiルータ、LCDモニタ、配線を詰め込むにはそれなりに配置に工夫が必要です。さらに、持ち運ぶ際に各パーツがずれないように、しっかりと固定されるようにしておかなければなりません。

まず1cm厚の発砲ポリエチレンシートを写真のようにカッターナイフでカットします。使用した発砲ポリエチレンシートは、比較的重いものを固定する緩衝材としての用途なので、PEライトA8という15倍発泡の結構固めのものを選んでいます。

f:id:nagixx:20140123123824j:plain

そして、ケース下面に一層目、二層目を順番に接着していきます。これが3台のマシンとACアタプタ、配線を固定する枠になります。

発泡ポリエチレンシートの接着にはセメダインスーパーXクリアを使いました。ポリエチレンは基本的には接着剤がつきにくい材質なのですが、表面がザラザラで、スーパーXは柔軟性のある弾性接着剤なので、特に接着力には問題はありません。

f:id:nagixx:20140123123933j:plainf:id:nagixx:20140123124057j:plain

ケース上面には、LCDモニタを取り付けるためのマジックテープを接着します。マジックテープの裏面には糊がついているものを使いますが、上面のクッション表面に取り付けるには弱いので、接着剤を使います。

f:id:nagixx:20140123123555j:plain

LCDモニタの裏面にも同様に対になるマジックテープを貼り付けます。こちらは表面がツルツルなので、そのまま貼れます。

このGeChic On-LAPというLCDは、ラップトップPCの背面に取り付けてセカンダリモニタとして使うことを意図した製品ですが、非常に薄い上にUSB給電ができるので採用しました。ラップトップ取り付け用の金具は、今回は邪魔なので取り外しています。

f:id:nagixx:20140123124354j:plain

さらに、LCDモニタの上にかぶせて、モニタ表面を保護すると同時にケースを閉じた時にマシンや配線類を押さえ込むクッションを、三層を貼り合わせて作ります。

こちらがLCDモニタ(上面)側。右下の切り欠きはモニタケーブルを通す空間です。

f:id:nagixx:20140123151513j:plain

ひっくり返して、こちらがマシン(下面)側。中央の二層分のへこみはキーボード格納スペース、右下の一層分のへこみはコンセントプラグ収納スペースです。

f:id:nagixx:20140611165340j:plain

ところで、こんな複雑な形をいちいち計測してカットできないよ、という方のために、図面をPDFで用意しましたので、ダウンロードしてお使いください。

f:id:nagixx:20140611121500j:plain

図面PDFへのリンク

パーツの配置と配線

順番にパーツを配置していきます。3台のマシンを置き、それぞれのACアダプタは縦にして3つ隣り合わせで並べてケーブルを小さくまとめて縛ります。NUCのACアダプタは3ピンなので、3ピン→2ピンの変換アダプタを介して、3つのケーブルを1つのコンセントにまとめるスプリッタケーブルで2個口電源タップに繋げます。そしてLCDモニタからのHDMIケーブルおよび給電用USBケーブルを、左端のマシンに接続します。ワイヤレスキーボード用のUSBドングルも左端のマシンに差し込みます。

f:id:nagixx:20140123143455j:plain

次にWifiルータにL型2ピン電源ケーブルを繋ぎ、もう一方のコンセントプラグは電源タップの残った一口に差し込みます。あとは各マシンのEthernetポートとルータのLAN側ポートの間をLANケーブルで接続します。Wifiルータはきっちり固定はしていませんが、ちょうど電源スプリッタケーブルの上の空間に収まります。Gigabit Ethernet対応、AC電源内蔵でこのサイズの無線ルータはこれ以外になく、販売終了にならないかちょっぴり不安・・と思ったらすでに2014年1月で販売終了になっていました。これを作りたい方はお早めの入手を!

f:id:nagixx:20140123151704j:plain

これで全体が組み上がりました。中層のクッションでLCDに蓋をして、キーボードをはめ込みます。電源タップのケーブルは、ケース下面の縁に沿って約2周させて、コンセントプラグを下の写真の位置にもっていきます。これでケースを閉じれば、クッションがぴったり合わさって、中のマシンやケーブル類がずれてしまうことはありません。

f:id:nagixx:20140124004724j:plain

Wifiルータの設定

OSインストールの前に、まずはWifiルータの設定を行ってネットワーク環境を整えます。まずはコンセントのプラグを壁の電源に差し込み、Wifiルータの電源を入れます。Wifi接続のための初期設定SSIDと暗号キーは、本体に付属の無線暗号キーステッカーに記載されているので、その通りに入力して接続します。Wifiルータの初期設定アドレスは「192.168.2.1」となっているため、Webブラウザで「http://192.168.2.1」に接続し、ユーザ名「admin」、パスワード「admin」で管理画面にログインします。

管理画面ではポータブルHadoopクラスタ向けに下記の項目を変更し、残りはデフォルト設定をそのまま使用します。SSIDと共有キーは任意の値で構いません。クラスタのアドレスはデフォルト設定の192.168.2.0/24のネットワークをそのまま使いますが、ルータのIPアドレスは192.168.2.254に変更し、192.168.2.1〜192.168.2.9をクラスタノードおよびその他の用途向けに固定アドレスとして確保して、残りの192.168.2.10〜192.168.2.253をDHCP用に割り当てます。

メニューカテゴリメニュー項目設定項目設定値
無線設定 基本設定 SSID mapr-demo
マルチSSID 「有効」のチェックを外して無効にする
セキュリティ設定 共有キー 任意のパスフレーズ
有線設定 LAN側設定 装置のIPアドレス 192.168.2.254
デフォルトゲートウェイ 192.168.2.254
DHCPクライアントレンジ 192.168.2.10 - 192.168.2.253

上記の設定が完了したら「適用」ボタンをクリックするとWifiルータが再起動します。最後にルータのWAN側ポートをインターネットに接続されているネットワークに繋ぎ、次のセットアップに進みます。

CentOSインストール

いよいよソフトウェアのインストールに入っていきましょう。NUCにはDVDドライブがないため、今回は起動にUSBメモリを使用し、OS本体はネットワークインストールで入れます。USBメモリ上にインストール用ブートイメージを作るのに、Windowsマシンを使います。

まずUNetbootin for Windowsと、CentOS 6.6のminimalイメージをWindowsマシンにダウンロードします。

USBメモリWindowsマシンに差し込み、UNetbootinを起動します。「Diskimage」を選択して先ほどダウンロードしたCentOS-6.6-x86_64-minimal.isoを指定します。また、Type欄で「USB Drive」を選択してDrive欄に差し込んだUSBメモリのドライブを指定し、「OK」をクリックします。しばらくブートイメージの作成に時間がかかりますが、終わったらUSBメモリを取り出します。

次は、各マシンへのインストールです。モニタとキーボードが必要なので、1台ずつ順番にHDMIケーブルとワイヤレスキーボードのUSBドングルを差し替えながら設定していきます。

USBメモリをNUCに差して、上面の電源ボタンを押してマシンを起動します。ちょっとうろ覚えですが、SSDは空なので自動的にUSBメモリがブートデバイスとして認識され、インストールプロセスが開始されるはずです(もしそうならなかったら、起動中のプロンプトでF2キーを押し、[Boot]メニューを開いてUSBメモリを選択します)。

CentOSのインストールプロセスについては、詳細の説明は省略しますが、注意すべき点をいくつか下に書いておきます。

  • 「Installation Method」の画面で、インストールメディアの選択画面が出てきたら「URL」を選び、次の「Configure TCP/IP」の画面ではデフォルトのDHCPを使用する設定のまま進み、「URL Setup」の画面で一番上の欄に「http://ftp.riken.jp/Linux/centos/6/os/x86_64/を入力します
  • ホスト名の指定の画面では、左側のマシンから「node1」「node2」「node3」と指定します。また、同じ画面の左下にある「ネットワークの設定」ボタンをクリックして、「有線」タブの「System eth0」というデバイスを選択し、「編集」に進みます。「IPv4のセッティング」タブで方式を「手動」に設定し、アドレスは左側のマシンから「192.168.2.1」「192.168.2.2」「192.168.2.3」、ネットマスク「24」、ゲートウェイ「192.168.2.254」、DNSサーバー「192.168.2.254」に設定します
  • インストールのタイプの指定の画面では、「カスタムレイアウトを作成する」を選択して、次のパーティション設定の画面で以下のようなレイアウトを作成します。LVMは使用せず、標準のパーティションを割り当てます。
    デバイス容量マウントポイントタイプ
    /dev/sda1 200MB /boot/efi EFI
    /dev/sda2 200MB /boot ext4
    /dev/sda3 58804MB / ext4
    /dev/sda4 58804MB /mapr ext4
    /dev/sda5 4095MB   swap
  • インストールするソフトウェアの選択画面では「Desktop」を選択します

 インストールが完了すると、マシンが再起動します。再起動後の初期設定の画面では、「ユーザーの作成」はとりあえず不要なので未記入で進みます。「日付と時刻」では「ネットワーク上で日付と時刻を同期化します」にチェックを入れます。「Kdump」は不要です。以上で、OSのセットアップはOKです。

諸々のOS設定

続いて、Hadoopクラスタを構築する下準備として、諸々の設定を行います。各ノードで次の手順を実行します。

「/etc/sysconfig/i18n」を次のように編集し、システム言語を「en_US.UTF-8」に設定します。

LANG="en_US.UTF-8"

「/etc/sysconfig/selinux」の次の行を編集し、SELinuxを無効にします。

SELINUX=disabled

ここで一旦マシンを再起動します。これでSELinuxは無効になっていますが、一部のファイルにSELinuxのラベルがついたままなので、下記のコマンドでラベルを全てクリアします。

# find / -print0 | xargs -r0 setfattr -x security.selinux 2>/dev/null

「/etc/sysctl.conf」に次の項目を追加します。

vm.overcommit_memory=0
net.ipv4.tcp_retries2=5

次のコマンドで設定を反映させます。

# sysctl -p

「/etc/hosts」を次のように編集します。

127.0.0.1       localhost
192.168.2.1 node1
192.168.2.2 node2
192.168.2.3 node3

次のコマンドでiptablesサービスを停止して、再起動時にもサービスが起動しないように設定します。

# service iptables stop
# service ip6tables stop
# chkconfig iptables off
# chkconfig ip6tables off

「/etc/ntp.conf」を編集して、3台の時刻が同期するようにNTPの設定を行います。インストール直後の設定ではインターネット上のNTPサーバに同期するようになっていますが、このポータブルHadoopクラスタは常にインターネット接続があるとは限らないため、node1のローカル時刻に他のノードを合わせる設定にします。

node1では「/etc/ntp.conf」の次の項目を編集します。デフォルトのserver設定はコメントアウトして、ローカル時刻の参照を意味する「127.127.1.0」を指定しています。

nrestrict 192.168.2.0 mask 255.255.255.0 nomodify notrap
server 127.127.1.0
#server 0.centos.pool.ntp.org
#server 1.centos.pool.ntp.org
#server 2.centos.pool.ntp.org

node2およびnode3では/etc/ntp.conf」の次の項目を編集します。参照先はnode1になっています。

server node1
#server 0.centos.pool.ntp.org
#server 1.centos.pool.ntp.org
#server 2.centos.pool.ntp.org

次のコマンドでNTPサービスを再起動します。

# service ntpd restart

次のコマンドで、MapRのシステムユーザ/グループを作成します。パスワードは任意に設定します。

# groupadd mapr -g 500
# useradd mapr -u 500 -g 500
# passwd mapr

rootユーザおよびmaprユーザはパスワードなしで別ノードにsshログインできると便利です。次のコマンドで、sshのパスワードを不要にします。

# ssh-keygen -t rsa -N '' -f ~/.ssh/id_rsa
# for host in node1 node2 node3; do ssh-copy-id $host; done
# su - mapr
$ ssh-keygen -t rsa -N '' -f ~/.ssh/id_rsa
$ for host in node1 node2 node3; do ssh-copy-id $host; done
$ exit

次のコマンドで、yumレポジトリからOpenJDKをインストールします。

# yum install java-1.7.0-openjdk-devel

インストール時に「/dev/sda4」のパーティションをMapR用に作成し、「/mapr」にマウントしていますが、MapRではブロックデバイスを直接使用するため、このファイルシステムはマウントを解除します。

# umount /mapr

そして起動時にもマウントしないように、「/etc/fstab」を編集して/dev/sda4の項目を削除します。

MapRインストール

では仕上げのMapR Hadoopディストリビューションのインストールです。各ノードで以下の手順を実行します。

まずはMapRのyumレポジトリを設定するために「/etc/yum.repos.d/maprtech.repo」を次のように編集します。

[maprtech]
name=MapR Technologies
baseurl=http://package.mapr.com/releases/v4.0.1/redhat/
enabled=1
gpgcheck=0
protect=1

[maprecosystem]
name=MapR Technologies
baseurl=http://package.mapr.com/releases/ecosystem-4.x/redhat
enabled=1
gpgcheck=0
protect=1

また、EPELパッケージも必要になるため、次のコマンドでEPELのレポジトリを設定します。

# wget http://download.fedoraproject.org/pub/epel/6/x86_64/epel-release-6-8.noarch.rpm
# yum localinstall epel-release-6-8.noarch.rpm

次にMapRのパッケージをインストールします。MapR 4.xではMapReduce v1とv2 (YARN) の両方のアプリケーションを同時に実行可能ですが、v1とv2間でメモリは共用できないため、16GBの限られたメモリを有効に使うためにこのクラスタではv1かv2のどちらかで構成することにします。

MapReduce v1用に構成するには次のコマンドを実行します。

# yum install mapr-cldb mapr-core mapr-core-internal mapr-fileserver \
mapr-hadoop-core mapr-jobtracker mapr-mapreduce1 mapr-nfs mapr-tasktracker \
mapr-webserver mapr-zk-internal mapr-zookeeper

MapReduce v2用に構成するには次のコマンドを実行します。History Serverはクラスタ内で1つのノードでしか稼働できないため、mapr-historyserverパッケージはnode1のみでインストールします。

# yum install mapr-cldb mapr-core mapr-core-internal mapr-fileserver \
mapr-hadoop-core mapr-historyserver mapr-mapreduce2 mapr-nfs mapr-nodemanager \
mapr-resourcemanager mapr-webserver mapr-zk-internal mapr-zookeeper

次のコマンドで、MapRのデータ用パーティションを指定するファイルを作成します。

# echo "/dev/sda4" > disks.txt

次のコマンドで、MapRの構成の初期設定を行います。このコマンドを実行すると、必要なサービスが自動的に起動します。MapReduce v1用に構成するには

# /opt/mapr/server/configure.sh -N cluster1 -C node1,node2,node3 \
-Z node1,node2,node3 -F disks.txt

MapReduce v2用に構成するには

# /opt/mapr/server/configure.sh -N cluster1 -C node1,node2,node3 \
-Z node1,node2,node3 -RM node1,node2,node3 -HS node1 -F disks.txt

さて、3台のノードすべてでここまでの設定が完了すると、数分後にMapRクラスタが稼働状態になります。ただ、MapR 4.0.1では一部のブラウザの最新の証明書に未対応のためにHTTPSでアクセスできない問題があるため、次の手順で各ノードで修正パッチを適用します。最後のコマンドでWebサーバが再起動するので、数分後に管理画面へのアクセスが可能になります。

# wget http://package.mapr.com/scripts/mcs/fixssl
# chmod 755 fixssl
# ./fixssl

Webブラウザで「http://192.168.2.1:8443」にアクセスすると、MapRのWeb管理画面が表示されます。ユーザ名「mapr」、パスワードにmaprユーザに設定したパスワードを入力してログインします。

f:id:nagixx:20141230001615p:plain

f:id:nagixx:20141230001642p:plain

MapRのクラスタをWebで登録して、ライセンスを適用します。インストール直後はBase Lisenceというライセンスが同梱されていますが、永久に無償のM3、もしくは30日間無償のM5トライアルライセンスを適用することで、NFSアクセス機能やHA機能、スナップショットなどのデータ管理機能を使用することが可能になります。

管理画面の右上、「Manage Licenses」をクリックすると出てくるダイアログで「Add Licenses via Web」ボタンをクリックします。するとMapRのWebサイトに進むかどうかという確認があるので、「OK」で進みます。Webサイトで必要事項を入力してユーザ登録を済ませると、M3もしくはM5トライアルのいずれかのライセンスを選べるので、どちらかを選択するとライセンスが発行されます。再度クラスタの管理画面に戻り、「Manage Licenses」のダイアログで「Add Licenses via Web」ボタンをクリックすると、先ほど発行されたライセンスがクラスタに適用されます。

ここで、サンプルのMapReduceジョブを実行してみましょう。円周率を計算するMapReduceジョブを実行する例です。MapReduce v1では

$ hadoop -classic jar /opt/mapr/hadoop/hadoop-0.20.2/hadoop-0.20.2-dev-examples.jar pi 10 10000

MapReduce v2では

$ hadoop -yarn jar /opt/mapr/hadoop/hadoop-2.4.1/share/hadoop/mapreduce/hadoop-mapreduce-examples-2.4.1-mapr-1408.jar pi 10 10000

最後にこのポータブルHadoopクラスタ特有の設定を一つ。MapRのデフォルト設定では色々なMapRのデーモンプロセスに結構多めのメモリが割り当てられていますが、このクラスタのマシンはメモリ16GBと比較的少ない容量しか持っていないので、各ノードの「/opt/mapr/conf/warden.conf」の次に示す行を編集して最小限の割り当てとし、より多くのメモリをMapReduceジョブに振り分けられるようにしましょう。

service.command.jt.heapsize.max=256
service.command.tt.heapsize.max=64
service.command.cldb.heapsize.max=256
service.command.mfs.heapsize.max=4000
service.command.webserver.heapsize.max=512
service.command.nfs.heapsize.max=64
service.command.os.heapsize.max=256
service.command.warden.heapsize.max=64
service.command.zk.heapsize.max=256

MapReduce v2用に構成するには上記に加えて、次のファイルも編集します。

/opt/mapr/conf/conf.d/warden.resourcemanager.conf

service.heapsize.max=256

/opt/mapr/conf/conf.d/warden.nodemanager.conf

service.heapsize.max=64

/opt/mapr/conf/conf.d/warden.historyserver.conf

service.heapsize.max=64

上の設定を行った後は、すべてのノードで次のコマンドでサービスを再起動して変更を反映します。

# service mapr-warden restart

ちょっとした便利な設定

ログイン画面の解像度設定

マシンの電源を入れた時に、左端のマシンnode1の画面がLCDに表示されますが、ログイン画面の解像度がLCDの解像度と一致していないので少しぼやけた画面になってしまいます。これを合わせるために、まず次のコマンドで接続されているモニタ名を確認します。下の例では「HDMI-1」です。

# xrandr -q
Screen 0: minimum 320 x 200, current 1366 x 768, maximum 8192 x 8192
VGA-0 disconnected (normal left inverted right x axis y axis)
HDMI-0 disconnected (normal left inverted right x axis y axis)
DP-0 disconnected (normal left inverted right x axis y axis)
HDMI-1 connected 1366x768+0+0 (normal left inverted right x axis y axis) 460mm x 270mm
1366x768 59.8*
1024x768 75.1 70.1 60.0
800x600 75.0 60.3
640x480 75.0 60.0
DP-1 disconnected (normal left inverted right x axis y axis)

そして「/etc/X11/xorg.conf.d/40-monitor.conf」を次のように編集します。

Section "Monitor"
Identifier "HDMI-1"
Option "PreferredMode" "1366x768"
EndSection

自動ログイン

デモの際には、LCDに表示するログイン画面から何も入力しないでも自動的にデスクトップ画面に移動すると便利です。そのような動作を行いたい場合には、「/etc/gdm/custom.conf」を次のように編集します。この例では、ログイン画面で30秒何も入力が無いと自動的にmaprユーザでログインします。

[daemon]
TimedLoginEnable=true
TimedLogin=mapr
TimedLoginDelay=30

電源ボタンによるシャットダウン

ポータブルクラスタをシャットダウンする際、パスワードや確認ボタンを押すこと無く、NUC上面の電源ボタンを押すだけで電源を切ることができれば非常に便利です。これを実現するためには、「/etc/polkit-1/localauthority.conf.d/org.freedesktop.logind.policy」を次のように編集します。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<action id="org.freedesktop.login1.power-off-multiple-sessions">
<description>Shutdown the system when multiple users are logged in</description>
<message>System policy prevents shutting down the system when other users are logged in</message>
<defaults>
<allow_inactive>yes</allow_inactive>
<allow_active>yes</allow_active>
</defaults>
</action>
</policyconfig>