イメージレイヤーを意識したDockerfileにしてRails&Webpackerのビルドスピードを上げる

# はじめに
本記事は、[エムスリーキャリア Advent Calendar 2022](https://qiita.com/advent-calendar/2022/m3c)の15日目の記事です。

# イメージレイヤーを意識しない`Dockerfile`の例
Rails&Webpackerなプロジェクトの本番環境を構築する際、おおまかに以下のような工程になります。
1. Ruby・Node.js・Yarnのインストール
1. `bundle install` & `yarn install`で依存関係のインストール
1. `rails assets:precompile`でアセットプリコンパイル
1. `rails server`でRailsを起動

これをイメージレイヤーを意識せずに`Dockerfile`に落とし込むと以下のような構成になります。
※各ミドルウェアのバージョンは以下の想定
* Ruby 2.7.7
* Node.js 14.x

```dockerfile:Dockerfile
# Ruby 2.7のイメージからビルドする
FROM ruby:2.7.7

# プロジェクトのソースコードをコピー
WORKDIR /app
COPY . /app

# Node.js v14.xをインストール
# https://github.com/nodesource/distributions/blob/master/README.md#installation-instructions
RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - &&\
    apt-get install -y nodejs

# Yarnを有効化
# https://yarnpkg.com/getting-started/install
RUN corepack enable

# 依存関係をインストール
RUN bundle install
RUN yarn install --check-files

# アセットプリコンパイル
RUN rails assets:precompile

# Railsを起動
CMD rails server -b 0.0.0.0 -p 3000
```

しかし、この`Dockerfile`は以下のような問題点を抱えています。
* **ソースコードを少し変えただけで、毎回が以下実行される**
    * Node.jsのインストール・Yarnの有効化(初回だけ実行すればいい）
    * 依存関係のインストール(依存関係の変更がなければ実行しなくていい）

そこでDockerのイメージレイヤーを意識した`Dockerfile`にすることで上記課題を解決していきます。

# イメージレイヤーについて
ビルド後のDockerイメージは、そのファイルシステムの状態がレイヤー状に積み重ねて保存されます。
イメージレイヤーは`docker history`コマンドで確認することができます。

```shell
$ docker build -t rails_example .
$ docker history rails_example                        
IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
8d9cd0b316f1   22 seconds ago       CMD ["/bin/sh" "-c" "rails server -b 0.0.0.0…   0B        buildkit.dockerfile.v0
<missing>      22 seconds ago       RUN /bin/sh -c rails assets:precompile # bui…   16.6MB    buildkit.dockerfile.v0
<missing>      36 seconds ago       RUN /bin/sh -c yarn install --check-files # …   207MB     buildkit.dockerfile.v0
<missing>      About a minute ago   RUN /bin/sh -c bundle install # buildkit        425MB     buildkit.dockerfile.v0
<missing>      4 minutes ago        RUN /bin/sh -c corepack enable # buildkit       0B        buildkit.dockerfile.v0
<missing>      4 minutes ago        RUN /bin/sh -c curl -fsSL https://deb.nodeso…   143MB     buildkit.dockerfile.v0
<missing>      4 minutes ago        COPY . /app # buildkit                          418kB     buildkit.dockerfile.v0
<missing>      17 hours ago         WORKDIR /app                                    0B        buildkit.dockerfile.v0
<missing>      8 days ago           /bin/sh -c #(nop)  CMD ["irb"]                  0B        
<missing>      8 days ago           /bin/sh -c mkdir -p "$GEM_HOME" && chmod 777…   0B        
<missing>      8 days ago           /bin/sh -c #(nop)  ENV PATH=/usr/local/bundl…   0B        
<missing>      8 days ago           /bin/sh -c #(nop)  ENV BUNDLE_SILENCE_ROOT_W…   0B        
<missing>      8 days ago           /bin/sh -c #(nop)  ENV GEM_HOME=/usr/local/b…   0B        
<missing>      8 days ago           /bin/sh -c set -eux;   savedAptMark="$(apt-m…   30MB      
<missing>      8 days ago           /bin/sh -c #(nop)  ENV RUBY_DOWNLOAD_SHA256=…   0B        
<missing>      8 days ago           /bin/sh -c #(nop)  ENV RUBY_VERSION=2.7.7       0B        
<missing>      8 days ago           /bin/sh -c #(nop)  ENV RUBY_MAJOR=2.7           0B        
<missing>      8 days ago           /bin/sh -c #(nop)  ENV LANG=C.UTF-8             0B        
<missing>      8 days ago           /bin/sh -c set -eux;  mkdir -p /usr/local/et…   45B       
<missing>      9 days ago           /bin/sh -c set -ex;  apt-get update;  apt-ge…   529MB     
<missing>      9 days ago           /bin/sh -c apt-get update && apt-get install…   152MB     
<missing>      9 days ago           /bin/sh -c set -ex;  if ! command -v gpg > /…   19MB      
<missing>      9 days ago           /bin/sh -c set -eux;  apt-get update;  apt-g…   10.7MB    
<missing>      9 days ago           /bin/sh -c #(nop)  CMD ["bash"]                 0B        
<missing>      9 days ago           /bin/sh -c #(nop) ADD file:553d20b03d45305e5…   124MB     
```

`Dockerfile`に書いた命令文(`RUN`, `COPY`など）ごとに逆順でレイヤーが作成されているのがわかります。
各レイヤーのサイズはその命令文で発生したファイルシステムの差分量であり、全レイヤーの合計がそのイメージのサイズとなります。

例えば、`COPY . /app`はソースコードがコピーされるのでその量(上記例では418kB)、`WORKDIR /app`や`RUN corepack enable`はファイルシステムに影響を与えないので0Bとなっています。

# `Dockerfile`のTips集
## レイヤーキャッシュを考慮して、変更頻度の少ないファイルから`COPY`・`ADD`を書く
一度イメージをビルドすると、ビルドしたマシンに各イメージレイヤーがキャッシュされます。
次回ビルド時、**前回ビルド時と変更がないレイヤーは、キャッシュが効いてスキップされます。**

**変更がない**とは、**命令文が変更されていない**、また`COPY`・`ADD`の場合は**追加されるファイルが変更されていない**場合を指します。

`Dockerfile`の上から走査して、キャッシュが効かない命令文があると、その下の命令文もキャッシュが無効になります。

「*イメージレイヤーを意識しないDockerfileの例*」では`Dockerfile`の上の方に`COPY . /app/`があるため、ソースコードを少し変更しただけでほぼ全てのキャッシュが無効になります。
「Node.js v14.xをインストール」「Yarnを有効化」にソースコードは不要のため、`COPY`はこれらの後に記述するべきです。
また、「依存関係をインストール」の前には依存関係を記載したファイルのみ`COPY`するべきです。
```diff_docker:Dockerfile
# Ruby 2.7のイメージからビルドする
FROM ruby:2.7.7

WORKDIR /app
- COPY . /app

# Node.js v14.xをインストール
# https://github.com/nodesource/distributions/blob/master/README.md#installation-instructions
RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - &&\
    apt-get install -y nodejs

# Yarnを有効化
# https://yarnpkg.com/getting-started/install
RUN corepack enable

+ # 必要なファイルのみコピーしてbundle install
+ COPY Gemfile Gemfile.lock /app/
RUN bundle install

+ # 必要なファイルのみコピーしてyarn install
+ COPY package.json yarn.lock /app/
RUN yarn install --check-files

+ # プロジェクトのソースコードをコピー
+ COPY . /app

# アセットプリコンパイル
RUN rails assets:precompile

#(以下略）
```

これにより、課題となっていた以下問題がレイヤーキャッシュにより解決します。
* **Node.jsのインストール・Yarnの有効化が毎回実行される**
    * 一度ビルドしたら次回以降のビルドはキャッシュされる
* **依存関係のインストールが毎回実行される**
    * `Gemfile`か`Gemfile.lock`が変更された時のみ`bundle install`される
    * `package.json`か`yarn.lock`が変更された時、または↑の`bundle install`された時のみ`yarn install`される

## マルチステージビルド化する
レイヤーキャッシュを考慮することでビルドにかかる時間を大きく短縮することができましたが、まだ「**`Gemfile`か`Gemfile.lock`を変更したら、必要のない`yarn install`も実行される**」課題があります。
これを解決するため、`Dockerfile`をマルチステージビルド化します。
マルチステージビルドにすることで、**ステージ毎にビルドを並列実行できる** (Docker 18.09以降導入されたbuildkit利用時）メリットもあります。

マルチステージビルドは、`Dockerfile`に`FROM`命令文を複数書くだけで実現できます。
以下のステージに分けた例を紹介します。
1. `bundle install`する
1. Node.jsとYarnをインストールする
1. 2.をベースに`yarn install`とアセットプリコンパイルする
1. 最終的に出力するイメージ

```dockerfile:Dockerfile
# このビルドステージでbundle installを行う
FROM ruby:2.7.7 as bundle_install

# bundle installに必要なファイルのみコピー
WORKDIR /app
COPY Gemfile Gemfile.lock /app/

RUN bundle install

# このビルドステージでNode.jsとYarnをインストール
FROM ruby:2.7.7 as ruby_with_nodejs

WORKDIR /app

# Node.js v14.xをインストール
# https://github.com/nodesource/distributions/blob/master/README.md#installation-instructions
RUN curl -fsSL https://deb.nodesource.com/setup_14.x | bash - &&\
    apt-get install -y nodejs && \
# Yarnを有効化
# https://yarnpkg.com/getting-started/install
    corepack enable

# このビルドステージでアセットプリコンパイル
FROM ruby_with_nodejs as assets_precompile

# yarn installに必要なファイルのみコピー
COPY package.json yarn.lock /app/
RUN yarn install

# プロジェクトのソースコードをコピー
COPY . /app/
# bundle_installビルドステージからインストールしたgemをコピー
# --fromで別のビルドステージからファイルをコピーできる
COPY --from=bundle_install /usr/local/bundle /usr/local/bundle

RUN rails assets:precompile

# このビルドステージでrailsを起動
FROM ruby_with_nodejs as rails

# bundle_installビルドステージでインストールしたgemをコピー
COPY --from=bundle_install /usr/local/bundle /usr/local/bundle
# assets_precompileビルドステージからプロジェクト全体をコピー
COPY --from=assets_precompile /app /app

# Railsを起動
CMD rails server -b 0.0.0.0 -p 3000
```

## (ついでに)イメージに必要ないファイルは`.dockerignore`で無視する
イメージレイヤーとは直接関係ありませんが、イメージのビルドやコンテナの実行に必要ないファイルを無視するよう`.dockerignore`を設定します。
これで不用意にレイヤーキャッシュが無効になることを避けることができます。

また`.env`のようなセキュリティ的に外出しするとマズいファイルや、`public/packs`やキャッシュなど開発中に生成される中間ファイルを無視する役割もあります。

```:.dockerignore
.git/
log/
node_modules/
tmp/
public/assets/
public/packs/
.dockerignore
.env
.gitignore
Dockerfile
README.md
```
# 最後に

エムスリーキャリアでは積極的にエンジニアを採用しております。
Dockerなど新めの技術を取り入れた開発環境で医療従事者の働き方を革新しませんか？

https://career.m3career.com/midcareer/engineer

# 参考記事
本家大元、docker.comのベストプラクティス集を参考にしました！
https://docs.docker.com/develop/develop-images/dockerfile_best-practices