はじめに
マイクロサービスアーキテクチャを採用したプロジェクトでは、複数のDockerイメージをビルドする必要があります。あるプロジェクトでは、APIサービス、ワーカーサービス、データ処理サービス、バッチジョブ、スケジューラーなど、合計5つのDockerイメージを管理していました。
当初、AWS CDK(Cloud Development Kit)を使用してインフラのデプロイとDockerイメージのビルドを一元管理していました。CDKは Infrastructure as Code のツールとして優れていますが、複数のDockerイメージを効率的に並列ビルドすることが困難であり、複数のイメージを持つマイクロサービス構成では大きなボトルネックとなっていました。
実際、5つのDockerイメージをCDKでビルドすると、約80分もの時間を要していました(※実数値を簡略化しています)。この長いビルド時間は開発速度のボトルネックとなり、フィードバックサイクルの遅延やコスト増加につながっていました。
この記事では、CDKからGitHub Actionsへビルドプロセスを移行し、Matrix Strategyを活用して並列ビルドを実現することで、約20分まで短縮(約4倍の高速化)した方法を紹介します。
課題:CDKでの直列ビルドのボトルネック
プロジェクト構成
例として、以下の5つのDockerイメージで構成されたプロジェクトを想定します:
- APIサービス - RESTful APIを提供するメインアプリケーション
- ワーカーサービス - 非同期タスク処理用のバックグラウンドワーカー
- データ処理サービス - データ変換・集計を行う処理エンジン
- バッチジョブ - 定期実行される重量級バッチ処理
- スケジューラー - タスクスケジューリングと調整を行うサービス
CDKでのDockerビルドの制約
当初、これらのDockerイメージは AWS CDK の DockerImageAsset または DockerImageCode を使用してビルドしていました:
import * as cdk from 'aws-cdk-lib';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets';
// CDKでのDockerイメージビルド(逐次実行される)
const apiImage = new DockerImageAsset(this, 'ApiImage', {
directory: path.join(__dirname, '../docker/api-service'),
});
const workerImage = new DockerImageAsset(this, 'WorkerImage', {
directory: path.join(__dirname, '../docker/worker-service'),
});
// ... 他の3つのイメージも同様
この方法には大きな課題がありました。実は、CDKは現時点でDockerイメージアセットのビルドをシリアル(順次)実行する仕様になっています。
この問題は私たちだけでなく多くの開発者が直面しており、AWS CDK CLIのGitHubリポジトリにも同様のIssueが報告されています:
Issue #226: Asset Build Parallelization
報告者の例では、5つのDockerイメージを順次ビルドすると50秒以上かかるのに対し、並列化すると約10秒で完了するとのこと。
asset-build-concurrencyというCLI引数で並列度を制御できるようにする機能が提案されています。(2025年1月報告、現在も未実装)
現時点(2025年11月)では、この機能はまだ実装されていません。CDKでの並列ビルドには以下の制約があります:
-
CDKの仕様による制約
- Dockerイメージアセットのビルドはシリアル実行が現在の仕様
- 並列化のオプションが公式にサポートされていない
- 将来的には
asset-build-concurrencyオプションが追加される可能性
-
ワークアラウンドの限界
- 外部ツールで並列化を試みることは可能だが複雑
- CDKのビルドプロセスとの統合が難しい
- 安定性やキャッシュ管理の問題が発生しやすい
この制約を回避するため、DockerビルドをCDKから分離し、GitHub Actionsに移行することにしました。
ビルド時間の内訳
各イメージのビルド時間は以下のような想定です(※実際の数値を簡略化しています):
- APIサービス: 約20分
- ワーカーサービス: 約18分
- データ処理サービス: 約16分
- バッチジョブ: 約15分
- スケジューラー: 約14分
CDKの逐次実行により、合計で約80分のビルド時間となっていました。
問題点
-
CDKの仕様によるシリアル実行
- Dockerイメージアセットのビルドは順次実行される(Issue #226)
- 並列化オプションは2025年1月時点で未実装
- イメージ数に比例してビルド時間が増加
-
長いフィードバックサイクル
- PRのマージまでに1時間以上待つ必要がある
- 小さな修正でも全イメージの再ビルドが必要
-
開発速度の低下
- 修正の確認に時間がかかり、開発のテンポが悪化
- 開発者が別の作業に移行し、コンテキストスイッチが発生
-
リソースの非効率な利用
- 1つのイメージのビルド中、CPUやメモリが遊休状態
- クラウドリソースの利用効率が低い
解決策:GitHub Actions Matrix Strategyへの移行
なぜGitHub Actionsなのか
CDKでの並列化の限界を克服するため、Dockerイメージのビルドプロセスを GitHub Actions に分離しました。この決断の理由は以下の通りです:
-
並列実行の容易さと柔軟性
- Matrix Strategyにより、複数のジョブを簡単に並列実行できる
- CDKよりも並列度の制御が容易で安定している
- 各ビルドが独立したランナーで実行され、リソース競合が発生しない
-
細かい制御が可能
- ビルドの順序、依存関係、並列度を細かく制御可能
- キャッシュ戦略を個別に最適化できる
- ビルド失敗時の挙動を柔軟に設定可能(fail-fast等)
-
CDKとの適切な責任分離
- CDK: インフラ(ECRリポジトリ等)のプロビジョニング
- GitHub Actions: Dockerイメージのビルドとデプロイ
この構成により、CDKはインフラの管理に専念し、Dockerビルドは並列実行が可能なGitHub Actionsに任せることができます。
Matrix Strategyとは
GitHub ActionsのMatrix Strategyは、同じジョブを異なるパラメータで並列実行する機能です(参考: The matrix strategy in GitHub Actions - RunsOn)。これにより、複数の環境やバージョンでのテスト、複数のDockerイメージのビルドなどを効率的に並列化できます。
CDKでは実現が困難だった並列ビルドを、Matrix Strategyを使うことで簡単に実装できます。
基本的な構造
strategy:
matrix:
image:
- name: api-service
dockerfile: docker/api-service/Dockerfile
repo-suffix: api-repository
- name: worker-service
dockerfile: docker/worker-service/Dockerfile
repo-suffix: worker-repository
max-parallel: 5
fail-fast: false
このシンプルな設定により、複数のDockerイメージを並列でビルドできます。
実装の詳細
1. ECRリポジトリの事前作成
まず、各Dockerイメージ用のECRリポジトリを事前に作成します。これにより、並列ビルド時の競合を避けることができます:
jobs:
deploy-ecr-repositories:
runs-on: ubuntu-latest
env:
AWS_REGION: 'us-east-1'
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Build Infrastructure
run: npm run build:infra
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Deploy ECR Stack
run: npm run deploy:ecr
AWS CDKを使用してECRリポジトリを動的に作成します:
import * as cdk from 'aws-cdk-lib';
import * as ecr from 'aws-cdk-lib/aws-ecr';
import { Construct } from 'constructs';
// リポジトリ名の定義(GitHub Actions のマトリックスと同期)
// ※以下は架空のリポジトリ名です
export const REPOSITORY_NAMES = [
'api-repository',
'worker-repository',
'processor-repository',
'batch-repository',
'scheduler-repository',
];
export class EcrStack extends cdk.Stack {
private readonly APP_NAME = 'myapp'; // ※架空のアプリケーション名
private readonly env: 'stg' | 'prod';
private readonly appNameAndEnv: string;
constructor(scope: Construct, id: string, env: 'stg' | 'prod', props?: cdk.StackProps) {
super(scope, id, props);
this.env = env;
this.appNameAndEnv = `${this.APP_NAME}-${env}`;
// 全リポジトリを作成
REPOSITORY_NAMES.forEach((repositoryName) => {
this.createEcrRepository(repositoryName);
});
}
private makeResourceId(resourceName: string): string {
return `${this.appNameAndEnv}-${resourceName}`;
}
private createEcrRepository(repositoryName: string): ecr.Repository {
return new ecr.Repository(this, this.makeResourceId(repositoryName), {
repositoryName: this.makeResourceId(repositoryName),
removalPolicy: cdk.RemovalPolicy.RETAIN,
emptyOnDelete: false,
lifecycleRules: [
{
description: 'Keep last 10 images',
maxImageCount: 10,
},
],
});
}
}
2. Matrix Strategyによる並列ビルド
次に、Matrix Strategyを使用して各イメージを並列ビルドします:
build-matrix:
environment: ${{ inputs.env }}
needs: [deploy-ecr-repositories]
if: always() && (needs.deploy-ecr-repositories.result == 'success' || needs.deploy-ecr-repositories.result == 'skipped')
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.set-tag.outputs.tag }}
strategy:
matrix:
image:
- name: api-service
dockerfile: docker/api-service/Dockerfile
repo-suffix: api-repository
build-target: api-service
remote-cache-port: 32001
- name: worker-service
dockerfile: docker/worker-service/Dockerfile
repo-suffix: worker-repository
build-target: worker-service
remote-cache-port: 32002
- name: data-processor
dockerfile: docker/data-processor/Dockerfile
repo-suffix: processor-repository
build-target: data-processor
remote-cache-port: 32003
- name: batch-job
dockerfile: docker/batch-job/Dockerfile
repo-suffix: batch-repository
build-target: batch-job
remote-cache-port: 32004
- name: scheduler
dockerfile: docker/scheduler/Dockerfile
repo-suffix: scheduler-repository
build-target: scheduler
remote-cache-port: 32005
max-parallel: 5
fail-fast: false
env:
AWS_REGION: 'us-east-1'
3. ビルドステップ
各マトリックスジョブで以下のステップを実行します:
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Set Image Tag
id: set-tag
run: echo "tag=${{ github.sha }}" >> $GITHUB_OUTPUT
- name: Configure AWS Credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
aws-region: ${{ env.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver-opts: network=host
- name: Build and Push Docker Image
env:
REPO_NAME: myapp-${{ env.SHORT_ENV }}-${{ matrix.image.repo-suffix }}
uses: docker/build-push-action@v5
with:
context: .
file: ${{ matrix.image.dockerfile }}
push: true
platforms: linux/amd64
provenance: false
tags: |
${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:${{ github.sha }}
${{ steps.login-ecr.outputs.registry }}/${{ env.REPO_NAME }}:latest
cache-from: type=gha,scope=${{ matrix.image.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}
build-args: |
ENV=${{ env.SHORT_ENV }}
AWS_REGION=${{ env.AWS_REGION }}
4. キャッシュ戦略の最適化
並列ビルドでは、各イメージごとに独立したキャッシュを使用することが重要です:
cache-from: type=gha,scope=${{ matrix.image.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}
この設定により:
- GitHub Actions Cache -
type=ghaを使用し、各イメージに異なるscopeを設定 - スコープ分離 - 各イメージが独立したキャッシュスコープを持ち、競合を防止
- mode=max - 全レイヤーをキャッシュし、2回目以降のビルドを大幅に高速化
結果と効果
ビルド時間の改善
| 項目 | 改善前 | 改善後 | 削減率 |
|---|---|---|---|
| ビルド時間 | 約80分(直列) | 約20分(並列) | 75%削減 |
| 最長イメージ | 20分 | 20分 | - |
| 合計実行時間 | 80分 | 20分 | 60分短縮 |
※上記の数値は実際の値を簡略化しています。
並列実行により、最も時間のかかるイメージ(20分)に他の4つのビルドが収まるため、劇的な時間短縮を実現しました。
パフォーマンスメトリクス
想定値(※簡略化しています):
- 改善前: 5イメージ × 平均16分 = 80分(順次実行)
- 改善後: max(20分, 18分, 16分, 15分, 14分) = 20分(並列実行)
- 削減率: 75%
- 追加コスト: ほぼゼロ(GitHub Actionsの並列実行は無料枠内で実行可能)
その他の効果
-
開発速度の向上
- PRのフィードバックサイクルが大幅に短縮
- 1日に複数回のデプロイが可能に
- 開発者の待ち時間が減少し、生産性が向上
-
コスト削減
- GitHub Actionsの実行時間が減少
- 同じ並列度で実行できるため、追加コストなし
- リソースの効率的な活用
-
開発者体験の向上
- ビルド完了を待つストレスが軽減
- より頻繁なデプロイによる安心感
- 早期のフィードバックによる品質向上
実装時の注意点
1. max-parallelの設定
max-parallelを設定しないと、全てのジョブが同時に実行され、リソース不足になる可能性があります:
strategy:
max-parallel: 5 # 同時実行数を制限
GitHub Actionsにはプランごとの同時実行数制限があります。Freeプランでは、Linux/Windowsランナーで最大20並列、macOSランナーで最大5並列です(参考: GitHub Changelog - Increased Concurrency Limit)。プロジェクトの規模に応じて適切な値を設定しましょう。
2. fail-fastの無効化
デフォルトでは、1つのジョブが失敗すると他のジョブもキャンセルされます。すべてのイメージのビルド結果を確認するため、fail-fast: falseを設定します(参考: Matrix Builds with GitHub Actions | Blacksmith):
strategy:
fail-fast: false # 1つ失敗しても他を続行
これにより、1つのイメージビルドが失敗しても、他のイメージのビルドは継続され、部分的な成功を得ることができます。
3. キャッシュの分離
各イメージのキャッシュを分離することで、キャッシュの競合を避けます:
cache-from: type=gha,scope=${{ matrix.image.name }}
cache-to: type=gha,mode=max,scope=${{ matrix.image.name }}
scopeパラメータに各イメージのユニークな名前を設定することで、並列実行時のキャッシュ競合を完全に防止できます。
4. 条件付き実行の活用
ECRリポジトリのデプロイがスキップされた場合でも、ビルドジョブを実行できるようにします:
if: always() && (needs.deploy-ecr-repositories.result == 'success' || needs.deploy-ecr-repositories.result == 'skipped')
これにより、既にリポジトリが存在する場合はデプロイをスキップし、直接ビルドに進むことができます。
アーキテクチャの工夫
インフラとコードの同期
ECRリポジトリ名をコードで一元管理することで、GitHub ActionsとCDKの設定を確実に同期させます:
// CDK側でリポジトリ名を定義(※架空の名前)
export const REPOSITORY_NAMES = [
'api-repository',
'worker-repository',
'processor-repository',
'batch-repository',
'scheduler-repository',
];
# GitHub Actions側で同じ名前を使用
strategy:
matrix:
image:
- repo-suffix: api-repository
- repo-suffix: worker-repository
# ...
再利用可能なワークフロー
このビルドプロセスを再利用可能なワークフローとして定義することで、複数の場所から呼び出すことができます:
# .github/workflows/reusable-docker-build.yml
name: Reusable Docker Build Workflow
on:
workflow_call:
inputs:
env:
description: 'Target environment'
required: true
type: string
outputs:
image-tag:
description: "Built image tag"
value: ${{ jobs.build-matrix.outputs.image-tag }}
# .github/workflows/main-ci.yml
jobs:
call-docker-build:
uses: ./.github/workflows/reusable-docker-build.yml
with:
env: ${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }}
secrets: inherit
まとめ
CDKでの逐次実行から、GitHub ActionsのMatrix Strategyによる並列実行に移行することで、複数のDockerイメージのビルド時間を約4倍高速化することができました。この改善により、開発速度の向上、コスト削減、そして開発者体験の向上という複数のメリットを得ることができました。
その他の高速化施策
今回のDocker並列ビルドに加えて、モノレポ全体のビルド高速化のためにTurborepoの Remote Cache も導入しています。Turborepoは、タスクの実行結果をリモートキャッシュに保存し、同じ入力に対するビルドをスキップすることで、CI/CD全体の実行時間をさらに短縮できます。
これらの施策を組み合わせることで、開発サイクル全体の効率化を実現しています。
学習ポイント
- 適切なツールの選択 - CDKはインフラ管理に、GitHub Actionsはビルドプロセスに特化
- 並列化の重要性 - 独立したタスクは並列実行することで大幅な時間短縮が可能
- 責任の分離 - ビルドプロセスとインフラ管理を分離することで、それぞれの強みを活かす
- Matrix Strategyの活用 - GitHub Actionsの強力な機能を理解し活用する
- キャッシュ戦略 - Docker BuildxキャッシュやTurborepo Remote Cacheなど、適切なキャッシュ設定により、さらなる高速化が可能
- インフラとコードの同期 - CDKとGitHub Actionsの設定を一元管理
マイクロサービスやモノレポ構成のプロジェクトでビルド時間に悩んでいる方は、ぜひMatrix Strategyの導入を検討してみてください。
参考文献
- AWS CDK CLI Issue #226: Asset Build Parallelization - CDKでのDockerビルド並列化に関する機能リクエスト
- The matrix strategy in GitHub Actions - RunsOn
- How to leverage GitHub Actions matrix strategy - Depot
- Matrix Builds with GitHub Actions | Blacksmith
- GitHub Actions Dynamic Matrix - Developer Friendly Blog
- Multi-platform image with GitHub Actions - Docker Docs
