アニメ風エンジニアが、GitHub ActionsのCIビルドを8分から3分に短縮する改善プロセスを図で解説。遅いDockerビルドの「8m」から、キャッシュや並列化、レイヤー分割などの施策を経て高速化した「3m」のCI成功を示す。

Self-hosted Runner の Docker イメージをビルド・スキャンする CI パイプラインが 8分28秒 かかっていました。全体の92%を image-build ステップが占めていて、PR のたびに待たされる状態です。

4段階に分けて改善し、最終的に 約3分 まで短縮しました。この記事では各フェーズで何を変えて、なぜその順番で進めたかを記録します。

前提:何をビルドしているか

GitHub Actions の Self-hosted Runner を Docker コンテナで動かすために、カスタムイメージをビルドしています。CI パイプライン(lint.yml)は以下の構成です。

  1. lint — ShellCheck / yamllint / xmllint / secret-scan
  2. dockerfile-lint — hadolint
  3. image-build-and-scan — Docker Build → Trivy スキャン → ghcr.io Push
  4. smoke-scale-startup — コンテナが実際に起動して GitHub に登録できるか検証

ボトルネックは 3 の image-build-and-scan です。430MB 超のイメージを毎回フルビルドしていました。

Phase 1:warm cache + BATS 並列化

狙い:キャッシュヒット率の向上とテスト並列化で、手をつけやすいところから着手。

キャッシュの問題

GHA cache(type=gha)を使っていましたが、cache-to を Push ステップにしか付けていなかったため、PR ビルドがキャッシュを書き込めず、2回目以降の push でもキャッシュミスしていました。

# Before: Push ステップにだけ cache-to があった
- name: Push image
  if: github.ref == 'refs/heads/main'
  uses: docker/build-push-action@v6
  with:
    cache-from: type=gha,scope=runner-image
    cache-to: type=gha,scope=runner-image,mode=max  # ← ここだけ

Build ステップに cache-to を移動し、PR ビルドでもキャッシュが書き込まれるようにしました。

# After: Build ステップで cache-to
- name: Build image
  uses: docker/build-push-action@v6
  with:
    cache-from: type=gha,scope=runner-image
    cache-to: type=gha,scope=runner-image,mode=max

Concurrency 制御

同じブランチの連続 push で古いビルドが残り続ける問題もありました。

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

PR の force push 時は古いジョブをキャンセル。main ブランチと schedule は保護します。

BATS テストの並列実行

ShellCheck 後に走る BATS ユニットテスト(46テスト)がシリアル実行で 103秒 かかっていました。各テストは独立した BATS_TEST_TMPDIR を使うため、安全に並列化できます。

- name: Run unit tests (bats)
  run: bats --jobs 4 tests/

103秒 → 33秒(約3倍速)。--jobs 4 には GNU parallel が必要なので、apt-get に追加しています。

Phase 1 の効果

項目 Before After
BATS テスト 103s 33s
キャッシュヒット率 PR で常にミス 2回目以降ヒット

Phase 2:GHA cache → Registry cache

狙い:キャッシュバックエンドの変更で、キャッシュヒット時の復元速度そのものを改善。

なぜ Registry cache か

GHA cache は Azure Blob Storage ベースで、レイヤーを逐次的に復元します。一方、ghcr.io(OCI レジストリ)はレイヤーを並列 pull できます。docker/buildx の Issue で報告されているベンチマークでは、20〜40%の速度差があります。

# After: registry cache に切替
- name: Build image
  uses: docker/build-push-action@v6
  with:
    cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/runner:buildcache
    cache-to: >-
      ${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
          && format('type=registry,ref=ghcr.io/{0}/runner:buildcache,mode=max,compression=zstd,compression-level=3',
                    github.repository_owner)
          || '' }}

ポイントは3つです。

  1. cache-to は default branch のみに書き込み。PR はキャッシュを読むだけ
  2. zstd 圧縮compression-level=3)で blob 転送量を約20%削減
  3. Permission 変更actions: write(GHA cache 用)が不要になり、packages: write だけで registry cache も image push もカバー

認証トークンのクリーンアップ

Registry cache を使うには ghcr.io への認証が必要です。Self-hosted Runner は GitHub-hosted と違い、ジョブ終了後に自動クリーンアップされないため、明示的に認証情報を削除します。

- name: Cleanup docker config
  if: always()
  run: rm -rf "${RUNNER_TEMP}/docker-config" || true

