
マルチステージビルドでDockerイメージのサイズを削減してみた
はじめに
こんにちは、SHIFTの開発部門に所属している Katayama です。
コンテナイメージ(Docker イメージ)のサイズが大きいと、CI でのレジストリへの push やそのコンテナイメージを使った Deploy(pull)時に転送時間がかかってしまうという事が起きる。
そこで今回は、Node.js の Express を例に、サーバーのDockerイメージを作成する際、マルチステージビルドを行いイメージのサイズを削減するというのをやってみたいと思う。
参考になるドキュメントとしては、Multi-stage buildsがある。
結論
今回マルチステージを導入してみた結果、161MB 分の削減が実現できた(マルチステージ導入前に比べると、約 30 %のイメージサイズ削減)。
そのマルチステージビルドを行う場合の Dockerfile の設定としては以下のようになった(今回は、さらなる効率性を考えたときに工夫できる部分であるレイヤー・キャッシュについては考慮していない。それに関しては次回の記事で取り上げたいと思う)。
FROM node:16.19.0-alpine3.16 AS builder
RUN mkdir /app
WORKDIR /app
COPY . .
RUN chmod +x ./build/app/entrypoint.sh && \
yarn install --frozen-lockfile && \
yarn build && \
mkdir for-next-stage && \
mv build config dist static package.json yarn.lock for-next-stage/
FROM node:16.19.0-alpine3.16
RUN mkdir /app
COPY --from=builder /app/for-next-stage /app
WORKDIR /app
RUN yarn install --production --frozen-lockfile
ENTRYPOINT ["./build/app/entrypoint.sh"]
上記の中でのポイントとしては、Multi-stage buildsにあるように、最終イメージに残したいもの以外を builder というステージで行い、不要なものはその builder ステージに捨てて必要なものだけが最終イメージに残るようにしている部分。
具体的には、Node.js であれば、node_modules がイメージサイズの肥大化の一番の原因になるが、マルチステージビルドを利用する事で build 時にしか必要のないモジュール(devDependencies のモジュール)を最終イメージに残さなくて済む。それにより devDependencies のモジュール分だけ、node_modules のサイズは小さくなり、イメージサイズ削減ができる。
細かい部分で言えば、build しているので、元々のソースである src 以下のファイルも全部不要になるので、build して dist 以下に吐き出されるファイルのみ、最終イメージに残るようにするのも、イメージサイズ削減に貢献するだろう。
今回検証してみた結果、161MB 分の削減が実現できた(約 30%のイメージサイズ削減)。

※マルチステージビルドを利用しない場合の Dockerfile は以下。
FROM node:16.19.0-alpine3.16
RUN mkdir /app
WORKDIR /app
COPY . .
RUN chmod +x ./build/app/entrypoint.sh && \
yarn install --frozen-lockfile && \
yarn build
ENTRYPOINT ["./build/app/entrypoint.sh"]
※ディレクトリ構成は以下。
study@localhost:~/workspace/node-express (main)
$ tree -I "node_modules|data|__tests__|commands|dist|support|static|mysql|config|.git|.vscode|.github|.env" -a
.
├── .dockerignore
├── .eslintrc.json
├── .gitignore
├── README.md
├── babel.config.js
├── build
│ └── app
│ ├── Dockerfile
│ └── entrypoint.sh
├── docker-compose.yaml
├── package.json
├── src
│ ├── index.js
│ ├── lib
...
│ ├── models
│ │ ├── dynamodb
...
│ │ └── sequelize
│ │ ├── init-models.js
│ │ └── user.js
...
│ ├── routes
│ │ ├── index.js
│ │ └── user
│ │ ├── index.js
│ │ └── v1
│ │ ├── user.js
│ │ └── users.js
...
├── webpack.config.js
└── yarn.lock
マルチステージビルドの Dockerfile の中身に関して
一部、中身について補足をする。
COPY
ここでは、Dockerイメージを作成する際に、そのイメージにプロジェクトルート直下のディレクトリ・ファイルをコピーする、という事をしている。".dockerignore"を設定しているので、そこで指定されているディレクトリはイメージにコピーされない。
.dockerignore の中身は以下("data/" というのはローカルの開発で利用している docker-compose の volume にマウントされるディレクトリ)。
__tests__
.github
.vscode
commands
data
dist
mysql
node_modules
support
.dockerignore
.gitignore
.ncurc.json
.prettierrc
.sequelizerc
commitlint.config.js
docker-compose.yaml
jest.config.js
README.md
※マルチステージビルドに比べれば効果は限定的だが、イメージにコピーされないファイルがあるので、".dockerignore"の設定により少しは docker イメージのサイズが削減できる。
参考までにマルチステージビルドを行う前の状態で、".dockerignore"の配置前後でのイメージのサイズは 29MB 程度であった(data ディレクトリだけは、docker-compose のマウントディレクトリで root アカウント所有になっているので、いずれのパターンでもイメージに含まれていない)。

