至高のDockerイメージ生成を求めて

本稿は良いDockerイメージを良い方法でビルドすることを探求した記録である。
Supership株式会社 Advent Calendar 2016の21日目にあたる。

2019年現在は@inductor氏の改訂版を見たほうが良い。
この記事で論じた望ましいコンテナイメージの姿は2019年でも変わらない。ただし、multi-stage buildのような新しい仕組みが普及したりツールの評価が定まってきたりと、実現に用いるツールの状況が2016年からやや変化している。

良い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/

しかし、コンパイルしたりパッケージシステムを利用したりすると急に話が難しくなる。
以下ではそうした幾つかのケースを見ていこう。

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

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

悪い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である。しかし、他にも特定の局面で便利なツールはあるので下記に述べる。

golang-builder

上で書いたようなビルド環境用コンテナを立ち上げる作業を自動化してくれる。
残念ながら汎用ツールではなく、golangプロジェクト専用である。

Bazel

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

Bazelは非常に再現性の高いビルドツールである。Bazelがビルドに用いるサンドボックス環境はDockerコンテナに比べると隔離レベルが低いものの、全てのビルド依存関係をBUILDファイルに完全に書き下すことを前提としているため同じ入力に対しては環境によらずに同じアーティファクトが生成される。
また、Bazelは様々な言語のビルドをサポートできるように拡張可能になっており、その機構を利用してDockerイメージのビルドにも対応している。
これらを組み合わせると、go getbundle installの類の依存パッケージ取得から、コンパイル、アセットの圧縮、Dockerイメージ構築までをすべてbazel buildコマンド1つで行うことができる。

Google Cloud Container Builder

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

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

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

c.f. 「Google Cloud Container Builderを使う」

コメントへの返信

http://b.hatena.ne.jp/entry/313355533/comment/kenzy_n

id:kenzy_n: 『このイメージを構築したのは誰だ!!』

究極のDockerイメージ生成の記事をお待ちしています。


http://b.hatena.ne.jp/entry/313355533/comment/hylom

id:hylom Linuxディストリビューションによって作られたバイナリ配布エコシステムを大規模に再発明している気がしてならない

その指摘は当たっていて、そもそも承知のようにDockerイメージの構造はAMIや.boxに比べれば.deb.rpmの構成のほうにむしろ近い。
大きな違いは2点あって、(1) git方式のcontent-hashで親レイヤーを一意に特定し、生成される環境が一意であることを保証している (2) Docker engineに実行される前提で、起動されるプロセスのためのメタデータを含んでいる。
言い換えれば、基本的にimmutableなコンテナ環境を作る目的に特化して若干特性は違うものの、Dockerイメージ自体はまさに.deb.rpmの再発明といえる。

そもそもDockerfileのデザイン的には「レイヤーの転送は効率的に行えるから、ある程度無駄なファイルがコンテナに含まれてもよい」という割り切りがあったように見える。しかし、debやrpmがビルド時依存を解決してビルド後に最終成果物だけをパッケージ化する仕組みを見てきている我々年寄りにはそういうのはなんとなく気持ち悪く見える。また、気分の問題だけでなく、実際に10秒で済んだはずのdeployに5分掛かるような問題が発生しつつあるのが今日の状況である。
そこで、結局我々はcontainer-nativeなdebian/rules.specを必要としているわけである。

実のところ、開き直って.debを作ってておいて最後にDockerイメージに変換してからデプロイする選択肢はあり得る。Bazelのdocker_buildルールはイメージに含むファイルのソースとして.debを受け付けるので、そういうやり方を取ることもできる。


http://b.hatena.ne.jp/entry/313355533/comment/mapk0y

id:mapk0y: どの様に作られたかがわかりやすい(出来れば Dockerfile で完結している)ことも結構重要なきがする。/COPY ではなく ADD を利用しているのが気になる

ADDは直しました。

一揃いのビルド設定だけで1つのツールでビルドからイメージ生成まで完結している利点は確かに欲しい。でも、その目的にはDockerfileは必ずしもすべてのケースで優秀ではないように見える。

開発環境構築それ自体が複雑なケースでは、ローカルの開発環境設定(Vagrantfile + Chefとか)とDockerfileとCI環境の設定(.travis.ymlとか)の複数を管理する必要が出てきて、これらの間で共通して使える環境設定スクリプトとかを作ろうとするとだいぶ面倒なことになる。この点では「コンパイラをどこから取得するか」「依存ライブラリをどこから取得するか」「どの手順でコード生成するか」「どうやってビルドするか」「何をDockerイメージに入れるか」を一貫して記述できるBazelの利点が目立ってくる。
そしてDockerfileを捨ててBazel BUILDファイルに一本化する勇気があれば、確かに1つのツールでビルドからイメージ生成まで完結するのである。

「わかりやすさ」に関して言えば、手順を書き下す「設定スクリプト」方式と、依存関係を書き下す「Chef/Puppet方式」のどちらがわかりやすいかは規模や場合による気がする。Dockerfileは前者でBazelは後者。golang-builderは全自動でやってくれるからどちらでもなく、Google Cloud Container Builderは両方の側面を持つ。

ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
コメント
この記事にコメントはありません。
あなたもコメントしてみませんか :)
すでにアカウントを持っている方は
ユーザーは見つかりませんでした