はじめに
チーム開発のメンバーからある日こんな要望が。
「docker-compose upした後に毎回rails db:create, rails db:migrateするの面倒すぎる。なんとかしろ。」
とのお達しがあったので、主にDocker、CircleCI、AWS周りを担当する僕が対応することとなりました。
僕も元々Railsからプログラマーの世界に飛び込んでいるので、Railsを書いていた時は思考停止して一つ一つのコマンドを叩いていたのですが、
確かにこれ面倒臭い...。
ということでさっさと自動化していきましょう〜!
コマンド一発でrails db:createするには?
今回実現すること
今まで、
docker-compose up
docker-compose exec api bin/rails db:create
docker-compose exec api bin/rails db:migrate
と順番にコマンドを叩いていたところを、
docker-compose up
だけで上記の3つのコマンドを叩いたのと同じ状態にします。
とりあえずやってみた
だいたいdocker-compose.ymlいじれば行けそうな気がしたんで、早速修正。
元のdocker-compose.ymlがコチラ。
version: "3"
services:
db:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: root
ports:
- "3306:3306"
api:
build: .
depends_on:
- db
command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/languageMemoApp
ports:
- "3000:3000"
tty: true
修正したものがコチラ。
version: "3"
services:
db:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: root
ports:
- "3306:3306"
api:
build: .
depends_on:
- db
command: /bin/sh -c "rm -f tmp/pids/server.pid && bundle exec rails db:create && bundle exec rails db:migrate && bundle exec rails s -p 3000 -b '0.0.0.0'"
volumes:
- .:/languageMemoApp
ports:
- "3000:3000"
tty: true
commandの部分にギチギチに詰め込んでみました。
見栄えは悪いですが、これで実現できるんじゃないか...!
と思ったのですが、これだとエラーが発生します。
エラーの内容は下記の通りです。
api_1 | Can't connect to MySQL server on 'db' (111 "Connection refused")
DBコンテナへのコネクションエラーです。
ただ、このエラー、コンテナの初回起動時のみ発生して、もう一度docker-compose upすると普通に起動できるようになるんです。
上記のdocker-compose.ymlでも、depends_onでDBコンテナへの依存関係は定義しているはずだし、DBコンテナの外部ポートも開けてる...。
それに、DBコンテナが起動していない状態なら、dbというホストが見つからないよ!とうエラーが出るはずなので、DBコンテナは問題なく起動できているっぽい。
原因が分からず、チームメンバーのDockerに詳しい方に相談しつつ1日半がたったある日、衝撃の事実を知りました。
depends_onは、起動順序だけを管理しており、コンテナが起動し終えるのを待ってくれない。
コチラをご覧ください。
Compose はコンテナの準備が「整う」まで待ちません(つまり、特定のアプリケーションが利用可能になるまで待ちません)。単に起動するだけです。
とのことです。。初めて知りました。
上記のdocker-compose.ymlで初回のみDBコネクションエラーが起きるのは、DBが起動を完了するのを待たずにAPIコンテナが起動し、rails db:createコマンドを実行していたため、エラーが発生していた模様です。
DBコンテナが起動し終えてからAPIコンテナを起動するには?
上記の公式ドキュメントに方法が書いてありました。
wait-for-it や dockerize のようなツールを使います。これらはラッパー用のスクリプトであり、アプリケーションのイメージに含めることができます。また特定のホスト側のポートに対して、TCP 接続を受け入れ可能です。
今回はwait-for-itを使ってみることにしました。
wait-for-itのリポジトリはコチラ
ここに使い方云々は書いてあるので、その通りに使っていきます。
- wait-for-itリポジトリをローカルにクローンする
- wait-for-it.shをrailsプロジェクトのあるルートディレクトリ直下に配置
- wait-for-it.shのwait_for_wrapperメソッドに下記を追加
rm -f tmp/pids/server.pid && bundle exec rails db:create && bundle exec rails db:migrate && bundle exec rails s -p 3000 -b '0.0.0.0'
wait-for-it.shの完成形は下記の通りです。
wait_for_wrapper()
{
# In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
if [[ $WAITFORIT_QUIET -eq 1 ]]; then
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
else
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
fi
WAITFORIT_PID=$!
trap "kill -INT -$WAITFORIT_PID" INT
wait $WAITFORIT_PID
WAITFORIT_RESULT=$?
if [[ $WAITFORIT_RESULT -ne 0 ]]; then
echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
fi
rm -f tmp/pids/server.pid && rails db:create && rails db:migrate && rails s -p 3000 -b '0.0.0.0'
return $WAITFORIT_RESULT
}
- docker-compose.ymlから、起動時に作成したシェルファイルを叩くようにする
entrypoint: ./wait-for-it.sh db:3306
引数のdb:3306はwait-for-it.shに書いてあったので指定しました。
docker-compose.ymlの完成形が下記の通りです。
version: "3"
services:
db:
image: mysql:5.7
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: root
ports:
- "3306:3306"
api:
build: .
depends_on:
- db
entrypoint: ./wait-for-it.sh db:3306
volumes:
- .:/languageMemoApp
ports:
- "3000:3000"
tty: true
これで無事、docker-compose upコマンド一発でDBの作成、マイグレーションまでを行うことに成功しました!!
おわりに
今回はチームメンバーからの要望を無事に実現できたわけですが、これがベストプラクティスでは無いような気がします...。(特にシェルスクリプトの部分とか)
この記事をご覧になった有識者の方、是非是非ご指摘ください。