Docker Executor では、Docker イメージをプライマリ イメージとして使用します。Docker イメージはコンテナの設計図であり、コンテナを生成するための命令 (インストラクション) が記述されています。この記事では、見過ごされがちな概念の中から、Docker イメージの記述とビルドのプロセスを最適化するうえで役立つものをいくつかご紹介します。

Docker イメージのビルドのしくみ

最初に、Docker build プロセスについて簡単に説明しましょう。Docker のビルド プロセスは、Docker CLI ツールdocker build コマンドを実行することでトリガーされます。

docker build コマンドを実行すると、Dockerfile というファイルで指定した命令に従って、Docker イメージがビルドされます。Dockerfile はテキスト ドキュメントで、イメージを作成するためにコマンド ラインで呼び出すすべてのコマンドを順に記述します。

Docker イメージは、読み取り専用の複数のレイヤーで構成されます。1 つのレイヤーが、Dockerfile に記述された 1 つの命令に相当します。レイヤーは積層状になっており、各レイヤーには 1 つ前のレイヤーとの変更差分のみが含まれます。私は、これらのレイヤーを一種のキャッシュと捉えています。変更があるたびに全レイヤーを更新するのではなく、変化のあるレイヤーのみが更新されるわけです。

下の例は、Dockerfile の内容を示したものです。

FROM ubuntu:18.04
COPY . /app
RUN make /app
CMD python /app/app.py

このファイル内にある各命令が、Docker イメージ内の各レイヤーに相当します。各命令を簡単に説明すると、次のようになります。

  • FROM は、レイヤー作成のベースとなる Docker イメージとして ubuntu:18.04 を指定します
  • COPY は、Docker クライアントのカレント ディレクトリからファイルをコピーします
  • RUN は、make を実行してアプリケーションを構築します
  • CMD は、コンテナ内で実行するコマンドを指定します

これら 4 つのコマンドはビルド プロセス中に実行され、それによって Docker イメージ内にレイヤーが作成されます。

イメージとレイヤーの詳しい解説については、こちらをご覧ください。

イメージ ビルド プロセスの最適化

Docker のビルド プロセスについて確認できたところで、続いては、イメージを効率的にビルドするうえで役立つ最適化のアドバイスをご紹介したいと思います。

エフェメラルなコンテナを作成する

Dockerfile でイメージを定義する際は、生成するコンテナがエフェメラル (一時的) なものになるようにしましょう。ここで言うエフェメラルなコンテナとは、コンテナを停止、破棄でき、その後で最小限のセットアップと構成で新しいコンテナを再構築して置き換えられることを意味します。使い捨て可能なコンテナと言い換えてもよいでしょう。各コンテナ インスタンスは新しく生成され、以前のインスタンスとは関連を持ちません。Docker イメージを作成する際は、エフェメラルなコンテナをできるだけ多く利用することをお勧めします。

不要なパッケージをインストールしない

必要のないファイルやパッケージをインストールすることは避けましょう。Docker イメージは、できるだけ無駄がないのが望ましい姿です。不要なインストールを避ければ、移植性の向上、ビルド時間の短縮、複雑さの軽減、ファイル サイズの削減といったメリットが得られます。たとえば、たいていのコンテナには、テキスト エディターをインストールする必要はありません。必要不可欠とは言えないアプリケーションやサービスはインストールしないようにしましょう。

.dockerignore ファイルを実装する

.dockerignore ファイルを使用すると、このファイル内で宣言したパターンと一致するファイルやディレクトリを除外できます。このファイルは、サイズが大きいまたは機密情報の含まれるファイルやディレクトリを必要がないのにデーモンに送信した結果、これらをパブリック イメージに追加してしまうのを防止する役目を果たします。

ソース リポジトリを再構築することなくビルドに無関係なファイルを除外したいときには、.dockerignore ファイルを使いましょう。このファイルは、.gitignore ファイルと同様の除外パターンをサポートしています。

複数行にわたる引数を並べ替える

後々の変更を容易にするために、複数行にわたる引数は、可能であればアルファベット順に並べましょう。こうすることで、パッケージの重複を回避でき、引数の更新がとても簡単になります。また、プル リクエストの可読性が高まり、レビューの手間を大幅に減らせます。同様に、バックスラッシュ ` \ ` の前にスペースを追加しておくとさらに見やすくなります。

具体例として、以下に Docker Hubbuildpack-deps イメージの一部を示します。

