クラウドワークス エンジニアブログ

日本最大級のクラウドソーシング「クラウドワークス」の開発の裏側をお届けするエンジニアブログ

CircleCI 2.0に移行して新機能を活用したらCIの実行時間が半分になった話

⠀人
/ ⁰⊖⁰ \ オカメインコエンジニアの五十嵐(@ganta0087)です。

CrowdWorksでは、サービスのCI環境としてCirlceCIを利用しています。

今回、CircleCI 1.0から2.0に移行すると同時に、新機能のキャッシュをフル活用したことで、コストを増加させることなくCI実行時間を半分にすることができました。

今回の記事では、CirlceCI 2.0のメリットや、どのようなチューニングを行ったのかをご紹介します。

f:id:ganta0087:20170404180116p:plain

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|[email protected]:|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の実行時間が半分になりました。

f:id:ganta0087:20170404180201p:plain

CIの実行時間が短縮されたことにより待ち時間も減ったため、コンテナ数を増やしても同じプランのまま高い効果を得られました。

当初はドキュメントがほとんどない状態だったのですが、現在は充実してきています。中でもConfiguration Referenceのページは網羅的なので目を通してみるとよいでしょう。また、今回紹介したもの以外にもDefining Multiple Jobsや、Parallelism with CircleCI CLIといった機能があり、今後試してみる予定です。

みなさんもCircleCI 2.0を活用してCI環境を改善してみてください。

We’re Hiring!

クラウドソーシングのクラウドワークスではエンジニアを募集中です/ ⁰⊖⁰ \

www.wantedly.com


  1. Dockerには特権を保持していないLXC上で動作させられないという制約があり、それを回避するためにDockerへ独自パッチを当てており、Docker 1.10からモジュールが分離されたことによりパッチを当てるのが厳しくなったため、古いバージョンを使い続けざるを得なくなってしまったそうです。 https://circleci.com/docs/2.0/migrating-from-1-2/#native-docker-support

  2. PrivateなDockerイメージを使ってCIを実行する場合はこの方式を用います。 https://circleci.com/docs/2.0/private-images/

  3. 2.0ではリポジトリのキャッシュ設定は明示的に書く必要があります。

© 2016 CrowdWorks, Inc., All rights reserved.