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 を先に生成するには。

  1. 使い捨てのコンテナを作る。
  2. ホストから Gemfile をコンテナに docker cp する。
  3. 使い捨てコンテナの中で bundle installdocker exec する。
  4. コンテナ内に生成された 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 で タイムゾーン呪いの書 などがバズっていたけど、ここまでまとまってる情報を前にただただ圧巻という他なかったです。すごい。

他にも bashmakegcc が入っていない。これらを入れておかないと 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 で良いじゃんみたいな気持ちも無くは無いんだけど、まぁ時と場合によって両方使ってます。

他にも /(?:機械|強化|深層)*学習/ 系のタスクを解くためにマシンパワーを限界まで使いたいみたいな時は、仮想化レイヤやコンテナレイヤを挟むことによるオーバーヘッドが無視できない場合もあって、そういう時は大人しくホストの環境を汚しましょう。