2017.03.09

Ansibleで簡単オーケストレーション


はじめに

こんにちは。次世代システム研究室のT.Tです。

以前の記事でAnsibleのベストプラクティスへの橋渡しとなる基本的な構成についてご紹介しました。
今回はローカル環境から本番環境まで通して適用できるplaybookの構成例について、ベストプラクティスに加えて効果的なplaybookの構築手法も取り入れた、より実践的な例をご紹介したいと思います。

構築する環境

構成図

以下の図のようにローカル、開発、ステージング、本番(図中ではそれぞれlocal、dev、staging、production)の4つの環境を構築し、Webサーバーではnginxとphpを稼働させ、DBサーバーではMySQLをmasterとslaveの構成で稼働させます。
php56

構成概要

環境 想定用途
ローカル 開発者のローカル環境で自由に構築と破棄ができる環境
冗長構成にも適用する
開発 外部ネットワーク経由でアクセスできる機能確認用の環境
サーバーコスト削減のために最小構成にする
ステージング 本番環境適用のために最終動作確認する環境
本番環境と同じ構成
本番 ユーザーにサービスを提供する環境
サーバーでトラブルが発生してもサービス稼働できるように冗長構成にする

環境ごとの要件

用途が異なるため、それに応じて環境ごとに要件も異なります。異なる部分だけを挙げると以下のようになります。
環境 要件 IPアドレス帯
ローカル Webサーバー2台
DBサーバー2台(master1台、slave1台)
利用者制限なし
192.168.33.[10-14]
開発 Webサーバー1台
DBサーバー1台(master1台)
利用者を開発関係者(xxx.yyy.zzz.1)にIPで制限を掛ける
DBサーバーでは外部ストレージを使う
192.168.33.[20-22]
ステージング Webサーバー3台
DBサーバー3台(master1台、slave2台)
利用者を開発関係者(xxx.yyy.zzz.1)と
連携サービス(xxx.yyy.zzz.2)にIPで制限を掛ける
DBサーバーでは外部ストレージを使う
192.168.33.[30-36]
本番 Webサーバー3台
DBサーバー3台(master1台、slave2台)
利用者のIP制限なし
DBサーバーでは外部ストレージを使う
192.168.33.[40-46]

playbookの方針

以前の記事でローカル環境を構築する部分について触れました。今回の例でも大筋の構成は同じものになるため、以降では差分となる箇所だけをご紹介したいと思います。

ローカル環境用に構築したものをさらに他の3つの環境に展開するためには、hostsとgroup_varsを環境ごとに用意して、環境に応じて適切な設定値を書き込むことで実現できます。
hosts.dev
hosts.local
hosts.production
hosts.staging
group_vars/
    dev
    local
    production
    staging
しかし、安易にこのような展開方針を取ると設定値の書き換えや設定を追加する場合に困った事態が発生します。例えばnginxの設定(/etc/nginx/sites-available以下のファイル)が環境ごとにほとんど同じ内容だとしても、環境ごとに書き換える設定値を探し出して変更しないといけず、見落としによる変更漏れが生じて本番環境での不具合に繋がり兼ねません。設定を変更する際も同様です。

このような事態は以下の方針で避けることができます。
  • 環境ごとに異なる値を宣言的に変数として定義する
  • 一事実一箇所にする

playbookの構成

group_varsの構成

Ansibleには標準で環境ごとに共通の値を使うための機能があり、group_varsにallという名前のファイルを用意して変数を定義するだけで各環境で共通の変数を使うことができます。そのため、まず以下のファイル構成に切り替えて共通の設定をallに抜き出します。
group_vars/
    all
    dev
    local
    production
    staging
playbookの構成自体はこれだけで完成していますが、allに抜き出す際に少し工夫が必要になります。そこで、次はallに抜き出す例を見てみたいと思います。

allの設定例

例えば、nginxのサイトのローカル環境の設定の内容は以下のようになります。

group_vars/local
nginx_sites:
  example-service:
    - |
      listen 80;
      access_log /var/log/nginx/access.log main;
      server_name local.example.com;
      ...
server_nameの設定値だけを環境ごとに変更したい場合、このままだとその部分だけを変更できず、nginx_sitesの内容をそのまま各環境のgroup_varsに定義することになります。すると、それに引きずられてポートの設定やログの出力先は各環境で同じ設定であるにも関わらず各環境のgroup_varsに定義しないといけなくなり、さらにこの共通の設定を変更する場合は各環境ごとに変更する必要があります。

これではallを導入する前の問題は残ったままですが、この問題は以下のようにserver_nameを変数として切り出すことで簡単に回避することが出来ます。

