RailsのDockerイメージを一番小さくする方法

Dockerでサーバーを構築し始めました。
alpine linuxってなんやねんと最初の最初は思っていましたが、割と直ぐに慣れてきて
実際に案件でDockerファイルを書き出した所、もっとdeployしやすいイメージを作りたいと下の記事を見つけて、イメージを小さくすることにしました。

[お前のDockerイメージはまだ重い💢💢💢](https://speakerdeck.com/stormcat24/oqian-falsedockerimezihamadazhong-i)

Dockerイメージは、気を抜くとすぐにGBを超えるサイズになります。
実際、「&&」を使ってレイヤーのまとめ込みくらいはしていたのですが、最初に作ったDockerイメージは**2.19GB**になりました。

ここから、実際の案件で使うrailsプロジェクトを、妥当な工数で小さなイメージへ変更していきます。

## 基本

まずは基本的なことをさらっとおさらい。

* 不要なファイルを作らない
* .dockerignoreで、いらないファイルを含めない
* 命令をまとめて、無駄なレイヤリングを排除する

この辺りのDocker全般の話は既にほかの記事で触れらているので、ここから先の話をします。

[Dockerfileはなぜ複雑になるのか](https://qiita.com/port-development/items/8ab04aee45dc7f5f96ab)

## Dockerのイメージの選択

Webフレームワークは、ここまでの歴史の中である程度正解の出ている世界で、他のフレームワークで行っている事をある程度参考にできますので、今回は、ググっている最中に見つけたLaravelの記事と近い正解になっています。

[Dockerコンテナイメージのダイエット - Laravel編](https://blog.hanhans.net/2017/12/13/multistage-php-build/)

ということで、まずは無駄なもののない軽いDockerイメージを選択します。

dockerhubの[ruby](https://hub.docker.com/_/ruby/)のリポジトリを見てみると分かりますが、イメージ自体はかなり複数の種類があります。

今回自分の案件はruby2.5ベースだったのでこちらを参考にしますが2.5のイメージでも、debianからalpineベースまで複数種類あります。一番小さいのはalpine linuxですが、イメージのサイズはこんな感じで大分違います。

| version | サイズ |
|---|---|
| 2.5.1 | 869MB |
| 2.5.1-slim |178MB |
| 2.5.1-alpine3.7 | 45.3MB |

通常のrubyのDockerfileは、railsの実行に不要なファイルを割と含んでいるので
出来るだけ、軽いイメージをベースに作成を行って行きます。

このあたりは、既に[Rails on Dockerプロダクションイメージの容量削減をしてみた](https://qiita.com/ainehanta/items/e8158ee9a568042134a3)
で同じことが書いてあります。

**ちなみに、以下の様にversionが上がるとイメージの容量は下がる傾向があります**

| version | サイズ |
|---|---|
| 2.6.0 | 868MB |
| 2.6.0-slim |126MB |
| 2.6.0:alpine-3.8 | 40MB |

まずここで、最初rubyのベースを使って**2.19GB**だったのがalpine-linuxをベースにすることで、**1.42GB**まで削減出来ました。
やっぱりイメージを変更するのは大きい。

ただ、alpineのイメージ自体は50MBもないのに、
「あ、gccが無い」とか「あ、mysqlの開発者向け入れないと」とかすると、せっかくのサイズがみるみる肥えていって、ちゃんとbundle install可能な頃には、40MB のイメージが 700MBくらいまで増えます。やっぱりビルドにはいろんなものが必要でこれに`bundle install`したり`yarn install`したりした結果、結局1GBは余裕で超えてしまいました。

### さらにダイエット

ここからがこの記事の本題です。
イメージが大きいという事は何かしら無駄なファイルがあるので
この後、ビルドしたイメージに`docker exec -it コンテナID /bin/sh`でログインして内部のディレクトリを以下のコマンドで表示してきます。
シンプルに`du / -hd 1`でディレクトリ別の容量を見てみます。

```
1.7M	/etc
2.8M	/tmp
16.0K	/media
636.0K	/sbin
5.2M	/lib
2.2M	/bin
4.0K	/srv
4.0K	/mnt
4.0K	/run
0	/proc
4.0K	/home
27.6M	/root
0	/sys
889.8M	/usr
0	/dev
300.0K	/var
618.5M	/rails
500.0K	/share
```

結果としては、`/usr`以下が889MBで最大。これにrailsを配置している`/rails`自体の容量が618MBと大きい様です.
さらに、そこからディレクトリを掘り込んで調べたら、結果として以下のディレクトリが容量の原因でした。

* 326.3M   /usr/local/share/.cache/yarn
* 245.1M	/rails/node_modules
* 262.2M	/rails/vendor

正直yarnのキャッシュとか絶対不要なので、これは消さないといけません。
これ以外にも、`/usr/`以下を探すとgccやgitなど、**`bundle install`に必要だけど、railsの実行には使わない**なファイルがありました。

ということで、不要なファイルを以下の様に設定します。

1. /usr以下にある、gccなどのbundle installを成功させるためのファイル
2. /node_modulesにあるjsはassets:precompileするために必要なファイル、それが終われば本番では不要
3. yarnのcacheも正直不要

ただ、`alpine-sdk`などの、`apk`でinstallしたものを`bundle install`実行後に消し去るのは、いろんなところにファイルが散らばっているので正直しんどいです。

なので、まずは、`bundle install`や`assets:precompile`を行って
その後に、マルチステージビルドを行って、railsの動作に必要なファイルだけを新規にinstallし直して、その後そこにbuildの終わったファイルをコピーする方針で考えました。

## マルチステージビルド

同じレイヤー上で作ったファイルをすぐ消せば、Dockerのファイル使用量は増えません。
ですが、gccなどビルド全般にわたって必要なものまでそれを適応しようとするのはしんどいので、マルチステージビルドで書きました。

この作戦で作ったDockerファイル、最初**2.19GB**だったものは、イメージの変更で**1.42GB**。
さらに不要なファイルを削除した結果**464MB**まで減りました。

実際のDockerfileの案件依存の部分を取り除くとこんな感じになりました。

Railsのイメージを作るときの雛形に使ってください。

```dockerfile
FROM node:8.9.4-alpine as node

FROM ruby:2.6-alpine as builder

# 依存関係のあるパッケージのinstall
# gccやgitなど、ビルドに必要なものもすべて含まれている
RUN apk --update --no-cache add shadow sudo busybox-suid mariadb-connector-c-dev tzdata alpine-sdk

WORKDIR /rails

COPY Gemfile Gemfile.lock ./

# rails5案件なのでwebpackerの動作環境を整えておく
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/include/node /usr/local/include/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node /opt/yarn /opt/yarn
RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs && \
    ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
    ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn

# bundle installした後、makeで発生した不要なファイルは削除。
RUN gem install bundler --version 1.16.1 && \
    bundle install --without development test --path vendor/bundle && \
    find vendor/bundle/ruby -path '*/gems/*/ext/*/Makefile' -exec dirname {} \; | xargs -n1 -P$(nproc) -I{} make -C {} clean

# yarn install
COPY package.json yarn.lock ./
RUN yarn install && yarn cache clean

# assets precompile
COPY Rakefile .babelrc ./
COPY app/javascript app/javascript
COPY app/assets app/assets
COPY bin bin
COPY config config
RUN RAILS_ENV=production bundle exec rails assets:precompile

FROM ruby:2.6-alpine

# パッケージ全体を軽量化して、railsが起動する最低限のものにする
RUN apk --update --no-cache add shadow sudo busybox-suid execline tzdata mariadb-connector-c-dev && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    apk del --purge tzdata

EXPOSE 3000

WORKDIR /rails

# gemやassets:precompileの終わったファイルはbuilderからコピーしてくる
COPY --from=builder /rails/vendor/bundle vendor/bundle
COPY --from=builder /usr/local/bundle /usr/local/bundle

COPY --from=builder /rails/public/assets/ /rails/public/assets/
COPY --from=builder /rails/public/packs/ /rails/public/packs/

COPY . /rails

# rails立ち上げのためのコマンドはうちの案件ではシェルスクリプトにまとめています。各社好きに書いてください
# 普通はpumaを立ち上げるとかそんな感じだと思います
ENTRYPOINT ["docker/rails/entrypoint.sh"]
CMD exec bundle exec puma -t "$PUMA_THREADS":"$PUMA_THREADS" -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```

ここまでやった結果の中身ですが、内部を`du / -hd 1`して覗いてみた結果464MBのイメージの中身は以下のような状態。

* 262.2MB	/rails/vendor
* 319.8MB  /rails

300MB以上が大半がrailsのコードのあるディレクトリでinstallしたgemが占めています。
その他になるOSと基礎的なライブラリで100MBちょっと。
OSと、railsのためのCライブラリとしては割と最小構成に近いところまできた様です。

ここまでくると、あとはrailsの中の細かいファイルを丁寧に削っていくことになります。
具体的には本番で使わない`.rubocop.yml`とかを、丁寧に`.dockerignore`に丁寧に入れていって。
最後に、vendor/bundle以下のファイルに手を出します。
gemの中の、`spec`ディレクトリ、`README.md`、あとgithubから直接取ってくるgemの場合`.git`を削ればさらに減らせますけどgemの中身に手を出すのはリスクと隣り合わせでしょう。
ちなみに、この案件で上記のファイルを消してみましたが、結果、5Mしか減りませんでした。

正直、数MBをダイエットさせるために、工数を割いてもあんまり意味ないのでここらへんが折り合い点かなって思います。

以上、現場からの報告でした。