
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 でもキャッシュミスしていました。
```yaml
# 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 ビルドでもキャッシュが書き込まれるようにしました。
```yaml
# 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 で古いビルドが残り続ける問題もありました。
```yaml
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` を使うため、安全に並列化できます。
```yaml
- 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%の速度差**があります。
```yaml
# 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 と違い、ジョブ終了後に自動クリーンアップされないため、明示的に認証情報を削除します。
```yaml
- 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レイヤーに分割
```dockerfile
# 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 でドキュメント変更をスキップ
```yaml
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 やイメージビルドは不要なのに、毎回フル実行されていました。
```yaml
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 を経由すると展開のオーバーヘッドが入ります。
```yaml
# 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
```
```bash
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つずつ計測しながら進める」方が、どの施策が効いたか分かるし、問題が出たときの切り戻しも楽です。
## 参考リンク
- [docker/build-push-action - Cache backends](https://github.com/docker/build-push-action/blob/master/docs/advanced/cache.md)
- [BuildKit cache mount](https://docs.docker.com/build/cache/optimize/#use-cache-mounts)
- [Trivy - OCI image input](https://aquasecurity.github.io/trivy/latest/docs/target/container_image/)

Self-hosted Runner の Docker イメージをビルド・スキャンする CI パイプラインが 8分28秒 かかっていました。全体の92%を image-build ステップが占めていて、PR のたびに待たされる状態です。
4段階に分けて改善し、最終的に 約3分 まで短縮しました。この記事では各フェーズで何を変えて、なぜその順番で進めたかを記録します。
前提:何をビルドしているか
GitHub Actions の Self-hosted Runner を Docker コンテナで動かすために、カスタムイメージをビルドしています。CI パイプライン(lint.yml)は以下の構成です。
- lint — ShellCheck / yamllint / xmllint / secret-scan
- dockerfile-lint — hadolint
- image-build-and-scan — Docker Build → Trivy スキャン → ghcr.io Push
- 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つです。
cache-to は default branch のみに書き込み。PR はキャッシュを読むだけ
- zstd 圧縮(
compression-level=3)で blob 転送量を約20%削減
- 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 の順番にした理由があります。
- Phase 1(cache + 並列化):変更が小さく、リスクが低い。まず確実に効く施策から着手
- Phase 2(Registry cache):キャッシュバックエンドの変更は影響範囲が広いが、Phase 1 で cache の挙動を理解した後なら安全に進められる
- Phase 3(Dockerfile 分割):Phase 2 の registry cache が安定してから、キャッシュ粒度の改善に着手。順番が逆だと、cache backend の問題と layer 分割の問題を切り分けにくい
- Phase 4(Schedule skip + OCI):Phase 1-3 で本筋を片付けた後、周辺の最適化
CI の改善は「一気にやる」より「1つずつ計測しながら進める」方が、どの施策が効いたか分かるし、問題が出たときの切り戻しも楽です。
参考リンク