group_vars/local
_nginx_server_name: local.example.com
group_vars/all
nginx_sites:
  example-service:
    - |
      listen 80;
      access_log /var/log/nginx/access.log main;
      server_name {{ _nginx_server_name }}
      ...
このように適切に設定値を変数化することで、各環境で変えなければいけない値が宣言的に変数化されると同時に、共通的に持つ値はall内で一元的に定義されていて、一事実一箇所も実現できています。これによって、例えばログの出力先を全環境で変更したい場合はallのaccess_logの値を変更するだけで全環境に適用されるようになり、変更漏れの危険性がなくなっていることが確認できます。

うまく変数化するために役立つ知識

ここでは、変数を宣言的に定義したり、一事実一箇所にしておくために役立つ知識をいくつか取り上げてみたいと思います。今回想定している環境を構築するためのplaybookの実装は本記事の最後にまとめてありますので、Ansibleに慣れている方はそちらをご覧になっていただくのが早いかもしれません。

group_varsの評価順

group_varsの各ファイルはまずYAMLとしてパースされて、その後Jinja2のスクリプトが評価されてplaybook内の変数として扱われるためgroup_varsはYAMLの仕様に従う必要があります。うまく変数を宣言するためにはプログラマティックな要素が欠かせませんが、以下のような記述はYAMLの仕様に反するためエラーになるので注意が必要です。
{% if new_php_package %}
php_packages:
  - php
  - "{{ new_php_package }}"
{% endif %}

Jinja2テンプレートエンジン

group_varsはYAMLのフォーマットに従った上でJinja2のスクリプトが利用できます。先述した例では、new_php_packageが定義されている場合であれば以下のよう書けます。
php_packages: ['php', "{{ new_php_package }}"]

定義済み変数

Ansible内で定義されている変数が利用でき、hostsファイルの値等が参照できます。この変数を使うと一事実一箇所を実現する際に役に立ちます。例えば以下のような変数があります。
inventory_hostname 実行中のホスト名
play_hosts 実行中のグループ内のホスト名のリスト
groups ホストグループの参照
これらの変数はhostsの値が以下の場合、それぞれ次のように展開されます。

hosts.local
[local_web]
192.168.33.11
192.168.33.12

[local_db]
192.168.33.13
192.168.33.14
inventory_hostname: 192.168.33.11 (192.168.33.11で実行中の場合)
play_hosts: [192.168.33.11, 192.168.33.12] (local_webのホストで実行中の場合)
groups.local_web: [192.168.33.11, 192.168.33.12]
groups.all: [192.168.33.11, 192.168.33.12, 192.168.33.13, 192.168.33.14]
Ansibleを実行するときにlimitオプションでホストを限定して実行するとplay_hostsはそのlimitで指定された範囲のホストの値しか返さなくなるので注意が必要です。

スクリプトでリストを生成する

Jinja2内ではpythonが使えるのでリストを次のように生成できます。
all_hosts: "{{ groups.local_web + groups.local_db }}"
また、先述のphp_packagesで挙げた例でnew_packageが定義されているかどうかに応じてリストの内容を変更する場合は次のように書けます。
php_packages: "{% set packages = [] %}{% if new_php_package is defined %}{{ packages.append(new_php_package) }}{% endif %}{{ packages }}"
この例のように書くと任意のスクリプトを実行できますが、あまり煩雑になる場合は可読性が下がったり、複雑なロジックを組むことによる不具合が生じる危険性があるので適度に利用するのが望ましいです。

ディクショナリ変数の生成

ディクショナリ変数で、キーと値を変数を使って生成する場合は次のように書けます。
key_name: ip_address
my_ip_adress: 192.168.0.1

host: { '{{ key_name }}': "{{ my_ip_address }}" }

秘匿情報の管理

Vault

ステージング環境や本番環境ではパスワードや暗号化用のキー等、秘匿情報として扱いたいものがあります。AnsibleではそのためにVaultの仕組みが用意されています。

この仕組みを使うとAnsibleで利用するファイル単位で暗号化して管理できます。ファイル単位で暗号化されるため、秘匿したい情報だけを別のファイルに分離しておかないと管理が煩わしくなってしまいます。今回の構成でgroup_vars/productionに秘匿情報を含めて暗号化すると、例えばnginx用の設定を編集する場合でも復号化して編集する必要があり、暗号化されているとはいえ秘匿情報をリポジトリに含めたくない場合はリポジトリでの管理も難しくなります。

ベストプラクティスではこの点も考慮されていますが、この説明だけだと分かり難いので具体的な構成にも触れているnoldorさんのブログ等を参考にすると分かり易いと思います。

