Ansibleのモジュール開発(実践編)

2016.05.22

はじめに

こんにちは、昨日もサバゲーでたくさん撃たれてきた藤本です。

前々回のAnsibleのモジュール開発(基礎編)、前回のAnsibleのモジュール開発(Python編)とAnsibleのモジュール開発する上でのルールや便利なユーティリティライブラリの使い方をご紹介しました。

今回のエントリでは実際にユースケースに則って、一つのAnsibleモジュールを作成してみます。

その他のAnsibleのモジュール開発シリーズは以下をご参照ください。

ユースケース

AWS環境では標準AMIにSwap領域が含まれていないことが多くあります。もちろんSwapさせないメモリ設計が一番ですが、安全を取ってSwap領域は確保しておきたいこともありますよね。そんなわけでSwap領域を作成するSwapモジュールを作成しました。

まずは一般的なLinuxにおけるSwap領域の作成手順です。

  1. Swap領域として確保したいサイズの空のファイル作成
  2. 作成したファイルのSwap領域化
  3. Mount設定ファイルへの追記
  4. SwapファイルのMount

続いて、要件です。

  • 対応OS
    • Amazon Linux
    • CentOS 5 / 6 / 7
    • Ubuntu 12.04 / 14.04
  • 対応OS以外はエラーで返す
  • 引数
    • Swapファイルサイズ(指定がない場合、OSメモリサイズと同値)
    • Swapファイルパス(必須)
  • 冪等性を担保する
  • ファイル作成コマンド
    • CentOS 6、Ubuntu 12.04 / 14.04、およびAmazon LinuxはfallocateコマンドでSwapファイルを作成する
    • CentOS 5 / 7はddコマンドでSwapファイルを作成する

それでは実装していきましょう。ソースコードは長くなったので、ページ最下部に貼っています。

メイン処理

初期処理

初期処理としてAnsibleModuleクラスで引数を受け取ります。今回は要件に挙げたSwapファイルサイズ(size)、Swapファイルパス(filepath)を引数に定義します。

from distutils.version import LooseVersion
from ansible.module_utils.basic import *
import os
import re

:

def main():
    module = AnsibleModule(
        argument_spec = dict(
            size=dict(required=False, type='str'), ### (1.1)
            filepath=dict(required=True, type='str') ### (1.2)
        )
    )
:
main()
(1.1) 引数:ファイスサイズ

ファイルサイズは必須ではないため、requiredにFalseを指定します。サイズの指定方法はfallocateddなどと互換性を持たせて、Unitを使えるようにしています。(100Mや1Gなど)

(1.2) 引数:ファイルパス

ファイルパスは必須項目でrequiredにTrueを指定します。

Factoryクラスによる処理インスタンス生成

def main():
:
    swap = Creator.createSwap(module=module) ### (2.1)
    
    ### (2.2) ->
    changed = False
    if swap.create_swapfile():
        changed = True
    if swap.make_swap():
        changed = True
    if swap.set_fstab():
        changed = True
    if swap.mount_swap():
        changed = True
    ### <- (2.2)

    module.exit_json(changed=changed) ### (2.3)
(2.1) Factory Method呼び出しによる処理インスタンス生成

詳細な処理内容は後述しますが、Factoryクラスから処理インスタンスを受け取ります。

(2.2) 処理実行・実行結果判定

作成手順の一つ一つで定義したメソッドを実行します。メソッドは一つ一つ変更の有無を受け取り、既に設定済みであれば、changedfalseを、一つでも変更が加われば、changedtrueを渡して、冪等性を担保します。

(2.3) 結果出力

エラーなく各処理を終えた場合、正常終了を表すexit_jsonメソッドで変更有無の結果が入ったjsonを返します。

Factoryクラス

今回のユースケースは要件に記載した通り、OSバージョンによってファイル作成コマンドを使い分けます。前回のエントリのOSバージョンを判定するパターンを利用し、OSを判定するFactoryクラスと、処理を記述するクラスを分けます。Factoryクラスは処理するOSディストリビューション、OSバージョンに対応する処理クラスのインスタンスを生成します。

ベースFactoryクラス

まずベースとなるFactoryクラスを定義します。

class Creator(object):
    platform = 'Generic' ### (3.1)
    distribution = None ### (3.2)
    swap_class = UnimplementedSwap ### (3.3)

    @classmethod
    def createSwap(cls, module):
        return load_platform_subclass(cls, args, kwargs).swap_class(module) ### (3.4)
(3.1) platformの定義
(3.2) distributionの定義

platformdistributionの説明は前回のAnsibleのモジュール開発(Python編)を参照してください。

