めもめも

このブログに記載の内容は個人の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

OpenStack&Dockerを操作するデモでAnsibleの本質を考えてみる

何の話かと言うと

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.

ばっちぐー。