Dockerで快適なRails開発環境を手に入れるためにやった6つのこと

  • 48
    いいね
  • 0
    コメント

Dockerで快適なRails開発環境を手に入れるためにやった6つのこと

はじめに

この記事はDockerその2アドベントカレンダーの14日目です。
DockerでRails開発環境を作るに当たってしたことについて書きます。

普通のRails環境構築については他に記事がいっぱいあるのでそちらを見てください。
なお、現在RailsでAPIサーバを書いているのでフロントエンドでのみ必要になることについては触れません。

また、今回の記事を書くにあたってリポジトリをgithubで公開しています。
https://github.com/yuemori/docker-adventcalendar-example

アジェンダ

  • Dockerfileとdocker-composeを環境毎に分ける
  • data volume containerを使ってデータをキャッシュする
  • entrykit, dockerizeでDBの起動待ちをする
  • ラッパースクリプトを書く
  • springを使えるようにする
  • ビルドタグをつける

Dockerfileとdocker-composeを環境毎にわける

まず前提として、開発用・本番用・テスト用のDockerfileとdocker-composeをそれぞれ作ります。

Dockerで開発環境を作る場合、volumeを使うことでリアルタイムにコンテナ側に変更が伝わるためvolumeを使ったほうが便利です。
しかし、productionやtest環境ではvolumeを使わない方がベストプラクティスと言えます。

docker-composeを分けることで docker-compose -f でdocker-composeファイルを指定することで、任意の環境のdockerクラスタを起動する事ができるようになります。
また、本番ではデータベースはコンテナを使っておらずdevelopmentやtestとは構成が異なるなど、環境毎の事情を吸収するのも分ける理由の一つです。

なお、production用のDockerfileやdocker-composeについては本記事では扱わないため触れません。

development

developmentはvolumeを使う前提で記述していきます。

Dockerfile.development
FROM ruby:2.3.3

ENV APP_ROOT /usr/src/app

WORKDIR $APP_ROOT

CMD ["rails", "server", "-b", "0.0.0.0", "--pid", "/tmp/server.pid"]
docker-compose.yml
version: '2'
services:
  database:
    container_name: myrails-development-database
    image: mysql:5.7
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
    ports:
      - 3306:3306

  rails:
    image: myrails-development
    container_name: myrails-development-rails
    build:
      context: .
      dockerfile: Dockerfile.development
    environment:
      RAILS_ENV: development
      DATABASE_URL: mysql2://root@database:3306/sample_database
    volumes:
      - .:/usr/src/app
    ports:
      - 3000:3000

test

test用のDockerfileはほぼproductionと同じものになりますが、productionと違いデータベースコンテナを使った方がCIが楽なので、基本的な部分を書いていきます。

Dockerfile.test
FROM ruby:2.3.1

ENV APP_ROOT /usr/src/app

WORKDIR $APP_ROOT

COPY Gemfile Gemfile
COPY Gemfile.lock Gemfile.lock

RUN bundle install -j4

COPY . $APP_ROOT

EXPOSE 3000

CMD ["rspec"]
docker-compose.test.yml
version: '2'
services:
  database:
    container_name: myrails-test-database
    image: mysql:5.7
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'

  rails:
    container_name: myrails-test-rails
    image: myrails-test
    build:
      context: ../ # ディレクトリを掘っているため
      dockerfile: Dockerfile.test
    environment:
      DATABASE_URL: mysql2://root@database:3306

解説

ポイントとして、テスト用のdocker-composeはルートディレクトリではなく dockerfiles などのディレクトリを作ってそこに設置します。

docker-composeはdocker-compose.ymlが置いてあるディレクトリをプロジェクトとして扱い、コンテナの管理をします。
そのため、development用とtest用のファイルを両方ルートディレクトリにおいておくとテストの実行でコンテナを起動するとdevelopment用のクラスタが停止させられるなど影響が出てしまいます。
-pオプションでプロジェクト名を指定すると切り替えることが出来ますが、面倒なのでディレクトリ自体を変更します。
(コンテナ名を指定して起動しても駄目だったので、もっと良い方法があったら教えてください!)

