Dynamic Inventoryとは

Dynamic Inventoryの使いどころ

ansible-playbookを実行するときに、-iでinventoryファイルだけでなく、JSONを標準出力するプログラムも渡せる。Dynamic Inventoryという機能だ。
本来は、ホストがAnsibleとは別で管理されている場合などに、ホストの情報をAPIやDBアクセスなどで取得・加工してAnsibleに渡すのが主目的の機能だが、これを利用してinventoryファイルに独自の書式を導入し、それをプログラミングで解読して標準のAnsibleのinventoryに加工し、JSON出力してみる。

Ansibleのinventoryファイルはグループが階層構造を作るとき読みづらくなるため、独自書式を導入することにより、可読性を向上させたい。

Dynamic Inventoryの仕様

端的にいうと実行可能な(つまり実行権限が付与されている)ファイルで、実行するとJSONを出力すればいいのだが、以下のような仕様があり、Ansible実行時にこれらに則ってプログラムファイルが実行される。

  • --listをつけて呼び出すと全group情報(group、groupのchildren、vars、hosts)をJSONで標準出力する
  • --host <HOSTNAME>をつけて呼び出すと指定したHOSTNAMEのhostvarsをJSONで標準出力する
  • _metaがkeyでhostvarsがvalueで定義されて、--list時にgroup情報と共にJSON出力されると、Ansible実行時に内部で--hostつきでhost数分呼び出されることがなくなり、パフォーマンス向上するため、推奨されている

つまり、--listつきでAnsibleから呼び出され、_meta.hostvarsの有無によって--host <HOSTNAME>がさらに呼び出される。

Inventoryの書式は少し複雑

Ansibleを作っていくとき、汎用性を高めるためにplaybookをミドルウェアごとに作成し、inventoryで各サーバにどのミドルウェアを構築するかを指定できるようにするとする。この場合、複雑なinventoryファイルを書かなくてはいけなくなる。

例えば、開発用サーバを1台作成するため、開発環境用inventoryを作るとする。このサーバにはapache, mysql, redisを入れる。inventoryのYAML(hosts.yml)は以下のようになる。

---
development:
  vars:
  children:
    - apache
    - mysql
    - redis

## ------------------------
## apache configuration
## ------------------------
apache:
  children:
    - apache00
apache00:
  hosts:
    - svr0001
  vars:
    apache:
      sites:
        - server_name: api.example.com
          listen: 443
          document_root: /var/www/api
          enable_https: true

## ------------------------
## mysql configuration
## ------------------------
mysql:
  children:
    - mysql00
mysql00:
  hosts:
    - svr0001
  vars:
    mysql:
      master: svr0001

## ------------------------
## redis configuration
## ------------------------
redis:
  children:
    - redis00
redis00:
  hosts:
    - svr0001
  vars:
    redis:
      master: svr0001

開発用サーバ(svr0001)を一台作成するだけなのに、ミドルウェアごとに設定を書くことになり、svr0001についての記述箇所が分散してしまう。書きづらいだけでなくsvr0001の全体像が掴みづらい。

このような記述になってしまう原因は、apache,mysql,redisのplaybookでhosts: apache, hosts: mysql, hosts: redisというように実行対象グループを限定していることにある。各ミドルウェアのplaybookをすべてincludeしている最上位playbookがあり、ansible-playbook実行時に指定するplaybookは常にその最上位playbookにしているのだが、あるサーバに対してどのミドルウェアのplaybookを実行するか判定するには、ミドルウェアのplaybookのhostsで判定するしかない。そのためsvr0001はapacheグループにも属さなければいけなし、mysqlグループとredisグループにも属さなければいけない。
開発環境では今後svr0001だけでなく、FQDN等が違うsvr0002も増やすことになるだろうし、グループ変数を別に定義しなければいけないことを考えると、apacheグループの下に子グループapache00, apache01, ...とさらに小分けしたグループを作らなければならない。
この実装方式では、YAMLが最上位のdevelopmentグループは別として、hostにたどり着くまでに3階層くだらなければいけないし、さらに重要なこととして、一サーバについての設定が分散してしまう。

Inventoryの書式に独自書式を追加する

先に見たような実装方式であってもinventoryを簡潔に書くために、独自の書式を追加する。 もともとグループ情報を定義するときに指定できる要素は以下となる。

  • vars: グループ変数
  • children: 子グループのキー
  • hosts: ホスト

これにsetupsという要素を独自に追加する。

setupsにはapache, mysql, redisなどを設定することができる。setupsにはあるサーバに対して実行したいミドルウェアplaybookの名称を書く。setupsがミドルウェアplaybook実行時のhosts:によるグループ制限に対して効くようになる。

なぜ独自に定義したsetupsに書いた値がホストグループに変わってhosts:によるグループ制限に効くのかというと、Dynamic Inventoryで実行されるプログラムでsetups要素を見つけたらYAMLのデータ構造を加工し、先に見たようなYAMLの構造に書き換えるからだ。つまり、setups要素に列挙したものが、ホストグループになるようにデータを加工する。

プログラムを見る前に、YAML(hosts.yml)がどれだけシンプルになるのか以下に記載する。

---
development:
  vars:
  children:
    - dev1_environment

dev1_environment:
  hosts:
    - svr0001
  setups:
    - apache
    - mysql
    - redis
  vars:
    apache:
      sites:
        - server_name: api.example.com
          listen: 443
          document_root: /var/www/api
          enable_https: true
    mysql:
      master: svr0001
    redis:
      master: svr0001

