Ansibleの設計方針

ベストプラクティスを語るにはまず前提としている設計方針を明確にしなければならない。

Ansibleのバージョンは2.7で、目標とするAnsibleの使い方は以下の通り。

  1. Webサーバ、DBサーバ、Cacheサーバなど多種多様なサーバの構築をひとつのPlaybookで対処できること
  2. WebサーバのFQDNやポート、DBサーバのレプリケーション構成などを変数として定義し、各プロジェクトで使いまわせるようにすること

1つめのひとつのPlaybookで各種サーバを構築するというのは、よく例で挙げられる構成とは違うと思う。Webサーバを構築するのであればwebserver.ymlでNginxとFluentdをインストールし、DBサーバを構築するのであればdbserver.ymlでMySQLをインストールするといったように、各サーバ種別ごとにPlaybookを用意しているのを例でよくみる。

しかし実装したAnsibleを複数のチームメンバに複数のプロジェクトで使用してもらうには、実行するPlaybookがひとつの方がインタフェースとしてわかりやすい。

2つめの各プロジェクトで使いまわせるようにするというのは、大量のプロジェクトを抱えているチームでは当然の要求となるだろう。複数のプロジェクトで使いまわせるように、変数はできるだけ多く定義できるようにする必要がある。そのため、変数定義の方法が簡単で、かつ視認性・閲覧性が高いようにする必要がある。

Ansibleのベストプラクティスな構成

実際の構成を見ながら説明していく。

ディレクトリ構成

まずはディレクトリ構成から。

.
├── group_vars
│   ├── all -> .
│   ├── general.yml
│   └── iptables.yml
├── inventories
│   ├── development.yml
│   └── production.yml
├── roles
└── setup.yml # メインのplaybook

変数定義

変数定義はgroup_vars/allディレクトリ配下に作成する。group変数はallレベルのもののみ定義し、例えばwebserverグループやdbserverグループなどの単位でのgroup変数はgroup_varsディレクトリ配下に作成しない。(all以外のgroup変数はInventoryの箇所で記載する。)

group_varsディレクトリはPlaybookと同じ階層かInventoryと同じ階層に置かなくてはいけない。しかし実際に変数を記載・確認するときのパスがgroup_vars/all/general.ymlinventories/group_vars/all/general.ymlでは階層が深すぎてユーザビリティが落ちるので、group_vars/allgroup_varsディレクトリへのsymlinkとしている。

allレベルのgroup変数なのでOS層にかかわることやミドルウェアのバージョンなど各環境や用途にかかわらない内容の定義を行う。allディレクトリ以下のYAMLファイルはすべてgroup変数として扱ってくれるため、変数の内容に合わせてファイルを適宜分ける。

$ cat group_vars/general.yml
略
ansible_become: yes

timezone: "Asia/Tokyo"

mysql:
  version: mysql57
  password:
    root: rootpasswd
    slave: slavepasswd
略

Inventory

inventoriesディレクトリの下にInventoryを環境ごとに作成する。

さきほど「all以外のgroup変数はInventoryの箇所で記載する」と記載した通り、Inventoryには積極的にgroup変数とhost変数を記載していく。group_varsディレクトリの下にall以外のgroup変数用ディレクトリやファイルを作成しない理由は、Inventoryに書いたほうが、Inventoryを見るだけでサーバの設定がすべてわかるのが記述性も閲覧性も高いと考えているから。変数をかなり書くため、iniではなく、階層構造が簡単に書けるYAMLで書く。

inventories/production.ymlは長くなるので後掲するが、工夫しているポイントは以下の三点。

  1. ホストは必ずグループに属させる
  2. グループはall.childrenに属さなければいけないが、all.childrenを書かなくてもAnsibleが自動で補完してくれるようなので可読性のためall.childrenは書かない
  3. グループの書式はユニークな任意のグループ名@実行したいrole1&実行したいrole2&...とする

2の工夫は本当だったら以下のように階層が深くなりすぎて読みづらくなるところを解消している。

all:
  children:
    group1:
      hosts:
        server0001:
        server0002:

3の工夫は本題の一番特殊なところ。Playbookの書き方と密接にかかわるのでPlaybookのところで再度取り上げる。

inventories/production.ymlは以下の通り。

---
api@nginx&td-agent:
  vars:
    munin:
      group: production
      subgroup: api
    nginx:
      sites:
        - server_name: api.example.com
          listen: 80
          https: false
  hosts:
    api0001:
    api0002:
    api0003:

maindb@mysql:
  vars:
    munin:
      group: production
      subgroup: maindb
  hosts:
    # shard 1
    maindbm0001:
    maindbs0001:
      mysql:
        master_host: maindbm0001
    maindbb0001:
      mysql:
        master_host: maindbm0001
        backup: { minute: 00, hour: 03 }
    # shard 2
    maindbm0002:
    maindbs0002:
      mysql:
        master_host: maindbm0002
    maindbb0002:
      mysql:
        master_host: maindbm0002
        backup: { minute: 00, hour: 04 }

