0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

CircleCI の CI 高速化 - Gen2 へ移行すべきジョブ・向いていないジョブ

0
Last updated at Posted at 2026-06-22

CircleCI の Gen2 リソースクラスは、Linux VM / Remote Docker が 2026 年 3 月に、Docker(x86)が 2026 年 5 月に GA(一般提供)になりました。公式ドキュメントには 「最大+40%」(Docker、gen1 比 1.4 倍)「最大+180%」(Linux VM マルチスレッドCPU)という数字が示されています。

ただし、これらの数字は CPUがボトルネックになっているジョブを理想的な条件で計測した場合 の値です。ネットワーク帯域やディスクI/Oがボトルネックのジョブでは、CPUが速くなっても待ち時間は変わらないため、改善効果はほぼゼロになります。

本記事では、Gen2への移行判断を4ステップで整理します。移行自体は config 1行の変更で済むため、正しい対象ジョブを見極めることがポイントになります。

Gen2 リソースクラスは有料プラン(Performance / Scale など)で利用できます。

最新の対応プラン・サイズは x86 (gen2) Docker のドキュメント料金ページ を参照してください。

1: ジョブの性質で事前に調査対象をフィルタする

まず、ジョブの内容から「CPUを継続的に使うか」を大まかに判断します。詳細な数値は次のステップで確認するため、ここでは仮の仕分けで十分です。

Gen2の効果が出やすいジョブ(CPU-bound)

  • 並列テスト実行(Jest、RSpec、pytest など)
  • マルチスレッドコンパイル(Go、Rust、Java、C/C++ など)
  • 静的解析(Semgrep、SonarQube など)
  • Dockerイメージビルド(RUN 内に計算処理が多い場合)

Gen2の効果が出にくいジョブ(Network/Disk-bound)

  • pip install / npm install / apt-get install などパッケージ取得
  • DockerイメージのPull
  • S3・GCS などからの成果物のダウンロード
  • COPY --from=... による大ファイルコピー

これらのジョブはCPUがほぼ待機状態のため、CPU性能が上がっても実行時間は変わりません。CPU使用率の低いジョブは small など小さい resource class のまま置いておく方がクレジット消費を抑えられます。

2: CPU使用率を確認する

大まかに調べる対象を選定した後は、データの確認を行いましょう。ジョブごとのCPU使用率は、 CircleCI の UI または Usage API で確認できます。

Resourcesタブで確認する

ジョブ詳細画面の「Resources」タブに、15秒間隔のCPU使用率グラフが表示されます。

例として以下に CPU-bound と Network/IO-bound それぞれのジョブでの測定結果です。

Redisのソースコードコンパイル(CPU-bound)

redis-compile-large の Resources タブ。Docker Large(4 CPU)。コンパイル開始後に CPU が 100% に張り付いている。

Docker Large(4 CPU)でテストしたところ、ジョブ時間は82秒でした。make -j$(nproc) によるコンパイルが開始した後から、CPUが100%に張り付き続けます。

ファイルダウンロード(Network/IO-bound)

io-download-large の Resources タブ。Docker Large(4 CPU)。CPU 使用率がピーク時でも 13% 程度でフラット。

Docker Large(4 CPU)をテストしました。こちらはジョブ時間が77秒で、1分以上かかっているにもかかわらず、CPU使用率はピーク時でも13%程度です。

このようにジョブの特性によってCPU利用率の傾向が変化します。グラフが高い値で推移していればCPU-bound、フラットで低ければNetwork/IO-boundであると判断しましょう。

Usage APIで一括確認する

ジョブ数が多いプロジェクトでは、ひとつひとつResourcesタブを確認するのは非効率です。CircleCI の Usage API を使うと、ジョブごとの平均CPU使用率をまとめて取得できます。

今回はPythonスクリプトを作成して収集します。スクリプトの実行には requests ライブラリが必要となりますので、事前にインストールしましょう。

pip install requests

以下のスクリプトを実行すると、gen1 resource class を使用中のジョブを CPU 使用率の高い順に表示します。Usage API が返す CSV の MEDIAN_CPU_UTILIZATION_PCT カラム(大文字)を集計しているため、Resourcesタブを手動で確認しなくても移行候補を一覧できます。

#!/usr/bin/env python3
"""
gen2-candidate-finder.py
Gen2 移行候補ジョブを CPU 使用率でスコアリングします。

使い方:
  export CIRCLECI_TOKEN="<your-token>"
  export ORG_ID="<your-org-id>"
  python gen2-candidate-finder.py --days 30
"""

