Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般
  • Ruby / Rails関連

Docker ComposeとDipで開発用コンテナを再利用可能にする(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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化する方法について詳しくは以下の記事をどうぞ。

クジラに乗ったRuby: Evil Martians流Docker+Ruby/Rails開発環境構築(翻訳)


そういうわけで、素のシステム設定でどこまで生産性を上げられるかを確かめるべく、私の新しいコンピュータにGitとDockerとDipだけをインストールしました。


🔗 Dockerとdipの世界に飛び込む

DockerとDocker Composeは間違いなく素晴らしいツールですが、たちまち設定で消耗するようになります。

DockerやDocker Composeには、ドキュメントを首っ引きで参照しなくても覚えられる程度のターミナルコマンドが「山ほど」あります。もちろん、それらすべてにエイリアスを設定してもよいのですが、結局.bashrc.zshrcを余計な設定で汚すことになってしまうでしょう。プロジェクトの大小にかかわらず、開発者向けのDocker設定をDockerfileやdocker-compose.ymlに全部ぶちこむのもやりすぎ感があります。

何か妙案はないものでしょうか?ありがたいことに、同僚のMisha MerkushinDipという素晴らしいツールを開発してくれました(dipは「Docker Interaction Process」の略です)。

bibendi/dip - GitHub

このツールを使えば、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_URLREDIS_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.jsdocsify-cliパッケージのインストールが必要です。しかしDockerの他には何もインストールしないと最初にお約束しましたので、コンテナに詰め込むとしましょう。

最初に、ベースとなる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を追加。

関連記事

docker-composeを便利にするツール「dip」を使ってみた


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。