Ansible Dynamic Inventoryをつくろう！

# **はじめに**

簡単簡単と言われて使い始めたAnsible、

- エージェントレス
- YAML記法
- 豊富なモジュール

簡単なことに加え、便利ですよね。
しかし、使い倒していく上でそろそろあることがお困りではないでしょうか。

そう、管理対象ホスト、インベントリの管理です。

インベントリはini形式に毛が生えたようなもので、基本的にはただのテキストファイルです。
グループ化や親子構造を取れたりするので便利ではあるのですが、共通化や汎用化を進めていくとグループが複雑なり、テキスト管理が辛くなってきます。

そうした場合、 **Dynamic Inventory** という機能を用いて、静的なファイルによる管理から動的なデータベース等による管理を選ぶことも可能です。
しかし、AWSやAzure、OpenStackやCloudStackなど、名だたるクラウド関連サービスや製品に対するDynamic Inventoryはあらかた用意されているのですが、そうしたクラウドのコア機能に触れない場合は中々手が出ないこともあるでしょう。多分。

でも大丈夫、Dynamic Inventoryをつくるのは意外と簡単です。
今回はDynamic Inventoryをいくつかの方法でつくってみたいと思います。
(コードが適当なのはやる気の低下によるものです。お察しください。)

下記の環境で試します。

```
$ ansible --version
ansible 2.2.0.0
  config file = /etc/ansible/ansible.cfg
  configured module search path = Default w/o overrides
```

# **Dynamic Inventoryとは**

まずDynamic Inventoryという機能がいかなるものなのかを学びましょう。

