Docker に Gemfile.lock を含む Ruby アプリケーションを良い感じに乗せる。
概要
docker image build
で毎回bundle install
が走ると遅くなりがちなので、なるだけキャッシュを効かせたい。よく変更されるアプリケーション部分の
COPY
よりも先に Gemfile と Gemfile.lock だけCOPY
しておいてRUN bundle install
するようなDockerfile
にしておく手法が良さそうだと思う。先に Gemfile.lock を生成しておく必要があるので、
bundle install
を走らせて Gemfile.lock を取り出すスクリプトを書いた。alpine ベースのイメージを使うと軽い。罠も無くは無いけど。
あと細かい知見とかを書いた。
詳細
/path/to/your/great/app
が以下のような構成になってるとして
$ tree /path/to/your/great/app
/path/to/your/great/app
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── app
│ └── app.rb
└── docker-entrypoint.sh
Dockerfile
をこう書いたとする。
FROM ruby:latest
ENV APP_ROOT /path/to/your/great/app
RUN mkdir -p $APP_ROOT
WORKDIR $APP_ROOT
COPY ./ .
RUN bundle install --path vendor/bundle
ENTRYPOINT ["./docker-entrypoint.sh"]
COPY ./ .
の後に RUN bundle install
があるので、Gemfile を弄ってなくてもアプリをちょっと変更する度に docker image build
のキャッシュが無効になってしまい、毎回 bundle install
が走ってしまう。
毎回 native extension
の build が走ったりするのは個人的には許容出来ない。時間も掛かるし、エコでもない。
そうすると大体以下の様に先に Gemfile と Gemfile.lock を COPY
して、先に RUN bundle install
するような Dockerfile
を書くようになると思う。
FROM ruby:latest
ENV APP_ROOT /path/to/your/great/app
RUN mkdir -p $APP_ROOT
WORKDIR $APP_ROOT
COPY ./Gemfile Gemfile
COPY ./Gemfile.lock Gemfile.lock
COPY ./.bundle .bundle
RUN bundle install --path vendor/bundle
COPY ./ .
ENTRYPOINT ["./docker-entrypoint.sh"]
Gemfile.lock を先に生成するには。
- 使い捨てのコンテナを作る。
- ホストから Gemfile をコンテナに
docker cp
する。 - 使い捨てコンテナの中で
bundle install
をdocker exec
する。 - コンテナ内に生成された Gemfile.lock を
docker cp
でホストに回収して終わり。
という内容のシェルスクリプトを書いて使っている。
本当は引数を取るように書いた方がいいんだろうけど面倒で毎回中を書き換えて使ってる。後で何とかします。
今気付いたけど .bundle ディレクトリ取り出してなかった。後で何とかします。
#!/usr/bin/env bash
RUBY_IMAGE_TAG="2.5.0"
GEMFILE_DIR="/path/to/your/great/app"
if [ ! -s "$GEMFILE_DIR"/Gemfile ]; then
echo "$GEMFILE_DIR""/Gemfile not found." 1>&2
exit 1
fi
NAME="gemfile_lock_generator_$(date '+%Y%m%d_%H%M%S')"
W_DIR="/opt/app/gemfile_lock_generator"
docker container create -it --name "$NAME" --rm ruby:"$RUBY_IMAGE_TAG"
docker container start "$NAME"
docker container exec -t "$NAME" mkdir -p "$W_DIR"
docker container cp "$GEMFILE_DIR"/Gemfile "$NAME":"$W_DIR"/Gemfile
docker container exec -t --workdir "$W_DIR" "$NAME" bundle install --path vendor/bundle
docker container cp "$NAME":"$W_DIR"/Gemfile.lock "$GEMFILE_DIR"/Gemfile.lock
docker container stop "$NAME"
いつの頃からか Docker にサブコマンドが導入されて本当に分かりやすくなった。
ターミナルでコマンドを直打ちするときは手癖で docker ps -a
とか docker images
とか docker rmi
とか打ってしまうけど、シェルスクリプトにまとめるときなどは意識的にサブコマンドを書くようにしている。
bundle platform
問題の話。
Gemfile.lock を生成したマシンと実行するマシンの bundle platform
が違っていると bundle install
時のなんらかの native extension
ビルドでコケる事例がある。
# コンテナ内に入って実行
$ bundle platform
Your platform is: x86_64-linux
# macOS 上で直接実行
$ bundle platform
Your platform is: x86_64-darwin16
ホストの macOS で生成された Gemfile.lock を安易に持っていくと死ぬ場合があるので大人しく同じ環境で生成している。
Mac 上で開発してた Rails アプリを本番サーバーに持っていこうとしたらなんか上手くいかないみたいなやつの Docker 版みたいなことが起こる訳だ。
本来 Docker 自体は x86_64-linux
に限定されたものではなく、armv7l
な Raspberry Pi などでも動くには動くのだが、狭義の Docker というと暗黙の了解で x86_64-linux
が前提であることが多い。
そのため Docker Hub に置いてある Raspberry Pi 向けのイメージには rpi-
などの prefix を付けるような文化になっていて涙ぐましい気持ちになったりもする。
ベースイメージのタグでメジャーバージョン指定しておく。
実際に使うには FROM
で引いてくるイメージのタグは詳細に指定した方が良いと思われる。latest
だとうっかりメジャーバージョンが上がってしまう。
指定に有効なタグ一覧は公式 https://hub.docker.com/_/ruby/ を見ましょう。
基本的にはバージョンアップには追従する姿勢だけど、CI で Docker イメージビルドを自動化してるような環境だと稀に事故が起こる。
alpine ベースにすると軽い。
Docker あるあるとして alpine
ベースのイメージがあればそちらを使うことによって容量をかなり節約出来る。
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
ruby 2.5.0-alpine3.7 42abb49f8ada 7 days ago 60.7MB
ruby 2.5.0 e8d112ff7588 7 days ago 869MB
通常だとベースイメージで 869 MB もあるところが 60.7 MB になる訳なので効果は大きい。
しかしまぁ alpine
ベースにすると色々なものがなくて不便なこともある。
例えば tzdata
などが入っていないため、 ENV['TZ'] = 'Asia/Tokyo'
の様な指定が出来なくなる。この場合は tzdata
を自前で入れるか、諦めて ENV['TZ'] = 'GMT-9'
などと記述すれば良い。
個人的にはタイムゾーン周りは政治と歴史の都合で完全な闇だと思っているので 'GMT-9'
みたいな指定の方が事故らないのではと思う。最近も Qiita で タイムゾーン呪いの書 などがバズっていたけど、ここまでまとまってる情報を前にただただ圧巻という他なかったです。すごい。
他にも bash
や make
や gcc
が入っていない。これらを入れておかないと native extension
のビルドが必要な場合の bundle install
でコケる。
gcc
libc-dev
musl-dev
g++
あたりは必要だったり不要だったりアンインストールしてはいけない場合があるので、最小構成で作ってみてエラーが出たら必要なのを足すみたいな感じにしてる。
という訳で最近雛形として使ってる Dockerfile
はこんな感じです。
FROM ruby:2.5.0-alpine3.7
ENV APP_ROOT /path/to/your/great/app
RUN mkdir -p $APP_ROOT
WORKDIR $APP_ROOT
COPY ./Gemfile Gemfile
COPY ./Gemfile.lock Gemfile.lock
COPY ./.bundle .bundle
RUN apk add --no-cache --virtual .bundledeps musl-dev make gcc libc-dev && \
apk add --no-cache g++ bash && \
bundle install --path vendor/bundle && \
apk del .bundledeps && \
rm -rf /var/cache
COPY ./ .
ENTRYPOINT ["./docker-entrypoint.sh"]
締め
Docker の世界観にも色々ややこしさはあるものの、各種 LL を管理するために入れた なんちゃらenv
系は壊れるときによくシステムを巻き込むことを考えると、環境を Docker に閉じ込められる方が幸せだと感じている。
特に LL の環境構築のベストプラクティスを理解することは昔は難しかったと思うのだが、今は Docker Hub にある公式の Dockerfile
を見れば良くなった。ありがたいことです。
Docker for Mac は結局 macOS の中で仮想マシンを動かしてるし、ホストとゲスト間のファイル共有が死ぬほど遅い問題のせいでボリューム機能が死ぬほど遅くて辛い話とかもあって、それなら virtualbox
+ vagrant
で良いじゃんみたいな気持ちも無くは無いんだけど、まぁ時と場合によって両方使ってます。
他にも /(?:機械|強化|深層)*学習/
系のタスクを解くためにマシンパワーを限界まで使いたいみたいな時は、仮想化レイヤやコンテナレイヤを挟むことによるオーバーヘッドが無視できない場合もあって、そういう時は大人しくホストの環境を汚しましょう。