Tabelog Tech Blog

食べログの開発者による技術ブログです

CircleCI の爆速&低燃費化

はじめに

この記事は 食べログアドベントカレンダー2024 の23日目の記事です🎄

こんにちは。食べログ開発本部 技術部 マイクロサービス化チームの栗山 a.k.a. @weakboson です。

本記事では食べログが行っているCI(継続的インテグレーション)改善の取り組みをご紹介します。CircleCI を前提としたフィーチャーや、まだ完了していない施策についても触れますのでご了承ください。

目次

1. 背景と目的

マイクロサービス化チームでは、食べログを高いテスト容易性と変更容易性を備えた、モジュラモノリス・アーキテクチャにリファクタリングする活動をしています。(食べログのモジュラモノリス化戦略)

明確な方向性を決めたリファクタリングを長期計画で進める際は、CIパイプラインに適応度関数という評価機構を組み込み、アーキテクトが意図した方向にシステムが改善しているかどうか観測できる状態にするのがよいと言われています。1 例えば SimpleCov によりカバレッジを計測、レポート作成して、カバレッジが低下する改修 ≒ 新しいコードの自動テストを書いていない改修は警告する仕組みが考えられます。

しかし、食べログのメインリポジトリはCI実行にかなりの長時間を要し、CI時間の延長を伴う適応度関数の組み込みには問題があります。この問題を抜きにしても、Pull request をマージした後デプロイまで長時間エンジニアが拘束されますし、この時間は開発への集中を妨げられます。

またCIコストの増加も問題でした。食べログのメインリポジトリは、1リポジトリに複数の Rails アプリケーションを含む、いわゆるモノレポです。2 これまではフィーチャーブランチでもモノレポ全体をCIしていたので、 CircleCI と、コンテナレジストリである DockerHub の、双方の利用コストが嵩んでいました。

ということで、CIの高速化とコスト削減という、一見両立が難しそうな課題解決をがんばっていきます。

2. 「テスト分割と並列実行」によるCIの高速化

CIの高速化には時間がかかるテストケースとその原因を調べて改善するなど様々な手法がありますが、CircleCI を採用しているなら最も即効性が高くて改善コスパが高いのは「テスト分割と並列実行」(Test splitting and parallelism - CircleCI)だと思われます。CircleCI のコスト(Credits)は大まかに 「ジョブを実行するコンピュートリソースサイズ(resource_class) * 並列数(parallelism) * 実行時間」 で決まります。並列数を2倍にしても実行時間が1/2にできればコストは変わりません。3

テスト分割方法はテスト名(name)、テストコードのサイズ(size)、以前の実行時間を元にしたタイミングデータ(timing data)とあり、タイミングデータによる分割が最も実行時間を均等に分割できる方法です。

食べログでは以前からある程度の並列数で実行していましたが、並列数を上げると不確定に失敗する flaky テストが多くなるため、思い切った高並列化ができてませんでした。実のところこれが本記事の執筆時点で施策が完了してない一番の要因です。食べログの flaky なテストの原因は以下のパターンが多いです。

  • 乱数の使い方がよくないためランダムで失敗する
  • 時間帯依存で夜間に実行すると失敗する
  • timecop で時間を止めた後に解除を忘れていて、後続のテストが失敗する4
  • テストのためコードをオーバーライドしているが元に戻していないため後続のテストが失敗する
  • テーブルにゴミデータを残していて影響を受ける後続テストが失敗する5

特に後続テストが失敗するパターンは、失敗したテストが原因ではなく、原因を探すところから調査が必要なので厄介です。2テストずつ順列組み合わせを全通り実行して、このタイプのテストをすべて洗い出そうと考えてますが、シリアルに実行すると1アプリケーションで約14日かかるので実行方法を検討してるところです。

すべてのCIジョブが同じ時間内に完了するように並列度を上げ、CIワークフロー全体で実行時間を62%短縮できました。2.63倍の高速化です。CircleCI コストは微増して5%アップの見込みです。

3. Pull request が Draft のうちはCI実行を抑制してコスト節約

速度改善の目処がついたので、次のセクションからコスト削減施策を紹介します。

食べログでは多人数で開発する大きめの案件は、開発初期からフィーチャーブランチを GitHub に push して、GitHub 経由でコードを共有するスタイルをとることが多いです。(小さい案件でも画面があるなら最低限エンジニアとデザイナの2名で開発します。)開発者同士でレビューするために Pull request も早めに作成することが多く、意図せず不要なCIを走らせていることが予想されました。

以前の開発フロー