extra-vars

Vaultの仕組みを使うと暗号化してファイルを管理できるようになり、Vault用のパスワードもサーバー外でしかるべき管理をすれば堅牢性は増しますが、Ansible実行時に毎回パスワードの入力が必要になり手間が増えてしまいます。

この手間を解消するためにパスワードをAnsibleを実行するサーバー内のファイルに格納しておき、playbookを適用する時にパスワードファイルを引数で渡して簡略化することもできます。しかし秘匿情報のファイルとパスワードを同じサーバーで管理するとサーバー内で平文で管理するのとあまり変わらないため、このような管理でも問題ない場合はサーバー内に平文で秘匿情報を管理することもできます。

この場合は、平文で秘匿情報を管理しているファイルをextra-varsの引数として渡すだけで実現できます。extra-varsで渡した値は最優先で適用されます。
ansible-playbook -i hosts.production site.yml --extra-vars=@/path/to/vault_file
この方法だとVault用のディレクトリ構成も不要になるためplaybookの構成もより簡略化されます。本記事ではこの管理方針を想定したディレクトリ構成になっています。

playbookの実装

冒頭で取り上げたplaybookの実装です。group_varsで使っているプリフィックスの「_」はgroup_vars内でしか参照されていないことを示すために使用しています。

hosts.local
[local_ci]
192.168.33.10

[local_web]
192.168.33.11
192.168.33.12

[local_db]
192.168.33.13
192.168.33.14

[local:children]
local_ci
local_web
local_db
hosts.dev
[dev_ci]
192.168.33.20

[dev_web]
192.168.33.21

[dev_db]
192.168.33.22

[dev:children]
dev_ci
dev_web
dev_db
hosts.staging
[staging_ci]
192.168.33.30

[staging_web]
192.168.33.31
192.168.33.32
192.168.33.33

[staging_db]
192.168.33.34
192.168.33.35
192.168.33.36

[staging:children]
staging_ci
staging_web
staging_db
hosts.production
[production_ci]
192.168.33.40

[production_web]
192.168.33.41
192.168.33.42
192.168.33.43

[production_db]
192.168.33.44
192.168.33.45
192.168.33.46

[production:children]
production_ci
production_web
production_db
group_vars/all
# geerlingguy.mysql
mysql_root_password: "{{ vault_mysql_root_password }}"
mysql_datadir: "{{ _mysql_datadir }}"
mysql_databases:
  - name: my_db
    collation: utf8_general_ci
    encoding: utf8
    replicate: 1
mysql_users:
  - name: my_app_user
    host: 'localhost'
    password: "{{ vault_mysql_password }}"
    priv: 'my_db.*:ALL'
  - name: my_app_user
    host: '{{ _mysql_allow_hosts }}'
    password: "{{ vault_mysql_password }}"
    priv: 'my_db.*:ALL'
mysql_server_id: "{{ play_hosts.index(inventory_hostname) + 1 }}"
mysql_replication_role: "{{ _mysql_replication_role }}"
mysql_replication_master: "{{ _mysql_replication_master }}"
mysql_replication_user: "{{ _mysql_replication_user }}"

# geerlingguy.php
php_packages:
  - php
  - php-fpm
  - php-opcache
  - php-mysql
php_enablerepo: "remi-php70"
php_date_timezone: "Asia/Tokyo"
php_enable_apc: false
php_enable_webserver: true
php_enable_php_fpm: true
php_webserver_daemon: nginx

# jdauphant.nginx
nginx_official_repo: True
nginx_worker_processes: "{{ ansible_processor_count }}"
nginx_worker_rlimit_nofile: 2048
nginx_remove_sites:
  - default
nginx_sites:
  example-service:
    - |
      listen 80;
      server_name "{{ _nginx_server_name }}";
      root   /var/www/service;
      {% for v in _nginx_allow_hosts %}
      allow {{ v }};
      {% endfor %}
      {% if _nginx_allow_hosts %}
      deny all;
      {% endif %}
      location ~ \.php$ {
        fastcgi_split_path_info  ^(.+\.php)(.*)$;
        if (-f $document_root$fastcgi_script_name){
          set $fsn $fastcgi_script_name;
        }
        include fastcgi_params;
        fastcgi_pass 127.0.0.1:9000;
        fastcgi_index index.php;
        fastcgi_param  SCRIPT_FILENAME  $document_root$fsn;
        fastcgi_param  PATH_INFO        $fastcgi_path_info;
        fastcgi_param  PATH_TRANSLATED  $document_root$fsn;
      }
group_vars/local
# vault vars
vault_mysql_root_password: Password!234
vault_mysql_password: Password!234