data volume containerを使ってデータをキャッシュする

このままではdevelopmentではコンテナを起動するたびに bundle installdb:migrate が必要になってしまうので、データをキャッシュするようにしていきます。
データのキャッシュにはdata volume containerを使います。

data volume containerを使ってデータをキャッシュすることで、例えば開発用にgemを追加したときなどの際にbundle installをフルで行わなくてよくなるなどのメリットがあります。
実際に開発してると、レビューなどの際にgemを追加したブランチに切り替えてbuild→自分のブランチに戻ってbuildなどで毎回フルインストールが走るのが地味にめんどくさいです。

data volume containerの追加

docker-compose.yml
version: '2'
services:
+  datastore:
+    container_name: myrails-development-datastore
+    image: busybox
+    volumes:
+      - bundle_install:/usr/local/bundle
+      - mysql:/var/lib/mysql
+
  database:
    container_name: myrails-development-database
    image: mysql:5.7
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
+    volumes_from:
+      - datastore
    ports:
      - 3306:3306

  rails:
    container_name: myrails-development-rails
    image: myrails-development
    build:
      context: .
      dockerfile: Dockerfile.development
    command: rails server -b 0.0.0.0
    environment:
      RAILS_ENV: development
      DATABASE_URL: mysql2://root@database:3306
+    volumes_from:
+      - datastore
    volumes:
      - .:/usr/src/app
    depends_on:
      - database
    ports:
      - 3000:3000
+
+ volumes:
+  mysql:
+    driver: local
+  bundle_install:
+    driver: local

解説

testではクリーンな環境にするためにdatabaseコンテナを毎回破棄しているので、data volume containerを使っていません。

bundle_installのvolume先が /usr/local/bundle なのは、Dockerhubのruby公式イメージのインストール先がそこのためです(この辺のこと)。
.bundle なども同じところにおいてあるため、そこをキャッシュします。

entrykit, dockerizeでDBの起動待ちをする

Dockerで気をつけないといけないことの一つに、コンテナの起動=サービスの起動ではない点があります。
特に開発環境やテスト実行時には、migrationの際にデータベースがまだ起動しておらず失敗するケースがあります。

解決策の一つとして docker-compose up -d database などとして先に起動してからsleepしてmigrationを行うというのがありますが、entrykitとdockerizeを使うことでそこを簡略化・自動化することが出来ます。

entrykitについてはEntrykitのすすめという非常にわかりやすい記事があるのでそちらを参考にしてください。
dockerizeについては記事が少ないので少し解説します。

dockerizeは公式のControlling startup order in Composeでも紹介されているツールで、applicationのdocker化(dockerize)をする際に便利な機能を提供してくれるものです。
機能がいくつかありますが、その1つに「tcp connectionをチェックして応答を待つ」機能があるのでそれを利用します。

development

Dockerfile.development
 FROM ruby:2.3.3

 ENV APP_ROOT /usr/src/app
+ENV DOCKERIZE_VERSION v0.3.0
+ENV ENTRYKIT_VERSION 0.4.0
+
+RUN apt-get update && apt-get install -y wget ca-certificates openssl \
+        # dockerize
+        && wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
+        && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
+        && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
+        # entrykit
+        && wget https://github.com/progrium/entrykit/releases/download/v${ENTRYKIT_VERSION}/entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
+        && tar -xvzf entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
+        && rm entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
+        && mv entrykit /bin/entrykit \
+        && chmod +x /bin/entrykit \
+        && entrykit --symlink

 WORKDIR $APP_ROOT
+
+ENTRYPOINT [ \
+  "prehook", "bundle install -j3", "--", \
+  "prehook", "dockerize -timeout 60s -wait tcp://database:3306", "--" \
+]
docker-compose.yml
   rails:
     image: myrails-development
     //...
     environment:
       RAILS_ENV: development
       DATABASE_URL: mysql2://root@database:3306/sample_database
+    depends_on:
+      - database
     volumes_from:
       - datastore
     volumes:

test

Dockerfile.test
 ENV APP_ROOT /usr/src/app
