コネヒト開発者ブログ

コネヒト開発者ブログ

Travis上でDockerを利用した継続的インテグレーションを実現する with レイヤーキャッシュ

こんにちは!2018年も残すところあと3週間、コネヒト Advent Calendar 2018 - QiitaのDay-9でございます。

ブラック・ラグーンの新刊発売の衝撃がまだ鳴り止まない!の金城(@o0h_)です。 今回はもう5回は読みました、皆さんはどうですか?

ブラック・ラグーン(11) (サンデーGXコミックス)

コネヒトでは継続的インテグレーションにTravisCIを利用しています。
ローカルの開発環境、プロダクトの本番環境にはDocker(ECS)を利用しています。
本番イメージのビルドやpushもTravisCI上で行っています。

かねてより、「折角DockerベースなのにCIは"別環境"なの悲しい」「良い感じにビルドしたイメージをキャッシュして、デプロイを早くできないか」といった声が上がっていました。
そして、サーバーサイドエンジニアが集い「今四半期中に、CIの活用についての見直しを行おう」という誓いを立てたのです。

よりDockerフレンドリーなCIが他にある中で、Travis CI上でのDocker活用には少し工夫がいるかもな・・という風にも感じております*1

具体的なtipsが世の中に出回るといいな、と思い今回は現時点で我々が得ている成果・知見についてご報告いたします。

TL;DR

  • Docker自体はTravis CIでもサポートされているので難なく使える
  • Dockerのビルドをキャッシュするためには、明示的なキャッシュの作成とアップロードが必要
  • マルチステージビルドの結果をキャッシュするには、target buildの結果を保存し --cache-from オプションを利用したビルドを行う

https://cdn.mamari.jp/authorized/5c0cc234-0d90-4bff-b968-001bac120002.jpg

今回の対象となるDockerfile(前提の共有)

JSとPHPが相乗りするイメージを利用しています。
そのため、ローカル環境でのビルド効率化のために、multi stage buildとBuildKitを利用しています。

f:id:o0h:20181209183955p:plain

これを、「Travis CI上でもばっちり使えるように、頑張ってみようぜ!」というのがこの記事の趣旨です。

【ここで参考になりそうな記事】

やりたいこと・目的

Travis CIは、以下の用途で利用しています

  • push/prごとのテストやスタイルチェックと言ったビルド
  • Amazon ECRへのデプロイ
    • docker build コマンドの実行(都度ゼロベースでのビルド)
    • レジストリへのpush、サービスイン

これを、以下のように変更するのが目的です

  • push/prごとのテストやスタイルチェックと言ったビルド
    • PHP系のテスト等は、Dockerコンテナ上で実行する
      • そのために、ビルドに係る処理コストを最小化する
  • Amazon ECRへのデプロイ
    • レイヤーキャッシュを利用して docker build を実行する
    • レジストリへのpush、サービスイン

Dockerのイメージビルド時のキャッシュ利用

Travis CIでのビルド時に、Dockerの「前に作ったイメージ」をキャッシュしておくことは可能でしょうか?
基本的には、こちらの記事で触れている通りの方法でDockerコンテナを利用することができます。

【ここで参考になりそうな記事】

multi stage build時のキャッシュについて

ここで注意として、「マルチステージビルドを使っていると、--cache-fromに最終ステージを指定してもレイヤーキャッシュがされない」という問題があります。

具体的には、

  1. 中間ステージのビルドにキャッシュが利用されない
  2. そのため、最終ステージの「手前」の部分に変更が生じる
  3. 手前が変更されているため、以後のレイヤーでもキャッシュを利用しない

かのように見える現象があります。
例えば、以下のようなDockerfileがあったとします

FROM aaa:latest as A
# hogehoge

FROM bbb:latest as B
# fugafuga

FROM xxx
# piyopiyo

これをビルドします

docker build -t xxx .

さて、ここで使った労力をレジストリにpushしておいて、別ホストでのビルド時にもcacheを使いたい!ですね。

docker pull mine/xxx:latest
docker build -t xxx --cache-from mine/xxx:latest

