Dockerfile for Rails6のベストプラクティスを解説

# 本記事の趣旨
[令和時代のRails運用]
(https://speakerdeck.com/joker1007/ling-he-shi-dai-falserailsyun-yong)
こちらのスライドに掲載されている以下のDockerfileが、キャッシュやマルチステージビルドを利用したベストプラクティスとして参考になりました。

```Docker:Dockerfile
# syntax = docker/dockerfile:experimental

# Node.jsダウンロード用ビルドステージ
FROM ruby:2.6.5 AS nodejs

WORKDIR /tmp

# Node.jsのダウンロード
RUN curl -LO https://nodejs.org/dist/v12.18.0/node-v12.18.0-linux-x64.tar.xz
RUN tar xvf node-v12.18.0-linux-x64.tar.xz
RUN mv node-v12.18.0-linux-x64 node

FROM ruby:2.6.5

# nodejsをインストールしたイメージからnode.jsをコピーする
COPY --from=nodejs /tmp/node /opt/node
ENV PATH /opt/node/bin:$PATH

# アプリケーション起動用のユーザーを追加
RUN useradd -m -u 1000 rails
RUN mkdir /app && chown rails /app
USER rails

# yarnのインストール
RUN curl -o- -L https://yarnpkg.com/install.sh | bash
ENV PATH /home/rails/.yarn/bin:/home/rails/.config/yarn/global/node_modules/.bin:$PATH

# ruby-2.7.0でnewした場合を考慮
RUN gem install bundler

WORKDIR /app

# Dockerのビルドステップキャッシュを利用するため
# 先にGemfileを転送し、bundle installする
COPY --chown=rails Gemfile Gemfile.lock package.json yarn.lock /app/

RUN bundle config set app_config .bundle
RUN bundle config set path .cache/bundle
# mount cacheを利用する
RUN --mount=type=cache,uid=1000,target=/app/.cache/bundle \
    bundle install && \
    mkdir -p vendor && \
    cp -ar .cache/bundle vendor/bundle
RUN bundle config set path vendor/bundle

RUN --mount=type=cache,uid=1000,target=/app/.cache/node_modules \
    bin/yarn install --modules-folder .cache/node_modules && \
    cp -ar .cache/node_modules node_modules

COPY --chown=rails . /app

RUN --mount=type=cache,uid=1000,target=/app/tmp/cache bin/rails assets:precompile

# 実行時にコマンド指定が無い場合に実行されるコマンド
CMD ["bin/rails", "s", "-b", "0.0.0.0"]
```
ここで使用されているBuildkitなどの要素について、勉強した内容をまとめたいと思います。

これらの方法により、開発効率の向上を実感しましたので、RailsとDockerを学習中の方のご参考になればと思います。

（投稿者はDockerを勉強中で、実務は未経験ですので、気になる点がありましたらコメントでご指摘をお願いします。）

注記：上記のDockerfileは、元スライドの物から、Node.jsのバージョンだけ、20/6/6時点での[最新ver](https://nodejs.org/en/download/)に変更しています。

# 参考記事
[Dockerfileを改善するためのBest Practice 2019年版](https://www.slideshare.net/zembutsu/dockerfile-bestpractices-19-and-advice)

[Docker Buildにおけるリードタイム短縮のための3つの改善ポイント]
(https://tech.plaid.co.jp/improve_docker_build_efficiency)
# 開発環境

- Mac OS X 10.15.4
- Docker 19.03.8
- Docker Desktop for Mac 2.3.0.3
- Ruby 2.6.5 Rails 6.0.2

# Dockerfileの解説
上記のDockerfileの要点を見ていきます。
１行目の`# syntax =`という部分は後述するBuildkitに関する記述です。

その次の

```Docker:
# Node.jsダウンロード用ビルドステージ
FROM ruby:2.6.5 AS nodejs

WORKDIR /tmp

# Node.jsのダウンロード
RUN curl -LO https://nodejs.org/dist/v12.18.0/node-v12.18.0-linux-x64.tar.xz
RUN tar xvf node-v12.18.0-linux-x64.tar.xz
RUN mv node-v12.18.0-linux-x64 node
```

ここでは、Railsに必要なNode.jsをインストールしています。

- tmpに移動
- node-v12.18.0-linux-x64.tar.xzをダウンロード、展開
- node-v12.18.0-linux-x64をnodeにリネーム

の結果、`/tmp/node`（本体）,  `/tmp/node-v12.18.0-linux-x64.tar.xz`（不要）
が生成されます。

最終的なDockerイメージを軽量にするために、必要なnode本体だけを残す必要があります。Dockerはレイヤー構造で履歴が残っているため、ただ単に`RUN rm node-v12.18.0-linux-x64.tar.xz`としても意味がないようです。

そこで、マルチステージビルドを利用しています。

## マルチステージビルド
[マルチステージビルド](https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/multistage-build)は、1つのDockerfileに複数のステージを分けて記述し、最後のステージの内容だけが最終イメージに含まれます。
例のDockerfileでは、`FROM`行が2箇所、つまり2つのステージがあります。

```Docker:
FROM ruby:2.6.5 AS nodejs
...
FROM ruby:2.6.5
```

1つ目の`AS nodejs`の記載で、ステージに`nodejs`と名付けています。これによって、2つ目のステージで、

```Docker:
# nodejsをインストールしたイメージからnode.jsをコピーする
COPY --from=nodejs /tmp/node /opt/node
ENV PATH /opt/node/bin:$PATH
```

上でインストールした`tmp/node`だけをコピーする事ができます。

今回の場合は、これによって節約できるのは15MBほどですが、Goのようなコンパイル言語で、ビルド結果のファイルだけを次のステージに渡すと、かなりの軽量化ができるようです。

ちなみに、2つ目のステージのyarnインストールは、ファイルそのものではなく、install.shをダウンロードして実行しているだけで、不要なファイルが残らないため、このままで問題ないのだと思います。

```Docker:
# yarnのインストール
RUN curl -o- -L https://yarnpkg.com/install.sh | bash
ENV PATH /home/rails/.yarn/bin:/home/rails/.config/yarn/global/node_modules/.bin:$PATH
```
## ユーザーの追加

```Docker:
# アプリケーション起動用のユーザーを追加
RUN useradd -m -u 1000 rails
RUN mkdir /app && chown rails /app
USER rails
```

ここでは、コンテナ内にユーザーを追加しています。デフォルトのrootユーザーのままでは、ホストとファイルを共有する際に権限の問題が発生するようです。

ただ、Docker for Macの場合、その問題は起こらないので、この設定は省略しても良いかもしれません。
（非rootユーザーにすると、vimを使いたい時にapt-getができないなど、色々と困る場面もありましたので..）

## bundle install

```Docker:
WORKDIR /app

# Dockerのビルドステップキャッシュを利用するため
# 先にGemfileを転送し、bundle installする
COPY --chown=rails Gemfile Gemfile.lock package.json yarn.lock /app/
```

作業ディレクトリに必要なパッケージ管理ファイルを置いています。その後、

```Docker:
RUN bundle config set app_config .bundle
RUN bundle config set path .cache/bundle
```

まず`bundle config`コマンドを使用して、installするpathを設定しています。

### bundle config

例として、`bundle config set path vendor/bundle`と設定しておくと、`bundle install`の際に、`bundle install --path vendor/bundle`とパスを指定した事と同じになります。

installの際に、`--path`を指定する方法は非推奨となったようなので、今後は`bundle config`を使いましょう。

[Bundlerでビルドオプションを指定する](https://qiita.com/thunders/items/101c6b329830fb1fb27d)

### Cache Mount
`bundle install`で、ビルド効率のためにCache Mountを利用しています。

```Docker:
RUN bundle config set path .cache/bundle
# mount cacheを利用する
RUN --mount=type=cache,uid=1000,target=/app/.cache/bundle \
    bundle install && \
    mkdir -p vendor && \
    cp -ar .cache/bundle vendor/bundle
RUN bundle config set path vendor/bundle
```

まずbundleのinstall先を`.cache/bundle`に設定します。

続く`--mount=type=cache`〜`target=/app/.cache/bundle`という記載が、Cache Mountを利用している部分です。
この記載を含むRUN命令の中では、targetに指定したpath（ここでは`/app/.cache/bundle`）の中身が、ホスト内に保存され、次回以降のbuildでcacheとして利用されるようになります。

したがって、直後の`bundle install`で`.cache/bundle`にインストールされたgemは、ホスト内部に保存されています。
このままだと、コンテナ内にgemがない状態になってしまうため、続けてvendorディレクトリを作り、そこに.cacheディレクトリから中身をコピーしています。

最後に`bundle config set path vendor/bundle`でpathを指定することで、bundlerがvendor/bundleを読みに行ってくれるようになります。

やや回りくどい気もしますが、これによってbuild時間が劇的に改善しました。[こちら](https://medium.com/nttlabs/docker-v18-09-%E6%96%B0%E6%A9%9F%E8%83%BD-%E3%82%A4%E3%83%A1%E3%83%BC%E3%82%B8%E3%83%93%E3%83%AB%E3%83%89-%E3%82%BB%E3%82%AD%E3%83%A5%E3%83%AA%E3%83%86%E3%82%A3-9534714c26e2)によると、
>`RUN --mount=type=cache` 命令をうまく活用すると，従来の`docker build`より33倍以上速いビルドも可能です．

私の環境ではbuildのたびにbundle installで300秒以上かかっていました。cacheがあれば、build時のbundle installは変更差分のみですぐ終わるので、気軽にbuildできます。

## Buildkit
上述のCache Mountを使うためには、Buildkitでbuildをする必要があります。

Buildkitとは、dockerのイメージビルドを便利にしてくれるビルドツールキットです。[こちら](https://github.com/moby/buildkit)にあるように、ビルドのそれぞれの過程ごとにかかった時間を表示してくれたりします。
他にもビルドの並列実行など、たくさんの機能があるようです。


[BuildKit によるイメージ構築]
(https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/build_enhancements)

### Buildkitの導入
主に2つの方法があります。

-  環境変数`DOCKER_BUILDKIT=1`を設定する。
- 試験機能モードを有効にすることで`docker buildx`コマンドを使う（Docker 19.03以上）。

1つ目は、`DOCKER_BUILDKIT=1 docker build .`のように環境変数を指定する簡単な方法です。

2つ目は、buildxというプラグインを利用する方法で、buildkitの全ての機能が有効になるとのことです。config.json (デフォルトでは`~/.docker/config.json`) に次のように指定します。

```json:~/.docker/config.json
{
    "experimental": "enabled"
}
```

これにより、環境変数なしで`docker buildx build .`のようにビルドを実行する事ができます。
[BuildKitによりDockerとDocker Composeで外部キャッシュを使った効率的なビルドをする方法]
(https://qiita.com/tatsurou313/items/ad86da1bb9e8e570b6fa)

### Buildkit Cache Mountの利用
`--mount`は新しい構文のため、Dockerfileの１行目に次の記述をする必要があります。

```docker
# syntax = docker/dockerfile:experimental
```
### Cacheの削除
`docker builder prune`

以上がbuildkitの使い方です。
簡単な設定をするだけで良いので、cacheを使わない場合も、取り入れてみるといいかもしれません。

## yarn install

```Docker:
RUN --mount=type=cache,uid=1000,target=/app/.cache/node_modules \
    bin/yarn install --modules-folder .cache/node_modules && \
    cp -ar .cache/node_modules node_modules
```

yarn installも同じくCache Mountを使います。

## asset precompile

```Docker:
COPY --chown=rails . /app

RUN --mount=type=cache,uid=1000,target=/app/tmp/cache bin/rails assets:precompile
```

最後にホストのファイルを全てコピーして、Cache Mountを利用してアセットをプリコンパイルします。

開発環境と本番環境でさらにステージを分けて、本番環境でのみプリコンパイルを行うなどの設定も考えられます。