※ちなみに、Best practices for writing DockerfilesのADD or COPYに記述がある通り、ADD の展開機能を利用しな場合には、COPY を利用する事が推奨される。
For other items (files, directories) that do not require ADD’s tar auto-extraction capability, you should always use COPY.(ADD の自動展開機能を必要としないもの(ファイルやディレクトリ)に対しては、常に COPY を使うようにしてください。)
参考(日本語のサイト):ADD と COPY
RUN chmod +x ... mv build config ...
ここでは devDependencies も含めて、全ての依存モジュールをインストールし、それらを利用して Webpack での build を行っている。この build の処理の中では ESLint によるチェックなども走るように設定しているため、production では不要になる devDependencies のモジュールもどうしても必要になる。
そのため、builder という別のステージを設け、そこで必要な処理(build)を行い、最終イメージに必要なものだけを別のディレクトリ(今回は /app/for-next-stage 以下)に固めて配置している。
chmod +x ./build/app/entrypoint.sh に関して
これはデフォルトではコピー元のパーミッションが適用され、そのパーミッションには実行権限がないので、イメージを作成する中で実行権限を付与する目的で行っている処理。
付与後は以下のように"x"(実行権限)が付与されている事が確認できる。

yarn install --frozen-lockfile に関して
--frozen-lockfileオプションは、インストール時に yarn.lock を生成せず、更新が必要な場合には install が失敗するようにするためのオプションであり、これにより lock ファイルの中身に基づいてライブラリ・モジュールをインストールするように設定できる。
COPY --from=builder /app/for-next-stage /app
マルチステージビルドでは重要な部分だが、ここで捨てるイメージ(実行済みのビルドイメージ)で生成した最終イメージに含めたいものをコピーしている。--from=[実行済のビルドステージ名]という書き方で、指定したビルドイメージから任意のディレクトリ・ファイルをコピーできる。
※公式だと、COPYに記述がある。
Optionally COPY accepts a flag --from= that can be used to set the source location to a previous build stage (created with FROM .. AS ) that will be used instead of a build context sent by the user. In case a build stage with a specified name can’t be found an image with the same name is attempted to be used instead.(オプションで COPY はフラグ-from=を受け付けます。これは、ソースロケーションを、ユーザが送信したビルドコンテキストではなく、以前のビルドステージ(FROM ... AS で作成)に設定するために使用されます。指定された名前のビルドステージが見つからない場合、代わりに同じ名前のイメージを使用しようとします。)
RUN yarn install --production --frozen-lockfile
せっかくマルチステージビルドで production では不要なモジュール(devDependencies のモジュール)をインストールする必要がない状態にしても、普通に yarn install を行ってしまうと、devDependencies もインストールされてしまうので、yarn install --production[=true|false]で dependencies のモジュールのみをインストールするようにしている。
まとめとして
今回、私の作成したイメージでは、マルチステージビルドの導入前後で 161MB だけイメージサイズが削減できた。
割合にするとマルチステージビルドにより、元のイメージから約 30%イメージサイズの削減ができた。
ただ、今回見てきた Dockerfile の構成は、レイヤー・キャッシュが考慮できておらず、ビルドが効率的とは言えない状態だった。
そこで、次回は Docker イメージのレイヤーを意識して、効率的にイメージを作成するという事をやってみたいと思う。
おまけ
マルチステージビルドあり・なしの各ディレクトリのサイズ比較
マルチステージビルドを利用して作成した最終の Docker イメージと、特に何もせず作成した Docker イメージのディレクトリのサイズを調べてみたが以下のようになっていた。

上記の比較から分かるように、上記で見たように node_modules の削減効果が一番出ている。
具体的には、node_modules のサイズが277.6M → 63.3Mになっており、約 1/4 のサイズ(-214.3MB)になっている事が分かる。
※node_modules のサイズの削減分だけ、Docker イメージのサイズの削減につながらなかったのは、Docker のレイヤーによるもので、イメージの中のファイルサイズ=イメージのサイズではないから(以下の表の通り、ベースの同じ部分を除いてそれぞれのイメージの差は、11 + 434 - (4.24 + 280) = 160.76Mである事が分かる)。

《この公式ブロガーの記事一覧》
執筆者プロフィール:Katayama Yuta
認証認可(SHIFTアカウント)や課金決済のプラットフォーム開発に従事。リードエンジニア。
経歴としては、SaaS ERPパッケージベンダーにて開発を2年経験。
SHIFTでは、GUIテストの自動化やUnitテストの実装などテスト関係の案件に従事したり、DevOpsの一環でCICD導入支援をする案件にも従事。その後現在のプラットフォーム開発に参画。
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/