+ENV DOCKERIZE_VERSION v0.3.0
+ENV ENTRYKIT_VERSION 0.4.0
+
+RUN apt-get update && apt-get install -y wget ca-certificates openssl \
+        # dockerize
+        && wget https://github.com/jwilder/dockerize/releases/download/$DOCKERIZE_VERSION/dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
+        && tar -C /usr/local/bin -xzvf dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
+        && rm dockerize-linux-amd64-$DOCKERIZE_VERSION.tar.gz \
+        # entrykit
+        && wget https://github.com/progrium/entrykit/releases/download/v${ENTRYKIT_VERSION}/entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
+        && tar -xvzf entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
+        && rm entrykit_${ENTRYKIT_VERSION}_Linux_x86_64.tgz \
+        && mv entrykit /bin/entrykit \
+        && chmod +x /bin/entrykit \
+        && entrykit --symlink

 WORKDIR $APP_ROOT

 COPY . $APP_ROOT

 EXPOSE 3000

+ENTRYPOINT [ \
+  "prehook", "dockerize -timeout 60s -wait tcp://database:3306", "--"
+]
+
 CMD ["rspec"]
docker-compose.test.yml
     build:
       context: ../ # ディレクトリを掘っているため
       dockerfile: Dockerfile.test
+    depends_on:
+      - database
     environment:
       RAILS_ENV: test
       DATABASE_URL: mysql2://root@database:3306/sample_database

解説

DockerfileではENTRYPOINTにentrykitのprehookを利用してdockerizeでの起動待ちを仕込み、docker-composeで depends_on しておきます。

こうしてdatabaseコンテナを先に起動させてからdockerizeで起動待ちをすることで、起動コマンドを docker-compose up に集約させたり、コマンド実行の際にdatabaseコンテナが起動してるかを気にせずに docker-compose run などを行うことが出来るようになります。
以下は起動待ちしている様子です。

rails.gif

また、 Dockerfile.test でprehookでdb:createなどを行っても良いですが、テスト時にrubocopなどを実行する際に毎回 db:create db:schema:load が走ると時間がかかってしまうので、prehookではなくテストスクリプト内で実行するようにします。

ラッパースクリプトを書く

毎回 docker-compose run --rm rails hogehoge などとしているとめんどくさいです。
特にテスト用は docker-compose -f dockerfiles/docker-compose.test.yml --rm rails で履歴から引っ張ってくるのもめんどいですしちょっとやってられませんのでラッパースクリプトを書きます。

ラッパースクリプトを書くことで、チーム内でのdocker-composeの取扱いを共通化出来たり、習熟度の低いメンバーには抽象化レイヤとして機能するなどのメリットがあります。

今回はbin/sampleとしてシェルスクリプトを追加します。
設置場所やスクリプト名などは適宜変えてください。アプリケーション名にするとスクリプトにも愛着が湧くかも。

wrapper script

ちょっと長いので適宜読み飛ばしてください:)
プロジェクトで使ってるやつを参考に新規で書きましたが、プロジェクトではrubocopの実行やannotateの差分チェックなどがテストに追加されていたり、ビルド前に必要なものの生成などがここに入ってきます。

bin/sample
#!/bin/bash

set -eu

RAILS_ROOT=$(cd $(dirname $0)/../ && pwd)
DEV_COMPOSE_FILE="${RAILS_ROOT}/docker-compose.yml"
TEST_COMPOSE_FILE="${RAILS_ROOT}/dockerfiles/docker-compose.test.yml"

function usage() {
  cat <<EOF
Usage:
  $(basename ${0} [command])

Commands:
  init        初期化する
  build       イメージをビルドする
  clean       コンテナをすべて破棄する
  start       サーバをバックグラウンドで起動する
  stop        サーバを停止する
  status      コンテナの起動状況を見る
  restart     サーバを再起動する
  logs        バックグラウンドで起動しているサーバのログを見る
  up          サーバをフォアグラウンドで起動する
  test        テストを実行する
EOF
}

function build() {
  docker-compose build
}

function init() {
  docker-compose down --volumes --remove-orphans --rmi all
  docker-compose build
  docker-compose run --rm rails rails db:create db:schema:load
}

