Rails
docker
dockerfile
docker-compose
Docker2Day 14

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

More than 1 year has passed since last update.


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 すれば動きます。