**[Dynamic Inventory](http://docs.ansible.com/ansible/intro_dynamic_inventory.html)** のページに色々書いてあるのですが、技術的に詳しいことは書いていないのでさっさと **[Developing Dynamic Inventory Sources](http://docs.ansible.com/ansible/developing_inventory.html)** のページを見ましょう。

> When the **external node script** is called with the single argument **--list** , **the script must output a JSON encoded hash/dictionary** of all the groups to be managed to **stdout** . Each group’s value should be either a hash/dictionary containing a list of each host/IP, potential child groups, and potential group variables, or simply a list of host/IP addresses, like so:

これを見ると、結局Dynamic Inventoryとは、

- **--list** 引数を取れる
- **JSON形式** のデータを標準出力する

**スクリプト** であればよいことがわかります。

## **基本構造**

Dynamic Inventory が出力すべきJSONの基本構造は、

```
{
    "databases"   : {
        "hosts"   : [ "host1.example.com", "host2.example.com" ],
        "vars"    : {
            "a"   : true
        }
    },
    "webservers"  : [ "host2.example.com", "host3.example.com" ],
    "atlanta"     : {
        "hosts"   : [ "host1.example.com", "host4.example.com", "host5.example.com" ],
        "vars"    : {
            "b"   : false
        },
        "children": [ "marietta", "5points" ]
    },
    "marietta"    : [ "host6.example.com" ],
    "5points"     : [ "host7.example.com" ]
}
```

のように、

```
{
    # シンプルにグループホストのみを定義する場合
    "<グループ名1>" : [ "<ホスト1>", "<ホスト2>" ],

    # varsやchildrenを定義する場合
    "<グループ名2>" : {
        "hosts"    : [ "<ホスト1>", "<ホスト2>" ],
        "vars"     : {
            "<変数名1>" : "<値1>"
        }
        "children" : [ "<子グループ1>", "<子グループ2>"]
    }
}
```

という構造を取れればよいようです。
Dynamic Inventoryが登場した当初はグループホストのみを定義する書き方しか対応していなかったようですが、 **バージョン1.3** 以降ではvarsやchildrenも一緒に定義できるようになりました。

## **応用: hostvars**

応用的な書き方として、hostvarsもDynamic Inventoryで表現することができます。
hostvarsは元々、Dynamic Inventory スクリプトに対して、 **--host <ホスト名>** の引数を渡すことで、指定したホストに関する hostvars が取得する機能を実装することで実現されていました。

```
$ ./dynamic_inventory --host moocow.example.com
{
    "asdf" : 1234
}

$ ./dynamic_inventory --host llama.example.com
{
    "asdf" : 5678
}
```

しかし、この仕様では hostvars の利用に関して、

1. Dynamic Inventoryに--listでホストグループを問い合わせる
2. Ansibleの実行グループから、実行対象ホストを特定する
3. 実行対象の各ホストに対して、それぞれ--host <ホスト名>でDynamic Inventoryを実行してhostvarsを取得する

というステップを踏む必要があり、 **実行対象ホストの数に応じて** Dynamic Inventory の起動回数が増え、それだけ **データストアへの問い合わせ** が多くなるという問題がありました。
そのため、グループの指定方法が拡張された1.3で hostvars の指定方法も、下記のように **ホストグループ情報に "_meta" を加えて出力させる** ことで、問い合わせ回数の最小化が可能になりました。

```
{
    # ホストグループの定義
    (省略)

    # hostvarsの定義
    "_meta" : {
       "hostvars" : {
          "moocow.example.com"     : { "asdf" : 1234 },
          "llama.example.com"      : { "asdf" : 5678 },
       }
    }
}
```

# **インベントリファイルの場合**

Dynamic Inventoryとの対比を見るため、まずは静的ファイルのインベントリを使っておきましょう。

```
$ cat inventory
[sample-servers]
192.168.100.10 host_var=hoge
192.168.100.20 host_var=fuga

[sample-servers:vars]
group_var=hogefuga
```

このとき、このような動きをします。

```
$ ansible -i inventory sample-servers -m debug -a "msg={{ host_var }}"
192.168.100.20 | SUCCESS => {
    "msg": "fuga"
}
192.168.100.10 | SUCCESS => {
    "msg": "hoge"
}

$ ansible -i inventory sample-servers -m debug -a "msg={{ group_var }}"
192.168.100.20 | SUCCESS => {
    "msg": "hogefuga"
}
192.168.100.10 | SUCCESS => {
    "msg": "hogefuga"
}
```

これと同等の動きを自作Dynamic Inventoryにさせてみましょう。

## **インベントリのJSON化**

公式ページで確認したように、Dynamic Inventoryとは、何かのデータストアそのものを指すわけではなく、 **JSON形式のホストリストを標準出力するスクリプト** でした。
なのでまずは、あのファイルと同等の意味を持つJSONを最終形として見ておきましょう。こちら。

```:sample_json
{
    "_meta": {
        "hostvars": {
            "192.168.100.10": {
                "host_var": "hoge"
            },
            "192.168.100.20": {
                "host_var": "fuga"
            }
        }
    },
    "sample-servers": {
        "hosts": [
            "192.168.100.10",
            "192.168.100.20"
        ],
        "vars": {
            "group_var": "hogefuga"
        }
    }
}
```

つまり、このJSONを吐き出すようにDynamic Inventoryスクリプトを作ればいいわけです。

# **インチキDynamic Inventory**

まずはDynamic Inventoryの挙動を確かめるべく、インチキをしてDynamic Inventoryを体感してみましょう。
そう、あのJSONをそのまま吐き出すスクリプトを作るのです。

先ほどのゴールとなるJSONをそのままファイルに書き出します。

```
$ cat ~/sample_json
(さっきのJSONの中身)
```

そしてそれを `cat` するだけのスクリプトを作りましょう。

```
$ vi fake_dynamic_inventory
#!/bin/bash

cat ~/sample_json
```

ではさっそくこのインチキDynamic Inventoryで動かしてみましょう。

```
$ ansible -i ./fake_dynamic_inventory sample-servers -m debug -a "msg={{ host_var }}"
ERROR! The file ./fake_dynamic_inventory looks like it should be an executable inventory script, but is not marked executable. Perhaps you want to correct this with `chmod +x ./fake_dynamic_inventory`?
The file ./fake_dynamic_inventory looks like it should be an executable inventory script, but is not marked executable. Perhaps you want to correct this with `chmod +x ./fake_dynamic_inventory`?
```

おやおや、実行権がないと怒られてしまいました。
そうです、Dynamic Inventoryスクリプトには **実行権** をつけなければいけないことにご注意ください。

```
$ chmod +x fake_dynamic_inventory

$ ansible -i ./fake_dynamic_inventory sample-servers -m debug -a "msg={{ host_var }}"
192.168.100.10 | SUCCESS => {
    "msg": "hoge"
}
192.168.100.20 | SUCCESS => {
    "msg": "fuga"
}

$ ansible -i ./fake_dynamic_inventory sample-servers -m debug -a "msg={{ group_var }}"
192.168.100.20 | SUCCESS => {
    "msg": "hogefuga"
}
192.168.100.10 | SUCCESS => {
    "msg": "hogefuga"
}
```

完璧ですね。まずは指定形式のJSONをAnsibleに食わせるときちんと動くことがわかりました。

# **RDBMS: PostgreSQLの場合**

まずはデータストアといえば、というわけでRDBMSからPostgreSQLに登場していただきます。
適当にDockerか何かでPostgreSQLを用意してください。あとテーブル作成用にpsqlも。
こんな感じに。

```
# PostgreSQLイメージの取得
$ docker pull docker.io/postgres

# postgresコンテナの起動
$ docker run --name postgres -e POSTGRES_PASSWORD=password -d postgres

# psqlのインストール
$ sudo yum install postgresql

# PostgreSQL on postgresコンテナへの接続
$ psql -h $(sudo docker inspect --format "{{ .NetworkSettings.IPAddress }}" postgres) -U postgres

postgres=#
```

ではテーブルを作ってデータを突っ込みましょう。
下記のテーブルを作ります。
仕様上の穴しかない非常にアホっぽいテーブルですが、コンセプトデータみたいなものなんで勘弁してください。

- groupsテーブル

|groupname|
|:---:|
|sample-servers|

- groupvarsテーブル

|groupname|varname|varvalue|
|:---:|:---:|:---|
|sample-servers|group_var|hogefuga|

- hostsテーブル

|hostname|groupname|
|:---:|:---:|
|192.168.100.10|sample-servers|
|192.168.100.20|sample-servers|

- hostvarsテーブル

|hostname|varname|varvalue|
|:---:|:---:|:---|
|192.168.100.10|host_var|hoge|
|192.168.100.20|host_var|fuga|

下記のDML文とDDL文をpsqlから流しておいてください。

```
CREATE TABLE groups (
    groupname varchar(16) unique
);

CREATE TABLE groupvars (
    groupname varchar(16) REFERENCES groups ( groupname ),
    varname varchar(16),
    varvalue varchar(16)
);

CREATE TABLE hosts (
    hostname varchar(16) unique,
    groupname varchar(16) REFERENCES groups ( groupname )
);

CREATE TABLE hostvars (
    hostname varchar(16) REFERENCES hosts ( hostname ),
    varname varchar(16),
    varvalue varchar(16)
);
```

```
INSERT INTO groups VALUES ( 'sample-servers' );

INSERT INTO groupvars VALUES ( 'sample-servers', 'group_var', 'hogefuga' );

INSERT INTO hosts VALUES ( '192.168.100.10', 'sample-servers' );
INSERT INTO hosts VALUES ( '192.168.100.20', 'sample-servers' );

INSERT INTO hostvars VALUES ( '192.168.100.10', 'host_var', 'hoge' );
INSERT INTO hostvars VALUES ( '192.168.100.20', 'host_var', 'fuga' );
```

さて、ここからこのデータベースに対して問い合わせを行い、あのJSON出力を得ることを考えます。
何かしらのプログラミング言語を使うとよいと思うので、今回はPythonにします。
PythonからPostgreSQLに接続することに関しては、 **[psycopg2](http://initd.org/psycopg/)** を使います。

```
$ sudo yum install python-psycopg2
```

では下記Pythonスクリプトを作成して、動かしてみてください。
PostgresSQLの接続先は `docker inspect` で勝手に取るようにしていますが、Docker以外でやっている場合はIPを直接書くなどしてください。

```py:postgresql_dynamic_inventory.py
#!/usr/bin/python
# -*- coding:utf-8 -*-

import commands
import psycopg2
import json

output_dict={}

# DBコネクションの定義
conn = psycopg2.connect(
    host = commands.getoutput('sudo docker inspect --format "{{ .NetworkSettings.IPAddress }}" postgres'),
    port = 5432,
    database="postgres",
    user="postgres",
    password="password")

cur_group = conn.cursor()
cur_group.execute("""SELECT groupname FROM groups""")

# グループごとに辞書を作成して追加する
for row_group in cur_group:
    group_dict={}
    grouphosts=[]
    groupvars={}

    cur_hosts = conn.cursor()
    cur_hosts.execute("""SELECT hostname FROM hosts WHERE groupname = %s""", (row_group[0],))

    for row_host in cur_hosts:
        grouphosts.append(row_host[0])

    cur_groupvars = conn.cursor()
    cur_groupvars.execute("""SELECT varname, varvalue FROM groupvars WHERE groupname = %s""", (row_group[0],))

    for row_groupvar in cur_groupvars:
        groupvars[row_groupvar[0]]=row_groupvar[1]

    group_dict["hosts"]=grouphosts
    group_dict["vars"]=groupvars
    output_dict[row_group[0]]=group_dict

cur_hosts = conn.cursor()
cur_hosts.execute("""SELECT DISTINCT hostname FROM hosts""")

meta_dict={}
hostvars={}

# ホストごとに辞書を作成して追加する
for row_host in cur_hosts:
    hostvar={}

    cur_hostvars = conn.cursor()
    cur_hostvars.execute("""SELECT varname,varvalue FROM hostvars WHERE hostname = %s""", (row_host[0],))

    for row_hostvar in cur_hostvars:
        hostvar[row_hostvar[0]]=row_hostvar[1]

    hostvars[row_host[0]]=hostvar

meta_dict["hostvars"]=hostvars
output_dict["_meta"]=meta_dict

# 辞書をJSON形式して標準出力する
print json.dumps(output_dict, indent=4)
```

特に何のひねりもなくfor文をまわしてdictを作ってjson形式で吐き出しています。
もっとうまい方法があるかもしれませんが、普通にやる場合はRDBMSである以上、ネスト構造の部分にfor文をあてていくことになるかと思います。
RDBMSということでテーブルのイメージはつきやすいですが、それをJSONにまとめて出力するのは少し大変そうです。

とはいえ、JSONとして吐き出すというDynamic Inventoryのルールは満たしているため、

```
$ chmod +x postgresql_dynamic_inventory.py

$ ansible -i ./postgresql_dynamic_inventory.py sample-servers -m debug -a "msg={{ host_var }}"
192.168.100.20 | SUCCESS => {
    "msg": "fuga"
}
192.168.100.10 | SUCCESS => {
    "msg": "hoge"
}
```

というように、Dynamic Inventoryとしての機能に問題はなさそうです。

## **もう少しがんばる場合**

今回は実装や設計の工夫はせずあくまでコンセプト的なところを確認するため、かなり面倒を省略しています。
例えば、

- グループの親子関係(children)を定義していない
- groupvarsで単純なkey:valueの変数しか定義していない
- hostvarsで単純なkey:valueの変数しか定義していない

といったところがあります。
親子関係はおそらくgroupsテーブルに親グループの情報を付加すればある程度いけると思いますが、varsでkey:valueだけでなく辞書形式の変数を導入しようとすると、いわゆる第一正規化の問題にあたり、ちょいと面倒になります。
ただ、最近のPostgreSQLやMySQLなどはデータ型として **[JSON型](https://www.postgresql.jp/document/9.5/html/datatype-json.html)** などが持てるようになりました。
そのため、これらを活用すると1行1カラムの中でも実質複数のデータを格納することができ、この問題を解決できるかもしれません。
ただ、RDBMSでJSONを使うというのはまだまだ発展途上なところがありますし、他の用途がないのであれば後述するような元々JSONデータが格納できる別のNoSQLータベースを選択するほうがいいのかもしれません。

# **NoSQL: MongoDBの場合**

続いてはMongoDBで同じことをやってみましょう。
MongoDBはNoSQLのうち、ドキュメント型に位置付けられるもので、端的に言えばスキーマレスでJSONのようなデータがそのままの形式で格納できます。

それでは、やってみましょう。

```
$ sudo docker pull docker.io/tutum/mongodb

$ sudo docker run -d -p 27017:27017 -p 28017:28017 -e MONGODB_USER="mongo" -e MONGODB_DATABASE="mongo" -e MONGODB_PASS="password" tutum/mongodb
```

MongoDBにアクセスするべくmongoクライアントを導入しましょう。
標準リポジトリにはMongoDB本体と一緒になっているやつしかなくてキモイので別途公式リポジトリを追加しておきます。

```:/etc/yum.repos.d/mongo.repo
[mongodb-org-3.2]
name=MongoDB Repository
baseurl=https://repo.mongodb.org/yum/redhat/$releasever/mongodb-org/3.2/x86_64/
gpgcheck=1
enabled=1
gpgkey=https://www.mongodb.org/static/pgp/server-3.2.asc
```

あとはmongoクライアントをインストールしてMongoDBにデータを突っ込んでおきましょう。

```
# mongoクライアントのインストール
$ sudo yum install mongodb-org-shell

# MongoDBへの接続
$ mongo mongo -u mongo -p password

# テストデータの追加
> db.inventory.insert({"_meta":{"hostvars":{"192\uff0E168\uff0E100\uff0E10":{"host_var":"hoge"},"192\uff0E168\uff0E100\uff0E20":{"host_var":"fuga"}}},"sample-servers":{"hosts":["192\uff0E168\uff0E100\uff0E10","192\uff0E168\uff0E100\uff0E20"],"vars":{"group_var":"hogefuga"}}})
```

ここで微妙なのはMongoDBが仕様上、ピリオドをキー名にできないことです。
そのため、テストデータをよく見てもらうと、IPアドレスにあるピリオドの部分が `\uff0E` になっていることがわかると思います。

```py:mongodb_dynamic_inventory.py
#!/usr/bin/python
# -*- coding:utf-8 -*-

import pymongo
import json
from bson.json_util import dumps

# DBコネクションの定義
conn = pymongo.MongoClient('127.0.0.1', 27017)
db = conn.mongo
db.authenticate("mongo","password")

inventory = db.inventory
output_dict = {}

# inventoryコレクションの取得
output_dict.update(inventory.find()[0])

# オブジェクトIDの除去
del output_dict["_id"]

# 辞書をJSON形式して標準出力する
print json.dumps(output_dict, indent=4)
```

当たり前といえば当たり前の話なのですが、MongoDBにはゴールとなるJSONを突っ込んでいるだけなので、プログラム側では特にやることがありません。
しいて言うなら、MongoDBによって暗黙的に付与されるオブジェクトIDを除去していることくらいでしょうか。

実際の動作を見ても、

```
$ chmod +x mongodb_dynamic_inventory.py

$ ansible -i ./mongodb_dynamic_inventory.py sample-servers -m debug -a "msg={{ host_var }}"
192．168．100．20 | SUCCESS => {
    "msg": "fuga"
}
192．168．100．10 | SUCCESS => {
    "msg": "hoge"
}
```

特に問題なさそうです。

## **ピリオドの扱い**

テストデータを用意する際に触れた通り、MongoDBに格納するオブジェクトはキー名にピリオドを持つことができません。
そのため、エンコードをした上でデータを投入しているのですが、実は上のコードでは

```
$ ./mongodb_dynamic_inventory.py
{
    "_meta": {
        "hostvars": {
            "192\uff0e168\uff0e100\uff0e10": {
                "host_var": "hoge"
            },
            "192\uff0e168\uff0e100\uff0e20": {
                "host_var": "fuga"
            }
        }
    },
    "sample-servers": {
        "hosts": [
            "192\uff0e168\uff0e100\uff0e10",
            "192\uff0e168\uff0e100\uff0e20"
        ],
        "vars": {
            "group_var": "hogefuga"
        }
    }
}
```

というように、Dynamic Inventoryとしての出力にもエンコードがそのまま残るようになっています。
全キー走査して置換するとかの方法もあるとは思いますが、このままでも動くこともあり、面倒なのでやっていません。
MongoDBへのデータ投入からPythonによる取り出しの流れでもっといい感じにできればいいのですが...。

ちなみに気付いた人もいるかもしれませんが、この影響でAnsibleが出力するホスト名が少しキモくなっています。

```
[普通]
192.168.100.10 | SUCCESS => {
    "msg": "hoge"
}

[今回]
192．168．100．10 | SUCCESS => {
    "msg": "hoge"
}
```

ピリオドが全角です。キモいけど我慢。

# **おわりに**

いかがだったでしょうか。
Dynamic Inventoryというとなんだか難しそうな仕組みに聞こえますが、フタを開けてみればただのJSONです。
もし静的なファイルで大量のホストを頑張って管理しているのなら、ここはひとつDynamic Inventory化してみるのもいいでしょう。
今回はバックエンドとしてPostgreSQLやMongoDBを使ったりしましたが、モノによってはもっと簡単かつシンプルに動かすことができるかもしれません。

(Ansible 2.4のロードマップに **[Inventory Overhaul(インベントリ大点検)](https://github.com/ansible/ansible/blob/devel/docsite/rst/roadmap/ROADMAP_2_4.rst)** という文言が見えますが詳細が見えてくるまでそっと見守りましょう。。。)