from __future__ import annotations

import argparse
import csv
import gzip
import io
import os
import sys
import time
from datetime import datetime, timedelta, timezone

import requests

API_BASE = "https://circleci.com/api/v2"
POLL_INTERVAL_SECONDS = 15
MAX_POLLS = 40
MAX_EXPORT_DAYS = 32  # Usage API の 1 リクエストあたりの上限は 32 日


def resolve_token(explicit: str | None) -> str:
    token = explicit or os.environ.get("CIRCLECI_TOKEN") or os.environ.get("CIRCLE_TOKEN")
    if not token:
        raise SystemExit("CIRCLECI_TOKEN または CIRCLE_TOKEN を export してください。")
    return token


def resolve_org_id(explicit: str | None) -> str:
    org_id = explicit or os.environ.get("ORG_ID")
    if not org_id:
        raise SystemExit("ORG_ID を export するか、--org-id を指定してください。")
    return org_id


def create_export(org_id: str, token: str, start: str, end: str) -> str:
    response = requests.post(
        f"{API_BASE}/organizations/{org_id}/usage_export_job",
        headers={"Circle-Token": token, "Content-Type": "application/json"},
        json={"start": start, "end": end},
        timeout=60,
    )
    response.raise_for_status()
    payload = response.json()
    job_id = payload.get("usage_export_job_id") or payload.get("id")
    if not job_id:
        raise RuntimeError(f"usage_export_job_id が見つかりません: {payload}")
    return job_id


def download_csv_text(url: str) -> str:
    response = requests.get(url, timeout=120)
    response.raise_for_status()
    return gzip.decompress(response.content).decode("utf-8")


def wait_and_download(org_id: str, job_id: str, token: str) -> list[str]:
    status_url = f"{API_BASE}/organizations/{org_id}/usage_export_job/{job_id}"
    headers = {"Circle-Token": token}
    print("Usage export を生成中", end="", flush=True)
    for _ in range(MAX_POLLS):
        data = requests.get(status_url, headers=headers, timeout=60).json()
        state = data.get("state")
        if state == "completed":
            print(" 完了")
            return [download_csv_text(u) for u in (data.get("download_urls") or [])]
        if state in {"failed", "error"}:
            raise RuntimeError(f"Export が失敗しました: {data}")
        print(".", end="", flush=True)
        time.sleep(POLL_INTERVAL_SECONDS)
    raise RuntimeError("Export が完了しませんでした。")


def parse_float(value: str | None) -> float:
    if not value or value == r"\N":
        return 0.0
    try:
        return float(value)
    except ValueError:
        return 0.0


def analyze(csv_parts: list[str], cpu_threshold: float) -> None:
    jobs: dict[str, dict] = {}
    for csv_text in csv_parts:
        for row in csv.DictReader(io.StringIO(csv_text)):
            resource_class = row.get("RESOURCE_CLASS", "")
            if ".gen2" in resource_class:
                continue  # gen2 は対象外(既に移行済みのジョブを除外)
            name = row.get("JOB_NAME", "unknown")
            cpu = parse_float(row.get("MEDIAN_CPU_UTILIZATION_PCT"))
            credits = parse_float(row.get("TOTAL_CREDITS"))
            if name not in jobs:
                jobs[name] = {"rc": resource_class, "cpu_list": [], "credits": 0.0, "runs": 0}
            jobs[name]["cpu_list"].append(cpu)
            jobs[name]["credits"] += credits
            jobs[name]["runs"] += 1

    results = sorted(
        [
            {
                "name": name,
                "rc": v["rc"],
                "avg_cpu": round(sum(v["cpu_list"]) / len(v["cpu_list"]), 1) if v["cpu_list"] else 0.0,
                "credits": int(v["credits"]),
                "runs": v["runs"],
            }
            for name, v in jobs.items()
        ],
        key=lambda x: x["avg_cpu"],
        reverse=True,
    )

    print(f"\n{'Job Name':<45} {'RC':<18} {'Avg CPU%':>8} {'Credits':>10} {'Runs':>6}  判定")
    print("-" * 105)
    for r in results:
        verdict = f"← Gen2 候補 (CPU >= {cpu_threshold:g}%)" if r["avg_cpu"] >= cpu_threshold else ""
        print(
            f"{r['name']:<45} {r['rc']:<18} {r['avg_cpu']:>7.1f}% "
            f"{r['credits']:>10,} {r['runs']:>6}  {verdict}"
        )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--org-id", help="CircleCI Organization ID(省略時は ORG_ID 環境変数)")
    parser.add_argument("--token", help="CircleCI Personal API Token(省略時は CIRCLECI_TOKEN 環境変数)")
    parser.add_argument("--days", type=int, default=30, help=f"取得日数(最大 {MAX_EXPORT_DAYS}、デフォルト 30)")
    parser.add_argument("--cpu-threshold", type=float, default=50.0,
                        help="Gen2 候補とみなす平均 CPU 使用率のしきい値(デフォルト 50)")
    args = parser.parse_args()

    org_id = resolve_org_id(args.org_id)
    token = resolve_token(args.token)
    days = min(args.days, MAX_EXPORT_DAYS)

    end = datetime.now(timezone.utc)
    start = end - timedelta(days=days)
    job_id = create_export(
        org_id, token,
        start.strftime("%Y-%m-%dT%H:%M:%SZ"),
        end.strftime("%Y-%m-%dT%H:%M:%SZ"),
    )
    csv_parts = wait_and_download(org_id, job_id, token)
    analyze(csv_parts, args.cpu_threshold)


