Docker マルチステージビルドのユースケースと実践

Docker が少し前から「マルチステージビルド」なる機能を提供していましたが、ちょっとだけ使い込んでみる機会があったので、自分なりにまとめてみました。

# TL;DR #

* 1つの Dockerfile に何個も `FROM` が書ける
  + 1つの `FROM` から次の `FROM` or ファイル末尾までが1ステージ
* `COPY --from=foo` で、既出のステージや出来合いのイメージからファイルをつまみ食いできる
  + `RUN` の成果物だけあればよいようなケースに最適 (コンパイル言語系のシステムとか)
* `FROM` の元ネタとして、既存イメージの他に既出のステージも指定できる
  + 多様だが共通部が多いようなイメージを作るケースで DRY にできる 

---
# `COPY --from=foo` で、既出ステージの成果物つまみ食い #

## `COPY --from=foo` の例 ##

次は、 jemalloc をビルドするだけの一時的なイメージを作るステージと、そのイメージからビルド済みのバイナリだけを抜き出してデプロイ済みにしたイメージを作るステージに分割する例です。

```Dockerfile
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 イメージをビルドするには、以下のようにします。

```sh
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つ目のステージでビルドしています。

```Dockerfile
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度だけ動きつつ、コンテナ内には最小限のファイルしかないようなイメージを作り分けることができます。

```sh
docker build --target=foo -t multi-stage-exp/rust-foo .
docker build --target=bar -t multi-stage-exp/rust-bar .
```

```console
$ 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` にして保護する開発向けイメージ用のステージと、アプリソースを取り込んでおいてデプロイすればすぐ動く本番向けイメージ用のステージという形で、ある意味分岐させています。

```Dockerfile
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` はもはや推奨されない」とどこかで耳にしたような気もしますが、正しく使えば有用なので気のせいであって欲しい。