Edited at

至高のDockerイメージ生成を求めて -2019年版-

この記事は@yugui氏の書いた至高のDockerイメージ生成を求めてに感謝しつつ、記事が投稿された当時には無かったさまざまな事情を組み込んで再度まとめたものである。


良いDockerイメージ

良いDockerイメージとは何だろうか。Dockerの利点は次のようなものだから、それを活かすイメージが良いものであるに違いない。


  1. ビルドしたイメージはどこでも動く


    • 適切にインストールされ、設定されたアプリケーションをそのままどこにでも持っていける。



  2. コンテナ同士が干渉し合うことはないので、任意のイメージを互いに配慮することなく柔軟に配備し実行できる

  3. 必要のないサービスがコンテナ内で走っていないので、セキュリティの向上に資する

  4. イメージの転送が効率的である


    • ベースイメージ部分は一度送ればいちいち再転送する必要がないので、ベースイメージを共有する複数のイメージを効率的に転送できる



  5. 標準のレジストリAPIやDocker Hubがあるので、豊富なビルド済みのイメージを利用できる。


    • アプリケーションを自前でビルド、設定しなくても使えることが多い



と、すると、良いイメージとは次のようなものであろうか。


  1. デプロイ先依存の情報が埋め込まれていない。そういうものはvolumeや環境変数やコマンドライン引数で受け付けられるようにデザインされている。

  2. 不必要にprivilegedコンテナを要求せず、コンテナ内では単一プロセスグループだけが走っている。しばしば単一プロセスだけが走っている

  3. 動作に必要な最小限のファイルだけが含まれている

  4. 適切にレイヤー分けされていて、可能な限りデータ再転送を抑えるようになっている

  5. 複雑なインスタンス化時設定を必要とせずに、事前によく設定されている

難しいのは(3)と(4)だ。本稿では次節以降において主にこれらを扱いたい。

他の点に関して言えば、(1)はアプリケーションモジュールの設定技法に属する話題だ。ただし、シンプルで再利用性のあるコンテナイメージに適切な設定を引き渡すにはコンテナ管理システムに様々な機能が必要になる。

実際、KubernetesではConfigMapやSecretsや永続ブロックストレージや、様々なところから自由にvolumeや環境変数を設定できるようになっている。それは(1)を満たすコンテナをサポートするために他ならない。


良いコンテナイメージをビルドするには

「最小限のファイル」を「適切にレイヤー分けする」という要請はビルドコンテキストからファイルをCOPYするだけのケースであれば難しくない。要は必要のないファイル、特にバイナリやスクリプト、あるいはCredentialはCOPYしなければよいのだ。.dockerignoreもそれを助けてくれる


悪いDockerfile

FROM ruby

COPY . /opt/myapp/


良いDockerfile

FROM ruby

COPY bin src config /opt/myapp/


.dockerignore

.git

vender
.idea
docker-compose.yaml

しかし、コンパイルしたりパッケージシステムを利用したりすると急に話が難しくなる。

以下ではそうした幾つかのケースを見ていこう。


依存パッケージリストの管理問題

まずはよく知られた例を見てみよう。


悪いDockerfile

FROM ruby

WORKDIR /opt/myapp

COPY ./ /opt/myapp
RUN bundle install # Gemfileに変更がなくても実行される


bundle install再実行の図

docker buildコマンドはビルド結果をキャッシュして不必要なビルドは省くとともに、過去にビルドされたベースイメージのIDも変わらないようにしてくれる。

しかし、上の例では.以下のファイルが一個でも変更された場合はCOPY行で生成されるイメージが変化し、それに依存するRUN行も再ビルドする必要が発生する。

よって、おそらく大抵は変化していないであろうruby gemsのセットを再取得し、さらにデプロイ時に再転送する必要がある。


良いDockerfile

FROM ruby

WORKDIR /opt/myapp

COPY Gemfile* /opt/myapp
RUN bundle install
COPY ./ /opt/myapp


そこでこのようにGemfileGemfile.lockだけ先に追加してbundle installを済ませる。こうするとRUN行はGemfileGemfile.lockが変更されたときにのみ実行され、また結果として生成されるレイヤーはそのときにのみ再転送されることになる。


コンパイル問題

さて、ではソースをコンパイルする言語の場合はどうだったろうか。


悪いDockerfile

FROM golang

COPY ./ /go/src/github.com/foo/bar
RUN go get github.com/foo/bar

コンパイル問題の図

まるで駄目である。

第一に、実行時はソースは必要ないのだからそれをイメージ内に残したくない。

第二に、実行時はGoコンパイラも要らないのでイメージ内に残したくない。

承知のように、一度いらないファイルを含むレイヤーを作ってからあとでRUN rm ...しても別のレイヤーができるだけなので問題の解決にはなっていない。

どうも、コンパイルをDockerfileの中でやってはいけないようだ。そこで、Dockerfileの外部でコンパイルした結果のバイナリだけをイメージに追加することにする。


