こんにちは、ニーリーの佐古です。
現在開発速度や開発者体験の向上のため、取り組みの諸々を遂行しています。
開発者体験とCI
天井の雨漏りが4か月ほど止まらないので私の開発者体験は酷いことになっています。
さて、皆さんCIの待ち時間はお好きですか?私は大嫌いです。 弊社バックエンドリポジトリのPR時CIはプロダクトの成長に合わせて実行時間が順調に伸びており、 開発速度と開発者体験の双方に悪影響をもたらしていました。
実は別チームで改善のための試みがなされたことはあったのですが、 そこで行き当たった問題をある程度解決してどうにかエピソードになる程度の成果を得られたので 簡単に記しておこうと思います。
前提
プロダクトはDjangoで、リポジトリはGitHubで管理されています。
AS-WAS

ボトルネックの確認
改めて実行してみます。

対応策
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()