RUN apt-get update && apt-get install -y \
  bzr \
  cvs \
  git \
  mercurial \ 
  subversion \
  && rm -rf /var/lib/apt/lists/*

アプリケーションを分離する

他のアプリケーションに依存しているアプリケーションは、いわば「くっついている」状態と言えます。これらのホスト先は同じホストやコンピューティング ノードであることもあれば、分かれていることもあります。このような状態はコンテナを使用しない環境では一般的ですが、マイクロサービスの場合、各アプリケーションをそれぞれ個別のコンテナに配置するのが望ましいかたちです。複数のコンテナにアプリケーションを分離すれば、スケールアウトやコンテナの再利用が容易になるからです。たとえば、Web アプリケーション スタックであれば、3 つのコンテナに分離できます。Web アプリケーション管理用、データベース管理用、インメモリ キャッシュ用の 3 つのコンテナとそれぞれのイメージといった具合です。

コンテナごとにプロセスを 1 つに制限するのは、有用な経験則です。コンテナはできるだけモジュール化して、すっきりした状態を保ちましょう。コンテナが相互に依存する場合は、Docker コンテナ ネットワークを利用すれば、コンテナ間通信を実現できます。

レイヤーの数を最小限に抑える

レイヤーを作成できる命令は、RUNCOPYADD だけです。他の命令では、一時的な中間イメージは作成されますが、最終的なビルドのサイズが増えることはありません。可能であれば、必要なアーティファクトのみを最終イメージにコピーするようにしましょう。そうすることで、追加のツールやデバッグ情報を中間のビルド ステージで扱いつつも、最終イメージのサイズを増やさずに済みます。

ビルド キャッシュを活用する

イメージのビルド時には、Dockerfile 内の命令が上から順に実行されます。Docker は命令ごとにキャッシュ内に既存のイメージがないかをチェックします。あればそれを利用し、同じイメージを重複して新規作成することはしません。このとき、Docker は以下のような規則に従います。

最初の命令で親イメージがキャッシュ内に既に存在していることが確認されると、次の命令は、そのベース イメージから派生したすべての子イメージと比較され、まったく同じ命令でビルドされたものがないかがチェックされます。なければ、キャッシュは無効化されます。

ほとんどの場合は、Dockerfile の命令と子イメージの単純な比較で十分です。しかし、命令によっては、さらなる検査や解釈が必要になるものがあります。

ADD 命令と COPY 命令では、イメージに含まれるファイルの内容が調べられ、各ファイルのチェックサムが計算されます。チェックサムの計算では、ファイルの最終更新時刻と最終アクセス時刻は考慮されません。キャッシュをチェックする際に、このチェックサムが、既にあるイメージのチェックサムと比較されます。内容やメタデータなど、ファイル内の何かが変更されていると、キャッシュは無効になります。

ADDCOPY 以外の命令では、キャッシュをチェックする際、キャッシュが一致するかどうかの検証時にコンテナ内のファイルを調べることはしません。たとえば、RUN apt-get -yというコマンドの処理が行われる際は、キャッシュが一致するかどうかの検証で、コンテナ内で更新されたファイルの内容は調べられません。キャッシュの一致は、コマンド文字列を使って判断されます。

キャッシュが無効化されると、以降の Dockerfile 命令ではキャッシュは使用されず、新しいイメージが生成されます。キャッシュをうまく利用するには、変更の多いレイヤーが下位に配置されるようイメージのレイヤーを構成する必要があります。たとえば、RUN ステップを複数個記述する場合は、頻繁に変更されるステップほど Dockerfile の下へ、変更頻度が少ないステップほど上へ並べるようにします。

CI パイプラインでの Docker イメージのビルドを最適化する

ここまで、主にコードや Docker CLI でのビルドの観点で Docker イメージのビルド プロセスに関する概念を説明しながら、最適化のヒントを紹介してきました。これらの最適化の工夫は CI パイプラインに組み込む場合でも大きな違いはありません。CircleCI には、自動化された Docker ビルド ジョブのスピードを大幅に向上させる独自の Docker ビルド最適化が組み込まれています。

具体例の前に申し上げておきますと、ここまでのセクションで紹介した最適化の概念はどれも、今お使いの CI パイプラインに組み込めます。特にキャッシュは有用です。Dockerfile の変更後のビルド時間を短縮するには、他のどの方法よりもキャッシュの利用が最も効果的です。

お使いの CI パイプラインでこれを実現するには、ビルド ジョブのランタイムに Docker Executor を使用し、Docker レイヤー キャッシュ (DLC) と呼ばれる機能を有効にすることで、ビルド時間を短縮できます。

DLC は、Docker イメージのビルドが CI プロセスの一環として定期的に行われる場合に役立つ優れた機能です。DLC では、ジョブ内で作成されたイメージ レイヤーが保持されます。つまり、ジョブ中にビルドされた Docker イメージの個々のレイヤーがキャッシュされるということです。以降の CircleCI の実行では、イメージ全体を毎回リビルドするのではなく、変更されていないイメージ レイヤーを再利用します。

そのため、コミット間で Dockerfile の変更が少ないほど、イメージ ビルド ステップが短時間で完了します。DLC は、machine Executor とリモート Docker 環境 (setup_remote_docker) のどちらでも利用できます。ただし、DLC の効果は、docker builddocker compose などの Docker コマンドで独自の Docker イメージを作成する場合にのみ限られます。初期環境のスピンアップ時間がすべてのビルドで短縮されるわけではない点には注意をしてください。DLC の詳しい説明については、こちらのドキュメントをご覧ください。

まとめ

この記事では、Docker イメージのビルドを最適化するテクニックをご紹介しました。Docker イメージを効率よく作成する際のガイドとして、ぜひお役立てください。紹介したビルド テクニックをうまく活用すれば、CI パイプラインのスピードを飛躍的に向上させることができます。

一方で、ほとんどの場合、カスタム イメージを自前でビルドする必要はありません。みなさんが CI パイプラインで使用できる CI 向けに最適化された Docker イメージを CircleCI が多数開発していますので、ぜひご活用ください。CircleCI におけるイメージの設計方針については、ブログ記事「CircleCI の次世代コンビニエンス イメージをリリース: より小さく、速く、確定的に」をご覧ください。

最後までお読みくださりありがとうございました。本記事がみなさんのお役に立てば幸いです。ご意見やご感想がございましたら、Twitter でお気軽に @punkdata 宛にメンションしてください。