Makefile

all: docker-build

docker-build: bin/bar
docker build -t gcr.io/my-project/foo .

bin/bar: *.go
go build -o bin github.com/foo/bar

.PHONY: docker-build



Dockerfile

FROM busybox

COPY bin/bar /usr/local/bin/

しかし、このやり方はまた新たな問題を幾つか含んでいる。


  • Golangコンパイラぐらいなら良いけど、ビルドに幾つものツールが必要な場合、ツールをどうやってインストールするのか書かれていない。


    • 汎用的なCI環境上で設定する際に大変である -- C++コンパイラとJavacとscalaとgoとrubyとpythonとnodeとrustとmakeとautotoolsとbisonとopensslとprotocと、それらの各種lintとパッケージマネージャーと各種Cライブラリを前提とするCI環境とかメンテナンスしたくない



  • ビルドの再現性が低い。せっかくビルド環境が完全にdocker内に隔離されていたのに、またビルドがローカル環境に依存するようになった。


    • 「自分の手元ではビルドできるよ」問題が復活する



そこで、先人はビルドに別のコンテナを使ってビルドを隔離する方法を発明した。

これならビルドに必要なツールは予めそういうdockerイメージを作っておけば簡単にdocker pullできるし、ビルド再現性も担保できている。


Makefile

all: docker-build

docker-build: bin/bar
docker build -t gcr.io/my-project/foo .

bin/bar: *.go
docker run --rm \
-v $PWD/bin:/go/bin \
-v $PWD:/go/src/github.com/foo/bar \
go get github.com/foo/bar

.PHONY: docker-build



Dockerfile

FROM busybox

COPY bin/bar /usr/local/bin/

上では単にgolangイメージを使ったが、もちろん自分たち固有のビルド要ツールチェーンが必要な場合は別途Dockerfileを書いて、ビルド環境用コンテナのイメージを予めビルドするのである。

ビルドに必要なツールが増えたり、ビルドステップが長くなるにつれてこの手間は膨れ上がる。そして、ビルドツールコンテナ自体をビルドする時間が長くなっていく。

ビルドステップごとに別のコンテナを立ち上げればビルド環境用イメージのDockerfileはシンプルに保つことができるが、今度は多数のステップの間の連携を管理する手間が増える。

膨れ上がったコンテナ化ビルドステップの図


よりよいツールを求めて

ここまででだいぶ話がややこしくなった。

もともとのシンプルなDockerfileの世界を思い出してみよう。Dockerfileにビルド手順を書いておけばdocker buildだけでローカル環境に依存せずに手元のマシンでもCI環境上でも同じようにビルドできるので楽という世界観だったはずである。

このとき、ビルドはコンテナ環境で隔離されているから再現性があって、しかもビルドに必要なツール自体もFROM golangのように書けるのでCI環境構築も楽なのだった。

それが、いまやDockerfileと、ビルド環境用のイメージをビルドするためのDockerfileと、それらを適切な順序で適切なコマンドでビルドするためのMakefileが必要になった。

最小限のサイズで適切にレイヤー分けされているイメージが欲しかっただけなのに。

そろそろよりよい良いツールが必要である。


良いイメージをビルドするための良いツール

そこで期待されるのがDocker Multi-stage buildである。しかし、他にも特定の局面で便利なツールはあるのでそれらを一通り列挙する。


Docker Multi-stage build

Docker Multi-stage buildとは、Dockerfileにおける FROM 句を複数回呼び出すことによって、複数のビルド(1ビルドの単位をステージと呼びます)を展開できる機能のことである(バージョン17.05より利用可能)。

こうすると、ある特定のステージから別のステージに対してファイルをコピーできるようになり、ソースコードのコンパイル用ステージとコンパイル済みバイナリの実行用ステージなどを分けることで、(ビルド用に入れた依存関係が成果物のDockerイメージに残らないため)イメージサイズを小さくできる効果がある。

具体的には以下のような形で記述される。


Dockerfile.example

FROM golang:1.13.0-alpine3.10 as builder

RUN apk --no-cache add git
WORKDIR /go/src/github.com/org/hoge-app
ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64
COPY go.mod go.sum ./
RUN GO111MODULE=on go mod download
COPY . /app
RUN go build -o /hoge-app

FROM alpine:3.10 as executor
COPY --from=builder /go/src/github.com/org/hoge-app /hoge-app
CMD ["/hoge-app"]


こうすることで、golangのビルドに必要だったライブラリ群がすべて不要となり、実行用のイメージ+生成したバイナリのDockerイメージが出来上がる。


BuildKitを用いたDockerイメージビルドにおける並列実行性の向上

BuildKitはDockerのバージョン18.06より搭載された次世代Dockerイメージビルドツールで、後方互換性を保ちながら、既存のビルダーに比べキャッシュやビルド実行並列性などが向上しているのが特徴である。

