Docker 実戦 #4 CI でのイメージビルド — GitHub Actions と BuildKit キャッシュ
ここまで全てのビルドをローカルで行いました。今そのビルドを CI に移す段階です。コード push → 自動ビルド → レジストリ push → (次の記事で) デプロイという流れです。
Docker 実戦 でこの記事の位置:
- #1 FastAPI コンテナ化
- #2 Django + PostgreSQL compose
- #3 React/Next.js ビルドコンテナ
- #4 CI でのイメージビルド — GitHub Actions と BuildKit キャッシュ ← この記事
- #5 レジストリ push とタグ戦略
- #6 クラウドデプロイ — Fly.io / Railway / ECS
この記事は GitHub Actions ベースですが、パターン自体は GitLab CI / CircleCI にもほぼそのまま移せます。核心は BuildKit + キャッシュ + マルチアーキ の組み合わせ。
CI で Docker ビルドの難しさ — キャッシュが消える #
ローカルでは同じイメージの二度目のビルドはほぼ即座に終わります。Docker デーモンがレイヤーキャッシュをディスクに持っているからです (中級 #2 ビルドキャッシュ で扱った話)。
CI は違います。毎ワークフローが綺麗な仮想マシン で始まります。キャッシュがないので毎回ベースイメージから取得して、依存性を最初から入れて、ビルドを最初から回します。Next.js プロジェクト一個が 5~8 分かかるのが普通の風景です。
この問題を解く道具が二つあります。
docker/setup-buildx-action— BuildKit ビルダーを GHA ランナーにインストール。type=ghaキャッシュ — BuildKit のキャッシュバックエンドとして GitHub Actions のキャッシュストレージを使用。ワークフロー間でレイヤーキャッシュが生き残る。
この二つが付けば二度目のビルドからローカルと似た速度が出ます。
最もシンプルなワークフロー #
.github/workflows/docker.yml 一ファイル。
name: Build and push image
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # GHCR push に必要
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=maxこのファイルがすること:
on.push.branches: [main]とpull_request両方をトリガー。PR ではビルドだけ、main push 時のみ push。permissions.packages: write— GHCR push はデフォルトトークンの packages 権限が必要。setup-buildx-actionで BuildKit ビルダーをインストール。login-actionで GHCR にログイン。PR では push しないのでログインもスキップ。build-push-actionのcache-from/cache-toが核心 — GHA キャッシュにレイヤーを保存/読み込み。
mode=max は全中間レイヤーをキャッシュに保存するという意味。mode=min (デフォルト) は最終成果物だけ保存しますが、マルチステージでは中間 stage がキャッシュされず効率が落ちます。max 推奨。
このワークフローは push のたびに :latest だけ更新します。運用では不足 — 次の節でタグ戦略を組みます。
タグ自動生成 — docker/metadata-action
#
:latest だけだと「今運用中のイメージはどのコミット?」を追跡できません。タグを自動で複数付けるのが定石。
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch # ブランチ名: main
type=ref,event=pr # PR: pr-123
type=sha,prefix=sha-,format=short # コミット: sha-a1b2c3d
type=semver,pattern={{version}} # タグ push 時: 1.2.3
type=semver,pattern={{major}}.{{minor}} # 1.2
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=maxmetadata-action がやること:
tagsパターンに合わせて自動生成。main push ならmain、sha-a1b2c3d、latestが一度に付く。labelsも自動生成。OCI 標準ラベル (org.opencontainers.image.sourceなど) が入って GHCR のパッケージページが自動でリポジトリと連結されます。
次の記事でタグ戦略をもっと深く扱います — ここでは「metadata-action が自動で取ってくれる」までで。
マルチアーキテクチャ — amd64 + arm64 #
Apple Silicon 開発者が増えてマルチアーキビルドがほぼ必須になりました (上級 #2 で扱ったテーマです)。
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max新しく入った要素:
setup-qemu-action— GHA ランナーは amd64 なので arm64 ビルドには QEMU エミュレーションが必要。このアクションが binfmt 登録を自動でしてくれます。platforms: linux/amd64,linux/arm64— buildx が二つのアーキテクチャを一度にビルドして manifest として束ねて push。
QEMU エミュレーションは 遅い。ネイティブビルド対比 3~5 倍。amd64 だけビルドして 1 分だったものが amd64 + arm64 で 4~5 分になりえます。イメージ利用先が amd64 クラウドだけなら、わざわざ arm64 を書く必要なし。
本当に速いマルチアーキが必要なら runs-on: [self-hosted, arm64] のような ARM ランナーを立ち上げてネイティブにビルドする方法がありますが、インフラコストが付いてくる選択です。
ビルド時シークレット — --secret
#
NEXT_PUBLIC_API_URL のような非シークレットは --build-arg で十分 (#3 参考)。しかしビルド時に本物のシークレット (例: プライベート npm レジストリトークン、GitHub PAT) が必要なら --build-arg ではなく --secret を使う必要があります。--build-arg はイメージヒストリに平文で残ります。
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
# ビルド時にマウントされるシークレット — イメージに残らない
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
corepack enable && pnpm install --frozen-lockfile- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
secrets: |
npmrc=${{ secrets.NPMRC_TOKEN }}
cache-from: type=gha
cache-to: type=gha,mode=maxランタイムシークレット (DATABASE_URL など) はビルド時に入れるものではありません — それはクラウド環境の環境変数 / シークレットマネージャに置きます (#6)。
ビルド時間を減らす — よくあるミスと解決 #
ビルドが遅いと PR サイクルが長くなり、結局使われなくなります。よく出会う問題:
キャッシュが生き残らない — cache-to: type=gha,mode=max が抜けているか、ワークフローが毎回違うキャッシュキーを使うケース。type=gha は自動でワークフロー + ブランチごとにキャッシュ分離してくれます。
COPY . . が早すぎる — コードを一文字変えるだけで以降の全段階がキャッシュミス。依存定義 (package.json、pyproject.toml、go.mod) を先にコピーして依存性を入れて、その次に COPY . . (#1、#2 のパターン)。
.dockerignore が空 — node_modules、.git、coverage、.next のようなものがビルドコンテキストにまるごと送信されると時間が分単位で増える。最初の段階の “Sending build context to Docker daemon” が長ければサイン。
複数のサービスが一ワークフローで直列ビルド — matrix で並列化。
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
service: [api, web]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}-${{ matrix.service }}
tags: |
type=ref,event=branch
type=sha,prefix=sha-,format=short
- uses: docker/build-push-action@v6
with:
context: ./${{ matrix.service }}
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha,scope=${{ matrix.service }}
cache-to: type=gha,mode=max,scope=${{ matrix.service }}scope を違うように置くのが核心 — そうしないと二つのサービスが同じキャッシュ空間を巡って衝突します。
attest と SBOM — サプライチェーンセキュリティ一段 #
CI でビルドするときもう一段重ねられる要素です (上級 #4 SBOM と署名 で扱います)。
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
sbom: true # SBOM 生成
provenance: mode=max # ビルド出所情報
cache-from: type=gha
cache-to: type=gha,mode=maxprovenance は「このイメージがどのワークフローのどのコミットでビルドされたか」を attestation として一緒に push。後で supply chain 検証を自動化するときこのデータが使われます。オンにしておいて損はほぼありません。
フルワークフロー — 一箇所まとめ #
上の断片を一ファイルにまとめた形。このままコピペして出発点として使えます。
name: Build and push image
on:
push:
branches: [main]
tags: ['v*.*.*']
pull_request:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # provenance に必要
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=sha-,format=short
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
sbom: true
provenance: mode=max他のキャッシュバックエンド — type=gha がいつ合いませんか
#
type=gha は GHA の中で最も楽な選択ですが二つの制約があります。
- キャッシュ限界 — リポジトリあたり GHA キャッシュ総サイズ 10GB。大きなイメージでは LRU で切られてミスが頻発。
- ワークフロー外では使えない — ローカルビルドは
type=ghaキャッシュを引っ張れません。
代替:
type=registry— キャッシュを別イメージタグでレジストリに push。全環境で共有可能。cache-to: type=registry,ref=ghcr.io/.../cache,mode=max cache-from: type=registry,ref=ghcr.io/.../cacheレジストリコストが追加されますが限界が事実上なし。
type=s3/type=gcs— クラウドストレージをキャッシュとして。大きな組織で標準化するのに良い。
小型プロジェクトなら type=gha で十分。キャッシュがよくミスると感じたらそのとき移してください。
よくある落とし穴 #
“buildx not found” エラー — setup-buildx-action 漏れ。全 Docker ビルドステップの前に置くべき。
permission denied — GHCR push — permissions.packages: write 漏れ。または organization の GHCR 設定で write が遮断されている可能性 (Settings → Actions → General → Workflow permissions)。
キャッシュは取れているのに毎回最初から — Dockerfile のどこかで timestamp のような非決定的データが入るケースです。RUN apt-get update && apt-get install -y ... のパッケージインデックスも時間によって変わります。apt キャッシュは BuildKit cache mount で分離します。
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y curlarm64 ビルドが 5 分越え — QEMU エミュレーションの限界。本当に頻繁ビルドなら ARM ランナー導入検討。
PR で secret が読めない — fork された PR は secrets アクセスが遮断されます。セキュリティ上意図された動作。信頼されたコントリビュータ PR に限って別のワークフロー (pull_request_target) を使えますが、セキュリティの落とし穴が多くお勧めしません。
まとめ #
- CI Docker ビルドの核心は BuildKit + GHA キャッシュ (
type=gha,mode=max) の組み合わせ。二度目のビルドからローカル並み速度。 - タグは
docker/metadata-actionで自動生成。ブランチ/PR/SHA/semver/latest が一度に。 - マルチアーキ (amd64 + arm64) は
setup-qemu+platforms一行。ただし QEMU は遅い — 不要なら amd64 だけ。 - ビルド時シークレットは
--build-argではなく--secretで。イメージに残りません。 - 複数のサービスを一リポジトリでビルドするなら
matrix+ 違うcache scopeで並列化。 sbom: true+provenance: mode=maxはオンにしておくのが普通利得。
次の記事 (#5 レジストリ push とタグ戦略) では タグ戦略 を本格的に扱います。semver / sha / latest の意味と落とし穴、GHCR vs Docker Hub vs ECR の分かれ道、イメージ retention 方針まで。