(3.3) 処理クラスの定義

Factoryクラスとして生成したい処理クラスを設定します。ベースFactoryクラスはOS個別Factoryクラスに一致しない場合に呼び出されます。今回のSwapモジュールでは対応OS以外はエラーとしたいため、未実装のメッセージとともにエラーで結果を返す処理クラスを設定します。

(3.4) 処理クラスの呼び出し

load_platform_subclass()で実行対象となるOSに合わせたFactoryクラスのインスタンスが生成され、インスタンスが持つ処理クラスがインスタンスとして返されます。

OS個別Factoryクラス

次にOS毎に用意するFactoryクラスを定義します。

パターンは2つです。1つはバージョンによらず処理クラスが同じパターン。もう一つはバージョンによって処理クラスが異なるパターン。

バージョンによらず処理クラスが同じパターン

実装はシンプルです。ベースFactoryクラスを継承し、処理クラスのインスタンス化処理はベースFactoryクラスに任せます。OS個別Factoryクラスではload_platform_subclass()で適切な呼び出しが行われるようにplatformdistributionを定義します。また該当した場合に行いたい処理が実装された処理クラスを設定します。

class AmazonLinuxCreator(Creator):
    platform = 'Linux'
    distribution = 'Amazon'
    swap_class = GenericSwap

バージョンによって処理クラスが異なるパターン

from distutils.version import LooseVersion
:
def _check_version(min=None, max=None):
    version = get_distribution_version() ### (4.1)
    if not version or \
       (isinstance(min, str) and LooseVersion(version) < LooseVersion(min)) or \
       (isinstance(max, str) and LooseVersion(version) >= LooseVersion(max)): ### (4.2)
        return False
    return True
:
class CentosCreator(Creator):
    platform = 'Linux'
    distribution = 'Centos'
    if _check_version(min='6', max='7'): ### (4.3)
        swap_class = GenericSwap
    elif _check_version(min='5', max='6'):
        swap_class = CentOS5Swap
(4.1) OSバージョン取得

get_distribution_version()の説明は前回のAnsibleのモジュール開発(Python編)を参照してください。

(4.2) バージョン判定

引数のminmaxで受け取ったバージョンの範囲にget_distribution_version()が該当するか否かを判定します。Python標準ライブラリのLooseVersionを利用することで複数の.で句切られるようなバージョンを大なり小なりで比較することができます。

(4.3) OSバージョンチェック

OSバージョンチェック関数を呼び出し、該当するバージョンの処理クラスを設定します。上記の場合、CentOS 6系であれば、6.xxといったバージョン番号が返るため、一つ目のif条件句に合致(6 <= 6.xx < 7)し、fallocateコマンドで実装されたGenericSwapクラスを処理クラスに設定します。CentOS 5系であれば、二つ目のelif条件句に合致(5 <= 5.xx < 6)し、ddコマンドで実装されたCentOS5Swapクラスを処理クラスに設定します。それによりベースFactoryクラスにより設定した処理クラスがインスタンス化され、mainメソッドに適切な処理クラスが返ります。

処理クラス

Factoryクラスにより実行対象のOSに応じて返される処理クラスを実装します。処理クラスは以下の3パターンを作成します。

  • fallocateコマンドパターン(ベース) : CentOS 6、Amazon Linux、Ubuntu 12.04 / 14.04
  • ddコマンドパターン : CentOS 5 / 7
  • 未対応OSパターン : OSX

fallocateコマンドパターン(ベース)

CentOS 6やAmazon Linux、Ubuntuに対応した処理クラスを実装します。ベースと書いたのはfallocateコマンドパターン、ddコマンドパターンはファイル作成のコマンドが異なるだけで、Swap領域化やマウント処理は同じ処理となるため、ddコマンドの処理クラスはファイル作成のメソッドのみオーバーライドするように実装します。そこそこ長くなってしまったのでポイントを絞って説明します。

class GenericSwap(object):

    CREATE_SWAPFILE_CMD = '/usr/bin/fallocate'
    MAKE_SWAP_CMD = '/sbin/mkswap'
    FILE_CMD = '/usr/bin/file'
    SWAPON_CMD = '/sbin/swapon'
    FSTAB_PATH = '/etc/fstab'

    def __init__(self, module):
        ### (5.1) ->
        self.module = module
        self.size = module.params['size']
        self.filepath = module.params['filepath']
        ### <- (5.1)

        if not self.size:
            self.size = self._get_memsize() ### (5.2)

    def create_swapfile(self):
        if os.path.exists(self.filepath): ### (5.3)
            return False

        ### (5.4) ->
        cmd = [self.CREATE_SWAPFILE_CMD, '-l', self.size, self.filepath]
        rc, out, err = self.module.run_command(cmd)
        if rc != 0:
            self.module.fail_json(msg='%s command failed. rc=%d, out=%s, err=%s' % (cmd[0], rc, out, err))
        ### <- (5.4)

        os.chmod(self.filepath, 0600)

        return True

    def make_swap(self):
