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


本記事の趣旨

令和時代のRails運用
こちらのスライドに掲載されている以下のDockerfileが、キャッシュやマルチステージビルドを利用したベストプラクティスとして参考になりました。

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に変更しています。

参考記事

Dockerfileを改善するためのBest Practice 2019年版

Docker Buildにおけるリードタイム短縮のための3つの改善ポイント

開発環境

  • 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の要点を見ていきます。
1行目の# syntax =という部分は後述するBuildkitに関する記述です。

その次の

# 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としても意味がないようです。

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

マルチステージビルド

マルチステージビルドは、1つのDockerfileに複数のステージを分けて記述し、最後のステージの内容だけが最終イメージに含まれます。
例のDockerfileでは、FROM行が2箇所、つまり2つのステージがあります。

FROM ruby:2.6.5 AS nodejs
...
FROM ruby:2.6.5

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

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

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

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

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

# 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

ユーザーの追加

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

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

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

bundle install

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

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

bundle config

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

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

Bundlerでビルドオプションを指定する

Cache Mount

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

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=cachetarget=/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時間が劇的に改善しました。こちらによると、

RUN --mount=type=cache 命令をうまく活用すると,従来のdocker buildより33倍以上速いビルドも可能です.

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

Buildkit

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

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

BuildKit によるイメージ構築

Buildkitの導入

主に2つの方法があります。

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

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

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

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

これにより、環境変数なしでdocker buildx build .のようにビルドを実行する事ができます。
BuildKitによりDockerとDocker Composeで外部キャッシュを使った効率的なビルドをする方法

Buildkit Cache Mountの利用

--mountは新しい構文のため、Dockerfileの1行目に次の記述をする必要があります。

# syntax = docker/dockerfile:experimental

Cacheの削除

docker builder prune

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

yarn install

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

COPY --chown=rails . /app

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

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

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