以下の内容はhttps://nealle-dev.hatenablog.com/entry/2025/06/10/121331より取得しました。


CIの時間を(できるだけ楽して)半分にしてみた

こんにちは、ニーリーの佐古です。

現在開発速度や開発者体験の向上のため、取り組みの諸々を遂行しています。

開発者体験とCI

天井の雨漏りが4か月ほど止まらないので私の開発者体験は酷いことになっています。

さて、皆さんCIの待ち時間はお好きですか?私は大嫌いです。 弊社バックエンドリポジトリのPR時CIはプロダクトの成長に合わせて実行時間が順調に伸びており、 開発速度と開発者体験の双方に悪影響をもたらしていました。

実は別チームで改善のための試みがなされたことはあったのですが、 そこで行き当たった問題をある程度解決してどうにかエピソードになる程度の成果を得られたので 簡単に記しておこうと思います。

前提

プロダクトはDjangoで、リポジトリはGitHubで管理されています。

AS-WAS

ついこないだまでのPR時CI。
こちらがもともとのGitHub CIのグラフです。 正直経験上そこまで悪いわけではないのですが、絶対的に15+分待つというのがとてもやーです。

ボトルネックの確認

改めて実行してみます。

ボトルネック探し
はい。UTですね。

対応策

UT実装の高速化

UTが遅いならUTを早くすればよいのです。ですが、分量が多いのでクイックに対応するのはやや難しいので継続的課題としています。*1

CIジョブの高速化

ソースのアーティファクト化

ソースの検証周りのジョブが複数存在し、そのためのcheckoutが何回も実行されています。 1回checkoutするたびに15秒かかるのですが、回数が増えてくるとこのオーバーヘッドがちりつもでだいぶ大きくなります。 そこで、一度最初にcheckoutした最初のリポジトリをアーティファクトとしてアップロードし、 後段はDLしてunzipするだけ、という方法に切り替えました。*2

アップロード

    outputs:
      cache-key: ${{ steps.set-key.outputs.key }}
      artifact-name: source-code
    steps:
      - uses: actions/checkout@2541b1294d2704b0964813337f33b291d3f8596b
      - run: zip -qr source.zip .
      - uses: actions/upload-artifact@v4
        with:
          name: source-code
          path: source.zip

ダウンロード

      - uses: actions/download-artifact@v4
        with:
          name: source-code
      - run: unzip source.zip

アップロードする分のオーバーヘッドが20秒程度ありますが、 ダウンロード1回ごとに5秒程度ほどずつ節約できます。 今後ソースの検証はさらに充実化させていく方針なので、じわじわ効果が上がっていく見通しです。

仮想環境のキャッシュ化

Pythonのプロダクトなのでジョブの度に仮想環境を用意する必要があるのですが、 めんどくさいし遅いのでキャッシュします。

キャッシュ化

    steps:
      - uses: actions/cache@v4
        with:
          path: .venv
          key: ${{ steps.set-key.outputs.key }}
      - name: Install uv and sync dependencies
        run: |
          curl -LsSf https://astral.sh/uv/install.sh | sh
          uv sync --frozen

利用

    - name: setup python
      uses: actions/setup-python@d27e3f3d7c64b4bbf8e4abfb9b63b83e846e0435 # v4.5.0
      with:
        python-version: 3.11.9
        architecture: 'x64'
    - name: Install uv
      uses: astral-sh/setup-uv@v5
      with:
        version: 0.6.2
    - uses: actions/cache@v4
      with:
        path: .venv
        key: ${{ inputs.cache-key }}

UTの並列化

いままでの手はじわじわ改善系ですが、一番手っ取り早いのがこれです。
手っ取り早すぎるのであまり最初に手を付けたくないまであります。

アイデア

UTを分割して並列実行するわけですが、実はこのプロダクトのUTが結構Flakyでして適当に分割するとまずうまく行きません。コケます。
ので、できるだけぶった切るにしても固まりにしてあげたいわけです。幸いアプリケーションが分割されているのでそれを切り取り線にしてみましょう。

  • アプリケーションごとのUTクラスに雑な重み付けをしてビンパッキングを行う
    • 重み付け
      • TransactionTestCase (DBをflushするので一番遅い): 重み5
      • TestCase (DBを使うので遅い): 重み4
      • SimpleTestCase (DBを使わないので速い): 重み1
    • ビンパッキングのスクリプトはベースをChatGPTに書かせる
  • matrix strategyで3並列実行させる
  • 極端なスローケースは事前に潰しておく
    • 1件とてつもなく重い(30+秒)ケースがあったので1.5秒にチューン
結果1

分割してみた
ぐぬぬ。

所感1

ここまでやって3分も短縮できていないなら無駄で、今までの取り組みがうまく行かなかったのもこれが原因です。 とはいえほぼ理由は分かっていて、一つだけ重いアプリが存在するためで、何らかの配慮が必要です。

対策

ではそのアプリも分けましょう。以下のハイブリッド戦略を採ります。

  • 重いアプリをテストディレクトリルート直下のディレクトリ単位でビンパッキングしてmatrix化
    • 工夫: このテストルート直下はディレクトリだけにしておくこと
  • 残り全部をアプリ単位でビンパッキングしてmatrix化
  • 並列数はそれぞれ3ずつ
結果2

もうちょっと分割してみた
うむ。

所感2

見てわかる通りオーバーヘッドがだいぶ*3ありますし、ビンパッキングの精度もあまり高くはないのですがファインチューニングする手間の方がもったいないという判断になりました。