function clean() {
  docker-compose stop
  docker-compose rm --force
  docker-compose down --volumes --remove-orphans --rmi all

  docker-compose -f $TEST_COMPOSE_FILE stop
  docker-compose -f $TEST_COMPOSE_FILE rm --force
  docker-compose -f $TEST_COMPOSE_FILE down --volumes --remove-orphans --rmi all
}

function start() {
  docker-compose up -d rails
}

function stop() {
  docker-compose stop rails
}

function logs() {
  docker-compose logs rails
}

function restart() {
  start
  stop
}

function status() {
  docker-compose ps
}

function up() {
  docker-compose up rails
}

function run_test() {
  export COMPOSE_FILE=$TEST_COMPOSE_FILE

  docker-compose down --volumes --remove-orphans
  docker-compose build
  docker-compose run --rm rails rails db:create db:schema:load
  docker-compose run --rm rails rspec
}

shift `expr $OPTIND - 1`

if [ $# -eq 0 ];then
  usage
  exit 1
fi

export COMPOSE_FILE=$DEV_COMPOSE_FILE

case "${1}" in
  "init")
    init
  ;;

  "build")
    build
  ;;

  "clean")
    clean
  ;;

  "start")
    start
  ;;

  "stop")
    stop
  ;;

  "status")
    status
  ;;

  "restart")
    restart
  ;;

  "logs")
    logs
  ;;

  "up")
    up
  ;;

  "test")
    run_test
  ;;

  *)
    usage
    exit 1
  ;;
esac

解説

このscriptを追加することで bin/sample init してから bin/sample start するだけでサーバの起動を行うことが出来ます。
別にthorとか使ってrubyで書いても良いんですが、やりたいことに対してちょっと大げさすぎるのでシェルで書いてます。

テスト実行はここじゃなくて別にスクリプトを追加しても良いですが、なにかしらまとめておくことでテスト実行のプロセスをチームで共通化出来て良いと思います。
pre-pushのgit hookを用意して bin/sample test しとくとかのアプローチも有効ですね。

springを有効にする

rails開発で便利なspringですが、このままだと使うことが出来ません。
そこで、spring用のサービスを追加します。

springサービスを追加する

docker-compose.yml
     ports:
       - 3306:3306

-  rails:
+  rails: &rails
     container_name: myrails-development-rails
     image: myrails-development
     build:
       context: .
       dockerfile: Dockerfile.development
-    environment:
+    environment: &rails_environment
       RAILS_ENV: development
       DATABASE_URL: mysql2://root@database:3306/sample_database
     depends_on:
      - database
     volumes_from:
       - datastore
     volumes:
        - .:/usr/src/app
     ports:
       - 3000:3000
+    tty: true
+    stdin_open: true
+
+  spring:
+    <<: *rails
+    container_name: myrails-development-spring
+    command: spring server
+    environment:
+      <<: *rails_environment
+      SPRING_SOCKET: /tmp/spring.sock
+    ports: []

 volumes:
   mysql:
bin/sample
   restart     サーバを再起動する
   logs        バックグラウンドで起動しているサーバのログを見る
   up          サーバをフォアグラウンドで起動する
+  exec        springコンテナでコマンドを実行する
   test        テストを実行する
 EOF
 }
@@ -62,6 +63,34 @@ function restart() {
   stop
 }

+function execute_command() {
+  local pid=$(docker-compose ps -q spring)
+
+  if [ -z "$pid" -o $(docker ps --quiet --filter id=${pid} | wc -l) -eq 0 ];then
+    docker-compose up -d spring
+
+    local message="waiting for spring start"
+
+    while true
+    do
+      log=$(docker-compose logs spring | grep "started on /tmp/spring.sock" | wc -l)
+
+      if [ $log -ge 1 ];then
+        echo "spring the started!"
+        break;
+      fi
+
+      message="$message."
+      echo -e "\r$message\c"
+
+      sleep 1
+    done
+  fi
+
+  docker-compose exec spring $@
+  exit $?
+}
+
 function status() {
   docker-compose ps
 }
@@ -129,6 +158,12 @@ case "${1}" in
     run_test
   ;;

