テストが失敗する原因を調査したところ、そのほとんどが【5】のLintの掛け忘れなどの単純なミスで、【4】で示したようにコンテナをローカルにプルしてきて調査しなければいけないほどの複雑な原因による失敗は(当然ですが)ほとんどありませんでした。
そこで、「環境依存で失敗し得るテスト」「環境依存で失敗しないテスト」を分割して実行することを考えました。環境に依存するテストはこれまで通り「docker build」「docker push」コマンドの後に実行し、依存しないテストはdocker buildの前に実行し、その段階で問題があれば、後続のdocker build/pushの処理をスキップする構成にしました。
つまり、「失敗することが分かっており、その原因が環境に依存しておらず、(おそらく)簡単に調査できるテスト」に関しては早期に失敗させ、パイプラインの多重度を下げることを実施しました。
テストの分割において、Googleが提唱しているTest Sizesの概念「Google Testing Blog, Test Sizes」を参考にしました。
テストを「small」「medium」「large」の3つに分類する指標です。表を見れば一目瞭然ですが、smallは「I/Oやネットワークアクセスなどは全てモック化し、外界への依存がないテスト」を指します。mediumは「ローカルホストだけからのアクセスがある環境でのテスト」、largeは「プロダクション環境に近い環境でのテスト」という分類です。
このうち、前述の「環境依存で失敗し得るテスト」はmedium、largeのテストとなっており、これらのテストは従来通りdocker build/pushの後に、smallのテストはdocker buildの前に行う形にパイプラインを修正しました。
下に修正後の図を示しています。
アプリケーション側の改修としては、「サイズごとにテストを分割して起動できるようにする」ようにしました。これは比較的簡単に実現できます。標準ツールや名前のパターンによって起動するテストを制御できため、今回アプリケーションはGo言語を用いて実装しました。
例えば、smallのテストは通常通りに記述し、mediumのテストはプレフィックスで「M_」を付けるようなルールに基づきテストを実装します。
func Test_Example(t *testing.T) { // 外界への依存がないTestを記述する } func TestM_Example(t *testing.T) { // 外界への依存があるTestを記述する }
このルールさえ守れば、実行時に下記のようなオプションを渡すことでテストの起動を分割できます。
$ go test -v -run='^Test([^M][^_])' # small testを起動 $ go test -v -run='^TestM_' # medium testを起動
XUnit系やXSpec系のテストフレームワークによるかとは思いますが、例えばディレクトリでテストを分けるのも有効な手段だと思います。この改修を行い、パイプラインを早期に失敗させることで多重度を下げることができ、一定の高速化を実現できました。
パイプラインの一部にDocker Buildが組み込まれる場合、Docker Buildの高速化は全体の速度、開発体験の向上につながります。
Docker Buildの高速化はキャッシュの活用が非常に重要です。つまり、Dockerのキャッシュの仕組みを理解することはCIパイプライン高速化の近道といえます。
DockerはClientの部分とDaemonの部分に分かれておりDaemon側がServerとして起動し状態を保持するようになっています(参考:Docker Architecture)。
ClientとDaemonが分かれていますが、Dockerfileを書くときは特に意識せずに相対パスで記述することができ、あたかもそのままアクセスできるように見えます。
Docker Buildを実行したディレクトリ、もしくは指定したディレクトリ以下をまるまるアーカイブしてClientはDaemonにHTTPで転送しています。この転送されている対象のディレクトリを「Build Context」と呼びます。
Deamon側では受け取ったDockerfileとBuild Contextからイメージを生成します。Dockerのイメージはレイヤー構造になっており、簡単にいえばファイルの差分を重ねて最終的なイメージを作っています。
Dockerfileにおけるファイルの差分を作るのは「ADD」「COPY」「RUN」の3つの命令です。ADDとCOPYはほとんど同じ命令なので実質COPYとRUNの2種類です。COPYはBuild Contextからファイルをコピーし新しい差分としてレイヤーを追加します。RUNは指定されたコマンドを実行して生成されたファイルを差分としてレイヤーに追加します。
大ざっぱにいうとDocker Buildは、このような処理を行っています。
Docker Buildは、v18.09でBuildKitを正式採用したことで、それ以前と比べて仕組みが変わっています。今回はBuildKit以前の話をします。2019年2月の記事執筆時点で「どこの環境でもBuildKitが使えるわけではない」点とキャッシュの基本的な考え方は変わらないためです。
そもそも、Dockerfile内での外部からのダウンロード、サードパーティーツールのビルドなどの重い処理で、現在扱おうとしているアプリケーションと直接は関係ないものについては基本的にはキャッシュしたいはずです。
Docker Buildはレイヤーのキャッシュの機構を持っています。Daemon内部ではレイヤーが親のレイヤーの情報だけ持っており木構造になっています。共通の親を持つレイヤーで自分と同じものの中で一番新しいものをキャッシュとします。
レイヤーが等しいという定義は具体的に、下記のことです。
RUNの場合は直感的に理解できますがCOPYのときは少し異なります。COPYの際、内部で同様の仕組みを使うためにCMDに転送するファイルパスとファイルのダイジェストを付加します。これにより、同じファイルでも別のファイルパスになればキャッシュが効きませんし、内容が変更された場合はダイジェストが変更されキャッシュが効かなくなります。Docker Buildは、最初にキャッシュが無効になったレイヤー移行はキャッシュを使いません。
以上のキャッシュの仕組みを踏まえると、「高速なDockerfile」とは下記のようなものです。
幾つか具体的に解説します。
・重い処理に必要な依存ファイルだけをCOPY、RUNする
以下に簡単なPythonコードの例を示します。
FROM python COPY . . RUN pip install -r requirements.txt -c constraints.txt CMD ["python", "app.py"]
このDockerfileだとBuild Contextの全てをCOPYした後に依存ライブラリのインストールが始まります。Build Context内のいかなるファイルが変更されても依存ライブラリのインストールが実行されます。下記コマンドは「requirements.txt」「constraints.txt」しか依存していません。
pip install -r requirements.txt -c constraints.txt
つまりrequirements.txtとconstraints.txtに変更がない場合はpip installを行わなくていいということです。
そこで、下記のような修正を加えると、依存ライブラリが追加されたときだけインストールが実行されるようになります。これは簡単で、かなりの効果がある例です。
FROM python COPY requirements.txt constraints.txt ./ RUN pip install -r requirements.txt -c constraints.txt COPY . . CMD ["python", "app.py"]
・COPY対象のファイルの変更頻度が考慮された命令の順序になっており、頻繁に重い処理が走らない
次に、下記のような独立した重い処理が並んでいる場合は、実際の開発での変更頻度によって命令を並べ替えてあげるだけでもDocker Buildの速度に大きく貢献します。
FROM image COPY a . RUN too_slow_cmd a COPY b . RUN too_slow_cmd b
COPYの表現力は弱いので、キャッシュを生かしたDockerfileを書きたい場合はプロジェクトのディレクトリ構造も考えなければなりません。
これまで説明してきたのは手元にDaemonが存在する場合でした。しかしCI環境のように、Daemonが“状態”を持てないような場合があります。
そういうときは、「--cache-from」オプションを使います。このオプションは外部から取得してきたイメージのレイヤーをキャッシュの対象にすることができます。これにより、Docker Pullができれば「Daemonが状態を持てない」問題は解決できます。CIにDocker Buildを組み込む上で最初に設定するオプションになるはずです。
--cache-fromは使う上で対象とするイメージを選ばなければなりません。開発のフローにもよりますが、プルリクエストベースで開発を行っている場合にキャッシュを活用する方法として「PR(Pull Request)番号でタグを付ける」ことがあります。PR番号でタグを付けて--cache-fromに指定することで自分の作業にのみ影響を受けるキャッシュになるので、とても効率が良くなります。
開発のフローによって最適な--cache-fromの選び方は変わってくるのでぜひ模索してみてください。特にこだわりがなければ「latest」を指定すると、それなりの効果が得られると思います。
Docker BuildのプロセスではDocker Push/Pullが頻繁に行われるため、イメージの軽量化も行っていかなければいけません。さらに高速にするためには、軽量なベースイメージの選定であったり、不要なファイルを残さないようにしたり、「Multi Stage Builds」を活用したりすることなどが挙げられます。
今回はアプリ開発者の視点で、コンテナベースのCIのメリット、デメリットを整理し、解説しました。
今後の展望としては、より高度なテスト(largeサイズやUIのテストなど)もパイプラインに組み込み、発展させていきたいと考えています。また、さらなるビルドの高速化を目指し、BuildKitなどの対応も視野に入れています。
次回は、運用/インフラ技術者の視点からコンテナベースのCI/CDについて解説します。
リクルートテクノロジーズ ITエンジニアリング本部 プロダクトエンジニアリング部 アプリケーション・ソリューショングループ所属
2015年リクルートホールディングス(当時)に新卒入社後、リクルートテクノロジーズに所属。入社以来、社内横断プッシュ通知基盤の運用開発や、アプリケーションの先進アーキテクチャなどのR&Dに従事。
リクルートテクノロジーズ ITエンジニアリング本部 プロダクトエンジニアリング部 アプリケーション・ソリューショングループ所属
2017年リクルートホールディングス(当時)に新卒入社。現在、リクルートテクノロジーズでリクルートグループにおけるコンテナの利活用や、アプリケーションの先進的なアーキテクチャの構築に従事。
Copyright © ITmedia, Inc. All Rights Reserved.