ちょっとした配慮

UTが並列数を食うので、他のジョブはワークフロー全体の時間を伸ばさない範囲で直列化しておきます。

最終結果

チューニング後の結果
ヨシ!

というわけでCIの時間を半分(約15分→約7分)にしました。

さいごに

  • 不満を大事に
  • モメンタム大事、鉄は熱いうちに打て
    • 工数的には1日ちょいで完了しました。思い付きでダッシュです。
  • If it works, it works.

付録: ビンパッキングのスクリプト

from pathlib import Path
import heapq
import ast
import sys
from collections import defaultdict
from typing import NamedTuple, Literal, cast

# --- 設定値 ---
HEAVY_APP = "ココニ ワケタイ アプリ イレル"
HEAVY_APP_PARTS = 3  # HEAVY_APPを占有させるシャード数
SCORE_MAP = {
    "TransactionTestCase": 5,
    "TestCase": 4,
    "SimpleTestCase": 1,
}
DEFAULT_SCORE = 1

Mode = Literal["app"]


class BinResult(NamedTuple):
    index: int
    labels: list[str]


def get_base_classes(file_path: Path) -> set[str]:
    try:
        tree = ast.parse(file_path.read_text(encoding="utf-8"))
    except Exception:
        return set()
    return {
        base.id
        for node in ast.walk(tree)
        if isinstance(node, ast.ClassDef)
        for base in node.bases
        if isinstance(base, ast.Name)
    }


def get_score(file_path: Path) -> int:
    bases = get_base_classes(file_path)
    return max((SCORE_MAP.get(b, DEFAULT_SCORE) for b in bases), default=DEFAULT_SCORE)


def collect_by_app_excluding_heavy(heavy_app: str) -> dict[str, int]:
    grouped: dict[str, int] = defaultdict(int)
    for f in Path("apps").rglob("tests/**/test_*.py"):
        app_name = f"apps.{f.relative_to('apps').parts[0]}"
        if app_name == f"apps.{heavy_app}":
            continue
        grouped[app_name] += get_score(f)
    return grouped


def collect_heavy_app_test_dirs(app_name: str) -> dict[str, int]:
    grouped: dict[str, int] = defaultdict(int)
    for f in Path("apps").rglob("tests/**/test_*.py"):
        if f.name == "__init__.py":
            continue
        parts = f.relative_to("apps").parts
        if parts[0] != app_name or "tests" not in parts:
            continue
        idx = parts.index("tests")
        if idx + 1 >= len(parts):
            continue
        subdir = parts[idx + 1]
        label = f"apps.{app_name}.tests.{subdir}"
        grouped[label] += get_score(f)
    return {k: v for k, v in grouped.items() if v > 0}


def binpack(grouped: dict[str, int], bins: int) -> list[BinResult]:
    bin_heap: list[tuple[int, int, list[str]]] = [(0, i, []) for i in range(bins)]
    heapq.heapify(bin_heap)

    for label, score in sorted(grouped.items(), key=lambda item: -item[1]):
        total, i, group = heapq.heappop(bin_heap)
        group.append(label)
        heapq.heappush(bin_heap, (total + score, i, group))

    return [BinResult(i, group) for _, i, group in sorted(bin_heap)]


def write_bins(results: list[BinResult], out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    for result in results:
        out_path = out_dir / f"{result.index}.txt"
        out_path.write_text("\n".join(result.labels), encoding="utf-8")
        print(f"[OUT] {out_path.name}: {len(result.labels)} items")


def main() -> None:
    if len(sys.argv) != 3:
        print("Usage: python binpack_tests.py app <split_count>")
        sys.exit(1)

    mode: Mode = cast(Mode, sys.argv[1])
    try:
        bins = int(sys.argv[2])
    except ValueError:
        print("Split count must be an integer")
        sys.exit(1)

    if mode != "app":
        print("Only 'app' mode is currently supported.")
        sys.exit(1)

    if bins < HEAVY_APP_PARTS + 1:
        print(f"Need at least {HEAVY_APP_PARTS + 1} bins to isolate HEAVY_APP.")
        sys.exit(1)

    print(f"🔹 Mode: {mode}, Bins: {bins}, HEAVY_APP: {HEAVY_APP}")

    # HEAVY_APP: testsサブディレクトリ単位で2分割
    heavy_dirs = collect_heavy_app_test_dirs(HEAVY_APP)
    heavy_bins = binpack(heavy_dirs, HEAVY_APP_PARTS)

    # その他: アプリ単位で(bins - HEAVY_APP_PARTS)分割
    other_apps = collect_by_app_excluding_heavy(HEAVY_APP)
    other_bins = binpack(other_apps, bins - HEAVY_APP_PARTS)

    # 統合して順序に注意してインデックス付け
    results = heavy_bins + [
        BinResult(bin.index + HEAVY_APP_PARTS, bin.labels) for bin in other_bins
    ]

    out_path = Path(".ci") / f"by_{mode}"
    write_bins(results, out_path)


if __name__ == "__main__":
    main()

*1:この辺のtryでDevinと取っ組み合いになった話はまた別の記事で。

*2:カバレッジ取得など、gitリポジトリとして機能しなければならないケースでは利用できないのでcheckoutする必要があります(1敗)。

*3:DB初期化のほか、カバレッジ取得がけっこう重いです。分割した分の集計は別ワークフロー(手動キック)に逃がしてあります。




以上の内容はhttps://nealle-dev.hatenablog.com/entry/2025/06/10/121331より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14