upinetree's memo

Web系技術の話題や日常について。

Docker + Rails開発環境Tips

普段なんとなくで設定していたのですが、今の所こんな感じでやるのがいいんじゃない?というのをそろそろまとめたくなったので書いてみました。 もっといい方法があれば教えてください〜。

bundle install の実行のたびにDockerイメージのビルドし直しになるのを回避する

Quickstart にあるような構成では、以下のように Dockerfile で bundle install する。

FROM ruby:2.5.3

RUN mkdir /app
WORKDIR /app

RUN gem install bundler
RUN bundle install

この構成はgem一式を含めてポータブルにするという意味では合理的なのだが、開発環境においてはそうでもない。なぜなら、開発環境ではGemfileの更新やgemのアップデート、Dockerfileの修正が頻繁に発生し、そのたびに各開発者のローカルマシンでのイメージのビルドが必要になってしまうからだ。

これを回避するには、イメージの外、つまりコンテナで bundle install を行って gem を保存すればよい。ただし、コンテナを破棄した際に gem も消えてしまうと困るので、キャッシュできる必要がある。そのためには以下の方法がある。

  • bundle install 用のディレクトリをホストと共有し、そこに格納する
  • gem 用の volume を用意し、そこに格納する

前者のほうがお手軽だが、後者のほうがディスクアクセス的に有利そうではある(未計測)。もしホストと共有したくないとか、パフォーマンス上問題になってきたら後者に乗り換える形でどうだろうか。

bundle install をコンテナで行い、そのディレクトリをホストと共有する

BUNDLE_PATH でインストール先をホストと共有するディレクトリ(この場合は /app 配下の /app/vendor/bundle)に指定。

FROM ruby:2.5.3

ENV APP_PATH=/app
ENV BUNDLE_PATH=$APP_PATH/vendor/bundle

RUN bundle config path $BUNDLE_PATH

なお、 Bundler は BUNDLE_PATH , .bundle/config の順で優先してパスの設定を読み込む。そのためどちらかを指定していれば問題ないが、まれに .bundle/config の設定がないとRailsrequire 'bundle/setup したときに BUNDLE_PATH を見てくれないことがあった。そのため上記の例では両方設定している(原因特定できておらず、環境要因かもしれないので要確認)。どの設定が効いているかは docker-compose run --rm app bundle config で確認できる。

docker-compose.yml は以下のように /app 配下をホストにマウントする。 vendor/bundledelegated フラグ付きでマウントすると高速に同期できる(ただし、コンテナ→ホストへのWriteパフォーマンスが犠牲になる。bundler配下ではほぼ発生しないので問題なし)

version: '3'

services:
  app:
    build: .
    command: ['./bin/rails', 's']
    tty: true
    stdin_open: true
    ports:
      - '3000:3000'
    volumes:
      # cached/delegated options for performance tuning
      - .:/app:cached
      - ./vendor/bundle:/app/vendor/bundle:delegated
      - ./node_modules:/app/node_modules:delegated

gem 用の volume を用意し、そこに格納する

BUNDLE_PATH はマウントされないところにしておく。

ENV APP_PATH=/app
ENV BUNDLE_PATH=/bundle
RUN mkdir $APP_PATH
WORKDIR $APP_PATH

そして docker-compose.yml でボリュームを用意し紐付ける。

version: '3'

services:
  app:
    volumes:
      - bundle:/bundle
volumes:
  bundle:
    # use default driver

binstub も考慮したい場合は、以下のようにすると良いらしい。これがなくても手動で binstub を生成する(そしてリポジトリに含める)ことは可能な気がするが、この方法の便利なところは entry point を定義しておくと docker-compose up のたびに最新の状態に保てることだろう。そういう意味では、 bin/setup を entry point に登録しておくと便利なのかもしれない。

https://medium.com/@jfroom/docker-compose-3-bundler-caching-in-dev-9ca1e49ac441

デバッガによる対話コンソールを有効にする

よくある docker-compose.yml では、以下のように docker-compose upRailsサーバも一緒に起動するようになっている。

services:
  app:
    build: .
    command: ['./bin/rails', 's', "-b", "0.0.0.0"]

この設定だと標準入力が閉じられているので、 binding.prybinding.irb のような対話コンソールによるデバッグが行えない。従って以下の設定を追加する。

services:
  app:
    build: .
    command: ['./bin/rails', 's', "-b", "0.0.0.0"]
    tty: true
    stdin_open: true

ただし、 docker-compose up でコンテナを起動した状態では、デバッガが起動した内容のログを確認できても、対話コンソールにアクセスできない。Railsサーバのコンテナは docker-compose up のプロセスの後ろにいるためのようだ。従って一工夫する必要がある。2通りの方法を紹介する。

Railsサーバのコンテナに attach する

docker attach コマンドを使うと、起動中のコンテナの標準入出力に接続することができる。これをRailsサーバのコンテナに利用する。具体的には次の操作を行う。

  1. デバッガのブレークポイントを仕込んで、そこを通る処理を実行する(処理を通って対話コンソールが待機状態になっているかどうかは docker-compose up のログ、もしくは docker-compose logs でも確認できる)
  2. 別のターミナルで docker attach $(docker-compose ps -q app) を行って接続。接続直後は何も出力されないので、エンターキーを押して対話コンソールが起動していることを確認すると良い
  3. デバッグを行う
  4. 対話コンソールを終了する(ctrl-dquit などで)
  5. コンテナへの接続を解除する。 ctrl-p ctrl-q を連続して入力(デフォルトの設定。 docker attach --detach-keys="<sequence>" で変更可能)。うっかり ctrl-c を押してしまうとコンテナごと終了してしまうので注意

