【Dockerfile全解説】Rails本番環境のための一番シンプルなDockerイメージを作る

何度か 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 を前提としているが、依存パッケージ等を変更すれば他の環境でも利用可能なはず。

```dockerfile:Dockerfile
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全解説

## ベースイメージと基本設定

```dockerfile
FROM ruby:2.6.5-alpine

WORKDIR /app

ENV RAILS_ENV="production"
ENV NODE_ENV="production"
```

ベースイメージには、[Docker 公式の Ruby イメージ](https://hub.docker.com/_/ruby)を使用。この例では Ruby のバージョンは 2.6.5 を使っているが、もちろん他のバージョンでも構わない。タグに `-alpine` とつくイメージは Alpine Linux ベースで、サイズが 50MB 程度とコンパクトなので、最終的なイメージサイズの削減に寄与する。

その他、簡単な基本設定をここで。アプリケーションコードは `/app` に置く想定なので、そこをワーキングディレクトリにする。本番環境向けに環境変数 `RAILS_ENV`, `NODE_ENV` を設定する。

## 依存パッケージのインストール

```dockerfile
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` オプションで複数のパッケージをまとめて「タグ」をつけることができ、後からそのタグを指定して一括アンインストールができて便利。

[^1]: Dockerのイメージサイズは、各レイヤー（`COPY`,  `RUN`などの命令ごとに1レイヤーが作られる）のサイズの合計になる。そのため、1レイヤーごとのサイズを小さく保つことが、最終的なイメージサイズの削減に寄与する。
[^2]: 厳密には`nodejs`と`yarn`はアセットプリコンパイル以降は不要だが、プリコンパイルまで含めて1命令にしてしまうとビルドキャッシュが効きにくくなるため、今回はインストールしたままにしている。このあたりはマルチステージにするのがうまい解決策だろうか。

<!-- -tをつけるとタグが付けられて〜〜 -->
<!-- --no-cacheが付いているのは〜〜 -->

## アプリケーションコードのコピー

```dockerfile
COPY . /app
```

Rails アプリケーションのコード（`.` にある想定）をすべてイメージ内の `/app` にコピーする。ほとんどのケースでアプリケーションコードになんらかの変更があると考えられるため、このフェーズ以降はビルドキャッシュが効きにくい。

## アセットのプリコンパイル

```dockerfile
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` 命令にまとめて、イメージサイズを抑えている。

## ランタイム設定

```dockerfile
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](https://github.com/krallin/tini) を使っている。tini は軽量な init の実装で、ゾンビプロセスの回収やシグナルの転送を行なってくれるプログラムである[^3]。[Mastodon で採用されている](https://github.com/tootsuite/mastodon/blob/v3.0.1/Dockerfile#L125)のを参考にした。

[^3]: 余談だが、以下のIssueでtiniの作者がinitの役割を詳しく解説していて参考になる: https://github.com/krallin/tini/issues/8

残りはデフォルトのコマンド（サーバー起動）の設定と、露出ポートの設定。

# 参考: ビルド時間とイメージサイズ

参考として、手元の環境で `rails new` しただけのプロジェクトを、上記の `Dockerfile` で `docker build` したときの結果を以下に記載する。

```bash
$ 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](https://github.com/tootsuite/mastodon/blob/26c1aba658d04617caf33783306b4504ca41f63c/Dockerfile)
    * ある程度の規模のRailsアプリのDockerfileのプラクティスが詰まっていて参考になる。
    * v2.8 から[Ubuntuベースになり](https://github.com/tootsuite/mastodon/pull/10100)、ステージも増えて少し複雑化している。
* [RailsのDockerイメージを一番小さくする方法](https://qiita.com/baban/items/99877f9b3065c4cf3d50)
* [Dockerfile を書くためのベストプラクティス解説編](https://www.slideshare.net/zembutsu/explaining-best-practices-for-writing-dockerfiles)