Docker ComposeとDipで開発用コンテナを再利用可能にする(翻訳)
はじめに:
Docker Composeファイルを管理しながら、最小限の労力で複数のDocker環境でコードを実行・テストする方法をご紹介します。YAML設定をいじくる時間を削減し、シンプルなコマンドを1つ実行するだけで任意のホストフォルダから指定のコンテナに取り込みましょう。本記事で紹介するRuby、Node.js、Erlangの事例をご覧いただき、ご自由に皆さんの環境に取り入れてください。
原文の免責事項
本記事(英語版)は、ベストな推奨方法を最新に保つため今後も更新を繰り返します。記事末尾のChangelogをご覧ください(原文Changelog)。
すべては1台の新しいMacから始まりました。私は商用プロジェクトに携わりながら1ダースほどの著名なオープンソースライブラリを抱えている関係上、自分のコンピュータ上でRuby、Node.js、Go、Erlangを最小限の手間で共存させる必要があります。プロジェクトAはRuby 2.6.6にロックされているかと思えば、ライブラリBはエッジ(Ruby 3.0)とレガシー(Ruby 2.5)の両方、および別実装(jruby
など)でも動作する必要がある、といった具合です。
当然、何らかのツールで環境を管理する必要があります。rvm、nvm、rbenv、pipenv、virtualenv、asdf、gvmなどなど、この手の略語は増える一方です。そうしたツールが増えるたびにOSに「マイナー」な設定が追加され、echo $PATH
の出力は画面からあふれ、ターミナルで新しいタブの読み込みに5秒もかかるというありさまです。
🔗 チャレンジを決意した
ホストOSに何もかもぶち込むのをやめて、さまざまなソフトウェアのさまざまなバージョンを個別に実行できたらどんなによいでしょう。コンテナはそんなときにうってつけです。私たちEvil Martiansのチームは、Docker黎明期から複数のサービスを必要とする複雑なプロジェクトでDocker化環境を使い続けています。
商用プロジェクトをDocker化する方法について詳しくは以下の記事をどうぞ。
そういうわけで、素のシステム設定でどこまで生産性を上げられるかを確かめるべく、私の新しいコンピュータにGitとDockerとDipだけをインストールしました。
🔗 Dockerとdipの世界に飛び込む
DockerとDocker Composeは間違いなく素晴らしいツールですが、たちまち設定で消耗するようになります。
DockerやDocker Composeには、ドキュメントを首っ引きで参照しなくても覚えられる程度のターミナルコマンドが「山ほど」あります。もちろん、それらすべてにエイリアスを設定してもよいのですが、結局.bashrc
や.zshrc
を余計な設定で汚すことになってしまうでしょう。プロジェクトの大小にかかわらず、開発者向けのDocker設定をDockerfileやdocker-compose.ymlに全部ぶちこむのもやりすぎ感があります。
何か妙案はないものでしょうか?ありがたいことに、同僚のMisha Merkushin がDipという素晴らしいツールを開発してくれました(dipは「Docker Interaction Process」の略です)。
このツールを使えば、dip.ymlという設定ファイルを別途作成して、docker-compose
の設定にあるさまざまなサービスを個別に実行する方法を思いのままに「制御」できるようになります。要するに設定可能なショートカットのリストをdip.ymlで管理するとお考えください。
$ dip ruby:latest
# 以下の長いコマンドを上のように短くできる
$ docker-compose -f ~/dev/shared/docker-compose.yml run --rm ruby-latest bash
上のdipコマンドを実行するだけで、ホストのボリューム内にマッピングされたソースコードフォルダ上で最新バージョンのRubyを実行するように設定されたLinux環境の一丁上がりです。
🔗 20個のYAMLファイルをわずか2個に
Dipで実にクールな点は、Docker Composeのショートカットを定義した柔軟なdip.yml
ファイルが、PWD
(カレントディレクトリ)から始まってファイルツリーのあらゆるディレクトリで探索されることです。つまり、自分のホームディレクトリにdip.ymlとdocker-compose.ymlを1個ずつ配置し、すべての共通設定をそこに保存しておけば、定型的なYAMLをプロジェクトからプロジェクトへコピペする必要がなくなります。それでは試してみましょう。最初にdip.ymlファイルを作成します。
cd $HOME
touch dip.yml
mkdir .dip && touch .dip/global-compose.yml
dip.ymlに以下を書き込みます。
# ~/dip.yml
version: "5.0"
compose:
files:
- ./.dip/global-compose.yml
project_name: shared_dip_env
interaction:
ruby: &ruby
description: rubyサービスのターミナルを開く
service: ruby
command: /bin/bash
subcommands:
server:
description: rubyサービスのターミナルを開く(公開ポート: 9292 -> 19292、3000 -> 13000、8080 -> 18080)
compose:
run_options: [service-ports]
jruby:
<<: *ruby
service: jruby
"ruby:latest":
<<: *ruby
service: ruby-latest
psql:
description: psqlコンソールを実行
service: postgres
command: psql -h postgres -U postgres
createdb:
description: PostgreSQLのcreatedbコマンドを実行
service: postgres
command: createdb -h postgres -U postgres
"redis-cli":
description: Redisコンソールを実行
service: redis
command: redis-cli -h redis
interaction
セクションで対応付けられている各キーは、docker-compose
フラグを置き換えるエイリアスとお考えください。service
サブキーはどのDocker Composeサービスを実行するかを定義し、command
サブキーは通常のdocker-compose run
に渡す引数です。今度はその強力なDocker Composeファイルを見てみましょう!
# ~/.dip/global-compose.yml
version: "2.4"
services:
# カレントの安定版Ruby
ruby: &ruby
command: bash
image: ruby:2.7
volumes:
# マジックはこれで全部です!
- ${PWD}:/${PWD}:cached
- bundler_data:/usr/local/bundle
- history:/usr/local/hist
# 開発エクスペリエスを向上させる
# さまざまな設定もここでマウント
- ./.bashrc:/root/.bashrc:ro
- ./.irbrc:/root/.irbrc:ro
- ./.pryrc:/root/.pryrc:ro
environment:
DATABASE_URL: postgres://postgres:postgres@postgres:5432
REDIS_URL: redis://redis:6379/
HISTFILE: /usr/local/hist/.bash_history
LANG: C.UTF-8
PROMPT_DIRTRIM: 2
PS1: '[\W]\! '
# gemfiles/*.gemfileファイルをCIでいい感じに使える
BUNDLE_GEMFILE: ${BUNDLE_GEMFILE:-Gemfile}
# 呪文その2
working_dir: ${PWD}
# よく使われる公開ポート(9292: Puma、3000: Rails)を指定
# `dip ruby server`でこれらのポートが公開されたコンテナを実行する
# ポート番号に1を追加している点に注意
# 9292番はホスト側コンピュータの19292番で使えるようになる
ports:
- 19292:9292
- 13000:3000
- 18080:8080
tmpfs:
- /tmp
# 別のRuby
jruby:
<<: *ruby
image: jruby:latest
volumes:
- ${PWD}:/${PWD}:cached
- bundler_jruby:/usr/local/bundle
- history:/usr/local/hist
- ./.bashrc:/root/.bashrc:ro
- ./.irbrc:/root/.irbrc:ro
- ./.pryrc:/root/.pryrc:ro
# エッジ版Ruby
ruby-latest:
<<: *ruby
image: rubocophq/ruby-snapshot:latest
volumes:
- ${PWD}:/${PWD}:cached
- bundler_data_edge:/usr/local/bundle
- history:/usr/local/hist
- ./.bashrc:/root/.bashrc:ro
- ./.irbrc:/root/.irbrc:ro
- ./.pryrc:/root/.pryrc:ro
# カレントのPostgreSQLフレーバー
postgres:
image: postgres:11.7
volumes:
- history:/usr/local/hist
- ./.psqlrc:/root/.psqlrc:ro
- postgres:/var/lib/postgresql/data
environment:
PSQL_HISTFILE: /usr/local/hist/.psql_history
POSTGRES_PASSWORD: postgres
PGPASSWORD: postgres
ports:
- 5432
# カレントのRedisフレーバー
redis:
image: redis:5-alpine
volumes:
- redis:/data
ports:
- 6379
healthcheck:
test: redis-cli ping
interval: 1s
timeout: 3s
retries: 30
# プロジェクト実行のたびに依存関係のリビルドを避けるためのボリューム
volumes:
postgres:
redis:
bundler_data:
bundler_jruby:
bundler_data_edge:
history:
Dockerのボリュームを使うようになると、ホスト上の正しいパスをどうするかにどうしても悩まされます。どういうわけか、オンラインのサンプルやチュートリアルでは、頭の中でファイルツリーをまったくたどらずに済むUnixの聖なる知恵がよく見逃されています。PWD
環境変数はどんなときも、文字どおりカレントワーキングディレクトリとして評価されるのです。
この点を押さえておけば、docker-compose.yml
を(プロジェクトルートに限らず)好きな場所に保存し、${PWD}:/${PWD}:cached
という呪文を唱えさえすれば、DockerfileにどんなWORKDIR
命令が書かれていてもカレントのフォルダをコンテナ内にマウントできます(私の例のようにベースイメージを使っている場合は、Dockerfileにアクセスすらできないかもしれません)。
共有Docker Composeファイルにあるサービスを利用すれば、PostgreSQLやRedisに依存するライブラリを開発できるようになります。必要なのは、コード内で環境変数DATABASE_URL
とREDIS_URL
を使うことだけです。たとえば、以下のような感じです。
# PostgreSQLをバックグラウンドで起動する
dip up -d postgres
# データベースを作成する(createdbはdip.ymlに定義したショートカット)
dip createdb my_library_db
# psqlを実行する
dip psql
# 後はたとえばテストを実行する
# `dip ruby`は既にbashを実行しているので`-c`で引数を追加できる
dip ruby -c "bundle exec rspec"
データベースが他のコンテナと同じdocker-compose.yml
を使っているので、データベースも同じDockerネットワーク内で動きます。
PumaなどのWebサーバーを動かす場合は、server
サブコマンドでポートをホストシステムに公開できます。
$ dip ruby server -c "bundle exec puma"
Puma starting in single mode...
...
* Listening on http://0.0.0.0:9292
Webサーバーはhttp://localhost:19292
でアクセスできます(ポートフォワーディング設定で9292
の前に1をプレフィックスしています)。
🔗 無料ボーナス: VSCodeとの統合
VSCodeでIntelliSenseを使いたい方は、Remote Containersとの合わせ技をどうぞ。dip up -d ruby
を実行して、実行中のコンテナにアタッチするだけでできます。
🔗 Ruby以外でも使える: DocsifyとNode.jsの例
Ruby以外の例も見てみることにしましょう。Docsifyというドキュメントサーバーを実行します。
Docsifyは、JavaScriptとNode.jsによるドキュメントサイト生成ツールで、私がやっているすべてのオープンソースプロジェクトでこれを使っています。Node.js
とdocsify-cli
パッケージのインストールが必要です。しかしDockerの他には何もインストールしないと最初にお約束しましたので、コンテナに詰め込むとしましょう。
Docsifyの技について詳しくは私の過去記事をどうぞ。
最初に、ベースとなるnodeサービスを~/.dip/global-compose.yml
で宣言します。
# ~/.dip/global-compose.yml
services:
# ...
node: &node
image: node:14
volumes:
- ${PWD}:/${PWD}:cached
# Where to store global packages
- npm_data:${NPM_CONFIG_PREFIX}
- history:/usr/local/hist
- ./.bashrc:/root/.bashrc:ro
environment:
NPM_CONFIG_PREFIX: ${NPM_CONFIG_PREFIX}
HISTFILE: /usr/local/hist/.bash_history
PROMPT_DIRTRIM: 2
PS1: '[\W]\! '
working_dir: ${PWD}
tmpfs:
- /tmp
グローバルな依存パッケージは、非rootユーザーのディレクトリに置くことをおすすめします↓。また、これらのパッケージをボリュームに入れて「キャッシュ」しておきましょう。
参考: docker-node/BestPractices.md at main · nodejs/docker-node
dipの設定ファイルで以下のようにNPM_CONFIG_PREFIX
環境変数を定義できます。
# dip.yml
environment:
NPM_CONFIG_PREFIX: /home/node/.npm-global
Docsifyサーバーを実行するのはドキュメントサイトにアクセスするためなので、ポートを公開する必要があります。それ用に別のサービスを定義し、サーバーを実行するためのコマンドも定義しましょう。
services:
# ...
node: &node # ...
docsify:
<<: *node
working_dir: ${NPM_CONFIG_PREFIX}/bin
command: docsify serve ${PWD}/docs -p 5000 --livereload-port 55729
ports:
- 5000:5000
- 55729:55729
docsify-cli
パッケージをグローバルにインストールするには、以下のコマンドを実行します。
dip compose run node npm i docsify-cli -g
以下のようにdip.ymlでnode
ショートカットを定義しておけば、コマンドがさらにシンプルになります。
# ~/dip.yml
interaction:
# ...
node:
description: Open Node service terminal
service: node
これで、コマンドをdip node npm i docsify-cli -g
と短くできました。
プロジェクトディレクトリでdip up docsify
を実行するだけでDocsifyサーバーを実行できます。
🔗 Erlangの例: アーティファクトのビルドを維持する
最後にコンパイル言語の世界の例をご紹介します。Erlangについてお話ししましょう。
これまでと同様、サービスを~/.dip/global-compose.yml
で定義し、対応するショートカットをdip.ymlで定義します。
# ~/.dip/global-compose.yml
services:
# ...
erlang: &erlang
image: erlang:23
volumes:
- ${PWD}:/${PWD}:cached
- rebar_cache:/rebar_data
- history:/usr/local/hist
- ./.bashrc:/root/.bashrc:ro
environment:
REBAR_CACHE_DIR: /rebar_data/.cache
REBAR_GLOBAL_CONFIG_DIR: /rebar_data/.config
REBAR_BASE_DIR: /rebar_data/.project-cache${PWD}
HISTFILE: /usr/local/hist/.bash_history
PROMPT_DIRTRIM: 2
PS1: '[\W]\! '
working_dir: ${PWD}
tmpfs:
- /tmp
# ~/dip.yml
interactions:
# ...
erl:
description: Open Erlang service terminal
service: erlang
command: /bin/bash
既にご紹介したPWD
の小技を使って、以下のように依存関係やビルドファイルを保存することもできます。
REBAR_BASE_DIR: /rebar_data/.project-cache${PWD}
これにより、デフォルトの_build
が、マウントされたボリューム内に変更されます (${PWD}
のおかげで他のプロジェクトとの衝突も避けられます)。ホストへの書き込みが発生しないので、コンパイルを高速化できます(特にmacOSユーザーにとって有用です)。
🔗 最後の技: composeファイルを分割する
global-compose.ymlが育ちすぎたなと思ったら、複数のファイルに分割してサービスの「性質」に応じてグループ化できます。dip
コマンドを実行すれば、すべてのファイルを探索して適切なサービスを見つけてくれます。
# dip.yml
compose:
files:
- ./.dip/docker-compose.base.yml
- ./.dip/docker-compose.databases.yml
- ./.dip/docker-compose.ruby.yml
- ./.dip/docker-compose.node.yml
- ./.dip/docker-compose.erlang.yml
project_name: shared_dip_env
これで完成です。完全な設定例についてはgistをご自由にお使いいただけます。お気づきの点や他の利用例がありましたらEvil MartiansのTwitterアカウントまでお寄せください。
追伸: 実を言うと当初の「ローカルコンピュータに何もインストールしない」という計画は挫折したと認めざるを得ません。irb
を手っ取り早く動かしたかったので、あきらめてbrew install ruby
を実行しました(それほど頻繁には使っていませんが)。
さらに追伸: 最近GitHub Codespacesにアクセスできるようになりました。詳細の把握はまだですが、今後ライブラリ開発の筆頭候補になりそうなので、オフラインで作業しなければならない場合を除けばローカルマシンの環境整備は不要になるかもしれません(果たしてそんな時代が来るのでしょうか?)。
🔗 原文Changelog
1.1.0 (2021-02-01)
dip ruby server
を追加。
概要
原著者の許諾を得て翻訳・公開いたします。