一歩すすんだ Fabric のタスク定義のしかた

Backlog ではユーザのみなさまに安定して Backlog をご利用いただけるよう 定期的にサーバやストレージの増強などのメンテナンスを行っています。 そして気付けば現在では数十台のホストで構成されるサービスとなっています。

さて、それだけのホストを運用していると、例えばアプリケーションを配備したり、 ミドルウェアを再起動するのも手動で行うには限界があります。 こういった作業に対して Backlog では Fabric を利用しています。
fabric

Fabric の魅力の一つはそのシンプルさです。例えばリモートホストに接続してコマンドを実行するには、以下のようなタスクを定義し、

@task
def simple():
    """
    show uname -a
    """
    run('uname -a')

fab コマンドで以下のように呼び出すだけです。

$ fab simple
[default] Executing task 'simple'
[default] run: uname -a
[default] out: Linux fabric-sample 3.2.0-23-generic #36-Ubuntu SMP Tue Apr 10 20:39:51 UTC 2012 x86_64 x86_64 x86_64 GNU/Linux
[default] out:

Fabric を使い出した頃、コマンドを列挙していくだけのシンプルさのおかげで、それまで shell スクリプトや手順書として Wiki に書いていたものをスムーズに移行することができました。

さて、この task デコレータを使ってタスクを定義する方法の他に、Task のサブクラスを利用する方法 がドキュメントでは紹介されています。例をそのまま引用すると、以下のようにサブクラスを定義すると、

class MyTask(Task):
    name = "deploy"
    def run(self, environment, domain="whatever.com"):
        run("git clone foo")
        sudo("service apache2 restart")

instance = MyTask()

以下の task デコレータを使った定義と同等になります。

@task
def deploy(environment, domain="whatever.com"):
    run("git clone foo")
    sudo("service apache2 restart")

実際 task デコレータも内部的には WrappedCallableTask という Task のサブクラスを 生成しています。ただ、これだけを見ると、ぶっちゃけデコレータを使った方法のほうが短くてシンプルに見えますね。

ですが、この Task をサブクラス化する方法は、

  • 複数のサービス間でタスクを共有したい
  • 定型的なタスクを沢山生成したい
  • 環境毎の切り替えを見通しよく実装したい

といった場合に活躍してくれます。では、実際にその内容をみていきましょう。

同様のタスクを別サービスでも使い回したい

チームで共通的に利用するミドルウェアやアプリケーションフレームワークを決めている場合、別のサービスでもあるサービスのタスクを使い回したくなることはままあります。実際、私たちも Backlog 以外に CacooTypetalk といったサービスを運用しており、共通的に利用したいタスクというのは少なからずあります。

そういった場合には、Task クラスを定義してライブラリにしておきます。

class DeployTask(Task):
    """
    deploy application
    """
    name = 'deploy'

    def run(self, *args, **kwargs):
        execute('tomcat7_stop')
        self.pre_deploy()        
        print yellow('do deploy')
        self.post_deploy()
        execute('tomcat7_start')        

    def pre_deploy(self):
        pass

    def post_deploy(self):
        pass

そして、どのサービスでも以下のように

deploy = DeployTask()

とタスク定義をすれば、コピペ不要で使い回しがききます。また、あるサービスでは一部サービス固有の処理がある、といった場合にはクラスを拡張することで重複した記述をしなくてもよくなります。

class CleanAndDeployTask(DeployTask):
    """
    clean work directory before deploy
    """
    def pre_deploy(self):
        print yellow('clean up /var/lib/tomcat7/work/')
        sudo('rm -fr /var/lib/tomcat7/work/*')

deploy = CleanAndDeployTask()

この嬉しさはオブジェクト指向で書く事の良さそのものですね。

起動、停止タスクだらけの fabfile

冒頭でも述べましたが、Fabric の主な利用シーンの一つにミドルウェアの起動、停止、再起動があります。こういったタスクはシンプルなものの、数が多くなると fabfile の中身がそればかりで膨らんでしまうこともよくあります。以下のようなイメージです。

@task
def nginx_stop():
    """
    nginx を停止する
    """
    sudo('service nginx stop')

@task
def nginx_start():
    """
    nginx を開始する
    """
    sudo('service nginx start')


@task
def nginx_restart():
    """
    nginx を再起動する
    """
    sudo('service nginx restart')

例では三つだけですが、これに memcached や tomcat その他のミドルも追加されると、同じような記述が沢山並んでしまい見通しが悪くなり、うっかり間違って書き変えてしまったり、新しく何かを追加するときにコピペして変更し忘れたり、といった事が起こりやすくなります。

そういった時に以下のようにタスクを自動生成するユーティリティメソッドを利用します。 ここでは、task デコレータを関数として呼び出してクラスを生成するラッパ関数を取得しています。

class Service(object):

    def __init__(self, name):
        self.name = name

    def start(self):
        sudo('service %s start' % self.name)

    def stop(self):
        sudo('service %s stop' % self.name)

    def restart(self):
        sudo('service %s restart' % self.name)

    def get_methods(self):
        return [self.start, self.stop, self.restart]

