Docker を導入して、もうそろそろ1年が過ぎようとしている。
まだ実際のシステムの Docker 化できていないし、必ずしもチームで共有できて無いし、進んでは壁にぶつかる日々なので何か答えを掴んだ訳でも無いのですが。
現状の Docker を使った開発について、今やっている事とこれからやりたい事、並べてまとめていく。
tl;dr
開発環境
- Windows 10 Pro
- Docker for Windows 17.11.0
実行環境の Docker 化
『実行環境を Docker の中に閉じ込める』と聞いて心躍った技術者は多いと思うし、自分もそうだった。
version: '3'
services:
app:
container_name: app
image: node:8-alpine
ports:
- "3000:3000"
volumes:
- ./:/usr/src/app
- yarn_cache:/usr/src/app/node_modules
working_dir: /usr/src/app
entrypoint: yarn run server
networks:
- default
volumes:
yarn_cache:
driver: 'local'
◆ 依存パッケージの管理方法
依存パッケージを含む方法は3つあって、
- 依存パッケージを含めた Image を作っておく
- Data Volume に入れておいて残しておく
- 実行時に
yarn install && yarn run server
みたいに入れる
1 は Immutabillity が高く、Docker の使い方としても正しい。が、依存パッケージが完全に固まった後であれば良いと思うが、開発初期段階では頻繁に追加 / 削除するため辛い。
3 は柔軟だが実行コストが高いので避けたい。
結果、2 が一番バランスが良いと判断している。
開発を始める前に、パッケージを取得してから始める。
docker-compose run --rm app-init
# 開発
docker-compose down -v # 終わったら捨てる
...
app-init:
container_name: app-init
image: node:8-alpine
volumes:
- ./:/usr/src/app
- yarn_cache:/usr/src/app/node_modules
working_dir: /usr/src/app
entrypoint: yarn install
networks:
- default
...
また、管理ツールを使って 何かするときには、コマンド自体を Docker 化して対応する
...
app-yarn:
container_name: app-init
image: node:8-alpine
volumes:
- ./:/usr/src/app
- yarn_cache:/usr/src/app/node_modules
working_dir: /usr/src/app
entrypoint: yarn
networks:
- default
...
Tips
bundler や pip のようにグローバル環境に容易にインストールできる場合は、そのインストール先 ( bundler であれば、/usr/local/bundle
) を Data Volume のマウント先にすれば良い。
しかし、yarn, mix のようにグローバルに一括でインストールする方法がない場合、既にマウント済みのフォルダの一部を改めて Data Volume にするという荒業で、上手い行く。
( 本当は、インストール先を指定する yarn install --modules-folder ...
を使いたかったが、不安定すぎて諦めている )
◆ ビルド、テスト
Docker は、Immutable 志向で環境を閉じ込める能力が高いので、ビルド、テストには最適 と言われている。
...
build:
container_name: build
image: gradle:4.0-jdk8-alpine
volumes:
- ./:/worker
- gradle_cache:/home/gradle/.gradle
working_dir: /worker
entrypoint: gradle --project-cache-dir=/home/gradle/.gradle assemble
networks:
- default
...
ちなみに、Spock は HTML でテストレポートを出してくれるが、ホスティングはしてくれない。これも Docker を使えばサクッと見られるので便利。
...
unit-test:
container_name: unit-test
image: gradle:4.0-jdk8-alpine
depends_on:
- test-report
volumes:
- ./:/worker
- gradle_cache:/home/gradle/.gradle
working_dir: /worker
entrypoint: gradle --project-cache-dir=/home/gradle/.gradle test
networks:
- default
test-report:
container_name: test-report
image: nginx:latest
ports:
- "8030:80"
volumes:
- ./build/reports/tests/test:/usr/share/nginx/html
networks:
- default
...
◆ 自動化
Docker がいくら軽量だからと言って、毎コマンド実行するには少し重い。
その場合、ファイル変更を検知してビルドやテストを自動で走らせてくれる Watcher 的な何かがいてくれると助かる。
ちなみに、先程の app
コマンドでは yarn run server
を実行しているが、これは中で webpack-dev-server --config webpack.config.dev.js
を実行している。
トリガーしない問題
Vagrant 等でも良くはまったが、マウントしたフォルダを Linux 側で変更監視していて、ホストマシン上で変更したのに変更イベントが発火しない問題。
この問題は、基本的には Polling するようにすると解決するので 、Watcher ツールがそれに対応しているならそれで解決する。
...
watchOptions: {
aggregateTimeout: 300,
poll: 1000,
ignored: [/node_modules/, /test/]
}
...
Go にも様々な Watcher ツールあるが、Polling できる realize を使っている。
...
watch:
container_name: watch
image: kentork/realize
ports:
- "9080:9080"
volumes:
- ./:/go/src/web
- go_cache:/go/pkg
working_dir: /go/src/web
entrypoint: realize run
networks:
- default
...
settings:
legacy:
status: true
interval: 2s
...
◆ イメージ
DockerHub のイメージの多くが Ubuntu や Debian だった時代、自分で Busybox や Alpine をベースとした Dockerfile 書いていたが、今では公式イメージの多くが Alpine に対応しており、特に理由がなければ公式使っておけばいい と思っている。
カスタマイズ
しかし、どうしても公式では対応しきれないケースもある。
その場合は、プロジェクトに Cluster/app
というフォルダを作り、そこにカスタム用に Dockerfile を置いて使う。( 何故 cluster かは、後ほど説明する )
i.e Node パッケージに git リポジトリを追加場合。公式イメージは git が入っていない。
FROM node:8-alpine
RUN apk add --update git && rm -rf /var/cache/apk/*
RUN git config --global http.sslVerify false
...
app:
container_name: app
build: ./Cluster/app
ports:
- "3000:3000"
volumes:
- ./:/usr/src/app
- yarn_cache:/usr/src/app/node_modules
working_dir: /usr/src/app
entrypoint: yarn run server
networks:
- default
...
感触
◎ 実行環境の独立 & 共有効果は高い
ローカルマシン上に実行環境を作る場合、言語のバージョン管理 ( i.e XXXenv, asdf... ) や パッケージのグローバル汚染 について考えなくてはいけない。
ここは何とか頑張っても、環境変数、Path、OS バージョン、ネットワーク環境 まで考慮に入れると、絶対どこかで躓く。
特に Windows ではこの システムリソースの分離・切り替え が苦手なので助かっている。
× 開発支援が受けられない
目下の課題がこの問題。ヘッドエイクが痛い。
開発支援機能
普段、エディタで開発する際、Auto Suggest, Type Hint, Linter, Symbol Jump, Highlight, Formatter 等様々な支援を受けている。
Highlight 等の一部は独立した機能であるケースが多いが、殆どの場合で 実行環境が無いと動かない。
今の所は、現実解として、ローカル環境 を使ってしまっているが、落とし穴嵌りそうで辛い。
language server
そこで期待しているのが、Microsoft が手動している language server protocol。
これは、ザックリ言うと上で述べた開発支援をサーバとして提供する機能。
これができれば、以下のような理想的な環境が実現できるはず。
今の所、これだけの言語で対応している と豪語しているが、出来はバラバラだし、VS Code 側の拡張の対応がいまいちなものも多く、まだまだこれからと感じる。
デバッグ
デバッグも、ローカルに実行環境が必要となる。
しかし、デバッグに関して言えば、元々リモートデバッグがそれなりに認知・利用されているので工夫すると何とかなる。
※ 動かないかも。 WIP WIP...
...
debug:
container_name: debug
image: kentork/delve
ports:
- "2345:2345"
- "8080:8080"
security_opt:
- seccomp:unconfined
volumes:
- ./:/go/src/web
- go_cache:/go/pkg
working_dir: /go/src/web
command: dlv debug main.go -l 0.0.0.0:2345 --headless=true --log=true -- server
networks:
- default
...
△ docker-compose でのコマンド実行
docker-compose は、クラスタを管理するという機能がメインであるが、実は 複雑なdockerコマンドを記録しておくタスク管理ツール としても優秀である。
しかし、docker-compose はあくまでクラスタ管理ツールなので、docker-compose up -d
では起動しない、コマンドとしてのサービス (既に矛盾しているが…) は作れない。
Define services which are not started by default
要望は昔からあるが、いまだベストプラクティスは見えない。
今は、こうしている
# run --rm を付ける
PS> docker-compose run --rm app-init
# サービスは個別に指定
PS> docker-compose up -d dbserver mqserver cacheserver
PS> docker-compose up app
Microservice
ここ最近、新たにシステムを構築する時は Microservice っぽい物を目指している。
そして Docker は、Microservice を実現するのにとても良い。
Twelve-Factor にできるだけ則る
Microservice の指針として有名なのが、Twelve-Factor という考え方。
- I. コードベース
- バージョン管理されている1つのコードベースと複数のデプロイ
- II. 依存関係
- 依存関係を明示的に宣言し分離する
- III. 設定
- 設定を環境変数に格納する
- IV. バックエンドサービス
- バックエンドサービスをアタッチされたリソースとして扱う
- V. ビルド、リリース、実行
- ビルド、リリース、実行の3つのステージを厳密に分離する
- VI. プロセス
- アプリケーションを1つもしくは複数のステートレスなプロセスとして実行する
- VII. ポートバインディング
- ポートバインディングを通してサービスを公開する
- VIII. 並行性
- プロセスモデルによってスケールアウトする
- IX. 廃棄容易性
- 高速な起動とグレースフルシャットダウンで堅牢性を最大化する
- X. 開発/本番一致
- 開発、ステージング、本番環境をできるだけ一致させた状態を保つ
- XI. ログ
- ログをイベントストリームとして扱う
- XII. 管理プロセス
- 管理タスクを1回限りのプロセスとして実行する
◆ アプリケーション構築
アプリケーション構築においては、以下のような事を意識している。
- 依存関係
- 全ての環境を
package.json
やglide.lock
等で明記
- 全ての環境を
- 設定の環境変数化
- 設定ファイルが合う場合も、必ず環境変数を展開するようにする
- ステートレス化
- スケールが必要な案件は稀だが、スケールも考慮する
- ログは標準出力
- Docker 化しない時は Systemd & Journal で問題なし
- HTTP(S)でサービス公開
- 起動 / 停止 の高速化とグレースフルシャットダウン
- イメージは即時立ち上がる状態でビルド
- イメージサイズは極力小さく ( < 100 MB )
- Migration 等もコード化 & Docker 化
◆ ビルド・タスクフロー
Twelve-Factor には、ビルドやタスクフローについての記述もあるが、これに関しては諸事情によって 手がつけられていない。
周辺システム疑似インテグレーションテスト ( 未だ未完成 )
上手く言葉にできないが、Microservice はサービス数が多くなると、一つのアプリケーションを試験するのが難しくなる。
◆ 依存地獄
例えばこの図で、Service1 を動作させる時、Database と Service2 が必要となる。
しかし、Service2 は Task2 を必要とする。
このサービスの依存関係が続く以上、全部用意できないと動かない。
され、こういう時普通はどうするか。
● 単体レベルでモック化
まずは、コードレベルでモック化して単体テストで動作を保証するケース。
DB や外部サービスとのやり取り部を抽象化しておいて、テスト時はモックと入れ替えたりみんなすると思う。それ。
● サービスレベルでモック化
複雑な動作が必要なく、固定データ返せれば良いのであれば、 easymock のように、さっと立ち上げられるサービスがあれば、十分だったりする。
( いや、単体テストは大事ですよ。うん。 )
だた、最近はこれをもう少し進めた開発をしている
◆ 関連するサービスのみ考える
全部考えると嫌になるので、まずは自分の担当しているサービスの周辺だけ考える。
友達の友達は、所詮他人。
Service1 を開発することをまず考える。依存するのは、DB と Service2 である。
● DB
データベースも Docker で建てる。
PostgreSQL も MySQL も、Docker イメージを起動した時に DBダンプを復元できる ので、開発に必要なデータはダンプとしてあらかじめチームで共有しておいて、それでサクッとコンテナとして立ち上げられるようにしておく。
...
db:
container_name: postgres
image: postgres:9.6
ports:
- "5432:5432"
volumes:
- ./Cluster/db:/docker-entrypoint-initdb.d
environment:
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
- POSTGRES_DB=test_db
networks:
- default
...
上記定義では、/Cluster/db
以下にスクリプトで落としてきて配置している。
現状は、サイズが大きくなることもあり、git 管理外にしている。
DB ダンプのバージョンは、ダウンロードスクリプト側に書かれていて、バージョン管理はできているが、DB ダンプを直接 git-lfs で管理しても良いんじゃないか、とは悩んでいる。
詳細は別記事とする。
● Service2 のモック
モックサーバも、Docker で建てる。
Service1 を Ruby で開発していると、モックサーバも Ruby の方が分かりやすいかな、みたいな感覚もあるけど、 Docker化するのでなんでも良い。
これを、Private Registry にでも登録しておけば、チームで共有ができる。
...
service2-mock:
container_name: service2
image: service2-mock:latest
depends_on:
- db
ports:
- "6000:6000"
environment:
- POSTGRES_HOST=postgres
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
- POSTGRES_DB=test_db
networks:
- default
...
さて、このモックサーバは、誰が作るべきか。
- Service2 の振る舞いを全て模する必要があるなら、Service2 担当者が
- Service1 の必要な振る舞いのみでようなら、Service1の担当者が
- 場合によっては、Service1 のコードベースに含めても良いかも
● Service1 の開発
...
app:
container_name: app
image: node:8-alpine
depends_on:
- db
- service2
ports:
- "3000:3000"
volumes:
- ./:/usr/src/app
- yarn_cache:/usr/src/app/node_modules
environment:
- POSTGRES_HOST=postgres
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
- POSTGRES_DB=test_db
- SERVICE2_HOST=service2
- SERVICE2_PORT=6000
working_dir: /usr/src/app
entrypoint: yarn run server
networks:
- default
...
補足
DB や Service2 が Port Forwarding しているのは、デバッグ用に開発者に公開しているだけで、内部的には docker-compose が管理する Alias 機能で接続している。
● Integration Test
ここまで来ると、Integration Test を書きたくなる。
Service2 のモック同様、Docker 化されているので、自分の好きな言語でテストできる。
...
integration-test:
container_name: test
image: node:8-alpine
volumes:
- ./Cluster/test:/usr/src/app
working_dir: /usr/src/app
environment:
- POSTGRES_HOST=postgres
- POSTGRES_USER=test
- POSTGRES_PASSWORD=test
- POSTGRES_DB=test_db
entrypoint: yarn run test
networks:
- default
...
自分は好みで、Nodejs + Ava でテストを書くことが多い。
◆ 課題
ミドルウェアの初期化どうしようかとか、データ管理方法とか、CI 時にこれを走らせたいとか、まだまだな部分も多い。
最大の問題は、チームでどう進めていくかで、各々違うスキルを持つ開発者同士でこんなやり方が成り立つのか、浸透させるコストとリターンのバランスがとれるのか、疑問しかない。
でも、なんか面白そうなのでもう少し頑張ってみる。
サービス乱立、連携の絡まり合いは避ける
現状、サービス作りまくってサービス間で連携しまくるような事は、極力避けている。
複雑性が上がるし、オーケストレーションが地獄そうなので。
◆ Message Queue を利用し非同期に
同期でなければならないフローを除き、できるだけ間に Queuing Server を置いて行く。
現状、RabbitMQ を建てて Pub/Sub でメッセージングしている。
◆ 役割に特化したミドルウェアを探す
サービスの役割を独立させたい場合、それ専用にサービスを作っても良いが、内製するとなんだかんだで嫌になる。
そんな時は、色々と探すと専用の役割を持ったツールが結構ある。
● RServe
例えば、演算資源が欲しい時は、RServe を利用している。
Docker イメージが無かったので作った。
これは、TCP 経由で R 演算を実行できるという代物。
あらかじめ関数を読み込ませておけば、呼び出しだけできる。
● ArangoDB
データ保存と加工を一括で管理することを考えると、ArangoDB も良さそう。
こっちは 公式 Docker イメージ もある。
こうやって、役割に特化したミドルウェアが結構あったりするので、それを使うことで製作コストや管理コストを減らせる。
◆ "機能で区切る" という事
これまでの流れから見ると、私は明らかに 機能でサービスを分けている。
しかし、これは変えたいと思っている。
Docker の本番環境への導入はできていない
本番環境への導入は進んではございません。
普通に、1 ~ 数台のマシン上に生で展開されます。全部盛りです。
心は Microservice ですが、体は Monolithic です。
◆ 次善策
その為、各プロジェクトに Playbook
フォルダを作り、そこに Ansible の Playbook を入れている。
それを取りまとめる専用プロジェクトを作り、各プロジェクトから Playbook を集めて実行している。
◆ 見通しの良いサービス設計
しかし、残念なことばかりでもなく。
TDD を語る時、『テストを意識して書かれたコードは良いコードになる』と言われる。
同様に、『Microservice を意識して設計されたアプリケーションは良いアプリケーションになる』 という気がしないでもない。
- 環境・必要なリソースが明確
- 必要な設定も明白
- 状態を持たない
- サービスが落としやすく立ち上げやすい
サービス化は Systemd で行っているが、環境変数やログの扱いを考えると、そこまで Docker と離れていない気はする。
オーケストレーション
ということで、残念ながら本格的なオーケストレーションはされていない。
Ansible で Configuration, Deploy した段階で大体の設定が完了してしまうし、Auto Scaling が必要なようなシステムを構築することもない。サービスもディスカバリしないし、
多分、Microservice の醍醐味 ( 苦味? ) はここなんだろうが…
感触
○ システムをサービスの集合としてとらえるのは悪くない
役割への意識がしっかり区切れること、コードベースも区切れることを考えるととても良いと思う。
ただ、行き過ぎない事が大切かなぁ。
× コンテキストでサービスを切りたい
今は、全体のシステムを考えたら、まず 機能毎 に分ける。
これは、言ってしまえば慣習であるし、技術的にも切り分けやすいのでそうしている。
ただ、これは辞めたいと思っている。コンテキスト毎 に分けたい。
※ 記事を書いている時に、素晴らしい記事が来ていた。
DB 分けるのは個人的にはしっくり来る。
これからやりたい事
まずは、開発支援機能のリモート化。
次はサービスの区切り方を変えること。
道は遠い。