⠀人
/ ⁰⊖⁰ \ オカメインコエンジニアの五十嵐(@ganta0087)です。
CrowdWorksでは、サービスのCI環境としてCirlceCIを利用しています。
今回、CircleCI 1.0から2.0に移行すると同時に、新機能のキャッシュをフル活用したことで、コストを増加させることなくCI実行時間を半分にすることができました。
今回の記事では、CirlceCI 2.0のメリットや、どのようなチューニングを行ったのかをご紹介します。
CircleCI 2.0について
CircleCI 2.0は現在ベータ版となっており、「CircleCI 2.0: Beta Access - CircleCI」から申し込むことができます。(試してみたところ個人のリポジトリではすぐに利用できるようです。)
申請したOrganizationのすべてのプロジェクトで突然バージョンが切り替わるわけではなく、.circleci/config.yml
(またはcircle.yml
)にversion: 2
と定義することで有効になるため、検証用のブランチだけ2.0にして試すことができます。
1.0から2.0の主な変更点は次の通りです。
カスタムビルドイメージ
1.0では多くの言語やツールがあらかじめインストールされたUbuntuイメージをCIのビルドイメージとして利用します。しかし、イメージに含まれていないものを利用する場合はCIの中でインストールする必要がありました。
2.0ではDockerイメージをベースにしてCIを実行できるようになりました。これにより、必要なライブラリなどをあらかじめセットアップしたDockerイメージを利用できるようになります。
ネイティブDockerのサポート
1.0でもDockerサポートは存在しています。しかし、CircleCI自身がベースコンテナとしてLXCを利用していることの影響で、独自パッチを当てた古いバージョンに限定されています。1
2.0では仮想マシンが起動して純粋なDockerを動かせるようになったため、最新の機能がフルに利用できるようになりました。CIのベースとしてこの仮想マシンを起動することも、Dockerイメージをベースにしつつ、途中でこの仮想マシンを起動することもできます。2
柔軟なジョブ構成
1.0では自動的にプロジェクトの種別を推測して、最適なセットアップとテストのコマンドが実行されます。CIの実行は明確なフェーズに分かれており、キャッシュをリストア・保存するタイミングも、コマンドを実行するタイミングも固定されています。実行コマンドをカスタマイズしたい場合はフェーズごとにコマンドを上書きしたり、フェーズの前後にコマンドを差し込んだりします。
2.0ではプロジェクト種別の推測はされず、決められたフェーズもなくなり、自分でステップを定義します。また、キャッシュも任意のタイミングで柔軟に行えるようになりました。
CircleCI 1.0から2.0への移行
1.0時代にCIが遅かった要因
CrowdWorksの場合、大きな要因としてGitリポジトリが巨大であることと、Gemのインストールに必要なライブラリや、テストに利用するミドルウェア本体などをキャッシュに入れていたため、リストアされるのがとても遅かったです。そういったライブラリなどをキャッシュしたいがために、dependenciesフェーズへ処理を無理やり詰め込んでおり、設定ファイルの見通しも決してよいものとは言えませんでした。
また、CIのコンテナが起動するまで30秒ぐらい掛かり、各ステップも全体的にもっさりとしており、mkdir
するだけなのに2秒掛かったりすることもありました。
そのようなステップが塵も積もって、セットアップに10分ぐらい掛かってしまっていました。
1.0から2.0に移行する際に行ったこと
ビルドイメージ&ミドルウェアのDocker化
CrowdWorksでは既にセットアップ済みのDockerイメージをローカル開発環境のために用意してあったため、移行がスムーズでした。
これにより、ライブラリのインストールやミドルウェアのセットアップ処理が不要になりました。
ミドルウェアのコンテナへの接続はlocalhost宛に通信します。ポート番号は、対象のコンテナのイメージが作られたDockerfile
内のEXPOSE
で指定されているポート番号です。注意点としてはIPv4でしかlistenされていないため、localhost
と指定するとIPv6で接続し、自動でIPv4で再接続してくれるクライアントでない場合はエラーになってしまいます。そのため、127.0.0.1
と明示的にIPアドレスで指定しておくと無難です。
ミドルウェアのコンテナの起動チェック
また、いろいろと速くなったおかげでミドルウェアのコンテナを利用する段階でコンテナの起動が間に合わない場合があります。そのため、以下のようにコンテナが起動するまで待つようなステップを入れました。
- run: name: MySQLの起動チェック command: | for i in $(seq $HEALTH_CHECK_RETRY_LIMIT) do mysql -h 127.0.0.1 -u root -e 'show databases' || (sleep $HEALTH_CHECK_RETRY_WAIT; false) && break done
HTTPプロトコルでアクセスできるものについてはcURLでチェックできますが、curl
コマンドの--retry
オプションはloopbackインターフェースには効かないため、自前でループを書く必要があります。
ソースコードのshallow clone
1.0ではソースコードのチェックアウト処理は上書きできませんでしたが、2.0では組み込みのcheckout
ステップを呼び出さずに自前でソースコードの取得処理を書くことができます。
デフォルトのcheckout
ステップは、clone & fetchでチェックアウトするようになっています。そして、clone済みのリポジトリをキャッシュしておき、2回目以降はそれを用いるようにします。3
しかし、リポジトリが巨大だとキャッシュのリストアに時間が掛かってしまいます。
そこでshallow cloneで取得するようにしたところ、キャッシュが不要なほどに速くなりました。
- run: name: GitHubへのHTTPS接続の認証設定 command: echo "machine github.com login $GITHUB_TOKEN" > ~/.netrc - run: name: GitリポジトリのCheckout command: | repository_https_url=$(echo $CIRCLE_REPOSITORY_URL | sed -e "s|git@github.com:|https://github.com/|") git clone --depth=1 --branch $CIRCLE_BRANCH --single-branch $repository_https_url . git reset --hard $CIRCLE_SHA1 || (echo "ビルド実行後にブランチが更新されました。Rebuildしてください。" 1>&2; false)
ビルドイメージにSSHクライアントをインストールしなくてよいことや、SSHよりも高速であることから、HTTPSでGitHubに接続しています。CircleCIによってリポジトリのURLが設定される環境変数CIRCLE_REPOSITORY_URL
は、SSH用のURLになっているためHTTPSのURLに置換しています。
また、プライベートリポジトリへのHTTPS接続は、GitHubのPersonal access tokenを上記のように~/.netrc
へ設定することで可能です。
新キャッシュ機構の活用
2.0で追加された新しいキャッシュ機構はとても便利です。
キャッシュがキーを持つようになり、任意のタイミングで指定されたキーのキャッシュをセーブ/リストアできるようになりました。また、キャッシュリストア時にキーを複数指定できます。キーの一致判定が前方一致であることを活用することで、キャッシュの効果を最大限活用できるようになっています。
- restore_cache: name: Restoring Cache - Bundler keys: - gems-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "Gemfile.lock" }} - gems-{{ .Environment.COMMON_CACHE_KEY }}- - run: name: bundle installの実行 command: bundle install --path=vendor/bundle - save_cache: name: Saving Cache - Bundler key: gems-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "Gemfile.lock" }} paths: - "/usr/src/app/vendor/bundle"
上記の例では、まずchecksum "Gemfile.lock"
の部分も含めてキーが完全に一致するキャッシュが存在する場合、そのキャッシュがリストアされます。それに一致するキャッシュが存在しない場合は、keys
に指定された2番目のキーについて前方一致でキャッシュを探索し、最も新しいキャッシュがリストアされます。これによって「全くキャッシュが存在しない」というケースをほぼ排除することができるため、CIのセットアップ時間を大幅に短縮することができます。
キャッシュキーの共通プレフィックスの導入
また、キャッシュはキーごとに不変であるため、キャッシュを再生成するためにバージョンプレフィックスを付与するTipsが公式ドキュメントに記載されています。しかし、キャッシュを再生成するために設定ファイルの編集を必要としたくなかったため、環境変数をキーに使用できることを利用し、CircleCIのWebの設定画面から環境変数を設定して利用するようにしました。こうすることで、キャッシュで何か問題が起きた場合、すぐに再生成させることができます。
Gitのコミットハッシュとの組み合わせ
さらに、Gitのコミットハッシュと組み合わせて高速化することも可能です。コントローラーのテストを高速化するため、assets:precompile
をあらかじめ実行しているのですが、次の例ではassets:precompile
の対象に変更が無かった場合はキャッシュをそのまま使うようにしています。
- run: name: assetsキャッシュのキーとなるファイル生成 command: | ./.circleci/bin/get_git_revision "app/assets" > tmp/app-assets-revision cat tmp/app-assets-revision - restore_cache: name: Restoring Cache - assets:precompile keys: - assets-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "tmp/app-assets-revision" }} - assets-{{ .Environment.COMMON_CACHE_KEY }}- - run: name: assets:precompile command: | current_assets_revision_file=tmp/app-assets-revision before_assets_revision_file=public/assets/app-assets-revision # assets:precompile対象のファイルが更新されていなければキャッシュをそのまま使う if [ ! -e $before_assets_revision_file ] || [ ! -e $before_frontend_revision_file ] || \ ! diff $before_assets_revision_file $current_assets_revision_file then bundle exec rake assets:precompile # Revisionファイルを更新 cp -f $current_assets_revision_file $before_assets_revision_file else echo "Skipped." fi - save_cache: name: Saving Cache - assets:precompile key: assets-{{ .Environment.COMMON_CACHE_KEY }}-{{ checksum "tmp/app-assets-revision" }} paths: - "/usr/src/app/tmp/cache/assets" - "/usr/src/app/public/assets"
./.circleci/bin/get_git_revision
では引数に渡されたパスの最新のコミットのハッシュを取得します。shallow cloneしているとすべて同じハッシュになってしまうため、GitHub APIを使って取得しています。普通にcloneしている場合はgit
コマンドだけで取得可能です。
このハッシュをキャッシュと一緒に保存しておき、リストアされたキャッシュのハッシュと一致する場合は処理をスキップします。キャッシュはリストアされたが、ハッシュが一致しないという場合はキャッシュを使ってassets:precompileが実行されるという仕組みになっています。
この方法を使って不要なコマンドの実行を避けることで、CIのセットアップに掛かる時間をかなり削ることができました。
まとめ
塵も積もって10分ぐらいになっていたセットアップ時間を1分に抑えることができました。
以前はビルドごとのコンテナ数を増やしてもセットアップのオーバーヘッドが大きかったため効果が小さかったのですが、セットアップ時間が削減されたことにより、コンテナ数の効果がテスト実行に直結するようになりました。そこで上記の改善に加え、コンテナ数も増やすことでCIの実行時間が半分になりました。
CIの実行時間が短縮されたことにより待ち時間も減ったため、コンテナ数を増やしても同じプランのまま高い効果を得られました。
当初はドキュメントがほとんどない状態だったのですが、現在は充実してきています。中でもConfiguration Referenceのページは網羅的なので目を通してみるとよいでしょう。また、今回紹介したもの以外にもDefining Multiple Jobsや、Parallelism with CircleCI CLIといった機能があり、今後試してみる予定です。
みなさんもCircleCI 2.0を活用してCI環境を改善してみてください。
We’re Hiring!
クラウドソーシングのクラウドワークスではエンジニアを募集中です/ ⁰⊖⁰ \
-
Dockerには特権を保持していないLXC上で動作させられないという制約があり、それを回避するためにDockerへ独自パッチを当てており、Docker 1.10からモジュールが分離されたことによりパッチを当てるのが厳しくなったため、古いバージョンを使い続けざるを得なくなってしまったそうです。 https://circleci.com/docs/2.0/migrating-from-1-2/#native-docker-support↩
-
PrivateなDockerイメージを使ってCIを実行する場合はこの方式を用います。 https://circleci.com/docs/2.0/private-images/↩