# geerlingguy.mysql
_mysql_datadir: /var/lib/mysql
_mysql_allow_hosts: 192.168.33.1%
_mysql_replication_role: "{{ 'master' if play_hosts.index(inventory_hostname) == 0 else 'slave' }}"
_mysql_replication_master: 192.168.33.13
_mysql_replication_user: {
  name: replica,
  host: '{{ _mysql_allow_hosts }}',
  password: "{{ vault_mysql_password }}",
  priv: '*.*:REPLICATION SLAVE',
}

_nginx_server_name: local.myservice.jp
_nginx_allow_hosts: []
group_vars/dev
# vault vars
vault_mysql_root_password: Password!234
vault_mysql_password: Password!234

# geerlingguy.mysql
_mysql_datadir: /mnt/mysql
_mysql_allow_hosts: 192.168.33.2%
_mysql_replication_role: ''
_mysql_replication_master: ''
_mysql_replication_user: []

_nginx_server_name: dev.myservice.jp
_nginx_allow_hosts: "{{ ['xxx.yyy.zzz.1'] + groups.dev_web }}"
group_vars/staging
# env variables
_mysql_datadir: /mnt/mysql
_mysql_allow_hosts: 192.168.33.3%
_mysql_replication_role: "{{ 'master' if play_hosts.index(inventory_hostname) == 0 else 'slave' }}"
_mysql_replication_master: 192.168.33.34
_mysql_replication_user: {
  name: replica,
  host: '{{ _mysql_allow_hosts }}',
  password: "{{ vault_mysql_password }}",
  priv: '*.*:REPLICATION SLAVE',
}

_nginx_server_name: staging.myservice.jp
_nginx_allow_hosts: "{{ ['xxx.yyy.zzz.1', 'xxx.yyy.zzz.2'] + groups.staging_web }}"
group_vars/production
# env variables
_mysql_datadir: /mnt/mysql
_mysql_allow_hosts: 192.168.33.4%
_mysql_replication_role: "{{ 'master' if play_hosts.index(inventory_hostname) == 0 else 'slave' }}"
_mysql_replication_master: 192.168.33.4%
_mysql_replication_user: {
  name: replica,
  host: '{{ _mysql_allow_hosts }}',
  password: "{{ vault_mysql_password }}",
  priv: '*.*:REPLICATION SLAVE',
}

_nginx_server_name: myservice.jp
_nginx_allow_hosts: []
site.yml
- include: webservers.yml
- include: dbservers.yml
webservers.yml
- hosts:
    - local_web
  remote_user: vagrant
  roles:
    - geerlingguy.repo-epel
    - geerlingguy.repo-remi
    - jdauphant.nginx
    - geerlingguy.php

- hosts:
    - dev_web
    - staging_web
    - production_web
  remote_user: root
  roles:
    - geerlingguy.repo-epel
    - geerlingguy.repo-remi
    - jdauphant.nginx
    - geerlingguy.php
dbservers.yml
- hosts:
    - local_db
  remote_user: vagrant
  roles:
    - geerlingguy.mysql

- hosts:
    - dev_db
    - staging_db
    - production_db
  remote_user: root
  roles:
    - geerlingguy.mysql

まとめ

group_varsでスクリプトを活用することにより、各環境の違いをうまく吸収して環境ごとの差分だけを宣言的に扱えるようになりました。スクリプトでは任意のコードが書けるため複雑なロジックも記述できますが、その複雑さにより却って管理が難しくなることもあるため濫用は禁物です。例えば、今回の環境にロードバランサー用のnginxを追加する場合、Webサーバーのグループに追加して複雑なスクリプトを書いて切り分けても実現できますが、この場合はサーバーグループを分けて管理するのが簡単です。

また、このように切り出された後のgroup_vars/allはインフラの仕様としての側面をより際立たせていて、実装が仕様そのものになっているとも言えます。実際にこのplaybookを実行して構築されるインフラの全容を把握するには、各playbookのroleやtemplateの詳細、デフォルト値等もある程度把握しておく必要はありますが、その上でこのように記述されていればどのように振る舞うかは大体想像できるのではないでしょうか。

今回はローカル環境のplaybookを拡張する形で全環境に適用できるplaybookを構築しましたが、出来上がったplaybookを改めて見てみると、group_vars/allをカノ二カルフォームとして、各環境ごとのgroup_varsの束縛条件に基づいてインフラインスタンスが生成されているようにも見えます。

これからAnsibleでオーケストレーションを実践する際のモヤモヤ感を解消する一助になれば幸いです。

参考リンク

次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。

皆さんのご応募をお待ちしています。