ちなみに

火曜日の空は僕を押しつぶした。

CircleCIの小手先による高速化手法

この記事は Money Forward Engineering Advent Calendar 2021 - Adventar の6日目の記事です。 昨日は TONY (id:galepilot) さんによる「チームで実践したKPTのボリュームを増やすコツ」でした。 ふりかえり、なんとなくやってしまうと惰性になるのでちゃんとチームで意図を理解したうえで実施するのは大切ですね。

CIを速くする意味

CIを速くする意味はなんでしょうか。

DORA(DevOps Research and Assessment) 2021年の Accelerate State of DevOps Report によると、エリートチームのリードタイムは1時間以内ということだそうです。 本当かという感じの短さですね。デプロイ頻度は変わらずオンデマンドということなのですが、1時間以内にトランクにマージされるとすると、デプロイ回数もすごいことになっていそうです。

さて、デプロイするためにトランクにコードをマージしたとすると何が起きるでしょうか。 そう CI (Continuous Integration) の実行です。

例えば仮に1日に1回デプロイするとしても、年に240回デプロイしていることになります。このときトランクでのCIの実行に30分かかったとすると120時間もの時間がCIを待っている時間に使われてしまいます。デプロイ待ちだけでもこれくらいかかるのですが、トランクにマージしているないコードを合わせると途方もない時間をCI待ちに使っていることになります。

これではリードタイムが下がってしまいますし、結果デプロイ頻度も下がります。また当然不具合があったときに修正をいれようとしても同じだけ待つ必要です。

これがCIが速くあるべき理由のひとつです。

また1エンジニアの立場からみても、CIを待っている時間とても無駄ですね。 フィードバックが遅いということはそれだけ開発のテンポが遅いということです。

少しならいいのですが、これがずーっと続くと考えるとモチベーションが低下しませんか。 これが従業員エンゲージメントの低下を招きます。

Four keys metric についてはLeanとDevOpsの科学が詳しいのでぜひご覧ください。

anchor.fm

texta.fm のLeanとDevOpsの科学の回も非常におすすめです。

推測するな計測せよ

速くすると言っても闇雲に作業するだけでは楽しいだけで意味はありません。 ちゃんと計測して効きそうなところから手を入れていきましょう。

幸いなことに CircleCI には Insights があるので、それを眺めたりするだけでもけっこうな情報が得られます。 金で殴ると速くなったりもするのですが、その辺はバランスなのでちゃんとコストも眺めておきましょう。

逐次実行をやめる

ビルドしたあとにユニットテストとE2Eテストを実行してステージングにデプロイするようなワークフローを考えてみましょう。 ここでボトルネックになっているのはE2Eにかかる時間のようです。

以下のような workflow があるとすると unit_teste2e_test のジョブは逐次実行されます。 しかし、これらは独立して実行可能だとするとユニットテストと同時にE2Eテストを実行した方が効率が良さそうです。

build_and_deploy:
  jobs:
    - build
    - unit_test:
        requires:
          - build
    - e2e_test:
        requires:
          - unit_test
    - deploy:
        requires:
          - e2e_test

これはこのように ファンアウト/ファンイン することで解決します。

build_and_deploy:
  jobs:
    - build
    - unit_test:
        requires:
          - build
    - e2e_test:
        requires:
          - build
    - deploy:
        requires:
          - unit_test
          - e2e_test

ワークスペースの保存を最適化

さてこれでE2Eテストの影響で極端に遅くなるということがなくなったようです。しかし今度はビルドの時間が気になってきました。

いくつか改善ポイントがありそうですが、実行時間を眺めてみるとどうやら persist_to_workspace しているところが遅いことが分かりました。

よくみるとリポジトリ全体を保存しているようです。

- persist_to_workspace:
  root: .
  paths:
    - ./

persist_to_workspace は対象のファイルのtarボールを作ってgzipしたものをアップロードして、それを attach_workspace で展開しています。

つまり対象のファイルが増えれば増えるほどtarボールを作ってアップロードするのも、それをダウンロードして展開するのも遅くなります。

よって必要なファイルだけを保存するようにしましょう。

- persist_to_workspace:
  root: .
  paths:
    - file1
    - file2

こういう感じ。これで build ジョブだけじゃなくて、保存されたファイルを使っていた後続のジョブも速くなりました。

これはリポジトリが太れば太るほど効果が大きいので、長年開発しているプロダクトには特におすすめです。

チェックアウトを速くする

CircleCiには checkout という組み込みのステップがあります。

これを使うと気軽のリポジトリからファイルをチェックアウト出来るのですが、実はこのステップはあんまり賢くなくてリポジトリをまるごとチェックアウトしてきてしまうので、先ほどの話にあったとおりリポジトリが太ってくるとどんどん遅くなります。

1つの回避策として .git/ をキャッシュしておくという方法が紹介されているようですが、これは結局キャッシュの出し入れにかかる時間を考えるとほとんど意味がないと思います。