+  "exec")
+    shift
+
+    execute_command $@
+  ;;
+
   *)
     usage
     exit 1

解説

ttyとstdin_openをtrueにしているのは、 binding.pry などを使うのに必要なためです。
(詳しくはこちらのStackOverFlowの回答を参考に)

springのサービスはrailsとほぼ同じ構成ですが、起動コマンドが spring server になっている点、portのバインドを行っていない点が異なります。
docker-compose up -d spring で起動して docker-compose exec spring rails console などとするとspringコンテナ上でコマンドが実行されます。
bundle install などもこっちでやったほうが楽です。 guard を使う場合もspringコンテナ上で起動します。

めんどいのでスクリプト側で自動化して bin/sample exec rails console などで実行できるようにしていますが、起動確認のところが苦しいので良い感じにする方法があったら教えてください…

ビルドタグをつけやすくする

最後に、ビルドタグをつけやすくします。
ビルドタグをつけやすくすることで、CIなどでイメージやコンテナの切り替えを楽にします。
CIでイメージやコンテナの破棄で悲惨なことにならないように必要なやつです。

少し工夫すると並列実行などにも応用できますが、ここでは触れないでおきます。
(というかまだ並列実行への対応が出来ていないので、こうしたら良さそうとかしか話せません…)

${BUILD_TAG}を追加する

bin/sample
 DEV_COMPOSE_FILE="${RAILS_ROOT}/docker-compose.yml"
 TEST_COMPOSE_FILE="${RAILS_ROOT}/dockerfiles/docker-compose.test.yml"

+export BUILD_TAG=${BUILD_TAG:-'latest'}
+
 function usage() {
   cat <<EOF
 Usage:
docker-compose.yml
 version: '2'
 services:
   datastore:
-    container_name: myrails-development-datastore
+    container_name: myrails-development-datastore-${BUILD_TAG}
     image: busybox
     volumes:
       - mysql:/var/lib/mysql
       - bundle_install:/usr/local/bundle

   database:
-    container_name: myrails-development-database
+    container_name: myrails-development-database-${BUILD_TAG}
     image: mysql:5.7
     environment:
       MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
@@ -18,8 +18,8 @@ services:
       - 3306:3306

   rails: &rails
-    container_name: myrails-development-rails
-    image: myrails-development
+    container_name: myrails-development-rails-${BUILD_TAG}
+    image: myrails-development:${BUILD_TAG}
     build:
       context: .
       dockerfile: Dockerfile.development
@@ -39,7 +39,7 @@ services:

   spring:
     <<: *rails
-    container_name: myrails-development-spring
+    container_name: myrails-development-spring-${BUILD_TAG}
     command: spring server
     environment:
       <<: *rails_environment
docker-compose.test.yml
 version: '2'
 services:
   database:
-    container_name: myrails-test-database
+    container_name: myrails-test-database-${BUILD_TAG}
     image: mysql:5.7
     environment:
       MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'

   rails:
-    container_name: myrails-test-rails
-    image: myrails-test
+    container_name: myrails-test-rails-${BUILD_TAG}
+    image: myrails-test:${BUILD_TAG}
     build:
       context: ../
       dockerfile: Dockerfile.test

解説

特に解説することもないですね…。

変数名が $BUILD_TAG なのは、jenkinsの環境変数を流用するためです。
こちらのissueを参考にしました

ちなみに、docker-compose 1.9.0から対応しているv2.1formatではデフォルト値を設定できるようになったみたいです
(早くDocker for macも対応してくれないかな……)

まとめ

以上、Dockerで快適なRails開発環境を手に入れるためにやった6つのことでした。
「こっちではこんな風にしてる」とか「こういうときどうしてる?」とかあれば気軽にコメントください。

参考

参考にさせていただいた記事の皆様にはこの場を借りてお礼申し上げます。ありがとうございます :bow:

その他

なんでdownとかrmとかに執拗に--volumes指定してるの?

CIで自動的に作成されたdocker volumeがJenkinsスレーブのディスク容量を枯渇させるという事件があったので毎回消すようにしてます。

guardがなんか動くけど変更を検知してくれないんだけど…

guard --force-polling すれば動きます。