具体的には、BuildKitでは並列に実行可能な命令は並列に行うという特徴を持っているためで、他のステージに依存しない命令を複数ステージに分割すると、並列ビルドの恩恵によりビルド時間が短縮される。

例えば、あるプログラムをコンパイルするために必要な対応が

・ビルドに必要な別のライブラリを取得

・ライブラリのビルドに必要なパッケージを入れて

・ライブラリをコンパイル

・プログラムのソースコードを取得

・プログラムのビルドに必要なパッケージを入れて

・プログラムをコンパイル

だった場合、プログラムのコンパイル処理はコンパイル対象コンパイルに必要な依存関係ライブラリの準備が必要なため並列実行が難しいが、それ以外の処理については並列に実行が可能である。

そのため、以下のように書くと同時にビルド処理が実行できる。

FROM hoge as base-lib

RUN 共通パッケージのインストール

FROM hoge as fetch-lib
RUN lib取得

FROM hoge as build-lib
COPY --from=fetch-lib /path/to/lib /path/to/lib # フェッチの処理に引っ張られる
RUN libコンパイル

FROM hoge as base-program
RUN 共通パッケージのインストール

FROM hoge as fetch-program
RUN program取得

FROM hoge as build-program
COPY --from=fetch-program /path/to/program /path/to/program # フェッチの処理に引っ張られる
COPY --from=build-lib /bin/lib /bin/lib# libのビルドに引っ張られる
RUN programコンパイル

FROM hoge as executor
COPY --from=build-program /bin/program /bin/program
CMD ["/bin/program"]


golang-builder

上で書いたようなビルド環境用コンテナを立ち上げる作業を自動化してくれる。

残念ながら汎用ツールではなく、golangプロジェクト専用である。

2019年におけるgolang-builderの状況: 2019年時点においてはこのツールは一般的とは言えず、multi-stage buildによってBuildのステージから実行環境のステージにCOPY --fromするのが主流である。また、プロジェクト自体もここ1年動きがないことから、Development Activityも低下していると言えるだろう。


Bazel

Dockerfileにすべてのビルド手順が書いてあるとうれしかったのは、再現性があるし、docker buildコマンドだけで簡潔に話が済むからである。

では、ビルド再現性があってコマンドが簡潔なら、ビルドはコンテナ内でなくても良いのではないだろうか。

Bazelは非常に再現性の高いビルドツールである。Bazelがビルドに用いるサンドボックス環境はDockerコンテナに比べると隔離レベルが低いものの、全てのビルド依存関係をBUILDファイルに完全に書き下すことを前提としているため同じ入力に対しては環境によらずに同じアーティファクトが生成される。

また、Bazelは様々な言語のビルドをサポートできるように拡張可能になっており、その機構を利用してDockerイメージのビルドにも対応している。

これらを組み合わせると、go getbundle installの類の依存パッケージ取得から、コンパイル、アセットの圧縮、Dockerイメージ構築までをすべてbazel buildコマンド1つで行うことができる。

2019年におけるBazelの状況: BazelはDockerイメージのビルダーとしても一定の支持を獲得しており、Bazelを使って様々なプラットフォームのアプリケーションを管理するというのは十分合理的な選択肢と言える。


Google Cloud Build

たとえ、上図のようにコンテナ化されたビルドステップの依存関係を管理し、コンテナを適切なvolumeや引数で立ち上げ、エラーを処理するのが大変だとしても、それをすべて他に任せられるとすれば問題ないのではないだろうか。

Google Cloud Buildはそれをやってくれるサービスだ。

ビルドの各ステップで利用するDockerイメージ、実行すべきコマンド、ステップ間の依存関係などをYAMLで指定すると、イメージのpullやvolume mount、ステップ間のアーティファクトの引き渡し、生成されたイメージのpushなどは自動的にやってくれる。

c.f. 「GCP Cloud Build の基礎からの説明 & CI/CD 実践」

2019年におけるCloud Buildの状況: Cloud BuildはGCPにおけるCI/CD環境構築のコンポーネントとして重要な一角を担っており、特にDockerイメージのビルドに関して言えばビルドツールの「kaniko」が使えるため、ニーズに合っていて選べるのであれば選んで良いと言える。


コンテナイメージのビルドを支えるさまざまなツール

Dockerイメージというが、OCICNCFが標準化を進める折、Docker社(dotCloud社)が生み出したこの「イメージ」や「コンテナ」はもはやDockerだけのツールとは言えなくなってきている。筆者の周りでも、Dockerイメージという名称よりはむしろコンテナイメージと呼ばれることが増えているように感じる。

Kubernetesもそうだし、Google App EngineのようなPaaSの基盤に使われることも増え、その多様性も注目すべきところだろう。

NTTの@AkihiroSuda氏のスライド資料にもある通り、「コンテナイメージ」をビルドするための様々なツールが開発されていることがわかる。