諦めて自前でチェックアウトする方が速いので以下のような コマンドを定義 しておくとべんりです。

shallow_checkout:
  steps:
    - run:
      name: Checkout repository
      command: |
        git init
        git remote add origin ${CIRCLE_REPOSITORY_URL}
        GIT_SSH_COMMAND="ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no" git fetch --depth=1 origin ${CIRCLE_SHA1}
        git checkout ${CIRCLE_SHA1}

これもある程度年数の経っているリポジトリには劇的に効くのでおすすめでです。

ふつうにキャッシュする

あとは割とふつうの話なのですが、次回の実行が速くなるようにインストールしたライブラリなどをキャッシュします。

キャシュを復元するときには複数キーを指定できて、前方一致してくれるので複数のキーを使って段階的なキャッシュになるようにしておくのがポイントです。

例えば npm の場合だと以下のようにすると使い勝手が良さそうです。( nodenv などを使っていて .node-version ファイルがある前提)

- save_cache:
  key: npm-cache-v1-{{ checksum ".node-version" }}-{{ checksum "package-lock.json" }}
  paths:
    - ~/.npm

キャッシュを戻すときに以下のようにキーを指定すると、まずは package-lock.json の内容が同じかどうか確認し、違っていれば .node-version の内容を確認する。 それも違った場合は npm-cache-v1- というキーでキャッシュを探すので、何も更新がなければ最新のキャッシュが使われて、そうでなくても同じ Node.js のバージョンのキャッシュが使われるなど出来る限り現状に近いキャッシュを選択できるので、無駄が少なくなります。

- restore_cache:
  keys:
    - npm-cache-v1-{{ checksum ".node-version" }}-{{ checksum "package-lock.json" }}
    - npm-cache-v1-{{ checksum ".node-version" }}-
    - npm-cache-v1-

ちなみに node_modules/ ではなくて ~/.npm をキャッシュしているのは npm ci を使って存性の解決をスキップする代わりに node_modules/ を毎回消しているからです。

これは前方一致なキャッシュキーを扱うときにありがちなミスだけど、npm-cache-v1 ではなく npm-cache-v1- (末尾にハイフンが付いている!)にしないと、例えば npm-cache-v10 に意図せず一致してしまうような問題がある。

id:r7kamura さんから指摘いただいて修正しました。

イメージの最適化

この辺りまでやってくると削るところが減ってくるので、Executor として使っているDockerイメージにボトルネックが移動している可能性があります。その場合は不要なものを含まない適切なイメージを選択するようにしてください。

ほとんどのケースでは CircleCi の ビルド済 Docker イメージ を使うといいでしょう。

ちなみにデータベースでのファイルへの永続化が必要ない場合はメモリストレージを使ってくれる -ram バリアントを使うのがおすすめです。

テストを分割する

これらの最適化で build ジョブはだいぶ速くなったと思うので次はテスト自体をもう少しどうにかしましょう。

もちろん根本的にテストを速くするというのが最も効果的だとは思うのですが、テストを安全に改善するのはなかなか難しく、その作業は困難を極める場合もあるでしょう。

安心してください。我々には金で殴るという方法が残されています。時間を金で買うのです。

CircleCIには テストの並列実行 をするための仕組みが用意されており。これを用いると複数のサーバーでテストを同時に実行することができます。

方法がとても簡単で、ジョブの parallelism キーを指定することで並列実行されるようになります。

jobs:
  unit_test:
    parallelism: 10
    steps:
      - ...

ここで重要なのは並列実行されるジョブのどこでどのテストを実行するかです。

ふつうにやるとちょっと難しそうですが、CircleCIではcircleci tests コマンドを使ってテストを適切に分割することができます。

TESTFILES=$(circleci tests glob 'spec/**/*_spec.rb' | circleci tests split --split-by=timings)

あとは $TESTFILES に格納されたテストファイルを実行するだけです。

--split-by=timings を使うと並列実行されるそれぞれのテストがだいたい同じくらいの時間で完了するように分割してくれます。

これには実行結果のデータが必要なので store_test_results のようにテスト結果を保存するのを忘れないでください。

- store_test_results:
  path: /path/to/test-results

あとはどのくらい分割すると効率良くテストを回せるかを実験しながら見つけましょう。当然ですが分割数を増やすほどの消費するクレジットの数も増えるのでお金がかかります。

最後に

小手先で出来る技を紹介してきましたが、なんとここに書かれたことの大半が公式のブログに掲載されています。 みなさん時間を無駄にしましたね。

circleci.com

まずは一次情報を当たりましょうという教訓を得られましたね。


Money Forward Engineering Advent Calendar 2021 - Adventar 、明日は maya akahane さんです。社のSlackにいるDeepLボットにはとてもお世話になっているので気になります。


この記事は Cornelius で書きました。