fabric2でホストグループを定義して並列実行できるようにする

# 概要
fabric2において事前に定義したホストグループ名を指定することで並列にタスクを実行できるfabfileを作ったので紹介します。
また最初にそのfabfile作成に至った経緯としてfabricとfabric2について感じていることを書きます。

# インストールと基本的な使い方
以下の記事を参考にさせてもらいました。
[fabric2のインストール手順と簡単な使い方](https://qiita.com/Esfahan/items/1e4bdf14b4a22263a1cf)

# fabricの用途
fabricでは以下の2つの用途が想定されていたと思います。

1. シェルコマンドを含んだ処理をタスクとして登録・実行する
2. 複数のサーバに対して直列または並列に一連のコマンドを実行し処理を行う

1についてはシェルコマンドとPythonのコードを組み合わせることで複雑な処理をわかりやすく書けるのが大きな利点。
2についてはsshコマンドを並列実行する時に様々な便利機能や拡張機能を提供してくれるのが利点。たとえばsudoパスワードや特定のキーワードを含むプロンプトに対する入力を1回で済ませられるようにできたり、それぞれのサーバにおける実行結果をPython上で受け取って条件分岐や繰り返し、集計と言った複雑な処理できるなどです。

最近はAWSやKubernetesなどが普及し個々のサーバにsshしてコマンドを実行することが減ってきていると思いますが、大企業のレガシーなシステムではいまだ個々のサーバ、それもたくさんのサーバをメンテナンスしなければならない状況は残っており、2019年でも2の用途でfabricが活躍するシーンはあると感じています。

# fabric2の個人的な印象
（まずPython3に移行するにはfabric2への移行が必要というのは前提として）
用途1がinvokeに分離されました。
そのためfabric2は用途2のみになってターゲットが分かりやすくなりました。

また、fabricではsshコマンドを実行するときに出てくるexecute, run, env.hostと言った関数/変数が初学者にとっては分かりづらかったのがfabric2ではConnection/SerialGroup/ThreadingGroupと言った接続先を表すインスタンスに対してrunを実行するという分かりやすいインターフェースになりました。

ただ、2019年2月13日時点ではfabricではできていたことをfabric2でどのように実現すればいいのかわからない細かな点が多く、移行コストが大きいと感じています。
[fabric2のソース](https://github.com/fabric/fabric)を見てもTODOになってるコメントが多く、その細かな点が移行できるのか不安です。（まだ把握しきれておらず、調査中です。）
そして残念ながら個人的にfabricからfabric2になって素晴らしくなった！と思う点はありません。fabric2しかPython3をサポートしないから移行しないといけないという消極的な理由で移行を進めています。

# fabric2に満足できない点
大きな点としては以下の2つが満足できません。

1. hosts引数に対する並列実行オプションが無い
2. 特定のホスト群に対して名前をつけて指定する機能が無い

1について、fabric2では以下のようにhostsオプションでタスクを実行するホストリストを指定できるのですが、
`$ fab --hosts host1,host2,host3 taskA taskB`
参考: [http://docs.fabfile.org/en/2.4/cli.html#runtime-hosts](http://docs.fabfile.org/en/2.4/cli.html#runtime-hosts)
fabricではこのホスト群に対して[parallelオプション](https://fabric-ja.readthedocs.io/ja/latest/usage/fab.html#cmdoption-P)の指定でタスクの並列実行ができていたのに、fabric2ではできなくなっています。（2019年2月13日現在）

2についてはfab実行時にホストリストはhosts引数で与えるしかなく、事前にファイルに保存しているホストリストを指定することができません。
fabricでは[roledefsという変数](http://docs.fabfile.org/en/1.14/usage/execution.html#roles)に定義して-Rオプションで指定できたのですが、2019年2月13日時点ではfabric2に同等の機能が無いようです。
fabric2ではせっかく[Groupというクラス](http://docs.fabfile.org/en/2.1/api/group.html)ができたのにそれに名前をつけてfabコマンドのオプションで指定できないのは残念です。
多種類かつ多数のサーバにタスク実行したい場合は自分でその仕組みを記述する必要があります。

# 自作のfabfileで実現したいこと
1. 定義したホストグループ名を指定してタスクを並列実行できること
2. ホストグループ名に含まれるコンポーネント名によって同じタスクでも必要に応じて具体的な処理が分岐されるようになっていること

1は上述したfabric2に満足できない点を満たすための機能です。
2はfabricにも無い個人的に欲しい機能で、たとえば異なるコンポーネントのサーバでも`stop`という同名のタスクでサーバのサービスアウト処理をできるようにするためです。

# 作ったfabfile (fabric2-parallel-sample)
ということで作成したのが[fabric2-parallel-sample](https://github.com/muumu/fabric2-parallel-sample)です。
こんなサーバーグループ群を定義して、

```python:サーバーグループの定義
# <component_name>.<environment>[.<group_number>]
HostGroups = {
    'web.dev': ['web.dev.example.com'],
    'ap.dev': ['ap.dev.example.com'],
    'web.prod.1': ['web001.example.com'],
    'web.prod.2': ['web{:03d}.example.com'.format(n) for n in range(2,4)],
    'web.prod.3': ['web{:03d}.example.com'.format(n) for n in range(4,6)],
    'ap.prod.1': ['ap001.example.com'],
    'ap.prod.2': ['ap{:03d}.example.com'.format(n) for n in range(2,4)],
    'ap.prod.3': ['ap{:03d}.example.com'.format(n) for n in range(4,6)]
}
```
こんな感じで実行します。

```sh:fabの実行
$ fab hosts web.prod.2 run --command=hostname
...
web002.example.com: hello
web003.example.com: hello
$ fab hosts web.prod.2 stop
...
Stopping web server...
Stopping web server...
$ fab hosts ap.prod.2 stop
...
Stopping application server...
Stopping application server...
$ fab group web001.example.com stop
...
Stopping web server...
```
まず`group web.prod.2`という指定で実行対象をweb001.example.comとweb002.example.comの2台にしています。（細かい話ですがわざわざ2台に絞る名前にしているのは、全てのサーバを同時にサービスアウトしてはいけないという想定をしているためです。）
コード上では自作のgroupcontext.py内にある`set_group`という関数でグローバル変数の[ThreadingGroup](http://docs.fabfile.org/en/2.1/api/group.html#fabric.group.ThreadingGroup)インスタンスに実行対象を保存します。
そしてrunというタスク内ではそのグローバル変数を取ってきてrunを呼び出します。

```python:groupタスクとrunタスクの実装
from groupcontext import set_group, get_group
@task
def group(c, name):
    print(name)
    group = set_group(name)
    print('hosts: [' + ','.join(group.hosts) + ']')
    print('component: ' + str(group.component))
    print('environment: ' + group.environment)

@task
def run(c, command=None, warn=False, print_result=True):
    c = get_group()
    r = c.run(command, warn=warn)
    if print_result:
        for connection, result in r.items():
            print(connection.host + ': ' + result.stdout.strip())
    return r
```
また、groupタスク内では実行対象のサーバー群が属するコンポーネントもグローバル変数のcomponentプロパティに保存しておきます。
そしてコンポーネント毎に処理内容を変えたい関数を実装しておきます。

```python:各コンポーネント共通インターフェースの実装
class Ap:
    def build(self, c):
        return c.run('echo "Building application server..."')

    def start(self, c):
        return c.run('echo "Starting application server..."')

    def stop(self, c):
        return c.run('echo "Stopping application server..."')

    def __str__(self):
        return 'ap'

class Web:
    def build(self, c):
        return c.run('echo "Building web server..."')

    def start(self, c):
        return c.run('echo "Starting web server..."')

    def stop(self, c):
        return c.run('echo "Stopping web server..."')

    def __str__(self):
        return 'web'
```

```python:stopタスクの実装
@task
def stop(c):
    c = get_group()
    # グローバル変数内のcomponentプロパティに保存されていたインスタンスからbuildを呼び出す
    # componentがwebならWebインスタンスのstopが呼び出される
    return c.component.stop(c)
```
最後に、ホストグループの辞書とホスト名からコンポーネント名を逆引きする辞書を持つことで、ホストグループ名指定 (web.prod.2など) とホスト名指定 (web001.example.comなど) の両方に対応しています。
※ホスト名指定のコマンド再掲: `$ fab group web001.example.com stop`
詳細はソースで: [https://github.com/muumu/fabric2-parallel-sample](https://github.com/muumu/fabric2-parallel-sample)

なおサンプルコードはコード中のホスト名を`/etc/hosts`で以下のようにlocalhostのIPを指定することで試せます。

```text:/etc/hosts
127.0.0.1       web001.example.com
127.0.0.1       web002.example.com
127.0.0.1       web003.example.com
127.0.0.1       web004.example.com
127.0.0.1       web005.example.com
127.0.0.1       web006.example.com
127.0.0.1       ap001.example.com
127.0.0.1       ap002.example.com
127.0.0.1       ap003.example.com
127.0.0.1       ap004.example.com
127.0.0.1       ap005.example.com
127.0.0.1       ap006.example.com
```

## TODO
カンマ区切りで複数のホストやホストグループを指定したり一部のホストを除外するとかの細かい機能は未実装です。
それと中身のコードの話ですが、（groupというタスクでセットしたホスト群を実行対象とするために）全てのタスクの冒頭で`c = get_group()`とcを書き換えている処理はdecoratorにしたいのですがtaskの引数をfabに認識されるように記述することができなくて保留にしてます。

# 最後に
fabric2はまだソース上にTODOが多く残っていたりcommitログも続いていたりプルリクが集まっていたりと開発途上のようなので、今後の改善に期待したいです。