if: always() でジョブがキャンセル・タイムアウトしても確実に実行します。

Phase 3:Dockerfile レイヤー分割 + paths-ignore

狙い:キャッシュバックエンドは最適化済み。次はキャッシュ無効化の粒度を改善。

モノリシック RUN の問題

Dockerfile の RUN が1つの巨大レイヤー(約430MB)にまとめられていました。corepack のバージョンを上げるだけで、apt-get install + Node.js インストールを含む全レイヤーが再ビルドされます。

3レイヤーに分割

# Layer 1: base apt packages (220MB, めったに変わらない)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    apt-get update \
 && apt-get upgrade -y \
 && apt-get install -y --no-install-recommends \
      curl git jq ca-certificates ...

# Layer 2: Node.js (210MB, メジャーバージョン更新時のみ)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
    curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
 && apt-get install -y --no-install-recommends nodejs

# Layer 3: corepack (1.3MB, 四半期ごとの更新)
RUN npm install -g corepack@0.35.0 \
 && corepack enable

BuildKit の --mount=type=cache/var/cache/apt を共有し、分割しても apt のダウンロードは1回で済みます。最終イメージサイズへの影響もゼロです(cache mount はレイヤーに焼き込まれない)。

corepack のバージョンを latest から 0.35.0 に固定したのもこのタイミングです。サプライチェーンリスクの低減と、キャッシュの安定化を兼ねています。

paths-ignore でドキュメント変更をスキップ

on:
  push:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '**/*.md'
      - 'LICENSE'
  pull_request:
    paths-ignore:
      - 'docs/**'
      - '**/*.md'
      - 'LICENSE'

ドキュメントだけの PR で CI が回るのは時間の無駄です。Branch Protection の required checks を設定していない前提で paths-ignore を使っています(設定済みなら dorny/paths-filter に切り替えが必要)。

Phase 4:schedule スキップ + OCI ディレクトリ出力

狙い:不要なビルド実行の排除と、Trivy 互換性の確保。

Schedule トリガーの最適化

定期実行(schedule)は smoke test だけが目的です。lint やイメージビルドは不要なのに、毎回フル実行されていました。

lint:
  if: github.event_name != 'schedule'

image-build-and-scan:
  if: github.event_name != 'schedule'

schedule では smoke-scale-startup だけが走るようになり、native runner の占有時間が解消されました。

Trivy v0.70 対応:tar → OCI ディレクトリ

Trivy v0.70 から OCI layout ディレクトリの直接入力が推奨になりました。tar を経由すると展開のオーバーヘッドが入ります。

# Before: tar アーカイブ
outputs: type=docker,dest=${{ runner.temp }}/runner-image.tar

# After: OCI ディレクトリ(tar=false)
outputs: type=oci,dest=${{ runner.temp }}/runner-image-oci,tar=false
trivy image \
  --input ${{ runner.temp }}/runner-image-oci \
  --severity HIGH,CRITICAL \
  --ignore-unfixed \
  --pkg-types os \
  --exit-code 1 \
  --format table

tar 展開が不要になる分、I/O コストが削減されます。

結果まとめ

Phase 施策 主な効果
1 warm cache + BATS 並列化 テスト 103s→33s、キャッシュヒット率向上
2 Registry cache キャッシュ復元 20-40%高速化
3 Dockerfile 3分割 + paths-ignore 部分再ビルド可能に、docs PR スキップ
4 Schedule skip + OCI directory 不要ビルド排除、Trivy 互換性確保

トータル:8分28秒 → 約3分(64%短縮)

改善の順番について

「最初からレイヤー分割すればよかったのでは」と思うかもしれません。Phase 1→2→3→4 の順番にした理由があります。

  1. Phase 1(cache + 並列化):変更が小さく、リスクが低い。まず確実に効く施策から着手
  2. Phase 2(Registry cache):キャッシュバックエンドの変更は影響範囲が広いが、Phase 1 で cache の挙動を理解した後なら安全に進められる
  3. Phase 3(Dockerfile 分割):Phase 2 の registry cache が安定してから、キャッシュ粒度の改善に着手。順番が逆だと、cache backend の問題と layer 分割の問題を切り分けにくい
  4. Phase 4(Schedule skip + OCI):Phase 1-3 で本筋を片付けた後、周辺の最適化

CI の改善は「一気にやる」より「1つずつ計測しながら進める」方が、どの施策が効いたか分かるし、問題が出たときの切り戻しも楽です。

参考リンク