結果は、残念ながら「A」のステージでキャッシュが効きませんでした。。。
という具合です。

これを回避するため、中間ビルドのイメージも個別に生成することにしました

【ここで参考にした記事】

docker save/loadを利用して、multi stage buildでもレイヤーキャッシュの恩恵を受ける

方向を転換し、「中間ステージごとにイメージを保持・読み込みを行い」「ステージごとに、必要なイメージを明示的に --cache-from 指定をして読み込ませる」というものにします。

なお、この段階で、「最終ステージ以外のものをレジストリに上げるのはどうなのかな・・・」という気持ちになったので、ローカルにイメージを置くことにしています。

docker build -t A --target A --cache-from A .
docker build -t B --target B --cache-from A --cache-from B .
docker build -t xxx --cache-from A --cache-from B --cache-from xxx .

--target を指定しつつタグ付けを行うことで、imageのsaveができるようになります。

docker save A -o A.tar
docker save B -o B.tar
docker save xxx -o xxx.tar

saveしたイメージをファイルとして共有することで、他ホストでも読み込み可能になります。

docker load A.tar
docker load B.tar
docker load xxx.tar

ということで、大まかな方針として「中間ステージ・最終ステージをそれぞれビルドして、セーブして、次のビルドの前にロードする」というやり方に決めました。

【ここで参考にした記事】

ビルド結果のキャッシュを作る

Travis CIは、 .travis.yml 内で「このディレクトリをキャッシュする」というパスを任意に指定することができます。
このディレクトリ下に書き出されたファイルは、次のビルドの際に冒頭で読み込まれ利用可能になるという仕組みです。

それを踏まえて、docker save の出力先を「キャッシュ対象ディレクトリ」にしてしまえば良さそう。

$HOME/docker というディレクトリを設け、そこに放り込むことにします。

# .travis.yml
cache:
  directories:
    - $HOME/docker

Travsi CIは、ビルドのメインとなる script ステージの後〜 deploy ステージの間に、キャッシュを作成・クラウド(S3)アップロードを行います。

  1. OPTIONAL Install apt addons
  2. OPTIONAL Install cache components
  3. before_install
  4. install
  5. before_script
  6. script
  7. OPTIONAL before_cache (for cleaning up cache)
  8. after_success or after_failure
  9. OPTIONAL before_deploy
  10. OPTIONAL deploy
  11. OPTIONAL after_deploy
  12. after_script

from: Job Lifecycle - Travis CI

なので、 before_cache の段階で docker save を実行してしまえばよいです。*2