このように、Dockerを利用せずにローカルでサーバを起動する場合とは全く感覚が異なる。特にチームで作業する場合などでは、効率を上げるためにも bin/attach のようなスクリプトを作っておくと良さそうだ。

#!/usr/bin/env bash
echo attach to $1 ...
docker attach --detach-keys="ctrl-c" $(docker-compose ps -q $1)

次のような起動スクリプトでさらにラップしても良い。

docker-compose up -d
bin/attach app

Railsサーバのコンテナだけ docker-compose up とは別に起動する

attach を利用する方法で以下のような懸念がある場合には、Railsサーバを独立したコンテナで起動すると便利だ。

  • bin/attach のようなスクリプトを作っても、意識のアップデートが必要。Dockerに慣れていない場合にハードルがある
  • Railsサーバのコンテナに変更や試行錯誤が多く、再起動する機会が多い
    • up -d で起動して stop appstart app で個別に再起動することはできるが、フォアグラウンドで起動、終了を制御したい

このためには、まず docker-compose.yml で次のようにRailsサーバを起動しないようにする。ここではかわりに bin/update を実行して環境の最新化を行うようにしている。

services:
  app:
    build: .
    command: ["./bin/update"]

そして、Railsサーバは次のようなスクリプトで起動する(Makefileに入れるのが楽だ)。

docker-compose up -d
./bin/sleep_until_app_ready
docker-compose run --rm --service-ports app bin/rails s

注意点は —service-ports オプション。 docker-compose.yml で設定した ports が、 run のときにはこのオプションがないと反映されない。

https://docs.docker.com/compose/reference/run/

The second difference is that the docker-compose run command does not create any of the ports specified in the service configuration. This prevents port collisions with already-open ports. If you do want the service’s ports to be created and mapped to the host, specify the --service-ports flag

途中で使っている ./bin/sleep_until_app_ready は以下のような簡単なスクリプトで、 bin/update が終了するのを待っている。

#!/usr/bin/env bash

while :; do
  sleep 1

  docker-compose exec db echo 'alive?' &>/dev/null
  DB_UP=$?

  docker-compose exec app echo 'alive?' &>/dev/null
  APP_UP=$?

  if [ $DB_UP -eq 0 ] && [ $APP_UP -eq 1 ]; then
    break
  else
    echo -n "."
  fi
done

echo "Ready for running app"

対話コンソールで利用されるページャ

ruby イメージはデフォルトでは less を備えていないので、デバッガは ruby 実装の自前ページャを利用する。ローカルで動かしたときのように less で見たい場合は、インストールするように Dockerfile を修正すると良い。

Springサーバーを有効にする

spring-docker-example が参考になる。

基本的には以下のようにSpringサーバ用のコンテナを立てて、

services:
  app:
    build: .
    volumes:
      - .:/app:cached
    depends_on:
      - db
      - spring

  spring:
    build: .
    volumes:
      - .:/app:cached
    command: ["./bin/spring", "server"]

spring.sock をコンテナ間で共有するだけ。そのために、 sock ファイルを tmp ディレクトリ配下(ホストにマウントして共有)に置くように、 config/spring_client.rb (Springが起動時に読み込む)でその場所を教えることにする。環境変数なので、もちろん他の方法で指定しても良い。

ENV["SPRING_SOCKET"] = "tmp/spring.sock"

コンテナをまたいだ spring の自動立ち上げはできない(方法あったら教えてほしい)ので、適宜 depends_on: spring を設定しておくと良い。

注意したい点として、 Springサーバのコンテナの環境変数を、Railsサーバのコンテナの環境変数と揃えておくこと。Springサーバのコンテナが異なる環境変数を利用し、それを元に各種定数を初期化してキャッシュすると、Railsサーバは異なる環境変数を元にした定数を参照することになる。例えば database.yml にERBで環境変数を埋め込んでいる場合に、意図通りに設定されない可能性がある。

default: &default
  host: <%= ENV.fetch("DATABASE_HOST") { 'localhost' } %>

同様の理由で、マウントするディレクトリやボリュームも同じ条件にしておくのが安全。Spring経由での Dir.glob の結果が異なることがあるため。

なお、 spring-docker-example の例では、ホストとコンテナの両方で同じ spring.sock を共有し、さらに SPRING_SERVER_COMMAND='docker-compose up spring' を設定したりPIDのネームスペースをホストにしたりして、ホストからSpringを利用して bin/rails c を行うことができるようにしている(というか、そもそもホストにRubyとSpringを入れて起動するのを前提にしているようにみえる)。ホストとの共有はまだ試したことがなくて、きっと spring コンテナの環境での Rails コンソールが立ち上がると思うのだが、本当に macOS 上の環境に依存しない( native extension の違いとか、 webpacker が実行する yarn check --integrity の結果とかの影響を受けない)のか気になるところ。今のところはホストからの利用はせずにコンテナ上で完結するように設定しているが、そのうち試したい。