:
    def set_fstab(self):
:
    def mount_swap(self):
:
    def _get_memsize(self):
        with open('/proc/meminfo') as f:
            for line in filter(lambda line: line.startswith('MemTotal:'), f.readlines()):
                return re.split('\s+', line)[1] + 'k'
(5.1) 初期化メソッド

AnsibleModule、引数はインスタンスに渡しておきます。

(5.2) Swapファイルサイズデフォルト値設定

Swapファイルサイズを指定しない場合、OSのメモリ値に合わせるといった初期設定を行います。処理内容としては/proc/meminfoMemTotalの値を取得しています。

(5.3) 設定ステータスの確認

メイン処理で説明した通り、モジュールは冪等性を担保するためにchangedtrue or falseを指定して実行結果を返します。各処理の前に変更の必要性を確認し、changedの値を決定します。Swapファイル作成処理の場合、指定されたパスにファイルが存在すればファイルを作成する必要がないため、変更無しと判定するFalseを返します。指定されたファイルがなければ、作成処理を行い、変更ありと判定するTrueを返します。

(5.4) OSコマンド実行

OSコマンドを実行する場合、AnsibleModuleのrun_command()を利用しましょう。また成功以外を想定しない場合、リターンコードのチェックを行い、エラーだった場合はfail_json()で失敗の結果出力をしましょう。

ddコマンドパターン

CentOS 5 / 7に対応した処理クラスを実装します。上記ベースクラスを継承し、create_swapfile()だけをオーバーライドしたクラスとなります。

class CentOS5Swap(GenericSwap):

    CREATE_SWAPFILE_CMD = '/bin/dd'
    BLOCK_SIZE = 1024 * 1024

    def create_swapfile(self):
        if os.path.exists(self.filepath):
            return False

        if self.size.endswith(('k', 'K')):
            self.size = float(self.size[:-1]) * 1024
        elif self.size.endswith(('m', 'M')):
            self.size = float(self.size[:-1]) * 1024 * 1024
        elif self.size.endswith(('g', 'G')):
            self.size = float(self.size[:-1]) * 1024 * 1024 * 1024
        else:
            self.size = float(self.size)

        count = int(self.size / self.BLOCK_SIZE)

        cmd = [self.CREATE_SWAPFILE_CMD, 'if=/dev/zero', 'of=%s' % self.filepath, 'bs=%d' % self.BLOCK_SIZE, 'count=%d' % count]

        rc, out, err = self.module.run_command(cmd)
        if rc != 0:
            self.module.fail_json(msg='%s command failed. rc=%d, out=%s, err=%s' % (cmd[0], rc, out, err))

        os.chmod(self.filepath, 0600)

        return True

未対応OSパターン

対応していないOSの処理クラスです。初期化メソッドでfail_json()を呼び出し、処理を行うことなく、モジュールを終了します。

class UnimplementedSwap(object):

    def __init__(self, module):
        distribution = get_distribution()
        module.fail_json(
            msg='swap module cannot be used on %s' % (distribution if distribution else get_platform())
        )

動作確認

今回動作確認したOSは以下の7パターンになります。

  • fallocateコマンドパターン(ベース) : CentOS 6、Amazon Linux、Ubuntu 12.04 / 14.04
    • CentOS 6
    • Ubuntu 12.04
    • Ubuntu 14.04
    • Amazon Linux
  • ddコマンドパターン
    • CentOS 5
    • CentOS 7
  • 未対応OSパターン
    • OSX (ローカル端末)

playbook.yml

- hosts: all
  become: yes
  tasks:
  - swap: filepath=/swap

変更あり(未設定)

まずは未設定の状態でansible-playbookを実行します。

# ansible-playbook playbook.yml -v
Using /Users/fujimoto.shinji/Techs/ansible/dev-module/ansible.cfg as config file

PLAY [all] *********************************************************************

TASK [setup] *******************************************************************
ok: [amazn]
ok: [localhost]
ok: [centos7]
ok: [ubuntu12]
ok: [ubuntu14]
ok: [centos6]
ok: [centos5]