def create_tasks(name, namespace):

    service = Service(name)
    for f in service.get_methods():
        fname = f.__name__
        # task description
        f.__func__.__doc__ = 'service %s %s' % (name, fname)
        # task name
        task_name = '%s_%s' % (name, fname)
        # task デコレータを関数として呼び出して、WrappedCallableTask を生成する関数を取得
        wrapper = task(name=task_name)
        rand = '%d' % (time.time() * 100000)
        namespace['task_%s_%s' % (task_name, rand)] = wrapper(f)

あとは定義する側では以下のように呼び出せば、stop / start / restart のタスクが自動的に生成されます。

create_tasks('nginx', globals())
create_tasks('postgresql', globals())
create_tasks('tomcat7', globals())

ちゃんとタスクとして認識されています。

$ fab -l
Available commands:

    nginx_restart       service nginx restart
    nginx_start         service nginx start
    nginx_stop          service nginx stop
    postgresql_restart  service postgresql restart
    postgresql_start    service postgresql start
    postgresql_stop     service postgresql stop
    tomcat7_restart     service tomcat7 restart
    tomcat7_start       service tomcat7 start
    tomcat7_stop        service tomcat7 stop

環境によるタスク実行内容の切り替え

一つのサービスで、プロダクション環境とステージング環境などと複数の環境を利用することもよくあるでしょう。そういった場合に、よく使われるテクニックとしては

$ fab switch:production sometask

といった形で、まず switch タスクで環境を変更したあとに、実際に実行するタスクを呼び出すというものです。switch タスク内でする処理の例としては env 環境変数にある role で指定するホスト名を変更したり、環境固有の値をセットアップするなど、です。

さて、sometask 側で実行する内容が共通の場合は良いのですが、場合によっては staging 環境と production 環境での処理には差がある、といった事もあるでしょう。そういった場合、sometask 側で

@task
def sometask():
    if env.run_environment == 'production':
        production_run()
    else:
        stage_run()

みたいな事をすれば通常はよいのですが、

環境毎に処理の切り替えが必要なタスクが多い
特定の環境では毎回同じ処理をしたい ( production だと必ず実行ログを残す、など )
といった場合には同様の処理が各タスクにあって見通しが悪くなりがちです。そこで、以下のように FactoryTask を作って関数呼び出しをラップしてあげることで、

環境毎の分岐をタスク側に書かなくてよい
環境固有の共通処理を FactoryTask 側に集約ができる
といったメリットがあります。

class FactoryTask(Task):

    def __init__(self, runners, desc=None, *args, **kwargs):
        super(FactoryTask, self).__init__(*args, **kwargs)
        self.runners = runners
        if desc is not None:
            self.__doc__ = desc  

    def run(self, *args, **kwargs):

        if not env.has_key('run_environment'):
            print red('this task should be run under some environment')
            return

        runner = self.runners.get(env.run_environment)
        if runner is None:
            print red('%s is not supported this task' % env.run_environment)
        else :
            print yellow('run %s under %s environment' % (self.name, env.run_environment))
            # production 環境では実行ログを残す
            if env.run_environment == 'production':
                args_str = ','.join('{}: {}'.format(*k) for k in enumerate(args))
                kwargs_str = ','.join('{}: {}'.format(*k) for k in kwargs.items())
                with settings(hide('running'), warn_only=True):
                    run('logger -t fabric %s args:[%s] kwargs:[%s]' % (self.name, args_str, kwargs_str))
            runner(*args, **kwargs)

def prod_run(a, b):
    print '%s %s' % (a, b)

def stage_run(a):
    print '%s' % a

@task
def switch(production=None):
    """
    switch task
    """
    env.run_environment = 'production' if production is not None else 'stage'

show_args = FactoryTask({
    'production' : prod_run,
    'stage' : stage_run
}, 'show args', name='show_args')

こちらを呼び出すには以下のようにします。

# staging のとき
$ fab switch show_args:a='1'
# production のとき
$ fab switch:production show_args:a='1',b='2'

ここでは、production と staging の違いとして引数の数を変え、production の場合は logger で実行ログを残す形としてみました。

ということで、冒頭でも述べたように Task をサブクラス化する方法は

複数のサービス間でタスクを共有したい
定型的なタスクを沢山生成したい
環境毎の切り替えを見通しよく実装したい
といった場合に、コピペをなくし、見通しを良くするといった所で活躍してくれます。このエントリで書いたサンプルコードは以下から取得が可能です。

https://github.com/nulab/fabric-sample
上記のページに記載のある必要なツール ( Vagrant、Ansible/、Fabric ) をインストールした上で、以下の下準備をしていただければ、本エントリで紹介した各タスクを実際に実行することが可能ですので、実際に動かしてみてイメージを掴んでいただければと思います。

$ git clone https://github.com/nulab/fabric-sample.git
$ cd fabric-sample
$ vagrant up
$ vagrant ssh-config > ssh.config

Fabric はシンプルなツールですが、こういったちょっと踏み込んだ使い方が出来るのも魅力ですね。また、Vagrant や ansible といったツールのおかげで簡単にこういったツールのテスト環境が共有出来るようになった事も素敵ですね。

ということで、Let’s enjoy Fabric !

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる