Dockerを知らなくてもDockerコンテナでHadoop環境を構築する

この記事は[Fujitsu extended Advent Calender 2016](http://qiita.com/advent-calendar/2016/fujitsu_ext)の20日目の記事です。

# やりたいこと

ここではDockerに関する操作を一切せずにHadoop環境をDockerコンテナで構築することを目指します。
前半は次のような方が手早くHadoop環境を作るまでの手順を書いています。

* Hadoopを使ってみたい
* Dockerを使えば環境が楽に用意できると聞いた
* でもDockerを勉強する時間はない

そのためにAnsibleを使うことにします。Ansibleを選んだのはDockerのインストールからコンテナの起動、停止までの全体の動作をプレイブックとして定義して処理を自動化できるからです。

後半では、擬似分散モードで動く既存のHadoopのDockerイメージに手を加えて分散モードで動くHadoopのDockerfileを作り、Ansibleを使ってDockerfileからイメージをビルドしてコンテナの起動と停止を自動化した手順を書いていきます。

# 動作環境

pipが使えるCentOS7系で動かすことを想定しています。


# Hadoop環境の構築

Ansibleのプレイブックを実行してHadoop環境を用意したあと、MapReduceを実行してコンテナを停止するまでの手順を記します。

1. Ansibleのインストール（Ansible 2.2.0以上が必要）　

    ```sh
    $ yum install -y ansible.noarch
    ```
2. 今回のプレイブックをGithubから取得する

    ```sh
    $ git clone https://github.com/kuromt/ansible-hadoop-docker
    ```

3. 構築する環境に合わせて設定ファイルを修正する
    * vars/hadoop.yamlを開きansible-hadoop-docker/hadoop-dockerのディレクトリのパスを`hadoop_dockerfile_dir`に入れ、Hadoopのマスタに名前をつける。ワーカの項目には動かしたいコンテナの数だけ名前をつけていく。
    
    ```vars/hadoop.yml
    hadoop_dockerfile_dir: /root/ansible-hadoop/hadoop-docker
    hadoop_master: hadoop00
    hadoop_worker:
      - hadoop01
      - hadoop02
    ```

4. （オプション）プロキシ環境下で利用する場合


    プロキシ環境下で利用する場合、プロキシの設定をvars/proxy.ymlで定義してください。Dockerとプロキシの関係については一枚目のFujitsu Advent Calenderの[9日目の記事](http://qiita.com/jeffi7/items/3e40c59744b5801fd40a)に書かれています。
    * vars/proxy.yamlにプロキシを設定する。
    
    ```vars/proxy.yml
    http_proxy: http://username:passwd@server:port
    https_proxy: https://username:passwd@server:port
    ```
    * hadoop-docker/Dockerfileとroles/install-docker/tasks/main.ymlのコメントアウトされたプロキシのための設定を有効にする。

    ```'hadoop-docker/Dockerfile'
    @@ -4,8 +4,8 @@
    FROM sequenceiq/pam:centos-6.5
    MAINTAINER nobuyuki-kuromatsu
 
    -ARG http_proxy
    -ARG https_proxy
    +#ARG http_proxy
    +#ARG https_proxy
    ```

    ```'roles/install-docker/tasks/main.yml'
    @@ -5,14 +5,14 @@
   file: path=/etc/systemd/system/docker.service.d state=directory
 # for environment behind proxy
-- name: set http proxy for docker.service.d
-  blockinfile:
-    dest: '/etc/systemd/system/docker.service.d/http-proxy.conf'
-    create: yes
-    block: |
-      [Service]
-      Environment="HTTP_PROXY={{ http_proxy }}" "HTTPS_PROXY={{ https_proxy }}"
-  register: set_docker_proxy
+#- name: set http proxy for docker.service.d
+#  blockinfile:
+#    dest: '/etc/systemd/system/docker.service.d/http-proxy.conf'
+#    create: yes
+#    block: |
+#      [Service]
+#      Environment="HTTP_PROXY={{ http_proxy }}" "HTTPS_PROXY={{ https_proxy }}"
+#  register: set_docker_proxy
 - name: start docker.
   systemd: name=docker.service enabled=yes daemon_reload=yes state=restarted
@@ -26,8 +26,8 @@
     path: "{{ hadoop_dockerfile_dir }}"
     name: hadoop-docker
     state: present
-    buildargs:
-      http_proxy: "{{ http_proxy }}"
-      https_proxy: "{{ https_proxy }}"
+#    buildargs:
+#      http_proxy: "{{ http_proxy }}"
+#      https_proxy: "{{ https_proxy }}"

    ```

5. ansible-playbookの実行
   実行するプレイブックの中身はこのようになっています。

    ```play-book.yml
    - hosts: localhost
      vars_files:
        - vars/hadoop.yml
        - vars/proxy.yml
      roles:
        - install-docker # DockerのインストールとHadoopイメージのビルド
        - start-master   # Hadoopのマスタコンテナの起動
        - start-worker   # Hadoopのワーカコンテナの起動
    #    - stop-all      # Hadoopのマスタとワーカコンテナの停止と削除
    ```

    `install-docker`はDockerのインストールとプロキシの設定をした後にHadoopイメージのビルドします。続く`start-master`と`start-worker`はHdoop用の仮想ネットワークを作りその上で分散モードのHadoopコンテナを起動します。コメントアウトしている`stop-all`はHadoopコンテナを止めたいときに使います。

     このプレイブックを実行してみます。全て終わるとHadoopのマスタコンテナとワーカコンテナが動いているはずです。Dockerイメージのビルドには時間がかかるのでのんびり待ちます。

    ```sh
    # プレイブックの実行
    $ ansible-playbook  play-book.yml
    [WARNING]: Host file not found: /usr/local/etc/ansible/hosts
    
    [WARNING]: provided hosts list is empty, only localhost is available


    PLAY [localhost] ***************************************************************

    TASK [setup] *******************************************************************
    ok: [localhost] 
    （略）
    ```

6. Hadoopのコンテナにログインして分散モードになっていることを確認する

    今回作ったDockerイメージはHadoopのマスタコンテナでパスワードなしでrootとしてログインできるsshdが2122ポートで動いているので、そこにsshで接続します。Dockerコマンドを触らずにコンテナを操作するためにこのようなことをしていますが、**絶対に本番環境等ではこのHadoopコンテナを利用しないでください。**

    Hadoopのマスタコンテナにログインしてワーカを認識していることを確認します。

    ```
    $ ssh -p 2122 root@localhost
-bash-4.1# cd /usr/local/hadoop/bin/
# NodeManagerの一覧を取得
-bash-4.1# ./yarn node -all -list
16/12/18 08:25:10 INFO client.RMProxy: Connecting to ResourceManager at hadoop00/172.18.0.2:8032
16/12/18 08:25:11 WARN util.NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
Total Nodes:2
         Node-Id	     Node-State	Node-Http-Address	Number-of-Running-Containers
  hadoop01:41279	        RUNNING	    hadoop01:8042	                           0
  hadoop02:46566	        RUNNING	    hadoop02:8042	                           0

    ```

    hadoo00コンテナのResourceManagerがhadoop01とhadoop02コンテナで動いているNodeManagerを認識していることがわかります。

7. HadoopでWordCountを動かしてみる
  分散モードで動いていることを確認できたので、HDFS上にHadoopのREADME.txtを保存してWordCountを実行してみましょう。HDFSでは/root/inputディレクトリがあらかじめ作られているので、この中にファイルをいれます。

    ```
    # HDFSへのデータの保存
    -bash-4.1# cd /usr/local/hadoop/bin
-bash-4.1# ./hdfs dfs -put ../README.txt input/
16/12/18 07:42:59 WARN util.NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable

    # WordCountの実行
-bash-4.1# ./hadoop jar ../share/hadoop/mapreduce/hadoop-mapreduce-examples-2.7.1.jar wordcount input output
16/12/18 07:44:16 WARN util.NativeCodeLoader: Unable to load native-hadoop library for your platform... using builtin-java classes where applicable
16/12/18 07:44:16 INFO client.RMProxy: Connecting to ResourceManager at hadoop00/172.18.0.2:8032
16/12/18 07:44:17 INFO input.FileInputFormat: Total input paths to process : 33
16/12/18 07:44:18 INFO mapreduce.JobSubmitter: number of splits:33
16/12/18 07:44:18 INFO mapreduce.JobSubmitter: Submitting tokens for job: job_1482064378555_0001
16/12/18 07:44:18 INFO impl.YarnClientImpl: Submitted application application_1482064378555_0001
16/12/18 07:44:18 INFO mapreduce.Job: The url to track the job: http://hadoop00:8088/proxy/application_1482064378555_0001/
16/12/18 07:44:18 INFO mapreduce.Job: Running job: job_1482064378555_0001
16/12/18 07:44:29 INFO mapreduce.Job: Job job_1482064378555_0001 running in uber mode : false
16/12/18 07:44:29 INFO mapreduce.Job:  map 0% reduce 0%
16/12/18 07:45:16 INFO mapreduce.Job:  map 18% reduce 0%
16/12/18 07:45:25 INFO mapreduce.Job:  map 21% reduce 0%
16/12/18 07:45:26 INFO mapreduce.Job:  map 33% reduce 0%
16/12/18 07:45:27 INFO mapreduce.Job:  map 42% reduce 0%
（略）
16/12/18 07:46:54 INFO mapreduce.Job:  map 85% reduce 28%
16/12/18 07:47:01 INFO mapreduce.Job:  map 100% reduce 28%
16/12/18 07:47:04 INFO mapreduce.Job:  map 100% reduce 67%
16/12/18 07:47:05 INFO mapreduce.Job:  map 100% reduce 100%
16/12/18 07:47:08 INFO mapreduce.Job: Job job_1482064378555_0001 completed successfully
16/12/18 07:47:09 INFO mapreduce.Job: Counters: 50
	File System Counters
		FILE: Number of bytes read=76647
		FILE: Number of bytes written=4094462
		FILE: Number of read operations=0
    （略）
    ```

    きちんと実行できました。他に扱いたいデータがあればホストからscp等でHadoopのマスタコンテナにコピーしてからHDFSに保存してください。

    #### 注意
    デフォルトのDockerの設定ではコンテナのローカル領域は10GBまでしかないため、大量のデータを置くことができません。あくまでお試し用としてお使い下さい。
ホストのディレクトリをコンテナがマウントすれば容量の制約を回避することもできます。その方法が書かれた記事は簡単に見つかるためここでは触れません。もしもホストのディレクトリをマウントした場合はHDFSのデータを保存するディレクトリの設定を変更して下さい。

8. コンテナを停止する
    Hadoopを使ってみた後は、play-book.ymlの`stop-all`のコメントアウトを外し、他のロールをコメントアウトしてから実行します。

    ```play-book.yml
    - hosts: localhost
      vars_files:
        - vars/hadoop.yml
        - vars/proxy.yml
      roles:
    #    - install-docker
    #    - start-master
    #    - start-worker
        - stop-all
    ```



    ```sh
    $ ansible-playbook play-book.yml
    [WARNING]: Host file not found: /usr/local/etc/ansible/hosts

    [WARNING]: provided hosts list is empty, only localhost is available


    PLAY [localhost] ***************************************************************

    TASK [setup] *******************************************************************
    ok: [localhost]

    TASK [stop-all : stop and remove hadoop master container] **********************
    changed: [localhost]

    TASK [stop-all : stop and remove hadoop worker containers] *********************
    changed: [localhost] => (item=hadoop01)
    changed: [localhost] => (item=hadoop02)

    PLAY RECAP *********************************************************************
    localhost                  : ok=3    changed=2    unreachable=0    failed=0   
    ```

ということで、dockerコマンドを触ることなくHadoop環境をコンテナで用意して分散処理することができました。

# 後半

ここからは記事の後半です。

プレイブックやDockerfileをどのように作ったのかを書いていきます。

##　用意するもの
利用者にはDockerもHadoopも使ったことがない人を想定しているため、次のものを用意することにします。

* [sequenceiq/hadoop-docker](https://github.com/sequenceiq/hadoop-docker)に手を加えて分散モードで利用できるようにしたHadoopのDockerイメージ
* インストールやイメージのビルド、コンテナの操作といったdockerコマンドを必要とする処理を自動化するAnsibleのプレイブック

## sequenceiq/hadoop-dockerについて
sequenceiq/hadoop-dockerは一つのコンテナの中で必要なHadoopのデーモンが全て動くいわゆる擬似分散モードになっています。コンテナを複数立ち上げてもそれらが連携することはなく一つのコンテナの中に閉じたHadoop環境が複数作られるだけです。

sequenceiq/hadoop-dockerは全てのデーモンがコンテナにとってのlocalhostで起動するためコンテナ間の通信を考慮する必要はありませんでしたが、分散モードを試したい今回はマスタとワーカにコンテナの役割を分けた上でワーカがNameNode（HDFSのマスタ）とResourceManager（YARNのマスタ）が起動しているマスタコンテナと相互に通信できるようにしなければなりません。

コンテナ起動時にマスタとワーカに期待する動作は次の通りです。

<dl>
  <dt>マスタコンテナ</dt>
  <dd>Hadoopの設定ファイルで自分の名前でNameNodeとResourceManagerを設定した後に、コンテナ内でNameNodeとResourceManagerが起動する。</dd>
  <dt>ワーカコンテナ</dt>
  <dd>Hadoopの設定ファイル中を書き換えてマスタの場所を設定した後にDataNodeとNodeManagerを起動する。</dd>
</dl>


## 分散モードのHadoopイメージのDockerfileを作る

まず、[sequenceiq/hadoop-dockerのGithubのリポジトリ](https://github.com/sequenceiq/hadoop-docker)からコードを取ってきて中身を確認してみます。

```sh
$ tree hadoop-docker 
hadoop-docker
|-- Dockerfile             # Hadoopが入った環境を整える。
|-- LICENSE
|-- README.md
|-- bootstrap.sh           # コンテナ起動時にsshdとHadoopのデーモンを起動する
|-- core-site.xml.template # NameNodeの場所の設定。コンテナ配備後にsedで書き換えてcore-site.xmlとして$HADOOP_CONF_DIRに保存される
|-- hdfs-site.xml 
|-- mapred-site.xml
|-- ssh_config
`-- yarn-site.xml          # YARNの設定

```

元々の動作ではHadoopの設定の書き換えとデーモンの起動はbootstrap.shで行なっていたため、ほとんどがbootstrap.shの修正ですみます。

最初に、マスタとワーカのどちらで振舞うべきかを示す引数とマスタの場所を示す引数を追加し、役割に応じて立ち上げるデーモンを切り替えられるようにしました。
また、NameNodeとResouceManagerが動作する場所（コンテナ）を指し示すために設定ファイルのテンプレートを追加しました。NameNodeの場所を設定する`fs.defaultFS`を記述するcore-site.xml.templateは既にあったため、追加するのはResouceManagerの場所を設定する`yarn.resourcemanager.hostname`が書かれたyarn-site.xml.templateだけで済みました。


他の変更点として、Dockerfileイメージをビルドするときに欧州のサイトからHadoopを取得している最中にcurlがタイムアウトしたためHadoopの取得先を国内のミラーサイトに変えたり、Dockerfile中で行なっていたHDFSのフォーマットをbootstrap.shのマスタの処理の中に移したりといったものがあります。（細かい変更は他にもあります）

これで分散モードで動くHadoopのイメージの元になるDockerfileができました。




## Dockerをインストールするプレイブックを作る

最初に述べたように、ここでは利用者にDockerに関する操作を一切させないことを目指しています。どうせならDockerコマンドだけではなくDockerのインストールも自動化したいと思ったので、そのための`install-docker`も用意することにしました。後になってからDockerのイメージのビルドといった下準備の処理も`install-docker`に加わり、Hadoopコンテナを動かすまでの前準備を一括して行うロールになってしまいました。


AnsibleにはDockerイメージをいじるためのdocker_imageモジュールやDockerコンテナを操作するためのdocker_containerモジュールがあり、「Dockerを知らなくても」とタイトルで書いた部分のほとんどがこれらのモジュールを使ってタスクをあらかじめ定義することで実現しています。これらに必要なdocker-pyもこのロールの中でインストールしています。

イメージをビルドするタスクに該当する処理を見てみます。

```roles/install-docker/tasks/main.yml
（略）
- name: build hadoop-docker images.
  docker_image:
    path: "{{ hadoop_dockerfile_dir }}"
    name: hadoop-docker
    state: present
    buildargs:
      http_proxy: "{{ http_proxy }}"
      https_proxy: "{{ https_proxy }}"
```

docker_imageモジュールはpathパラメータで指定されたディレクトリにあるDockerfileをビルドします。また、プロキシ環境下でイメージをビルドするために必要な設定も与えています。

これで分散モードのHadoopが動くDockerイメージのビルドができるようになりました。


#### 個人的に気になったこと その1
このタスクを作っている時、docker_imageモジュールとDockerでイメージをビルドする時に叩く`docker build`コマンドのデフォルトの動作が違うことに気づきました。
`docker build`コマンドでイメージをビルドする場合はイメージが既に存在していたとしても以前のビルド後にDockerfileに手を加えられているとそれを検知して修正後のDockerfileからイメージをビルドし直してくれるのですが、docker_imageモジュールは`force: yes`を指定しないとビルドし直してくれませんでした。
今回はDockerfileに手を加えることを想定していないため、`force: yes`は外しています。

## Hadoopのコンテナを起動するプレイブックを用意

Hadoopコンテナを起動するロールとして`start-master`と`start-worker`を用意します。先ほど作ったイメージは起動時の引数を切り替えることでマスタとワーカの役割を分けることができるようにしていました。次に担保すべきことはコンテナ間で相互に通信ができることです。

Dockerはコンテナ間の通信を実現するためにいくつかの方法を持っています。よく紹介されている方法は`--link container_name`で接続したいコンテナ名を指定することです。ただし、この方法は双方向の通信ではなく片方向の通信しかできない上に、既に動いているコンテナしか指定することができないため、順番に起動するHadoopのマスタとワーカのコンテナ間で双方向の通信を確保することができません。

そこで、後からコンテナを追加してもコンテナ名で通信が可能なユーザ定義の仮想ネットワークを作りそのネットワーク上でHadoopのコンテナを起動することにします。AnsibleはDockerの仮想ネットワークを操作するdocker_networkモジュールも持っているのでこれを使うことにします。

仮想ネットワークを作ってHadoopのマスタコンテナを起動する`start-master`のプレイブックを作ります。

```role/start-master/tasks/main.yml
- name: create hadoop-network
  docker_network:
    name: hadoop-network

- name: run hadoop master container
  docker_container:
    name: "{{ hadoop_master }}"
    hostname: "{{ hadoop_master }}"
    image: hadoop-docker
    command: master {{ hadoop_master }}
    state: started
    purge_networks: yes
    networks:
      - name: hadoop-network
        aliases:
          - "{{ hadoop_master }}"
    ports:
      - "2122:2122"
      - "9000"
      - "19888:19888"

```

一つ目のタスクでHadoopコンテナを作るためのhadoop-networkを作っています。このタスクを実行したあとホスト側でDockerのネットワークを確認すると、デフォルトのbridge、host、noneのに加えて新しくhadoop-networkがbridgeドライバで作られていることが分かります。

```sh
$ docker network ls
NETWORK ID          NAME                DRIVER              SCOPE
ccdbd510466e        bridge              bridge              local               
71acb8dc92eb        hadoop-network      bridge              local               
2c76e290d424        host                host                local               
834660ee4e16        none                null                local     
```

二つ目のタスクはhadoop-networkネットワーク上でマスタコンテナを起動します。`networks`パラメータの中の`aliases`でこのコンテナを`vars/hadoop.yml`で指定したマスタの名前で登録しているため、後から追加するワーカコンテナはマスタのaliasでマスタコンテナと通信することができるようになっています。

ワーカコンテナを起動するロールもマスタと同じように作りました。

#### 個人的に気になったこと その2
よく見ると、コンテナを起動するタスクで`purge_networks: yes`というパラメータがあります。これは、`networks`で指定しているネットワーク以外のネットワークから離脱するためのものです。

実は`purge_networks: yes`を指定せずにコンテナを起動すると、このコンテナはデフォルトのネットワークであるbridgeとhadoop-networkの両方に紐づけられました。すると、[docker_containerモジュールのドキュメント](https://docs.ansible.com/ansible/docker_container_module.html)の`published_ports（portsのalias）`のパラメータの説明にあるように、`networks`パラメータが指定されたコンテナはコンテナが所属するネットワークリストのうち最初に見つかったbridgeドライバのネットワークのipv4アドレスにバインドされるため、マスタの公開ポートは期待するhadoop-networkではなくbridgeに紐づけられてしまいます。すると、デフォルトのbridgeネットワークにはalias機能がないためコンテナ間の名前解決ができなくなってしまいワーカコンテナはマスタコンテナと通信することができませんでした。

試しにdocker_containerモジュールではなく以下のdockerコマンドでコンテナを起動するとhadoop-networkのみに紐づけられることが確認できました。

```sh
# コンテナの起動
$ docker run -d --network hadoop-network --name hadoop00 --hostname hadoop00 hadoop-docker master hadoop00
4d36cfe7b8f3e16c62a684a7696ea948cb457e46db5035701e9b447bf0fd9715
# bridgeネットワークに属するコンテナ
$ docker network inspect bridge |grep -A 4 Containers
        "Containers": {},
        "Options": {
            "com.docker.network.bridge.default_bridge": "true",
            "com.docker.network.bridge.enable_icc": "true",
            "com.docker.network.bridge.enable_ip_masquerade": "true",
# hadoop-networkに属するコンテナ
$ docker network inspect hadoop-network |grep -A 4 Containers
        "Containers": {
            "4d36cfe7b8f3e16c62a684a7696ea948cb457e46db5035701e9b447bf0fd9715": {
                "Name": "hadoop00",
                "EndpointID": "7d03fb8a5839dbe9be41f44799acb1fd6ffa6b6ccddc3c14044bb85d050a49e3",
                "MacAddress": "02:42:ac:12:00:02",
```
	

どなたか他に良い回避方法をご存知でしたら教えて下さい。

## Hadoopのコンテナを停止するプレイブックを用意

コンテナを停止するためのロールである`stop-hadoop`を用意します。
コンテナを起動するタスクのうち不要なパラメータを消して`state: absent`にしただけです。

```roles/stop-all/tasks/main.yml
- name: stop and remove hadoop master container
  docker_container:
    name: "{{ hadoop_master }}"
    state: absent

- name: stop and remove hadoop worker containers
  docker_container:
    name: "{{ item }}"
    state: absent
  with_items:
    - "{{ hadoop_worker }}"

```

これでDockerのインストール、イメージのビルド、Hadoopの起動と停止を実現するプレイブックが揃いました。

# まとめ

当初の目的通りDockerコマンドを使わずにHadoopのDockerコンテナで分散処理を試すことができました。

今回初めてAnsibleのDockerモジュールを触ってみたのですが、元々HadoopとDockerに慣れていたこともありそんなに苦労しないだろうと想定していました。ところが慣れによる先入観からAnsibleモジュールの思わぬ挙動になかなか気づかずコンテナ間で通信ができない原因の調査のために試行錯誤を繰り返すことになり大変でした。ただ、トラブルシューティングは好きなのでそういった苦労も含めて新しいモジュールを使うことを楽しみながら記事を書くことができました。

Dockerは便利なだけではなく楽しい技術です。まだDockerに触れたことがないという方は、このプレイブックを動かすとお手元のサーバにDocker環境が出来上がっているはずですので実際にDockerのコマンドを叩いてみることをオススメします。