if __name__ == "__main__":
    try:
        main()
    except requests.HTTPError as exc:
        print(f"CircleCI API error: {exc.response.status_code} {exc.response.text}", file=sys.stderr)
        raise SystemExit(1) from exc

Organization ID の取得方法

CircleCI web app の「Organization Settings」→「Overview」に表示されている「Organization ID」をコピーしてください。UUID 形式(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)の値です。

データ反映の遅延について

Usage API のデータは前日分までが日次で反映されるため、当日実行したジョブは翌日以降に確認してください。1 回のリクエストで指定できる期間は最大 32 日間で、過去 13 か月分まで遡って取得できます。

スクリプトを実行した結果は、以下の通りです。

Job Name                                      RC                 Avg CPU%    Credits   Runs  判定
---------------------------------------------------------------------------------------------------------
redis-compile-large                           large                 60.9%        681     25  ← Gen2 候補 (CPU >= 50%)
...
io-download-large                             large                  4.3%        184      6

redis-compile-large(60.9%)はGen2候補として上位に出ています。io-download-large(4.3%)は候補外として下位に出ており、Resourcesタブで確認した結果と一致しています。

これらのデータを組み合わせて変更する対象を特定しましょう。

3: Config を1行変更してテストする

特定したジョブの移行を行う方法はとてもシンプルです。config.ymlresource_class.gen2 を追加しましょう。

# 変更前
resource_class: large

# 変更後
resource_class: large.gen2

変更後にYAMLをコミットしてpushすれば、ジョブが実行されます。

4: 結果を確認してロールバックを判断する

移行後のジョブが成功したら、実行結果を確認しましょう。パイプライン実行結果の一覧ページを見ると、総実行時間が確認できます。

Gen1/Gen2 の duration 比較。redis-compile は Gen1 82秒に対し Gen2 49秒。io-download は Gen1 77秒に対し Gen2 70秒。

今回のサンプルでは、次のような変化が発生しました。

ジョブ 種別 Gen1 Gen2 変化
redis-compile-large CPU-bound 82秒 49秒 ▲40%
io-download-large Network/IO-bound 77秒 70秒 ▲9%

CPU-boundのRedisコンパイルは40%短縮できています。一方でNetwork/IO-boundのファイルダウンロードは、大きな変化は見当たりませんでした。

改善効果が薄いと思った場合は、変更を元に戻してで完了します。

# ロールバック: .gen2 を外すだけ
resource_class: large   # large.gen2 → large

まとめ

Gen2の効果はCPUがボトルネックかどうかで決まります。4ステップをまとめると次の通りです。

  1. ジョブの性質で仮判断:コンパイル・並列テストはCPU-bound候補、パッケージ取得はNetwork-bound
  2. CPU使用率を確認:Resourcesタブで目視確認、またはUsage APIスクリプトで一括スコアリング
  3. config 1行変更で移行resource_class: largeresource_class: large.gen2
  4. duration を確認してロールバック判断:改善がなければ1行で元に戻す

「Gen2に変えれば速くなる」という前提で全ジョブを一括移行するのではなく、CPU使用率を確認してから対象を絞ることで、確実に効果のある移行ができます。

参考ドキュメント

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?