# .travis.yml
before_cache:
  >
    mkdir -p $HOME/docker && rm $HOME/docker/* && docker images -a --filter='dangling=false' --format '{{.Repository}}:{{.Tag}} {{.ID}}' | xargs -n 2 -t sh -c 'docker save $0 -o $HOME/docker/$1.tar'
  1. cache保持用のディレクトリを(存在しなければ)作り
  2. 利用されていないファイルが生き残り続けないよう、ディレクトリ配下のファイルを一旦全て削除し
  3. docker imagsで取得した「存在するイメージ(タグ)」を、ファイル名にIDを使って保存する
    1. タグを利用すると/が入ってきてややこしくなるため、安全かつ一意な文字列としてIDを利用する

これで、「必要そうなイメージをキャッシュに回す」ことができるようになります。

【ここで参考にした記事】

image保存のチューニング

しかしながら、これは結構なオーバーヘッドになります。
1つは、docker save自体が結構な時間がかかること。saveするイメージは少ないほうが良いです。
2つ目に、キャッシュを使える場面で活かしきれていないこと。multi stage buildを利用するにあたり「キャッシュを最大限生かせるように」考えていますから、「めったに変更がない中間ステージイメージ」が存在するという状態も作られています。これを「毎回、絶対に作り直す」というのは効率が悪く思います。

そのため、「保存されている内容と今使っている内容がに差異がなければ、そのimageのsaveは省略する」ことで省力化できると効率が良いです。

また、Travis CIのキャッシュの保存・取得は、外部ストレージへのネットワーク経由のアップロード・ダウンロードによって行われます。それを考えると、ファイルサイズが小さい方が有利です。
このタイミングで、出力イメージのgunzip圧縮も行うことにしました。

# .travis.yml
before_cache:
  - cache_threshold=$(date “+%Y%m%d %H:%M”)
  - docker images -a --filter=‘dangling=false’ --format ‘{{.Repository}}:{{.Tag}} {{.ID}}’ | xargs -n 2 -t sh -c ‘if [ -e $HOME/docker/$1.tar.gz ]; then touch $HOME/docker/$1.tar.gz; else docker save $0 | gzip -2 >$HOME/docker/$1.tar.gz && echo “$0($1) saved”; fi’
  - find $HOME/docker/. \! -newermt “$cache_threshold” | xargs rm -rf
  1. before_cacheに入った時点での日時をメモしておき
  2. cacheディレクトリ以下のimageそれぞれについて、
    1. いま利用されているものとIDが同一であれば、touch してタイムスタンプだけ更新
    2. IDが違ったら = 内容に変更が生じていたら、docker saveを行って上書き
  3. 1のステップでメモした日時とディレクトリ下のファイルのタイムスタンプを比較、「before_cacheに入る段階より古い」ものを削除
    1. これで「もう利用してないイメージ」が破棄される

ようにしました。

Travis CI上でキャッシュ済みイメージを利用したビルドを行う

ここまでで、 $HOME/docker ディレクトリに「使えそうなイメージ」が配置されています。
あとは、これを実際に利用したビルドを行うようにしましょう。

# .travis.yml
before_install:
  - if [[ -d $HOME/docker ]]; then ls $HOME/docker/*.tar.gz | xargs -I {file} sh -c "zcat {file} | docker load"; fi

instal:
  - docker build -t A --target A --cache-from A .
  - docker build -t B --target B --cache-from A --cache-from B .
  - docker build -t xxx --cache-from A --cache-from B --cache-from xxx .

こうすることで、以前にビルドしたイメージからうまくキャッシュを利用してくれるようになりました。

script/deployでDockerイメージを利用する

あとは、dockerコマンド経由でのテスト実行や静的解析など、自由に「いつもの環境」での処理実行を行えるようになります。
script ステージでのコンテナの利用は、先に紹介したTravisのドキュメントをご参照ください。

また、デプロイ用に改めてイメージをビルドする必要がある!という場合も、同様に(必要に応じた)--cache-fromオプションの指定などで対応できると思います。

感想

本記事の内容は、まだまだ改善の余地があるような気もしています・・・それでも、チーム内で求めていた「大体こんな感じ」という水準までは、一旦持ってくることができました。そうして、公開に至っております。

また、Travisの利用するキャッシュサイズが大きくなることや、docker loadに関するオーバーヘッドが大きくなり、プロジェクトによっては今までよりビルドごとに要する時間が大きくなるんだろうな、という印象も受けています。
それでも、チーム内では「テストの確実性が上がる、本場と同等のイメージで動かせる恩恵がある」という事で合意を得ています。
ここでもまた、「やはりDocker使うならイメージが小さいのが正義・・・!」という気持ちを新たにしました。
ということで、次にやりたいのは「イメージをめっちゃダイエットする」です🐶

「ここ、もっとこうした方が良いのでは?」「ここの理解が間違っている!」といった箇所がありましたら、お気軽にフィードバックをいただけると嬉しいです!

話が固くなりましたので、最後にいらすとやさんのホトトギスを見てお別れしたいと思います。

明日は @katsutomu さんが元気な記事を書いてくれます!

f:id:o0h:20181210005627p:plain

*1:CircleCI等の他サービスを利用すると、もっと容易にDcokerベースの継続的インテグレーションができるかもね!という声は社内でも上がりました。検討の結果、今のスコープでは「CIの乗り換えはしない」という結論に至っております

*2:最初、after_deployでキャッシュを作成していたら上手く動かず、ハマりました・・・