1. Qiita
  2. 投稿
  3. docker

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

  • 22
    いいね
  • 0
    コメント

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

良いDockerイメージ

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

  1. ビルドしたイメージはどこでも動く
    • 適切にインストールされ、設定されたアプリケーションをそのままどこにでも持っていける。
  2. コンテナ同士が干渉し合うことはないので、任意のイメージを互いに配慮することなく柔軟に配備し実行できる
  3. 必要のないサービスがコンテナ内で走っていないので、セキュリティの向上に資する
  4. イメージの転送が効率的である
    • ベースイメージ部分は一度送ればいちいち再転送する必要がないので、ベースイメージを共有する複数のイメージを効率的に転送できる
  5. 標準のレジストリAPIやDocker Hubがあるので、豊富なビルド済みのイメージを利用できる。
    • アプリケーションを自前でビルド、設定しなくても使えることが多い

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

  1. デプロイ先依存の情報が埋め込まれていない。そういうものはvolumeや環境変数やコマンドライン引数で受け付けられるようにデザインされている。
  2. 不必要にprevilegedコンテナを要求せず、コンテナ内では単一プロセスグループだけが走っている。しばしば単一プロセスだけが走っている
  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

ADD 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が必要になった。
最小限のサイズで適切にレイヤー分けされているイメージが欲しかっただけなのに。

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

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

残念ながら、決定版といえるツールはまだ現れていないように見える。しかし、いくつか良いものはあるので紹介しようと思う。

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などは自動的にやってくれる。

Comments Loading...