TASK [swap] ********************************************************************
fatal: [localhost]: FAILED! => {"changed": false, "failed": true, "msg": "swap module cannot be used on Darwin"}
changed: [amazn] => {"changed": true}
changed: [ubuntu14] => {"changed": true}
changed: [ubuntu12] => {"changed": true}
changed: [centos5] => {"changed": true}
changed: [centos7] => {"changed": true}
changed: [centos6] => {"changed": true}

NO MORE HOSTS LEFT *************************************************************

PLAY RECAP *********************************************************************
amazn                     : ok=2    changed=1    unreachable=0    failed=0
centos5                    : ok=2    changed=1    unreachable=0    failed=0
centos6                    : ok=2    changed=1    unreachable=0    failed=0
centos7                    : ok=2    changed=1    unreachable=0    failed=0
localhost                  : ok=1    changed=0    unreachable=0    failed=1
ubuntu12                   : ok=2    changed=1    unreachable=0    failed=0
ubuntu14                   : ok=2    changed=1    unreachable=0    failed=0

localhostのOSXは未対応OSパターンのため、failedtrueが返され、失敗となりました。その他OSはchangedtrueが設定されていて、swapモジュールによって変更があったことを表現しています。それでは実際に設定されているのか確認してみます。

# ssh -F ssh_config amazn /sbin/swapon -s
Filename				Type		Size	Used	Priority
/swap                                  	file	503264	0	-1

# ssh -F ssh_config centos5 /sbin/swapon -s
Filename				Type		Size	Used	Priority
/dev/mapper/VolGroup00-LogVol01         partition	1048568	148	-1
/swap                                   file		508920	0	-5

# ssh -F ssh_config centos6 /sbin/swapon -s
Filename				Type		Size	Used	Priority
/dev/dm-1                               partition	1015804	0	-1
/swap                                   file		502044	0	-2

# ssh -F ssh_config centos7 /sbin/swapon -s
Filename				Type		Size	Used	Priority
/dev/dm-1                              	partition	1048572	0	-1
/swap                                  	file	500732	0	-2

# ssh -F ssh_config ubuntu12 /sbin/swapon -s
Filename				Type		Size	Used	Priority
/dev/mapper/vagrant--vg-swap_1          partition	524284	0	-1
/swap                                   file		501360	0	-2

# ssh -F ssh_config ubuntu14 /sbin/swapon -s
Filename				Type		Size	Used	Priority
/dev/mapper/vagrant--vg-swap_1          partition	524284	0	-1
/swap                                   file		500768	0	-2

/swapというファイルでSwap領域を認識していることが分かります。

変更なし(設定済み)

続いて、既に設定済みの状態でもう一度、ansible-playbookを実行します。

# ansible-playbook playbook.yml -v
Using /Users/fujimoto.shinji/Techs/ansible/dev-module/ansible.cfg as config file

PLAY [all] *********************************************************************

TASK [setup] *******************************************************************
ok: [amazn]
ok: [localhost]
ok: [centos7]
ok: [ubuntu12]
ok: [ubuntu14]
ok: [centos6]
ok: [centos5]

TASK [swap] ********************************************************************
fatal: [localhost]: FAILED! => {"changed": false, "failed": true, "msg": "swap module cannot be used on Darwin"}
ok: [amazn] => {"changed": false}
ok: [centos5] => {"changed": false}
ok: [ubuntu14] => {"changed": false}
ok: [ubuntu12] => {"changed": false}
ok: [centos7] => {"changed": false}
ok: [centos6] => {"changed": false}

NO MORE HOSTS LEFT *************************************************************

PLAY RECAP *********************************************************************
amazn                     : ok=2    changed=0    unreachable=0    failed=0
centos5                    : ok=2    changed=0    unreachable=0    failed=0
centos6                    : ok=2    changed=0    unreachable=0    failed=0
centos7                    : ok=2    changed=0    unreachable=0    failed=0
localhost                  : ok=1    changed=0    unreachable=0    failed=1
ubuntu12                   : ok=2    changed=0    unreachable=0    failed=0
ubuntu14                   : ok=2    changed=0    unreachable=0    failed=0

既に設定済みなのでchangedfalseを返しています。ただし、この返り値はあくまでモジュールの実装上のことなのでモジュール開発した場合は、テストツールを利用するなどして設定が行われているか、想定しない設定が行われていないか動作確認しましょう。

まとめ

いかがでしたでしょうか?
3回に分けて、Ansibleのモジュール開発の基礎から実践までご紹介しました。これからも色々と試してみてTipsをご紹介できればと思います。

今回のAnsibleのモジュール開発シリーズで作成した一連のソースコードをGithubにアップロードしました。色々試してみたい方はcloneして試してみてください。

s-fujimoto/develop-ansible-module

ソースコード