LoginSignup
5
1

More than 3 years have passed since last update.

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

Posted at

tl;dr

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

対象

  • Docker上で実験ができる(コードを動かせる)人
  • 並列処理を書く勉強を一旦後回しにしたい人

はじめに

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

似たアプローチとして,AWSのsagemaker-experimentsがあり,実験・学習ジョブは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で表現すると以下のようになります.

docker-compose.yaml
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

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

そこで,jsonnetを利用します

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と同じことを表現しています.

パースした結果はこのようになります.
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"
   }
]

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 起動時に実行するコマンド


Portテーブル

docker run -p相当です

名前 説明
host 提供するポート
container 公開したいポート


Volumeテーブル

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

名前 説明
host 提供するディレクトリ
container マウントしたいディレクトリ

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

起動順序とネットワーク

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

依存するコンテナをdependsに書くと,それに従った起動順序でコンテナを起動します.
また,networksにネットワーク名を書くと同一のネットワーク内にコンテナが生成され,コンテナ間で通信ができます.
これはdocker-composeのnetworksdepends_onに対応しています.

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と保存されている状態で以下のコマンドを実行します

$ combu -f experiment.jsonnet run

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

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

$ combu -f experiment.jsonnet kill

インストール

ここまで読んだあなたはきっとcombuを試したくなっていると思います.
combuのリリースタブからファイルをダウンロードし,/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を参考にしています.

decotra

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


decotraの例です
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()

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

実はこんなところがダメ

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

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

まとめ

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

github.com/funwarioisii/combu

5
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
1