何の話かと言うと
www.slideshare.net
上記のセミナーで、OpenStackとDockerを組み合わせた活用方法を紹介して、例として下記のような環境を構築しました。
MySQLとEtherpad-Lite(nods.jsのアプリ)を別々のVMインスタンス上にコンテナでデプロイして、連携動作させるという環境です。
そして、このセッションの後、さいとうさんが、この環境構築をAnsibleで自動化するデモを実施してくれました。
www.slideshare.net
このデモの内容をベースにして、Ansibleで複数の処理を自動化する際のプレイブックの組み方(どういう単位で作業を分割するとよいのか)をちょっと考察してみたいと思います。
実行環境の準備は次の通りです。Ansible2.0がリリースされたので、これを使用する前提の手順になります。CentOS7のゲストOSに「centos」ユーザーでログインして、次を実行します。
$ sudo -i # yum -y install epel-release # yum -y install gcc python-devel python-pip git # pip install ansible shade # exit $ ssh-keygen $ cat <<EOF >~/.ansible.cfg [defaults] deprecation_warnings = False EOF
この後、使用するテナントに合わせたkeystonercファイルを作成して、読み込んでおきます。使用するプレイブックは下記から入手してください。
$ git clone https://github.com/enakai00/ansible_eplite
GitHubにある上記のプレイブックは、本記事の執筆後にRefactoringしているので、下記で説明しているものと少し内容が異なります。
「ボタンを順番に押すツール」としてのAnsible
Ansibleの特徴(宣伝文句?)として、エージェントレスでほげほげ、という話はよく聞きますが、そこは本質では無いと思っています。Puppetだって、エージェントを常駐させずに使う事も多いですし。それよりは、操作対象の「粒度」の違いが大きい気がしています。たとえば、Puppetは、OS内部の設定を細かく制御するのが得意ですが、Ansibleの場合は、もう少し大きい粒度で、すでにある程度自動化されている処理を束ねて連携するような捉え方をすると、うまく使いこなせる気がしています。言うならば、「ボタンを押すだけの処理でも、ボタンが増えてくるとどの順番で押せばよいかわからなくなってくるので、ボタンを押す順番をプレイブックにまとめて自動化する」という感覚です。
たとえば、前述の環境であれば、ざっくりと、
(1) テナント環境を整える(公開鍵登録、セキュリティグループ作成など)
(2) VMインスタンスを起動する
(3) Cinderボリュームを接続する
(4) Cinderボリュームをフォーマットしてマウントする
(5) コンテナでアプリをデプロイする
という5つの処理が必要です。(1)〜(3)は、OpenStackの機能ですでに自動化されており、Horizonの画面でまさに「ボタンを押すだけ」の処理です。同様に(5)は、Dockerで自動化されています。このように既存の自動化をさらに上位でまとめ上げるのがAnsibleが特に活躍できる場面なのかなぁと。((4)の部分は特に自動化されていませんが、この程度の処理であれば、Ansibleでも何とかなるでしょう。ただし、こういうOS内部の処理が複雑化した場合は、ここだけ別途、Puppetを呼び出すという使い方もありかも知れません。)
「プログラム言語」としてのAnsible
IaaS環境を評して「プログラマブル・インフラストラクチャー」と呼ぶことがありますが、プログラム可能なのはよいとして、いったいどんなプログラム言語を使えばいいのでしょうか? Ansibleのプレイブックを「プログラム言語」として捉えることで、プレイブックを見通しよく構成できるかも知れません。プログラムを書く時に、個別の機能をサブルーチンで実装しはじめるボトムアップ派と、サブルーチンはできてるものと思って、先にそれらを呼び出すメインルーチンを書いていくトップダウン派があるでしょう。今の場合、トップダウンのアプローチを取るのであれば、上述の(1)〜(5)を実施するサブルーチンが用意されているものとして、これらを順番に呼び出すメインルーチンを書くことになります。Ansibleでは、既存のプレイブックをインクルードして、1つのプレイブックにまとめる機能があるので、こんな感じになりそうです。
main.yml
- include: lib/prep_tenant.yml - include: lib/create_instances.yml - include: lib/attach_volumes.yml - include: lib/mount_volume.yml - include: lib/deploy_eplite.yml
ただし、これではサブルーチンに受け渡す引数がありません。VMインスタンスの起動やボリュームの接続は汎用的な処理として実装しておいて、引数でインスタンス名などの個別情報を渡すようにした方がよいでしょう。Ansibleのプレイブックでは、var要素で引数を受け渡すことが可能です。どの範囲を引数指定するかは考慮が必要ですが、ここではあくまでサンプルとして、インスタンス名やボリューム名などの名称部分だけを引数に渡しておきます。(その他の値はサブルーチン側で実装したデフォルト値とします。もちろん、引数として上書きは可能です。)
main01.yml
- include: lib/prep_tenant.yml - include: lib/create_instances.yml vars: servers: - name: epmysql meta: "managed=yes" - name: eplite meta: "managed=yes" - include: lib/attach_volumes.yml vars: volumes: - name: mysql_volume volsize: 2 server: epmysql
main02.yml
- include: lib/check_reachable.yml vars: servers: - name: epmysql - name: eplite - include: lib/mount_volume.yml vars: server: epmysql - include: lib/deploy_eplite.yml
おっと。。。ここで、メインルーチンが2つに分かれて、「lib/check_reachable.yml」という新たなサブルーチンが追加されています。これは、Ansibleのダイナミックインベントリーに関連する制限事項のためです。まず、ダイナミックインベントリーは、プレイブックを実行する時点で稼働中のVMインスタンスを検索して、処理対象のVMインスタンスのIPアドレスを自動取得する仕組みです。ところが今の場合は、VMインスタンスを作成するところから処理が始まるため、main01.ymlを実行するタイミングでは、まだVMインスタンスのIPアドレスを取得することができません。そこで、main01.ymlの実行が終わって、VMインスタンスが出来た後に、あらためて、main02.ymlを実行する形にしてあります。
つまりこれは、
・main01.ymlは、OpenStackのAPIを操作して、VMインスタンスとCinderボリュームを作成するという、OpenStackを対象とした操作
・main02.ymlは、VMインスタンス内のゲストOSを対象として操作
という操作対象の違いによる分割になっています。なお、main02.ymlの冒頭に追加した「lib/check_reachable.yml」は、操作対象のゲストOSの起動が完了して、SSH接続可能になっていることをチェックする(SSH接続可能になるまで待機する)サブルーチンです。
ここでは、ダイナミックインベントリーのスクリプトは、Ansibleに同梱されているサンプルを次のようにカスタマイズしています。
・localhostもダイナミックインベントリーから呼び出し可能とする。(OpenStackのAPIを操作する際は、便宜的に操作対象ノードとして、localhostを指定します。)
・メタデータとして「managed=yes」が設定されたVMインスタンスのみを処理対象とする。(「- hosts: all」で全ノードを対象としたプレイブックを実行すると、ダイナミックインベントリーで検索されたすべてのVMインスタンスについて、構成情報の取得が行われます。無関係のVMインスタンスから構成情報を取得するのを避けるため、このようにしています。)
OpenStackを操作するサブルーチン
まずは、テナント環境を整える処理です。公開鍵の登録とセキュリティグループの作成を行います。「tasks:」で指定された処理を順番に実行していきます。手続き型言語っぽい考え方ですね。「with_items:」は指定されたリストの要素を変数「item」に順番にセットしながらループします。OpenStack APIをコールする際の認証情報は、環境変数から取得していますので、いつもの「keystonerc_hoge」を読み込んだ状態で実行してください。
lib/prep_tenant.yml
- hosts: localhost vars: os_auth_url: "{{ lookup('env','OS_AUTH_URL') }}" os_username: "{{ lookup('env','OS_USERNAME') }}" os_password: "{{ lookup('env','OS_PASSWORD') }}" os_project_name: "{{ lookup('env','OS_TENANT_NAME') }}" os_region_name: "{{ lookup('env','OS_REGION_NAME') }}" keypairs: - name: "step-server" public_key_file: "/home/centos/.ssh/id_rsa.pub" secgroups: - name: "eplite" desc: "secgroup for eplite" rules: - protocol: "icmp" port_range_min: -1 port_range_max: -1 remote_ip_prefix: "0.0.0.0/0" - protocol: "tcp" port_range_min: 22 port_range_max: 22 remote_ip_prefix: "0.0.0.0/0" - protocol: "tcp" port_range_min: 80 port_range_max: 80 remote_ip_prefix: "0.0.0.0/0" - protocol: "tcp" port_range_min: 3306 port_range_max: 3306 remote_ip_prefix: "0.0.0.0/0" tasks: - name: import keypairs os_keypair: state=present name="{{ item.name }}" public_key_file="{{ item.public_key_file }}" with_items: keypairs - name: create security group os_security_group: state=present name="{{ item.name }}" description="{{ item.desc }}" with_items: secgroups - name: add rules to secgroup os_security_group_rule: state=present security_group="{{ item[0].name }}" protocol="{{ item[1].protocol }}" port_range_min="{{ item[1].port_range_min }}" port_range_max="{{ item[1].port_range_max }}" remote_ip_prefix="{{ item[1].remote_ip_prefix }}" with_subelements: - secgroups - rules
続いて、VMインスタンスの起動とフローティングIPの割り当てです。main01.ymlの方でメタデータ「meta: "managed=yes"」を指定している点に注意してください。
lib/create_instances.yml
- hosts: localhost vars: os_auth_url: "{{ lookup('env','OS_AUTH_URL') }}" os_username: "{{ lookup('env','OS_USERNAME') }}" os_password: "{{ lookup('env','OS_PASSWORD') }}" os_project_name: "{{ lookup('env','OS_TENANT_NAME') }}" os_region_name: "{{ lookup('env','OS_REGION_NAME') }}" config: key_name: "step-server" flavor: "m1.small" image: "Docker01" secgroups: - "eplite" nics: - net-name: "private01" auto_ip: no ext_net: "ext-network" int_net: "private01" tasks: - name: create servers os_server: state: present timeout: 200 name: "{{ item.name }}" key_name: "{{ config.key_name }}" flavor: "{{ config.flavor }}" image: "{{ config.image }}" security_groups: "{{ config.secgroups }}" nics: "{{ config.nics }}" auto_ip: "{{ config.auto_ip }}" meta: "{{ item.meta }}" with_items: "{{ servers }}" - name: create and assign floating_ip to server os_floating_ip: state=present reuse=yes network="{{ config.ext_net }}" server="{{ item.name }}" with_items: "{{ servers }}"
最後にCinderボリュームの作成と接続です。なお、Ansibleが標準提供するモジュールは、べき等性チェックが入っているので、すでに完了している処理は自動でスキップされます。
lib/attach_volumes.yml
- hosts: localhost vars: os_auth_url: "{{ lookup('env','OS_AUTH_URL') }}" os_username: "{{ lookup('env','OS_USERNAME') }}" os_password: "{{ lookup('env','OS_PASSWORD') }}" os_project_name: "{{ lookup('env','OS_TENANT_NAME') }}" os_region_name: "{{ lookup('env','OS_REGION_NAME') }}" tasks: - name: create database volume os_volume: state: present size: "{{ item.volsize }}" display_name: "{{ item.name }}" with_items: "{{ volumes }}" - name: attach database volume os_server_volume: state: present server: "{{ item.server }}" volume: "{{ item.name }}" with_items: "{{ volumes }}"
main01.ymlを実行すると次のようになります。1つの「PLAY」がインクルードされた1つのサブルーチンに対応します。べき等性チェックで実際には処理が行われていない部分もあります。「changed」と記載された部分は、実際に変更処理が走った部分です。
$ ansible-playbook -i bin/openstack.py main01.yml PLAY [localhost] ************************************************************** GATHERING FACTS *************************************************************** ok: [localhost] TASK: [import keypairs] ******************************************************* ok: [localhost] => (item={'public_key_file': '/home/centos/.ssh/id_rsa.pub', 'name': 'step-server'}) TASK: [create security group] ************************************************* changed: [localhost] => (item={'rules': [{'port_range_min': -1, 'port_range_max': -1, 'protocol': 'icmp', 'remote_ip_prefix': '0.0.0.0/0'}, {'port_range_min': 22, 'port_range_max': 22, 'protocol': 'tcp', 'remote_ip_prefix': '0.0.0.0/0'}, {'port_range_min': 80, 'port_range_max': 80, 'protocol': 'tcp', 'remote_ip_prefix': '0.0.0.0/0'}, {'port_range_min': 3306, 'port_range_max': 3306, 'protocol': 'tcp', 'remote_ip_prefix': '0.0.0.0/0'}], 'name': 'eplite', 'desc': 'secgroup for eplite'}) TASK: [add rules to secgroup] ************************************************* changed: [localhost] => (item=({'name': 'eplite', 'desc': 'secgroup for eplite'}, {'protocol': 'icmp', 'port_range_max': -1, 'port_range_min': -1, 'remote_ip_prefix': '0.0.0.0/0'})) changed: [localhost] => (item=({'name': 'eplite', 'desc': 'secgroup for eplite'}, {'protocol': 'tcp', 'port_range_max': 22, 'port_range_min': 22, 'remote_ip_prefix': '0.0.0.0/0'})) changed: [localhost] => (item=({'name': 'eplite', 'desc': 'secgroup for eplite'}, {'protocol': 'tcp', 'port_range_max': 80, 'port_range_min': 80, 'remote_ip_prefix': '0.0.0.0/0'})) changed: [localhost] => (item=({'name': 'eplite', 'desc': 'secgroup for eplite'}, {'protocol': 'tcp', 'port_range_max': 3306, 'port_range_min': 3306, 'remote_ip_prefix': '0.0.0.0/0'})) PLAY [localhost] ************************************************************** GATHERING FACTS *************************************************************** ok: [localhost] TASK: [create servers] ******************************************************** changed: [localhost] => (item={'meta': 'managed=yes', 'name': 'epmysql'}) changed: [localhost] => (item={'meta': 'managed=yes', 'name': 'eplite'}) TASK: [create and assign floating_ip to server] ******************************* changed: [localhost] => (item={'meta': 'managed=yes', 'name': 'epmysql'}) changed: [localhost] => (item={'meta': 'managed=yes', 'name': 'eplite'}) PLAY [localhost] ************************************************************** GATHERING FACTS *************************************************************** ok: [localhost] TASK: [create database volume] ************************************************ changed: [localhost] => (item={'server': 'epmysql', 'name': 'mysql_volume', 'volsize': 2}) TASK: [attach database volume] ************************************************ changed: [localhost] => (item={'server': 'epmysql', 'name': 'mysql_volume', 'volsize': 2}) PLAY RECAP ******************************************************************** localhost : ok=10 changed=6 unreachable=0 failed=0
ゲストOS内部を操作するサブルーチン
まず指定のVMインスタンスにSSH接続可能であることをチェックする処理です。
lib/check_reachable.yml
- hosts: localhost tasks: - name: check ssh reachability command: ./ssh_check.py "{{ item.name }}" args: chdir: /home/centos/ansible_eplite/playbooks/bin with_items: "{{ servers }}"
これは、やや突貫で作ったお手製スクリプト「ssh_check.py」を呼びだしています。スクリプトの中身はこちらです。ダイナミックインベントリー用のスクリプト「openstack.py」を呼び出して、VMインスタンス名に対応するIPアドレスを見つけて、SSHできるか確認しています。
bin/ssh_check.py
#!/bin/python from subprocess import * import yaml, time, sys output = Popen(['./openstack.py', '--list'], stdout=PIPE) data = yaml.load(output.stdout) hostvars = data['_meta']['hostvars'] args = sys.argv succeed = 0 for host in hostvars: if not ('openstack' in hostvars[host] and 'ansible_ssh_host' in hostvars[host]): continue name = hostvars[host]['openstack']['name'] if name != args[1]: continue addr = hostvars[host]['ansible_ssh_host'] count = 12 while count > 0: print "Checking if %s:%s is reachable.... " % (name, addr), rc = call(['ssh', '-oStrictHostKeyChecking=no', addr , 'uname']) if rc == 0: print "ok." succeed = 1 break else: print "failed, try again." count -= 1 time.sleep(10) if succeed != 1: print "Failed." sys.exit(1)
次はボリュームのフォーマットとマウント処理。処理対象のノードを変数「server」で受け渡しています。各タスクでは、地味にOSコマンドレベルの処理をしています。
lib/mount_volume.yml
- hosts: "{{ server }}" vars: device: /dev/vdb dir: /data tasks: - name: create filesystem filesystem: fstype=xfs dev="{{ device }}" sudo: yes - name: create directory file: path="{{ dir }}" state=directory sudo: yes - name: mount volume mount: state=mounted name="{{ dir }}" src="{{ device }}" fstype=xfs sudo: yes - name: set selinux context file: path="{{ dir }}" setype=svirt_sandbox_file_t sudo: yes
そして、コンテナによるアプリケーションのデプロイ。「- hosts: all」ですべてのVMインスタンスを処理対象ノードにしていますが、「when: ansible_hostname == "hogehoge"」という条件指定により、ホストネームによって処理内容を変えています。また、コンテナ起動時にVMインスタンスのフローティングIPを環境変数に設定する必要があります。これは、"{{ ansible_ssh_hosts.hogehoge[0] }}" という特殊変数を通して取得しています。(このような特殊変数の内容は、ダイナミックインベントリーによって取得されています。)
$ cat lib/deploy_eplite.yml - hosts: all vars: require: packages: - python-docker-py epmysql: device: /dev/vdb dir: /data image: "enakai00/epmysql:ver1.0" ports: - "3306:3306" volumes: "/data:/var/lib/mysql" expose: - 3306 eplite: image: "enakai00/eplite:ver1.0" ports: - "80:80" expose: - 80 env: FIP: "{{ ansible_ssh_hosts.eplite[0] }}" DB_PORT_3306_TCP_ADDR: "{{ ansible_ssh_hosts.epmysql[0] }}" tasks: - name: install require packages yum: state: latest name: "{{ item }}" with_items: "{{ require.packages }}" sudo: yes - name: start epmysql docker: state: started name: epmysql image: "{{ epmysql.image }}" ports: "{{ epmysql.ports }}" expose: "{{ epmysql.expose }}" volumes: "{{ epmysql.volumes }}" tty: True sudo: yes when: ansible_hostname == "epmysql" - name: start eplite docker: state: started name: eplite image: "{{ eplite.image }}" ports: "{{ eplite.ports }}" expose: "{{ eplite.expose }}" env: "{{ eplite.env }}" tty: True sudo: yes when: ansible_hostname == "eplite"
実行結果は次のとおりです。
$ ansible-playbook -i bin/openstack.py main02.yml PLAY [localhost] ************************************************************** GATHERING FACTS *************************************************************** ok: [localhost] TASK: [check ssh reachability] ************************************************ changed: [localhost] => (item={'name': 'epmysql'}) changed: [localhost] => (item={'name': 'eplite'}) PLAY [epmysql] **************************************************************** GATHERING FACTS *************************************************************** ok: [89a348aa-881b-462d-ba4f-fc167461d936] TASK: [create filesystem] ***************************************************** changed: [89a348aa-881b-462d-ba4f-fc167461d936] TASK: [create directory] ****************************************************** changed: [89a348aa-881b-462d-ba4f-fc167461d936] TASK: [mount volume] ********************************************************** changed: [89a348aa-881b-462d-ba4f-fc167461d936] TASK: [set selinux context] *************************************************** changed: [89a348aa-881b-462d-ba4f-fc167461d936] PLAY [all] ******************************************************************** GATHERING FACTS *************************************************************** ok: [89a348aa-881b-462d-ba4f-fc167461d936] ok: [localhost] ok: [74509487-1c0e-4a37-9c59-2eb3536f15a2] TASK: [install require packages] ********************************************** ok: [localhost] => (item=python-docker-py) changed: [89a348aa-881b-462d-ba4f-fc167461d936] => (item=python-docker-py) changed: [74509487-1c0e-4a37-9c59-2eb3536f15a2] => (item=python-docker-py) TASK: [start epmysql] ********************************************************* skipping: [74509487-1c0e-4a37-9c59-2eb3536f15a2] skipping: [localhost] changed: [89a348aa-881b-462d-ba4f-fc167461d936] TASK: [start eplite] ********************************************************** skipping: [89a348aa-881b-462d-ba4f-fc167461d936] skipping: [localhost] changed: [74509487-1c0e-4a37-9c59-2eb3536f15a2] PLAY RECAP ******************************************************************** 74509487-1c0e-4a37-9c59-2eb3536f15a2 : ok=3 changed=2 unreachable=0 failed=0 89a348aa-881b-462d-ba4f-fc167461d936 : ok=8 changed=6 unreachable=0 failed=0 localhost : ok=4 changed=1 unreachable=0 failed=0
できあがった環境を確認します。
$ openstack server list +--------------------------------------+-------------+--------+-------------------------------------------+ | ID | Name | Status | Networks | +--------------------------------------+-------------+--------+-------------------------------------------+ | 74509487-1c0e-4a37-9c59-2eb3536f15a2 | eplite | ACTIVE | private01=192.168.101.52, 192.168.200.105 | | 89a348aa-881b-462d-ba4f-fc167461d936 | epmysql | ACTIVE | private01=192.168.101.51, 192.168.200.102 | | 19a232c4-f6f9-4c90-962e-93360af63127 | step-server | ACTIVE | private01=192.168.101.32, 192.168.200.101 | +--------------------------------------+-------------+--------+-------------------------------------------+ $ openstack volume list +--------------------------------------+--------------+--------+------+----------------------------------+ | ID | Display Name | Status | Size | Attached to | +--------------------------------------+--------------+--------+------+----------------------------------+ | ba5a78fb-782c-4136-8138-0b110772a8ba | mysql_volume | in-use | 2 | Attached to epmysql on /dev/vdb | +--------------------------------------+--------------+--------+------+----------------------------------+ $ ssh centos@192.168.200.102 df /data ファイルシス 1K-ブロック 使用 使用可 使用% マウント位置 /dev/vdb 2086912 54472 2032440 3% /data $ ssh -t centos@192.168.200.102 sudo docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 05063d2d877e enakai00/epmysql:ver1.0 "/usr/local/bin/init." 3 minutes ago Up 3 minutes 0.0.0.0:3306->3306/tcp epmysql Connection to 192.168.200.102 closed. $ ssh -t centos@192.168.200.105 sudo docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e1f3b53572e6 enakai00/eplite:ver1.0 "/usr/local/bin/init." About a minute ago Up About a minute 0.0.0.0:80->80/tcp eplite Connection to 192.168.200.105 closed.
ばっちぐー。