Docker 実戦 #4 CI でのイメージビルド — GitHub Actions と BuildKit キャッシュ

読了 8分

ここまで全てのビルドをローカルで行いました。今そのビルドを CI に移す段階です。コード push → 自動ビルド → レジストリ push → (次の記事で) デプロイという流れです。

Docker 実戦 でこの記事の位置:

この記事は GitHub Actions ベースですが、パターン自体は GitLab CI / CircleCI にもほぼそのまま移せます。核心は BuildKit + キャッシュ + マルチアーキ の組み合わせ。

CI で Docker ビルドの難しさ — キャッシュが消える #

ローカルでは同じイメージの二度目のビルドはほぼ即座に終わります。Docker デーモンがレイヤーキャッシュをディスクに持っているからです (中級 #2 ビルドキャッシュ で扱った話)。

CI は違います。毎ワークフローが綺麗な仮想マシン で始まります。キャッシュがないので毎回ベースイメージから取得して、依存性を最初から入れて、ビルドを最初から回します。Next.js プロジェクト一個が 5~8 分かかるのが普通の風景です。

この問題を解く道具が二つあります。

  1. docker/setup-buildx-action — BuildKit ビルダーを GHA ランナーにインストール。
  2. type=gha キャッシュ — BuildKit のキャッシュバックエンドとして GitHub Actions のキャッシュストレージを使用。ワークフロー間でレイヤーキャッシュが生き残る。

この二つが付けば二度目のビルドからローカルと似た速度が出ます。

最もシンプルなワークフロー #

.github/workflows/docker.yml 一ファイル。

.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-actioncache-from/cache-to が核心 — GHA キャッシュにレイヤーを保存/読み込み。

mode=max は全中間レイヤーをキャッシュに保存するという意味。mode=min (デフォルト) は最終成果物だけ保存しますが、マルチステージでは中間 stage がキャッシュされず効率が落ちます。max 推奨。

このワークフローは push のたびに :latest だけ更新します。運用では不足 — 次の節でタグ戦略を組みます。

タグ自動生成 — docker/metadata-action #

:latest だけだと「今運用中のイメージはどのコミット?」を追跡できません。タグを自動で複数付けるのが定石。

metadata-action 導入
- 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=max

metadata-action がやること:

  • tags パターンに合わせて自動生成。main push なら mainsha-a1b2c3dlatest が一度に付く。
  • 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 はイメージヒストリに平文で残ります。

Dockerfile — secret 使用
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
ワークフロー — secret 注入
- 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.jsonpyproject.tomlgo.mod) を先にコピーして依存性を入れて、その次に COPY . . (#1#2 のパターン)。

.dockerignore が空node_modules.gitcoverage.next のようなものがビルドコンテキストにまるごと送信されると時間が分単位で増える。最初の段階の “Sending build context to Docker daemon” が長ければサイン。

複数のサービスが一ワークフローで直列ビルドmatrix で並列化。

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 と署名 で扱います)。

attestations + 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=max

provenance は「このイメージがどのワークフローのどのコミットでビルドされたか」を attestation として一緒に push。後で supply chain 検証を自動化するときこのデータが使われます。オンにしておいて損はほぼありません。

フルワークフロー — 一箇所まとめ #

上の断片を一ファイルにまとめた形。このままコピペして出発点として使えます。

.github/workflows/docker.yml — 最終
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 pushpermissions.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 curl

arm64 ビルドが 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 方針まで。

X