2017.03.09
Ansibleで簡単オーケストレーション
はじめに
こんにちは。次世代システム研究室のT.Tです。
以前の記事でAnsibleのベストプラクティスへの橋渡しとなる基本的な構成についてご紹介しました。
今回はローカル環境から本番環境まで通して適用できるplaybookの構成例について、ベストプラクティスに加えて効果的なplaybookの構築手法も取り入れた、より実践的な例をご紹介したいと思います。
構築する環境
構成図
以下の図のようにローカル、開発、ステージング、本番(図中ではそれぞれlocal、dev、staging、production)の4つの環境を構築し、Webサーバーではnginxとphpを稼働させ、DBサーバーではMySQLをmasterとslaveの構成で稼働させます。
構成概要
環境 | 想定用途 |
ローカル | 開発者のローカル環境で自由に構築と破棄ができる環境 冗長構成にも適用する |
開発 | 外部ネットワーク経由でアクセスできる機能確認用の環境 サーバーコスト削減のために最小構成にする |
ステージング | 本番環境適用のために最終動作確認する環境 本番環境と同じ構成 |
本番 | ユーザーにサービスを提供する環境 サーバーでトラブルが発生してもサービス稼働できるように冗長構成にする |
環境ごとの要件
用途が異なるため、それに応じて環境ごとに要件も異なります。異なる部分だけを挙げると以下のようになります。
環境 | 要件 | 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でオーケストレーションを実践する際のモヤモヤ感を解消する一助になれば幸いです。
参考リンク
次世代システム研究室では、アプリケーション開発や設計を行うアーキテクトを募集しています。アプリケーション開発者の方、次世代システム研究室にご興味を持って頂ける方がいらっしゃいましたら、ぜひ 募集職種一覧 からご応募をお願いします。
皆さんのご応募をお待ちしています。
グループ研究開発本部の最新情報をTwitterで配信中です。ぜひフォローください。
Follow @GMO_RD