この予想が正しいか?時期尚早なCIを抑えるといくらコストが削減できるか?ということを CircleCI のインサイトAPIから取得した約1,500ブランチのメトリクスを元に検証、試算しました。(https://circleci.com/docs/api/v2/index.html#tag/Insights)

1.CircleCI にデータが存在するブランチ一覧を取得(Get all branches for a project)

curl -X GET \
      -H "circle-token: ${CIRCLE_CI_TOKEN}" \
      'https://circleci.com/api/v2/insights/${PROJECT_SLUG}/branches?workflow-name=${WORKFLOW_NAME}'
{
  "org_id": "01234567-89ab-cdef-0123-4567890abcde",
  "branches": [
    "feature_xxxxx",
    "feature_yyyyy",
    ...
  ],
  "project_id": "01234567-89ab-cdef-0123-4567890abcde"
}

2.取得したブランチのタイムシリーズデータを取得(Job timeseries data)

curl -X GET \
      -H "circle-token: ${CIRCLE_CI_TOKEN}" \
      "https://circleci.com/api/v2/insights/time-series/${PROJECT_SLUG}/workflows/${WORKFLOW_NAME}/jobs?branch=${BRANCH_NAME}"
{
  "name": "rspec_app_a",
  "metrics": {
    "total_runs": 1,
    "failed_runs": 0,
    "successful_runs": 1,
    "median_credits_used": 150,
    "duration_metrics": {
      "min": 462,
      "mean": 0,
      "median": 462,
      "p95": 462,
      "max": 462,
      "standard_deviation": 0.0,
      "total": 462
    },
    "success_rate": 0.0,
    "total_credits_used": 150,
    "throughput": 1.0
  },
  "min_started_at": "2024-06-19T05:04:08.726Z",
  "max_ended_at": "2024-06-19T05:11:51.112Z",
  "timestamp": "2024-06-19T00:00:00.000Z"
}

ワークフローのジョブごと、時間粒度(granularity. daily または hourly)ごとに1件のデータが取れます。これを加工して以下の数値を算出します。

  • ブランチ生存期間: 一番古いデータの min_started_at から 一番新しいデータの max_ended_at まで
  • CI回数: 代表システム(スマホアプリAPI)CIジョブの total_runs 合計
  • CIキャンセル回数: 代表システムCIジョブの successful_runs 合計 - failed_runs 合計 - total_runs 合計

「キャンセル回数とはなんぞや?」CircleCI にはCI実行中同じブランチに push があると中断して最新のリビジョンでやり直す機能があります。キャンセル回数が多いブランチは前のCIが終わる前に push する状況の、開発初期から不要に何回もCIしていたブランチだと予想されます。

この3つの数値でプロットした分布図がこちらです。

CIメトリクス分布図

すべての分布図で正の相関があるように見えます。特に「ブランチ生存期間 x CI回数」(A)、「CI回数 x CIキャンセル回数」(C)には強い相関があるように見えます。「ブランチ生存期間 x CIキャンセル回数」(B)には生存期間が長いにも拘わらずキャンセル回数ゼロの優等生ブランチがいくつか見えます。(黄色くマークした部分)これはいくつかピックアップして調べると、どれもミドルウェアバージョンアップに向けた Nightly Build のような、コード実装はしてないブランチでした。

この分析より、生存期間の長いブランチ ≒ 多人数で開発する大きめの案件のブランチで意図しない不要なCIが多いという事前予想は、おそらく正しいと結論付けました。

その不要なCIはどれくらいあるのか?

1ブランチの理想的なCI回数は1回です。開発中のコードは開発者自身でテストしてるので「打たせて取るのが上手いピッチャーは1回3球で3アウト取れる!」みたいな無謀な目標ではないでしょう。関係ないと思ってた領域に影響して失敗した、あらー、くらいの感覚です。

ブランチあたりCI回数
中央値 3
平均値 6.00

中央値は3回でした。多くのブランチはさほど不要にCIしてませんが、それでも2回は減らせるとがわかりました。

早めに Pull request を作成してコードを共有するスタイルは開発体験がよいので止めたくありません。このスタイルを(あまり)変えずにCI回数を抑えるため、Pull request が Draft 状態のときはCIを早期キャンセルする仕組みを考えました。

  skip_if_draft_pr:
    executor:
      name: base
    resource_class: small
    steps:
      - run:
          name: Pull request が Draft ならCIをスキップ
          command: |
            api_endpoint="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls/${CIRCLE_PULL_REQUEST##*/}"
            draft=$(curl -s -H "Authorization: token ${GITHUB_API_TOKEN}" "${api_endpoint}" | jq '.draft')


            if [[ "${draft}" == true ]]; then
              echo "Pull reqeust が Draft なのでスキップします"
              curl -X POST https://circleci.com/api/v2/workflow/${CIRCLE_WORKFLOW_ID}/cancel -H 'Accept: application/json' -u "${CIRCLECI_API_TOKEN}:"
              echo "スキップしました"
            fi

CircleCI はワークフローをマイルドに完全中断するのが難しく、exit 1 などの過激な手段でCIを中断すると、テスト失敗率が異常な数値になります。どうしようか…と悩みましたが、下國さん(Kubernetes、何をどうやって監視する? ~ 食べログにおけるオンプレKubernetes監視事例紹介 ~執筆者)がワークフロー自身のキャンセルAPIを叩くというイカした手法で実装してくれました。

現在の開発フロー

これが早い時期からの不要なCIを抑止できる現在の開発フローです。この施策だけで CircleCI コストを54%削減できる見込みです。

4. CircleCI Filters を使ったモノレポCIの最適化

前述のように食べログのメインブランチはモノレポで、1リポジトリに10個以上の Rails アプリと、アプリ間で共有しているライブラリのディレクトリがいくつかあります。

食べログのメインリポジトリファイル構成

これらのどのディレクトリのコードが変更されても全ての Rails のCIジョブを実行していました。

以前の食べログのメインリポジトリのCI

アプリのコードが編集されたらそのアプリのCIジョブだけを、共有ライブラリが編集されたら利用してるアプリのCIジョブだけを実行すれば、コスト削減できますよね?(なお各アプリのCIジョブは並列実行してるので時間は短縮できません。)

理想のCI 理想のCI

やってみたけど労力に見合うコスト削減効果はなかった…とならないよう、マージリクエストで変更があったディレクトリのファイル数を csv で出力する Ruby スクリプトを書いて、Excel で「"app_a列 > 0"ならapp_aはCIする、"x_lib > 0"なら全アプリCIする…」とデータをコネコネして試算したところ、CircleCI コストは37%削減できる見込みでした。これはやる価値ありと判断しました。

#!/usr/bin/env ruby

# 変更があった Rails アプリだけCIすればよいディレクトリ
APP_DIRS = %w(
  app_a
  app_b
  # ...
)

# 複数 Rails アプリでCIが必要になる共有ライブラリのディレクトリ
SHARED_DIRS = %w(
  x_lib
  y_models
  #...
)

# 2024年度のマージコミットを取得するコマンド
merge_commits_command = 'git log --merges master --pretty=format:"%h %ad" --date=iso --since="2024-04-01"'

# マージコミットの結果を取得
merge_commits = `#{merge_commits_command}`.split("\n")

# 各マージコミットの変更ファイルを取得してディレクトリの第一階層で集計
commit_data = {}

merge_commits.each do |line|
  commit_hash, _date = line.split(' ', 2)
  show_command = "git diff --name-only --pretty=format: '#{commit_hash}^...#{commit_hash}'"
  changed_files = `#{show_command}`.split("\n").reject(&:empty?)

  # 第一階層のディレクトリごとにファイルを集計
  directory_count = Hash.new(0)
  changed_files.each do |file|
    dir = file.split('/').first
    directory_count[dir] += 1
  end

  commit_data[commit_hash] = directory_count
end

puts (['Commit Hash'] | APP_DIRS | SHARED_DIRS).join(',')

commit_data.each do |commit_hash, directory_count|
  row = [commit_hash]
  (APP_DIRS | SHARED_DIRS).each do |dir|
    row << directory_count[dir].to_i
  end
  puts row.join(',')
end
Commit Hash,app_a,app_b,,,,x_lib,y_models,,,,
c0dd8fdc0dc7,0,0,,,,0,0,,,,0,0,,,,
14a939948425,0,0,,,,0,0,,,,7,0,,,,
5914edde3323,0,0,,,,0,0,,,,12,1,,,,
...

CircleCI でモノレポの変更があったディレクトリだけをCIするような制御には path-filtering Orb を使います。

シンプルな CircleCI 設定では .circleci/config.yml にCIの本体を記述しますが、path-filtering を利用する場合以下のように 変更を検知する正規表現 パラメータ 設定値 のフィルタを記述します。とても直感的なので詳細は省きます。

# .circleci/config.yml
# ...
workflows:
  setup:
    jobs:
      - filter:
          name: check-updated-files
          # 3-column, whitespace-delimited mapping. One mapping per
          # line:
          # <regex path-to-test> <parameter-to-set> <value-of-pipeline-parameter>
          mapping: |
            app_a/.*         run-app-a-workflows      true
            app_b/.*         run-app-b-workflows      true
            # ...
            x_lib/.*         run-x-lib-workflows      true
            y_models/.*      run-y-models-workflows   true
            # ...
          base-revision: master
          config-path: .circleci/continue_config.yml

そして続きの設定 .circleci/continue_config.yml で、設定したパラメータを元にジョブやフローの制御ができます。6

# .circleci/continue_config.yml
# ...
workflows:
  rspec_app_a:
    jobs:
      - rspec:
        app_name: "app_a"
    when:
      or:
        # app_a は app_a, x_lib のいずれかに変更があったときCIジョブ実行
        - << pipeline.parameters.run-app-a-workflows >>
        - << pipeline.parameters.run-x-lib-workflows >>
        # main ブランチでは必ずCIジョブ実行
        - equal: [ "main", << pipeline.git.branch >> ]

  rspec_app_b:
    jobs:
      - rspec:
        app_name: "app_b"
    when:
      or:
        # app_b は app_b, x_lib, y_models のいずれか変更があったときCIジョブ実行
        - << pipeline.parameters.run-app-b-workflows >>
        - << pipeline.parameters.run-x-lib-workflows >>
        - << pipeline.parameters.run-y-models-workflows >>
        # main ブランチでは必ずCIジョブ実行
        - equal: [ "main", << pipeline.git.branch >> ]

実際には CircleCI 公式の path-filtering Orb は Git LFS オブジェクトを取得しない skip smudge に対応していないといった問題があり、同等の機能を自作しました。

5. 実際の効果と今後の展望

これらの施策をまとめた結果です。

CI実行時間は62%短縮、2.63倍速くなりました! 実測値です!

CI高速化の結果

CircleCI コスト削減はしばらく運用してみないとわからないので見込みですが、70%削減できる見込みです!

施策 CircleCIコスト増減(見込み)
「テスト分割と並列実行」によるCIの高速化 ↑ 5%
Pull request が Draft のうちはCI実行を抑制してコスト節約 ↓ 54%
CircleCI Filters を使ったモノレポCIの最適化 ↓ 37%
合計 ↓ 70%

ということで、CIの高速化とコスト削減が両立できる見込みです!

6. まとめ

  • CircleCI の「テスト分割・並列実行」によるCI高速化はかんたんに導入できてコストは微増程度なので改善コスパは最高!7
  • flaky なテストは並列度を上げる際問題になることがあります。優先的に潰しましょう!
  • 時期尚早なCIのブロックを仕組み化することで、開発者体験をほとんど損なうことなくCI回数を低減できます
  • モノレポのCIは filter で最適化できます。必要なCIジョブだけ実行してコスト削減しましょう!
  • 今回コスト削減の試算に使った CircleCI のインサイトAPIはこのために CircleCI を採用する理由になり得る強力な機能です。Let's 皮算用!

このようにマイクロサービス化チームでは、リファクタリングに不可欠なCI改善なんかもやっています。本記事の改善施策は試算とPoCをマイクロサービス化チーム、正式実装をSREチームの共同作業で行いました。また第3セクションはデザイナの寺西さんが素晴らしい開発フロー図を描いてくれました。ありがとうございました!

越境的に活動するエンジニアリングにご興味のある方は、ぜひ採用サイトからご連絡をください! エンジニア組織の文化風土などを面接前に知りたい方は、本採用プロセス前にカジュアルな面談も可能です。フリーコメント欄に「カジュアル面談希望」とご記載ください。ご応募お待ちしております!

明日は河村さんによる「OpenAI Realtime APIの仕組みを紐解く」です。お楽しみに!


  1. 4年で17倍に成長したエンジニア組織を支えるアーキテクチャの過去と未来
  2. 巨大モノレポのKubernetes移行プロジェクト:食べログの実践的アプローチ
  3. 現実的には、コンピュートリソースは実行可能になるまでのスピンアップ時間を必要とし、並列化するとCI実行時間に対するスピンアップ時間の割合が増加するため、並列度を上げると若干のコスト増加は避けられません。
  4. ActiveSupport::Testing::TimeHelperstravel_to を使うとブロックを抜けたとき自動的に travel_back が発動して解除されるため、この問題が発生しづらくオススメです。
  5. 「単体テストの考え方/使い方」によると、このようなケースを想定してDBはテスト前にクリーンアップするパターンがよいとされています。
  6. 本記事ではシンプルに説明するため .circleci/continue_config.yml を静的ファイルとしてますが、食べログでは CircleCI のもう1つの機能 dynamic config を使ってこのファイルを動的に生成しています。巨大になりがちな CircleCI 設定を、バックエンド開発チーム、フロントエンドチーム、SETチームなどで分割統治しやすくなるのが食べログで動的生成をする主な理由です。
  7. ただしコンテナイメージの Pull 回数は並列数に比例するので、コンテナレジストリの利用コストを考慮して設定しましょう。DockerHub は他社の似たサービスより格段に低コストで、契約Tier 内ではコストが変わらないので、有力な選択肢になります。