同一のイメージから大量のコンテナを作成するcombuの話

## tl;dr
- シミュレーション向けに1つのイメージから大量のコンテナ(実験環境)を作るツールを作った
- お金に余裕がある人はAWSのsagemaker-experimentsを使うと良いのかも
- 中間生成物収集ツールのdecotraも見てね

## 対象
 - Docker上で実験ができる（コードを動かせる）人
 - 並列処理を書く勉強を一旦後回しにしたい人

## はじめに
自分が行っていたシミュレーションの実験では複数の環境に対して，複数のパラメータを必要とし，それら全てを探索対象とする必要がありました．
つまり，`実験数=環境数*パラメータ数`となります．
これを順々に処理すると途方もなく時間がかかりそうな気がしていました．
また，途中で解が定まらず，次の実験が始まらないことがありました．
そこで，実験数分だけDockerコンテナを起動し，各パラメータを実行時に渡す方法で並行処理を実現する方法について考え，実装しました．
それが今回紹介したい`combu`です．

似たアプローチとして，AWSの[sagemaker-experiments](https://aws.amazon.com/jp/blogs/news/amazon-sagemaker-experiments-organize-track-and-compare-your-machine-learning-trainings/)があり，実験・学習ジョブは`Trial`，Trialの集合を`Experiment`と呼んでいます．
わかりやすいので，本記事ではこの2つの単語を借りることとします．

## combu
combuは大量のコンテナを起動・破壊するためのコンテナオーケストレーションツールです．コンテナオーケストレーションというと，Kubernatesやdocker-composeが想起されると思います．実際に，開発したcombuはdocker-composeを使っていて不便に感じたものを解消するために作りました．（そして一度Kubernatesに挫折したことがあります．）

combuの特長は以下の2つです

- 導入が容易
- コマンドが簡単

ダウンロードしてパスを通すだけ，すぐ使えます．コマンドが2つしかないので，すぐ覚えられます．

### experimentを表現する
docker-composeでは何がダメだったか，想定例を通して確認していきます．

1から5のIDによって管理されている複数の環境を，AとBの2つのアルゴリズムで解くというexperimentをdocker-compose.yamlで表現すると以下のようになります．

<details><summary>docker-compose.yaml</summary><div>

```docker-compose.yaml
version: '2'
services:
  solver-a-1:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-a-1
    command: make simulation MODE=a ID=1
  solver-a-2:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-a-2
    command: make simulation MODE=a ID=2
  solver-a-3:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-a-3
    command: make simulation MODE=a ID=3
  solver-a-4:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-a-4
    command: make simulation MODE=a ID=4
  solver-a-5:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-a-5
    command: make simulation MODE=a ID=5
  solver-b-1:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-b-1
    command: make simulation MODE=b ID=1
  solver-b-2:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-b-2
    command: make simulation MODE=b ID=2
  solver-b-3:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-b-3
    command: make simulation MODE=b ID=3
  solver-b-4:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-b-4
    command: make simulation MODE=b ID=4
  solver-b-5:
    image:
      registry.com/funwarioisii/solver
    container_name: solver-b-5
    command: make simulation MODE=b ID=5
```
</div></details>


これはかなり冗長で，とても1000個のtrialを並列して始めるには向いていません

そこで，[jsonnet](https://jsonnet.org)を利用します


```experiment.jsonnet
local solver(id, mode) = {
    name: "solver-%s-%d" % [mode, id],
    image: "registry.com/funwarioisii/solver",
    cmd: "make simulation ID=%d MODE=%s" % [id, mode]
};

[
    solver(id, mode)
    for id in std.range(1, 5)
    for mode in ["a", "b"]
]
```

これで先程のyamlと同じことを表現しています．
<details><summary>パースした結果はこのようになります．</summary><div>

```experiment.json
[
   {
      "cmd": "make simulation ID=1 MODE=a",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-a-1"
   },
   {
      "cmd": "make simulation ID=1 MODE=b",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-b-1"
   },
   {
      "cmd": "make simulation ID=2 MODE=a",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-a-2"
   },
   {
      "cmd": "make simulation ID=2 MODE=b",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-b-2"
   },
   {
      "cmd": "make simulation ID=3 MODE=a",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-a-3"
   },
   {
      "cmd": "make simulation ID=3 MODE=b",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-b-3"
   },
   {
      "cmd": "make simulation ID=4 MODE=a",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-a-4"
   },
   {
      "cmd": "make simulation ID=4 MODE=b",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-b-4"
   },
   {
      "cmd": "make simulation ID=5 MODE=a",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-a-5"
   },
   {
      "cmd": "make simulation ID=5 MODE=b",
      "image": "registry.com/funwarioisii/solver",
      "name": "solver-b-5"
   }
]
```
</div></details>


Jsonnetの使い方に関する記事は色々あるので検索してみてください．
ここではパースするとJSONになる便利なやつということで進めていきます．

まず，`local solver(id, mode) = { ... }`はtrialのテンプレートになります．
次に`[solver(id, mode) for id in std.range(1, 5) for mode in ["a", "b"]]`でテンプレートをもとに複数のtrialが宣言されていきます．

combuでは現在以下のパラメータを受け取ることが出来ます．

|名前|説明|
|---|---|
|name|コンテナ名(trial名)|
|image|利用するイメージ名|
|ports|配列 Portテーブル参照|
|volumes|配列 Volumeテーブル参照|
|networks|配列 コンテナが属するネットワーク|
|depends|配列 起動時に立ち上げておくべきコンテナ名|
|cmd|起動時に実行するコマンド|

<details>
<summary>Portテーブル</summary><div>

`docker run -p`相当です

|名前|説明|
|---|---|
|host|提供するポート|
|container|公開したいポート|
</div></details>

<details>
<summary>Volumeテーブル</summary><div>

`docker run -v`相当です
Volumeコンテナなどの利用を想定していないので，現在は直接ホストマシンのストレージにアクセスします

|名前|説明|
|---|---|
|host|提供するディレクトリ|
|container|マウントしたいディレクトリ|
</div></details>

Pythonistaにはお馴染みの内包表記で，複数のtrialをベースにしたexperimentを表現しています．

### 起動順序とネットワーク

私の場合は実験に関する様々なデータをクラウド上に保存してあり，アクセスごとに料金が加算される方式のようだったので，データの取得にキャッシュサーバを間に挟んでいます．
そのためtrialの前にキャッシュサーバを起動する必要があります．
さらに，キャッシュサーバにアクセス可能である必要があります．

依存するコンテナを`depends`に書くと，それに従った起動順序でコンテナを起動します．
また，`networks`にネットワーク名を書くと同一のネットワーク内にコンテナが生成され，コンテナ間で通信ができます．
これはdocker-composeの[networks](http://docs.docker.jp/compose/networking.html)と[depends_on](http://docs.docker.jp/compose/startup-order.html)に対応しています．

```experiment.jsonnet
local solver(id, mode) = {
    name: "solver-%s-%d" % [mode, id],
    image: "registry.com/funwarioisii/solver",
    cmd: "make simulation ID=%d MODE=%s" % [id, mode],
    networks: ["simulation"],
    depends: ["loader"]
};

[
    solver(id, mode)
    for id in std.range(1, 5)
    for mode in ["a", "b"]
] + [
    {
        name: "loader",
        image: "registry.com/funwarioisii/loader",
        networks: ["simulation"],
    }
]
```

### 識別子
combuでは1つのjsonnetファイルに対し，1つの識別子をつけることが出来ます．
これはexperiment単位でなんらかの識別子が必要になったためです．
しかし，jsonnetはパース後毎回同じjson配列を出力するのが仕様になっており，ランダムな値は使えません．
そこでcombuでは実行時に`local uuid = "UUID";`と記述されている場合は，`"UUID"`をランダムに生成した12文字で置き換えるようにしています．
各trialにこの`uuid`をうまく渡すことで，どのexperimentでのtrialなのかを管理しやすくなります．
これは同じ実験を複数回繰り返す際に便利です．

```experiment.jsonnet
local uuid = "UUID";
local solver(id, mode) = {
    name: "solver-%s-%d" % [mode, id],
    image: "registry.com/funwarioisii/solver",
    cmd: "make simulation ID=%d MODE=%s UUID=%s" % [id, mode, uuid],
    networks: ["simulation"],
    depends: ["loader"]
};

[
    solver(id, mode)
    for id in std.range(1, 5)
    for mode in ["a", "b"]
] + [
    {
        name: "loader",
        image: "registry.com/funwarioisii/loader",
        networks: ["simulation"],
    }
]
```

ところでUUIDと書いていますが，UUIDの定義に沿っていないので，後ほど変更します

### 起動と破壊

ここまでは主にexperimentの表現方法について紹介しました．
ここからは，実際に`combu`の使い方について説明します．

まず，experimentが`experiment.jsonnet`と保存されている状態で以下のコマンドを実行します

```bash
$ combu -f experiment.jsonnet run
```

これでコンテナが順々に起動されていきます．
やや込み入ったことを書くと，依存ごとに依存グループを作成し，グループごとにコンテナを並列起動しています．

作成したコンテナは，以下のコマンドで全て破壊できます．

```bash
$ combu -f experiment.jsonnet kill
```

### インストール
ここまで読んだあなたはきっと`combu`を試したくなっていると思います．
[combuのリリースタブ](https://github.com/funwarioisii/combu/releases)からファイルをダウンロードし，`/usr/local/bin`などに配置し，パスを読み込み直してください．
これでいつでどこでも`combu`が使えます．


## 自分の実験パターンとcombu
誰しも「いつもこのルーチンで実験を回す」というのがあると思います．
自分は大体以下の手順を踏みます．

1. Dockerイメージを作る
2. コンテナ上のJupyterでEDA
3. ノートブックをスクリプトにして，makeで操作できるようにする
4. 計算資源が豊富なPCにコンテナを持っていって，実験を回す

そして，Dockerの操作からスクリプトの実行までプロジェクト内で使いたいコマンドは全てMakefileで管理できるようにしています．

これらを容易にするための，実験プロジェクトのディレクトリ構成は以下のようになっています．
combuのためのjsonnetは`docker`ディレクトリに入れています．

```
|-- Makefile
|-- docker
    |-- Dockerfile
    |-- experiment.jsonnet
|-- src
    |-- xxx.py
|-- scripts
    |-- experiment_1.py
|-- notebook
    |-- eda.ipynb
|-- requirements.txt
```

また，Makefileは以下のようになっています．
`make simulation`などでtrialが走るようにしています．
そして`make run-experiment`するとcombuがコンテナを起動し，パラメータを`make simulation`に渡す流れになります．

```
create-image:
	docker build -t funwarioisii/experiment -f docker/Dockerfile

create-container:
	...

push-image:
	...

simulation:
	python scripts/simulation_1.py  \
		--mode=$(MODE) \
		--id=$(ID)
		--uuid=$(UUID)

run-experiment:
	combu -f docker/experiment.jsonnet run

kill-experiment:
	combu -f docker/experiment.jsonnet kill
```

こうすることで，プロジェクトに関連する操作や，実験条件などを繰り返し実行しやすく，持ち運びやすく，忘れにくく（！）しています．
このあたりは[cookiecutter-docker-science](https://github.com/docker-science/cookiecutter-docker-science)を参考にしています．

## decotra
大量に作成したtrialでの計算結果などをいかに集約すべきかという問題があります．
これを解決する拙作[decotra](https://github.com/funwarioisii/decotra)があるので紹介します．
decotraは関数の実行結果をS3に送るものです．
研究室ではAWSに契約していないので，私は[minio](https://hub.docker.com/r/minio/minio/)をエンドポイントとして使っています．
これは先ほど紹介したsagemaker-experimentsで言えば`tracker`のようなものです．
結果を保存したい関数に`@decotra.track(BUCKET_NAME)`とデコレーションをつけ，`with decotra.path('key')`とwith句内で実行します．

<details>
<summary>decotraの例です</summary><div>

```example.py
import numpy as np

import decotra
from decotra import track

BUCKET_NAME = "bucket-name"


class Operation:
    @track(BUCKET_NAME)
    def mul(self, x, y):
        return x * y

    @track(BUCKET_NAME)
    def add(self, x, y):
        return x + y

    @track(BUCKET_NAME)
    def tanh(self, x):
        return np.tanh(x)


def main():
    op = Operation()

    epoch = 20
    for e in range(epoch):
        with decotra.path(f"{decotra.saved_prefix}{e}/"):
            op.tanh(op.add(op.mul(1, 2), 3))


if __name__ == '__main__':
    main()
```
</div></details>

`pip install decotra`でインストールできるので良ければ使ってみてください．

## 実はこんなところがダメ

運用している中で，以下の欠点を見つけています．

- 計算資源を占拠してしまう
    - combuがというより，作成したコンテナが
    - 自分の実験ではメモリに余裕はあったもののCPUがほぼダメでした
- GPUが使えない
    - Docker SDKを眺めているのですが，GPUを使う方法がわかっていません＞＜

## まとめ
実験構成の記述方法，combuの使い方，プラクティス，周辺ツールについて紹介しました．
是非使ってみてください．
Issue/Pull Request大歓迎です．
普段Goを使っていないので，コードスタイルに関する指摘などもバシバシ送ってくださると嬉しいです．

[github.com/funwarioisii/combu](https://github.com/funwarioisii/combu)