Playbook

ひとつのPlaybookのみとするというルールから、setup.ymlという名前をエントリーポイントのPlaybookとした。setup.ymlからはNginxやMySQLなどの目的別のroleを呼び出している。

$ cat setup.yml
---
- hosts: all
  roles:
    ## common roles
    - { role: os,      tags: os }
    - { role: account, tags: account }

## middlewares
- { name: nginx,     roles: [ { role: nginx, tags: nginx } ], hosts: ~.+@(.+&)*nginx(.+)* }
- { name: mysql,     roles: [ { role: mysql, tags: mysql } ], hosts: ~.+@(.+&)*mysql(.+)* }
- { name: redis,     roles: [ { role: redis, tags: redis } ], hosts: ~.+@(.+&)*redis(.+)* }
- { name: memcahced, roles: [ { role: memcached, tags: memcached } ], hosts: ~.+@(.+&)*memcached(.+)* }
略

hosts

setup.ymlからすべてのroleを呼び出すが、Webサーバを作るときは当然MySQL roleを実行してほしくない。

これを実現するために、Playbookのhostsで処理対象とするかどうかのフィルタリングをおこなう。

- hosts: ~.+@(.+&)*nginx(&.+)*
  roles:
    - role: nginx
      tags: nginx

hostsにはグループ名やホスト名がかけるだけではなく、ワイルドカードや正規表現が使える。正規表現を使うには~から始まっていなければいけない。

ここでInventoryファイルでグループの書式を特殊なものにしたことが活きている。@の後ろに&区切りで実行したいroleを記載したので、それにマッチするような正規表現をhostsに書けば、記載されているグループに属するホストに対してだけ実行される。

ただしsetup.ymlでは実際にはワンライナーで書いている。理由はsetup.ymlでの処理が一覧してわかるようにするため。hostsに設定している値が正規表現で見にくいため、nameを追加し、一番左に置いている。

become

OSのセットアップやミドルウェアのインストールではroot権限が必要になることが大半だが、become: yesを毎回書くのは冗長なので、allレベルのgroup変数でansible_become: yesを設定し、全groupでrootになるようにしている。

gather_facts

速度向上のためにgather_facts: noにしたいことが大半なので、ansible.cfggathering=explicitにして、毎回gather_facts: noを書かなくていいようにしている。

たとえばredis.confではOSのメモリから計算したメモリ量をmaxmemoryに設定したりするため、Ansibleのファクト変数が必要な時だけgather_facts: yesと書いてもいいが、以下のようにsetupモジュールとwhenを組み合わせることで、Inventoryのグループ名の@以降に特定のrole名を書いたときだけファクト変数を収集するようになる。

- hosts: all
  tasks:
    - name: gather facts
      setup:
      tags: always
      when: group_names | select('match', '^.+@(.+&)*mysql(&.+)*') | list
          + group_names | select('match', '^.+@(.+&)*redis(&.+)*') | list
          + group_names | select('match', '^.+@(.+&)*memcached(&.+)*') | list

最初に記載したものと合わせると、site.ymlの全量は以下のようになる。

- hosts: all
  roles:
    ## common roles
    - { role: os,      tags: os }
    - { role: account, tags: account }
  tasks:
    - name: gather facts
      setup:
      tags: always
      when: group_names | select('match', '^.+@(.+&)*mysql(&.+)*') | list
          + group_names | select('match', '^.+@(.+&)*redis(&.+)*') | list
          + group_names | select('match', '^.+@(.+&)*memcached(&.+)*') | list

## middlewares
- { name: nginx,     roles: [ { role: nginx, tags: nginx } ], hosts: ~.+@(.+&)*nginx(.+)* }
- { name: mysql,     roles: [ { role: mysql, tags: mysql } ], hosts: ~.+@(.+&)*mysql(.+)* }
- { name: redis,     roles: [ { role: redis, tags: redis } ], hosts: ~.+@(.+&)*redis(.+)* }
- { name: memcahced, roles: [ { role: memcached, tags: memcached } ], hosts: ~.+@(.+&)*memcached(.+)* }
略

まとめ

hostsへの正規表現導入とグループ名の書式ルール設定が肝になり、エントリーポイントのPlaybookをひとつにしてユーザビリティを高めている。

しかし変数をどこに定義するかというのもユーザビリティにはかなり重要で、今の分類が一番わかりやすいと考えている。

  • 変数定義が散らばらない
  • 一つのファイルを見ればそのサーバで何が設定されるのかわかる

これらの観点で変数定義をどうするかを決めると、一プロジェクト全体にかかわる変数定義の場所(つまりgroup_vars/all/)と、各ホストの定義と同じファイル(つまりinventories/production.ymlのgroup変数とhost変数)のみとするのがいい。