何度か Rails の本番環境を Docker で構築してきて、イメージの設計図ともいえる Dockerfile
も段々と洗練され、ここしばらくで一番シンプルなものに落ち着いた。シンプルでありながら、イメージサイズをコンパクトに、またビルド時間もなるべく抑えられるバランスの良い構成にできたので、紹介しようと思う。
方針
シンプルな Dockerfile
を目指しつつも、十分実用(本番環境用)として使えることを意識している。具体的には、以下のような方針:
-
Dockerfile
をなるべくシンプルに保つ → 読みやすく、保守しやすいように- 命令は基本的に最低限かつ素直に書いて、Docker特有の「トリック」は必要なときにだけ使う
-
Dockerfile
が複雑になりがちなマルチステージビルドなどは、今回は不採用
-
イメージサイズをなるべく小さくする(が、最小にはこだわらない) → デプロイ時間の短縮、ストレージの節約のため
- 不要なパッケージやキャッシュ等は極力削除する
- ただし、
Dockerfile
が複雑になる場合は、イメージサイズよりDockerfile
のシンプルさを優先 - 参考:
rails new
しただけのプロジェクトのイメージサイズは415MB(詳細は後述)
-
ビルドキャッシュを効きやすくする → ビルド時間の短縮のため
- ビルドに一番時間がかかるOSパッケージやgemのインストールは極力スキップする(使用するgemに変更がない場合)
-
実用でよく使うWebpacker、Active Storage等の機能も考慮する
- アセットのプリコンパイル、ImageMagickのインストールなど
Dockerfile全体
まず Dockerfile
の全体を以下に記載する。コメントや空行を除くと32行というシンプルな構成。それぞれの命令の詳しい解説は後述する。
※Ruby 2.6.5 / Rails 6.0 / PostgreSQL を前提としているが、依存パッケージ等を変更すれば他の環境でも利用可能なはず。
FROM ruby:2.6.5-alpine
WORKDIR /app
ENV RAILS_ENV="production"
ENV NODE_ENV="production"
# 依存パッケージのインストール
COPY Gemfile Gemfile.lock /app/
RUN apk add --no-cache -t .build-dependencies \
build-base \
libxml2-dev\
libxslt-dev \
&& apk add --no-cache \
bash \
file \
imagemagick \
libpq \
libxml2 \
libxslt \
nodejs \
postgresql-dev \
tini \
tzdata \
yarn \
&& gem install bundler:2.0.2 \
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
&& apk del --purge .build-dependencies
# アプリケーションコードのコピー
COPY . /app
# アセットのプリコンパイル
RUN SECRET_KEY_BASE=placeholder bundle exec rails assets:precompile \
&& yarn cache clean \
&& rm -rf node_modules tmp/cache
# ランタイム設定
ENV RAILS_SERVE_STATIC_FILES="true"
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0", "-p", "3000"]
EXPOSE 3000
Dockerfile全解説
ベースイメージと基本設定
FROM ruby:2.6.5-alpine
WORKDIR /app
ENV RAILS_ENV="production"
ENV NODE_ENV="production"
ベースイメージには、Docker 公式の Ruby イメージを使用。この例では Ruby のバージョンは 2.6.5 を使っているが、もちろん他のバージョンでも構わない。タグに -alpine
とつくイメージは Alpine Linux ベースで、サイズが 50MB 程度とコンパクトなので、最終的なイメージサイズの削減に寄与する。
その他、簡単な基本設定をここで。アプリケーションコードは /app
に置く想定なので、そこをワーキングディレクトリにする。本番環境向けに環境変数 RAILS_ENV
, NODE_ENV
を設定する。
依存パッケージのインストール
COPY Gemfile Gemfile.lock /app/
RUN apk add --no-cache -t .build-dependencies \
build-base \
libxml2-dev\
libxslt-dev \
&& apk add --no-cache \
bash \
file \
imagemagick \
libpq \
libxml2 \
libxslt \
nodejs \
postgresql-dev \
tini \
tzdata \
yarn \
&& gem install bundler:2.0.2 \
&& bundle install -j$(getconf _NPROCESSORS_ONLN) --deployment --without test development \
&& apk del --purge .build-dependencies
冒頭の COPY
で、bundle install
に必要な Gemfile
, Gemfile.lock
をイメージにコピー。依存パッケージのインストールがビルドで最も時間がかかるフェーズなので、なるべくビルドキャッシュが効くように、必要最低限のファイルのみコピーする。
残りの行は、Docker 特有の「トリック」を使っている部分の1つで、大きな1つの RUN
命令になっている。ここでOSのパッケージインストールと、bundle install
をすべて一気にやる。1つの命令にまとめているのは、bundle install
した後、ランタイムに不要なOSパッケージをアンインストールしてイメージの容量を削減するため1 2。
apk
は Alpine Linux のパッケージマネージャ。apk
でインストールするのは、ネイティブ拡張付きの gem をコンパイルするのに必要なコンパイラ群、PostgreSQL, Actvie Storage, Nokogiri 等に必要なコマンドやライブラリ、Webpack の実行に必要な Node.js など。-t
オプションで複数のパッケージをまとめて「タグ」をつけることができ、後からそのタグを指定して一括アンインストールができて便利。
アプリケーションコードのコピー
COPY . /app
Rails アプリケーションのコード(.
にある想定)をすべてイメージ内の /app
にコピーする。ほとんどのケースでアプリケーションコードになんらかの変更があると考えられるため、このフェーズ以降はビルドキャッシュが効きにくい。
アセットのプリコンパイル
RUN SECRET_KEY_BASE=placeholder bundle exec rails assets:precompile \
&& yarn cache clean \
&& rm -rf node_modules tmp/cache
rails assets:precompile
を実行する。Webpacker を使っている場合は、このときに yarn install
や Webpack のビルドなども同時に行われる。SECRET_KEY_BASE
は、適当な値をセットしておかないと Missing `secret_key_base`
エラーになってしまう。
コンパイルが終わったら、不要なキャッシュと node_modules
を消してしまう。node_modules
は、アセットをビルドした後なら削除してOK。これでだいぶイメージの容量が減る。ここも「トリック」を使っている部分で、不要なファイルの削除までを1つの RUN
命令にまとめて、イメージサイズを抑えている。
ランタイム設定
ENV RAILS_SERVE_STATIC_FILES="true"
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["bundle", "exec", "rails", "server", "-b", "0.0.0.0", "-p", "3000"]
EXPOSE 3000
環境変数 RAILS_SERVE_STATIC_FILES
は、Rails が静的アセットも配信するかどうかの設定。このイメージ単体で Web サーバーとして完結するように、デフォルトでは ON にしている。
エントリポイントには tini を使っている。tini は軽量な init の実装で、ゾンビプロセスの回収やシグナルの転送を行なってくれるプログラムである3。Mastodon で採用されているのを参考にした。
残りはデフォルトのコマンド(サーバー起動)の設定と、露出ポートの設定。
参考: ビルド時間とイメージサイズ
参考として、手元の環境で rails new
しただけのプロジェクトを、上記の Dockerfile
で docker build
したときの結果を以下に記載する。
$ rails -v
Rails 6.0.2.1
$ rails new sample-rails-project --database=postgresql
$ cd sample-rails-project
$ cp /path/to/Dockerfile /path/to/.dockerignore .
$ time docker build --no-cache .
...
Successfully built c88f89af77e5
docker build --no-cache . 1.81s user 1.40s system 0% cpu 6:05.71 total
$ docker images | grep c88f89af77e5
<none> <none> c88f89af77e5 8 minutes ago 415MB
ビルド時間は、手元の環境では約6分だった。環境の詳細は以下を参照。
イメージサイズは415MBとなった。もちろん環境やインストールする gem、NPM パッケージなどによって変わってくるが、最小構成ではこのくらいコンパクトになる。
環境:
- macOS 10.15
- Docker Desktop 2.1.0.5
- MacBook Pro 13-inch, 2016
- Dual-Core Intel Core i7 3.3GHz
- Memory 16GB
まとめ
シンプルでありながら、本番環境向けに十分実用できる Rails の Dockerfile
を作成した。プロジェクトによっては不足するパッケージや処理などもありそうだが、シンプルなのでこれをベースにしたカスタマイズもしやすいと思う。
アプリケーションコードに変更があるとアセットのプリコンパイルが毎回必要だったり、まだランタイムに不要なパッケージを完全に除去できていないなどの課題はあるため、シンプルさを損なわない範囲でできることがあれば改善していきたい。マルチステージ化は方向性の一つと考えている。
参考資料
-
Mastodon v2.7 までの Dockerfile
- ある程度の規模のRailsアプリのDockerfileのプラクティスが詰まっていて参考になる。
- v2.8 からUbuntuベースになり、ステージも増えて少し複雑化している。
- RailsのDockerイメージを一番小さくする方法
- Dockerfile を書くためのベストプラクティス解説編
-
Dockerのイメージサイズは、各レイヤー(
COPY
,RUN
などの命令ごとに1レイヤーが作られる)のサイズの合計になる。そのため、1レイヤーごとのサイズを小さく保つことが、最終的なイメージサイズの削減に寄与する。 ↩ -
厳密には
nodejs
とyarn
はアセットプリコンパイル以降は不要だが、プリコンパイルまで含めて1命令にしてしまうとビルドキャッシュが効きにくくなるため、今回はインストールしたままにしている。このあたりはマルチステージにするのがうまい解決策だろうか。 ↩ -
余談だが、以下のIssueでtiniの作者がinitの役割を詳しく解説していて参考になる: https://github.com/krallin/tini/issues/8 ↩