これだけシンプルになった。

  • apache00のような子グループがなくなった
  • svr0001が属するグループがapache, mysql, redisではなく唯一のグループとなった
  • svr0001が属するグループに意味がわかる名前をつけられる
  • svr0001が属するグループの変数が一箇所にまとまった

独自書式を可能にするプログラム

独自要素であるsetupsをどのように標準のinventoryに書き換えているか、実際のプログラム(d_inventory)を書く。

#!/usr/bin/python
# -*- coding: utf-8 -*-

import yaml
import json
import os
import copy

hosts_yml = os.path.join(os.path.abspath(os.path.dirname(__file__)), "hosts.yml")
f = open(hosts_yml, "r")
data = yaml.load(f)

## YAML内の独自定義のsetups句の解析を行い、Ansibleで使用できるinventory形式に変換する

# dictの中を変換していくため、変換用にdeepcopyする
data_rep = copy.deepcopy(data)

for k, v in filter(lambda (k, v): "setups" in v, data.items()):
  # YAML内の最上位階層に属する第一階層の変換
  # setupsで定義されているvalueをchildrenに設定し、残りの項目を下層に移す
  del data_rep[k]["setups"]
  remains = data_rep[k]
  data_rep[k] = {"children": v["setups"]}

  # 第二階層(group)作成
  # setupsでもともと定義されていた値(apache,mysql,etc)をkeyに、valueには第三階層(subgroup)を指すchildrenのみを持つ
  for group in v["setups"]:
    group_value = data_rep.setdefault(group, {"children": []})
    # 第三階層(subgroup)を指すkey名を決定する。uniqになるように文字列生成
    subgroup_name = group + "_in_" + k
    group_value["children"].append(subgroup_name)
    # 第三階層(subgroup)作成。valueにはもともと最上位階層で定義されていたhosts/vars情報を追加していく
    data_rep[subgroup_name] = remains

# _metaをキーにhostvarsを定義しないと、--listで取得できた全hostについて--hostでDynamic Inventoryを呼びだしてしまう
# パフォーマンスのため、hostvarsを取得するのに--hostで呼び出してもらうのではなく--listで一気に取得させるように_metaを必ず返すようにする
data_rep.setdefault("_meta", {"hostvars": {}})

# --hostで呼び出されると、引数で指定したhostのhostvarsを返す仕様にすべきだが、
# --hostは呼ばれないようにしたので、--listが指定されているかどうかを気にせずに必ず全体をJSONで返却する
print json.dumps(data_rep)

コメントを詳細に書いたが、setups要素をchildrenに変換したりして、YAMLのデータ構造を変えている。

このプログラムが--listつきで呼び出されると(実際にはコメントで記載した通りオプションは見ていないが)、次のJSONを出力する。

$ ./d_inventory --list | jq
{
  "development": {
    "children": [
      "dev1_environment"
    ],
    "vars": null
  },
  "_meta": {
    "hostvars": {}
  },
  "mysql_in_dev1_environment": {
    "hosts": [
      "svr0001"
    ],
    "vars": {
      "apache": {
        "sites": [
          {
            "enable_https": true,
            "document_root": "/var/www/api",
            "server_name": "api.example.com",
            "listen": 443
          }
        ]
      },
      "redis": {
        "master": "svr0001"
      },
      "mysql": {
        "master": "svr0001"
      }
    }
  },
  "redis": {
    "children": [
      "redis_in_dev1_environment"
    ]
  },
  "dev1_environment": {
    "children": [
      "apache",
      "mysql",
      "redis"
    ]
  },
  "apache_in_dev1_environment": {
    "hosts": [
      "svr0001"
    ],
    "vars": {
      "apache": {
        "sites": [
          {
            "enable_https": true,
            "document_root": "/var/www/api",
            "server_name": "api.example.com",
            "listen": 443
          }
        ]
      },
      "redis": {
        "master": "svr0001"
      },
      "mysql": {
        "master": "svr0001"
      }
    }
  },
  "mysql": {
    "children": [
      "mysql_in_dev1_environment"
    ]
  },
  "apache": {
    "children": [
      "apache_in_dev1_environment"
    ]
  },
  "redis_in_dev1_environment": {
    "hosts": [
      "svr0001"
    ],
    "vars": {
      "apache": {
        "sites": [
          {
            "enable_https": true,
            "document_root": "/var/www/api",
            "server_name": "api.example.com",
            "listen": 443
          }
        ]
      },
      "redis": {
        "master": "svr0001"
      },
      "mysql": {
        "master": "svr0001"
      }
    }
  }
}

正しくapache, mysql, redisのグループにsvr0001が属していることになる。
一番初めに書いたYAMLよりは階層が深くなってしまっているが、これは人が読み書きするのではなく、Ansibleが読むだけなので気にしないでいいだろう。
また変数がすべてのグループに書かれているが、apacheのplaybookを実行しているときにmysqlに関する変数が定義されていても影響はないため問題ないし、同上の理由により可読性も気にしないでいいだろう。

Dynamic Inventoryでどこまで書式を拡張してもいいか

このようにDynamic Inventoryを使うことで、独自の書式をinventoryに取り入れることができ、チームの運用にあったYAMLを書くことができるようになる。
ただしあまりに拡張しすぎると返って複雑になったり柔軟性を失ったりしかねないので、拡張のメリットの大きさや実装の簡易さを十分考慮した方がいい。
ちなみに、今回の実装では、filter(lambda (k, v): "setups" in v, data.items())を冒頭で実行しているため、setupsという独自要素が一切なければ、そのままYAMLのデータ構造を変えずにJSON出力できるようになっており、独自書式でも受け付けるし、従来通りのAnsible標準の書式でも受け付けられるようになっている。このような後方互換性も考慮に入れた方がいいだろう。