パイプラインが遅い — その代償は思っているより大きい
ちょっとした設定変更をプッシュしたとする。環境変数を更新したり、Dockerfileのコメントのタイポを直しただけかもしれない。そして待つ。5分。8分。時には10分。チームの全エンジニアが、毎日何度もその壁にぶつかる。
自分も経験がある。参加したあるプロジェクトでは、CIパイプラインがコミットのたびにDockerイメージをゼロから再ビルドしていた――npmの依存関係を全部ダウンロードして、ネイティブモジュールを再コンパイルして、何もかも。「動いているからそのまま」という理由でキャッシュを設定している人が誰もいなかった。Remote CachingとBuildKitを導入したところ、平均ビルド時間が9分から90秒以下に短縮された。これが、開発者がコードをプッシュすることを再び楽しめるようになる類の変化だ。
このガイドでは、ビルドが遅い根本的な原因の理解から、パイプラインにリモートキャッシュを組み込む実装まで、具体的な手順をすべて解説する。
基本概念:Dockerビルド中に実際に何が起きているのか
レイヤーキャッシュとCIで機能しない理由
Dockerのビルドはレイヤーで構成される。Dockerfile内の各命令(RUN、COPY、ADD)が新しいレイヤーを作成する。Dockerはこれらのレイヤーをディスクにキャッシュするため、そのレイヤーやそれ以前のレイヤーに変更がなければ、命令の再実行をスキップしてキャッシュされた結果を使う。
問題は、ほとんどのCIランナーがエフェメラル(使い捨て)であること。GitHub ActionsやGitLab CIがランナーを起動するたびに、クリーンな状態から始まる。ローカルキャッシュはない。毎回がコールドビルドで、Dockerはすべてをゼロからやり直す。
Docker BuildKitがもたらすもの
BuildKitはDocker 18.09から利用可能な次世代ビルドエンジンだ。従来のビルドエンジンと比べていくつかの利点がある:
- 独立したビルドステージの並列実行
- より優れたキャッシュ管理ときめ細かいキャッシュ制御
- リモートストレージへのキャッシュのエクスポート・インポートのサポート
- レイヤーに認証情報を焼き込まないSecretsとSSHフォワーディング
ここで注目したい機能はキャッシュのエクスポート/インポート――具体的には、ビルドキャッシュをレジストリにプッシュして、次回の実行時に取得することだ。
Remote Caching:欠けていたピース
Remote Cachingとは、Dockerビルドキャッシュを永続的な場所――通常はDocker Hub、Amazon ECR、GitHub Container Registry(GHCR)などのコンテナレジストリ――に保存することを指す。CIランナーが新しいビルドを開始するとき、ビルド前にそのキャッシュを取得する。変更されていないレイヤーは完全にスキップされる。
BuildKitはいくつかのキャッシュバックエンドをサポートしている:
- registry — コンテナレジストリにOCIイメージレイヤーとしてキャッシュを保存(最も一般的)
- local — ローカルファイルシステムにキャッシュを保存(永続ボリュームを持つセルフホストランナーに有用)
- s3 — S3互換バケットにキャッシュを保存(BuildKitの外部キャッシュドライバー経由)
- gha — GitHub Actionsネイティブキャッシュ(レジストリ不要、緊密な統合)
実践:すべてを組み合わせる
ステップ1 — BuildKitを有効にする
まずBuildKitが有効になっていることを確認する。Docker 23.0以降はデフォルトで有効になっている。旧バージョンの場合は環境変数を設定する:
export DOCKER_BUILDKIT=1
またはDockerデーモンの設定ファイル(/etc/docker/daemon.json)に永続的に設定する:
{
"features": {
"buildkit": true
}
}
ステップ2 — キャッシュに優しいDockerfileを書く
リモートキャッシュに触れる前に、Dockerfileの構造を正しく整えよう。ルールはシンプルだ:変更頻度が低いものをファイルの先頭に置く。
レイヤーの順序が悪い典型的なNode.jsアプリの例:
# 悪い例:依存関係のインストール前にソースコードをコピーしている
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
ソースファイルを1つ変更するたびに、COPY . .レイヤーが無効化される。つまりnpm installが毎回ゼロから実行されることになる。
解決策:依存関係のインストールとソースコードのコピーを分離する。
# 良い例:依存関係をソースコードとは別にキャッシュする
FROM node:20-alpine
WORKDIR /app
# 最初にパッケージファイルをコピー
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ソースコードの変更はここで行われるが、npm ciはすでにキャッシュされている
COPY . .
CMD ["node", "index.js"]
これによりnpm ciはpackage.jsonまたはpackage-lock.jsonが変更されたときだけ再実行される。ソースファイルの編集は依存関係レイヤーにまったく影響しない。
ステップ3 — キャッシュエクスポートを使ってビルドする
BuildKitの--cache-toと--cache-fromフラグを使って、レジストリとキャッシュをやり取りする。your-registry/your-imageは実際のイメージパスに置き換えること:
# ビルドしてレジストリにキャッシュをエクスポートする
docker buildx build \
--cache-from type=registry,ref=your-registry/your-image:cache \
--cache-to type=registry,ref=your-registry/your-image:cache,mode=max \
-t your-registry/your-image:latest \
--push \
.
2つのフラグについて:
--cache-from— ビルド前にレジストリから既存のキャッシュを取得する--cache-to mode=max— 最終イメージだけでなく、すべての中間レイヤーをキャッシュにエクスポートする。保存データは増えるが、次回のビルドでの再利用が最大化される。
ステップ4 — GitHub Actionsとの統合(完全な例)
GHCRをキャッシュバックエンドとして使用する完全なGitHub Actionsワークフローの例:
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: コードをチェックアウト
uses: actions/checkout@v4
- name: Docker Buildxをセットアップ
uses: docker/setup-buildx-action@v3
- name: GHCRにログイン
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: ビルドしてプッシュ
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache,mode=max
最初のビルドは必ずコールドになる――まだキャッシュが存在しないため、フルタイムかかる。2回目以降のビルドからは、変更されていないレイヤーがレジストリから直接取得される。その差はすぐに実感できるはずだ。
ステップ5 — 代替案:GitHub Actionsキャッシュバックエンド
キャッシュ保存のためにレジストリを管理したくない場合、GitHub Actionsにはネイティブのキャッシュバックエンドが内蔵されている。キャッシュレイヤーにレジストリの認証情報は不要だ:
- name: ビルドしてプッシュ
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
cache-from: type=gha
cache-to: type=gha,mode=max
ghaバックエンドはGitHubの組み込みキャッシュストレージを使用する――リポジトリあたり10GB。セットアップはシンプルだが、トレードオフが2つある:キャッシュは7日間使われないと削除され、大きなイメージではレジストリベースのキャッシュより遅くなることがある。
ステップ6 — マルチステージビルドでさらに高速化
リモートキャッシュとマルチステージビルドを組み合わせることで、最終イメージを小さく保ちつつ、キャッシュの再利用を最大化できる。
# ステージ1:依存関係のインストール(積極的にキャッシュする)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ステージ2:アプリケーションのビルド
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# ステージ3:本番イメージ(実行に必要なファイルのみ)
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
mode=maxを使うと、BuildKitは3つのステージをそれぞれ別々にキャッシュする。ソースコードを変更してもpackage.jsonを変更していなければ、depsステージはキャッシュから取得され、npm ciは一切実行されない。
キャッシュが機能しているか確認する
ビルド出力を確認しよう。キャッシュされたレイヤーは次のように表示される:
#5 CACHED
#6 CACHED
#7 [builder 3/4] RUN npm run build 0.3s # このステージだけが実行された
CACHEDとマークされたステップはリモートキャッシュから取得され、完全にスキップされた。これが9分から90秒になる仕組みだ。
実際の数字:期待できる効果
結果はイメージサイズや依存関係の変更頻度によって異なる。しかし、自分が関わってきたNode.jsとPythonの複数のプロジェクトを通じて、パターンは一貫している:
- コールドビルド(キャッシュなし):典型的なNode.jsまたはPythonアプリで5〜12分
- ウォームビルド(ソースのみ変更):30〜90秒
- ウォームビルド(依存関係の変更):2〜4分(依存関係ステージのみ再ビルド)
最大の効果が得られるのは、依存関係のインストールが重いプロジェクト――npm install、pip install -r requirements.txt、go mod downloadだけで60秒以上かかるものだ。それがリモートキャッシュによってほとんどのコミットで排除されるステップだ。
今すぐ始める:5ステップチェックリスト
遅いCIパイプラインは時間を無駄にするだけでなく、集中力を妨げ、フィードバックを遅らせ、小さく頻繁にコミットする習慣を静かに損なっていく。Docker BuildKitを使ったRemote Cachingは、パイプラインに加えられる最もコスパの高い変更の一つであり、驚くほど少ないコード量で実現できる。
チェックリストはこちら:
- ソースコードをコピーする前に依存関係をインストールするようDockerfileを再構成する
- CI環境でDocker Buildxを有効にする
- レジストリまたはGHAキャッシュを指す
cache-fromとcache-toフラグを追加する mode=maxを使ってすべての中間レイヤーをキャッシュする- 最終イメージを小さく保つためにマルチステージビルドを検討する
これが整えば、ほとんどのコミットが2分以内にビルドされる。それ以上かかるものは、実際に重要な変更が加わったコミット――それがあるべき姿だ。

