【Docker】マルチステージビルドって言うほど良いか? → 言うほど良いわとなった話


結論

マルチステージビルドを活用することでコンテナイメージのサイズを大幅に抑えられる可能性があります。
以下はNuxt.jsアプリケーションの例です。

$ docker images
REPOSITORY          TAG                 IMAGE ID            CREATED              SIZE
nuxt                single-stage        fba421d5de5b        About a minute ago   371MB
nuxt                multi-stage         a40d0000d0a8        10 minutes ago       22MB

検証手順

環境

  • Docker version 19.03.12
  • CentOS Linux release 7.8.2003 (Core)
  • Node.js v12.18.3
  • create-nuxt-app/3.3.0 linux-x64

Nuxtアプリケーションを作成する

npx create-nuxt-app <アプリ名> でNuxt.jsアプリケーションを作成します。構成は自由です。どのように設定しても、多少サイズの違いはあれ同じような結果を得られます。

ルートディレクトリに以下のファイルを追加する

Dockerfile-Single

FROM node:lts-alpine
WORKDIR /app
COPY . ./
RUN npm install -g http-server && \
    npm install && \
    npm run build
EXPOSE 8080
CMD [ "http-server", "dist" ]

この記事ではマルチステージじゃないビルドは便宜的にシングルステージビルドと呼んでいます。正しい用語が分からなかったので詳しい方は教えてください!
アプリケーションを動かすためのhttp-server を持ってきて、ビルドしたファイル群を乗っけています。

Dockerfile-Multi

FROM node:lts-alpine AS build-stage
WORKDIR /app
COPY . ./
RUN npm install && \
    npm run build

FROM nginx:stable-alpine AS production-stage
COPY --from=build-stage /app/dist /usr/share/nginx/html
EXPOSE 80
CMD [ "nginx", "-g", "daemon off;" ]

ビルドの段階ではnode.jsのイメージを使い、実行段階ではnginxのイメージを使用しています。 as でステージに名前を付けたり、 --from でどのステージのファイルかを指定したりできます。
最終的にビルドステージで生成したディレクトリをnginxのドキュメントルートに置いています。

.dockerignore

node_modules
dist
Dockerfile*

Dockerfileは誤差のようなものですが、node_modulesdist は使うもの次第で膨らみます。それぞれ npm installnpm build によってコンテナ内で実行されるので、イメージ作成段階では除外します。

それぞれのイメージをビルドし、動作確認する

シングルステージの場合

$ docker build -t nuxt:single-stage --file Dockerfile-Single .
$ docker run -dit -p 8080:8080 --name nuxt-single nuxt:single-stage
$ docker exec nuxt-single wget -S -O- localhost:8080
Connecting to localhost:8080 (127.0.0.1:8080)
  HTTP/1.1 200 OK
(以下省略 レスポンスデータとしてHTMLが返ってきていればOK!)

マルチステージの場合

$ docker build -t nuxt:multi-stage --file Dockerfile-Multi .
$ docker run -dit -p 80:80 --name nuxt-multi nuxt:multi-stage 
$ docker exec nuxt-multi wget -S -O- localhost:80
Connecting to localhost:80 (127.0.0.1:80)
  HTTP/1.1 200 OK
(以下省略 レスポンスデータとしてHTMLが返ってきていればOK!)

この時点でそれぞれのイメージサイズを比較すると、冒頭のような結果を得られます。

マルチステージビルドの使いどころ

今回挙げた例では、アプリケーションのビルドの時は必要だけど実行の時はいらないものを捨てることでイメージのサイズダウンが実現されています。
つまり、マルチステージビルドは実行環境だけあればいいコンテナのイメージを作る際に威力を発揮すると言えます。
例えばCI/CD用のコンテナなんかはビルドの成果物さえあれば良く、むしろビルド時のみに必要なファイルたちは無駄にコンテナサイズを膨らませる原因となってしまいます。マルチステージビルドの使いどころですね。

余談

node_modules.dockerignore から外して npm install を省略することで npm install により発生するネットワーク通信を抑えることができます。node_modules を一度Dockerデーモンに送るのと npm install を実行するのとでは、ネットワーク環境にもよりますがそれほど時間に差はないです。

また、マルチステージビルドを利用すると中間イメージが <none:none> として残ってしまいます。
これを避けるには以下のいずれかを実施します。

  • --target オプションで中間イメージにタグ付けする(もちろんタグが付いたイメージは残ります)
  • 最後のイメージのビルド成功後、docker image prune を実行する

関連のGitHub Issue では「仕様です(意訳)」で切り捨てられるも、オプションでどうにかできないか、イメージのビルダーを新しいものにすることで対応できないかといった議論が行われていたようです。現状、新しいビルダー(BuildKit)をデフォルトにすることで対応するというのが本線のようです。(個人的にはBuildKitを使ったとしても、docker image pruneするのとdocker builder prune するのとに如何ほどの違いが...? という疑問あり。。。特定ビルドに対応する中間イメージやキャッシュのみを削除したい)

参考