Docker が少し前から「マルチステージビルド」なる機能を提供していましたが、ちょっとだけ使い込んでみる機会があったので、自分なりにまとめてみました。
TL;DR
- 1つの Dockerfile に何個も
FROM
が書ける- 1つの
FROM
から次のFROM
or ファイル末尾までが1ステージ
- 1つの
-
COPY --from=foo
で、既出のステージや出来合いのイメージからファイルをつまみ食いできる-
RUN
の成果物だけあればよいようなケースに最適 (コンパイル言語系のシステムとか)
-
-
FROM
の元ネタとして、既存イメージの他に既出のステージも指定できる- 多様だが共通部が多いようなイメージを作るケースで DRY にできる
COPY --from=foo
で、既出ステージの成果物つまみ食い
COPY --from=foo
の例
次は、 jemalloc をビルドするだけの一時的なイメージを作るステージと、そのイメージからビルド済みのバイナリだけを抜き出してデプロイ済みにしたイメージを作るステージに分割する例です。
FROM alpine:3.8 as jemalloc
RUN apk update && apk upgrade && apk add --update --no-cache alpine-sdk
ARG JEMALLOC_VERSION=5.1.0
COPY jemalloc-${JEMALLOC_VERSION}.tar.bz2 ./
RUN tar xjf jemalloc-${JEMALLOC_VERSION}.tar.bz2 && \
cd jemalloc-${JEMALLOC_VERSION} && \
./configure && make && make install
FROM ruby:2.6-alpine3.8 as ruby-with-jemalloc
ARG JEMALLOC_SHLIB_MAJOR=2
COPY --from=jemalloc /usr/lib/libstdc++.so.* /usr/lib/libgcc_s.so.* /usr/lib/
COPY --from=jemalloc /usr/local/lib/libjemalloc.so.${JEMALLOC_SHLIB_MAJOR} /usr/local/lib/
RUN ldconfig /lib /usr/lib /usr/local/lib
この Dockerfile を使って jemalloc 入りの ruby イメージをビルドするには、以下のようにします。
docker build --target=ruby-with-jemalloc -t multi-stage-exp/ruby .
細かいこと
-
FROM
のas
以降にステージの名前をつけられる。Dockerfile の文法上は無くても問題ないのですが、その場合は
--from=2
のような指定をすることになり、メンテナンス性が落ちるためなるべくつけた方がいいでしょう。 -
FROM
で指定する元ネタは、各ステージ同士で系譜が違っていても構わない。ただし、 ABI レベルで互換性がないようなケースでは実用的でないでしょう。
COPY --from=foo
ができるとどう幸せなのか?
-
イメージを小さくできる
ファイルをビルドするのに使うだけであとは要らないようなものをいちいち後始末として明示的に削除しなくて済む(先ほどの例がこれにあたります)
-
複数イメージで共通で使うファイルをビルドする場合、ビルド自体は1度で済ませることが可能
ぱっと見では docker build の出力は毎回ビルドしているかのようだが、 docker のビルドキャッシュが効くため2つ目以降のイメージビルドは実質ノータイムで済んでいるはず(次の例がこれにあたります)
COPY --from=foo
の別の例
次の例では、 Rust で書かれていて cargo build
すると foo
, bar
2つのバイナリができるプログラムを、1つ目のステージで Rust の処理系をインストールしつつ一括でビルドし、 foo
のみデプロイされたイメージと bar
のみデプロイされたイメージを2つ目と3つ目のステージでビルドしています。
FROM ubuntu:18.04 as builder
ENV RUST_VERSION stable
ENV PATH $PATH:/root/.cargo/bin
RUN \
apt-get update && apt-get -y upgrade &&\
apt-get install -y --no-install-recommends \
build-essential curl pkg-config libssl-dev ca-certificates && \
curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain ${RUST_VERSION} && \
/root/.cargo/bin/rustup component add rustfmt-preview
WORKDIR /root
COPY sample ./
RUN cargo build
FROM ubuntu:18.04 as foo
COPY --from=builder /root/target/debug/foo /usr/local/bin/
CMD foo
FROM ubuntu:18.04 as bar
COPY --from=builder /root/target/debug/bar /usr/local/bin/
CMD bar
(Rust のコードは略: println!("Hello, foo!");
の類)
これを、以下の要領で docker build
してやると、 cargo build
が実質1度だけ動きつつ、コンテナ内には最小限のファイルしかないようなイメージを作り分けることができます。
docker build --target=foo -t multi-stage-exp/rust-foo .
docker build --target=bar -t multi-stage-exp/rust-bar .
$ docker run --rm multi-stage-exp/rust-foo
Hello, foo!
$ docker run --rm multi-stage-exp/rust-bar
Hello, bar!
FROM
の元ネタに既出のステージを使う
注意: この機能はドキュメント化されていません! たまたま使えてしまっているだけの可能性があります。
Dockerfile を書いていて、「途中までは全く作り方は一緒なのに、後処理が微妙に違うイメージを何種類も用意しないといけない」場面で困った人は多いと思います。
今までだと、
- DRY ではなくなるが、 Dockerfile をコピペして作り分ける
- 共通する部分だけの中間イメージを作る Dockerfile と、個別イメージ用の Dockerfile に分割する
しか無かったのですが、マルチステージによって3つ目のアプローチが可能になりました。それが、
- 共通する部分までで1ステージとし、このステージをベースに個別イメージ用のステージを用意
というアプローチです。
Rails + webpacker + VUE.js が動くのに十分な共通ステージとその変種
Rails をはじめとするインタプリタ系言語の web アプリ開発では、アプリソースをコンテナ内に入れずフレームワーク本体だけが入ったコンテナにホスト共有ボリュームをマウントして迅速な開発をしたい一方で、本番やそれに近い動作モードではオールインワンのイメージを柔軟にデプロイして運用したい、という欲求がしばしば出てきます。
次の例では、フレームワーク本体だけが含まれるステージをベースに、アプリソースをホスト共有ボリュームで供給する想定でホスト側から汚染されると困る部分を VOLUME
にして保護する開発向けイメージ用のステージと、アプリソースを取り込んでおいてデプロイすればすぐ動く本番向けイメージ用のステージという形で、ある意味分岐させています。
FROM ruby:2.5-alpine as base
RUN apk update && apk upgrade && apk add --update --no-cache alpine-sdk postgresql-dev nodejs yarn tzdata
RUN mkdir /app
WORKDIR /app
ARG BUNDLE_OPTIONS
COPY Gemfile Gemfile.lock ./
RUN bundle install -j$(getconf _NPROCESSORS_ONLN) ${BUNDLE_OPTIONS}
COPY package.json yarn.lock ./
RUN yarn install
COPY bin bin/
COPY config config/
COPY Rakefile config.ru ./
RUN bundle exec rails webpacker:install && \
bundle exec rails webpacker:install:vue && \
bundle exec rails webpacker:install:typescript && \
yarn install && \
sed -i -e s:mm/dd/yyyy:yyyy/mm/dd:g /usr/local/bundle/gems/bootstrap-datepicker-rails-*/vendor/assets/javascripts/bootstrap-datepicker/core.js || true
EXPOSE 3000
CMD [ "bundle", "exec", "rails", "s", "-p", "3000", "-b", "" ]
# Stage 1: for use-cases to have the Rails app within shared-with-host volume,
# mainly development env.
FROM base as shared
# To retain the contents in node_modules through an annonymous volume,
# as well as to enjoy benefits of shared-with-host volume.
VOLUME /app/node_modules
# Stage 2: for environments without shared-with-host volumes, usually production and the like.
FROM base as self-contained
COPY . ./
COPY --from=base /app/Gemfile.lock /app/yarn.lock ./
RUN bundle exec rake assets:precompile
FROM base as
〜 となっているところに注目。
途中まで共通で、後ろのステージで複数の変種を作ることが可能というわけです。
その他
- 特に説明をしませんでしたが、ある特定のステージのイメージをビルドしたい時は、
docker build --target
ステージ名 のようにします。 -
VOLUME /foo
などとすると、指定したパス以下のその時点までのレイヤーの内容で埋められたボリュームが、コンテナ開始時にマウントされます。-
node_modules
やvendor/bundler
といった、ホスト共有ボリュームの範囲からは除外したいが内容物は使いまわしたいようなニーズに向いているでしょう。 - 「
VOLUME
はもはや推奨されない」とどこかで耳にしたような気もしますが、正しく使えば有用なので気のせいであって欲しい。
-