言語を切り替える
テーマを切り替える

GitHub Actions Matrix ビルド:マルチプラットフォーム・マルチバージョン並列テストの実践ガイド

昨年、あるオープンソースプロジェクトから相談を受けました。CI 設定ファイルが 800 行を超えて膨れ上がっているというのです。開いてみると、びっしりと重複した job 定義が並んでいました。

Node 16 を Ubuntu で実行、Node 16 を Windows で実行、Node 16 を macOS で実行……その後 Node 18、Node 20 と同じことを繰り返しています。テストコマンドを変更するには 12 箇所も修正が必要です。新しいバージョンを追加するには、コピー&ペーストで十数分かかります。

その時、まだ多くの人が手書きでマルチバージョン・マルチプラットフォームのテストを維持していることに気づきました。

GitHub Actions の Matrix 機能は、これらの重複した設定を自動的に展開してくれます。いくつかのオペレーティングシステムとランタイムバージョンを定義するだけで、すべての組み合わせを自動的に実行してくれます。

シンプルに聞こえますが、実際に使ってみると意外な落とし穴があります。組み合わせ数の爆発によるコスト増、一つのタスクが失敗すると全てが停止する問題、特定の組み合わせを除外する方法がわからない……これら全て私が経験したことです。

この記事では、最も基本的な Matrix 構文から始め、exclude/include による精密な制御、fail-fast 戦略の選択、max-parallel による並列制限、そして動的 Matrix 生成まで、段階的に解説します。最後に、そのままプロジェクトで使える 5 つの本番用 workflow テンプレートを提供します。

2. Matrix の核心概念:ワンクリックで複数タスクを展開

Matrix の核心ロジックはシンプルです。いくつかの次元を定義すると、GitHub Actions が自動的にデカルト積を展開してくれます。

例えば、プロジェクトを Ubuntu、Windows、macOS の 3 つのシステムでテストし、同時に Node.js 18、20、22 の 3 つのバージョンに対応させる必要があるとします。従来の方法では、9 個の job を手書きする必要があり、各 job で実行環境、インストール手順、テストコマンドを繰り返し定義しなければなりません。

Matrix を使えば、次のように書くだけです。

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]

runs-on: ${{ matrix.os }}

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-node@v4
    with:
      node-version: ${{ matrix.node }}
  - run: npm test

この 10 行の設定で、GitHub Actions は自動的に 3 x 3 = 9 個の並列タスクを生成します。各タスクは異なる matrix.osmatrix.node の値を受け取り、すべての組み合わせを実行します。

先ほどの 800 行の設定ファイルのプロジェクトは、Matrix で再構築後、約 120 行まで削減されました。コード量が 60%以上削減されました。メンテナンスコストも下がり、新しいバージョンを追加するには配列に数字を 1 つ追加するだけで、job 定義をコピー&ペーストする必要がなくなりました。

Matrix でできること

  • ワンクリックでマルチプラットフォーム・マルチバージョンのテスト組み合わせを生成
  • すべての設定を自動的に展開し、重複コードの手書きを回避
  • exclude を使用して既知の問題のある組み合わせを除外
  • include を使用して特殊設定のテストケースを追加
  • 並列数を制御し、速度とコストのバランスを調整

ただし解決できない問題

  • テスト自体の品質が悪い場合、Matrix でも救えない
  • 組み合わせが多すぎてコストが爆発する - これは自分で次元数を制御する必要がある
  • 依存関係のインストールが遅い - キャッシュ戦略と組み合わせる必要がある

正直なところ、Matrix 自体は難しくありません。難しいのは、実際のエンジニアリング問題をどう解決するかです。次から基本構文から始め、段階的に解説していきます。

3. 基本構文:os x version の組み合わせ原理

Matrix の組み合わせルールは、数学のデカルト積と同じです。定義した各次元は、他の次元と全順列組み合わせを作ります。

1 つの次元、N 個の値 → N 個のタスク

2 つの次元、M x N 個の値 → M x N 個のタスク

3 つの次元、A x B x C 個の値 → A x B x C 個のタスク

具体的な例を見てみましょう。Python プロジェクトがあり、Linux と Windows で Python 3.9、3.10、3.11、3.12 の 4 つのバージョンをテストし、同時に PostgreSQL と MySQL の 2 種類のデータベースをテストする必要があるとします。

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ['3.9', '3.10', '3.11', '3.12']
        database: [postgresql, mysql]
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Setup ${{ matrix.database }}
        run: |
          # 対応するデータベースサービスを起動
          if [ "${{ matrix.database }}" = "postgresql" ]; then
            docker run -d -p 5432:5432 postgres
          else
            docker run -d -p 3306:3306 mysql
          fi
        shell: bash
      - run: pip install -r requirements.txt
      - run: pytest

この設定は 2 x 4 x 2 = 16 個のタスクを生成します。各タスクは独立した実行環境で、互いに干渉しません。

Matrix 変数へのアクセス方法

  • ${{ matrix.os }} — 現在のタスクのオペレーティングシステムを取得
  • ${{ matrix.python-version }} — 現在のタスクの Python バージョンを取得
  • ${{ matrix.database }} — 現在のタスクのデータベースタイプを取得

これらの変数は runs-onstepsenv などの場所で使用でき、各タスクの動作を動的に調整できます。

よくある落とし穴:多くの人が Matrix が依存関係のインストールを自動的に処理してくれると思いがちです。実際には、各タスクは独立した環境で、依存関係のインストールは繰り返し実行されます。これが問題になります。依存関係のインストールに 2 分かかる場合、16 個のタスクで 32 分の待ち時間が発生します(仮に直列実行した場合)。

解決方法は 2 つあります:

  1. キャッシュを使用pipnpm の依存関係ディレクトリをキャッシュし、重複ダウンロードをスキップ
  2. 組み合わせ数を削減 — exclude を使用して不要なテスト組み合わせを除外

キャッシュ戦略については、以前の記事 [GitHub Actions キャッシュ戦略:CI/CD パイプラインを 5 倍高速化] で詳しく解説したので、ここでは割愛します。次は、exclude/include を使ってテスト組み合わせを精密に制御する方法に焦点を当てます。

4. exclude/include:テスト組み合わせの精密な制御

Matrix はデフォルトですべての次元を全順列組み合わせしますが、実際のプロジェクトでは、「ある組み合わせはテスト不要」や「ある組み合わせは特別な処理が必要」という状況によく遭遇します。

4.1 exclude:無効な組み合わせを除外

以前、Python プロジェクトをメンテナンスしていた時、この問題に遭遇しました。Windows + Python 3.9 の組み合わせが常に失敗するのです。ある依存ライブラリが Windows の 3.9 バージョンで互換性の問題があったからです。しかし、このプロジェクトは主に Linux サーバーへのデプロイを想定しており、Windows は副次的なサポートだったため、この特定のバグを修正するために時間を費やす価値がありませんでした。

そんな時、exclude が役立ちます。

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    python-version: ['3.9', '3.10', '3.11', '3.12']
    exclude:
      - os: windows-latest
        python-version: '3.9'
      - os: macos-latest
        python-version: '3.9'

この設定は、Windows と macOS 上の Python 3.9 テストを除外します。元の 3 x 4 = 12 個のタスクから 2 個を除外して、10 個のタスクになります。

exclude の典型的な使用シナリオ

  1. 既知の互換性問題 — あるバージョンが特定のシステムで動作しない
  2. リソース制限 — セルフホストランナーの数が限られており、組み合わせ数を減らす必要がある
  3. エッジケース — 一部の組み合わせはユーザーがほとんど使わず、CI 時間を費やす価値がない

4.2 include:特殊設定を追加

include の役割は逆です。追加のテスト組み合わせを追加したり、特定の組み合わせに追加の変数を補完したりできます。

例えば、Python 3.12 のテストでのみカバレッジレポートを有効にし、他のバージョンでは不要な場合:

strategy:
  matrix:
    python-version: ['3.10', '3.11', '3.12']
    include:
      - python-version: '3.12'
        coverage: true

steps:
  - uses: actions/checkout@v4
  - uses: actions/setup-python@v5
    with:
      python-version: ${{ matrix.python-version }}
  - run: pip install -r requirements.txt
  - name: Run tests
    run: |
      if [ "${{ matrix.coverage }}" = "true" ]; then
        pytest --cov=src --cov-report=xml
      else
        pytest
      fi
    shell: bash

ここで include は 2 つのことを行っています:

  1. 新しい組み合わせを追加 — Python 3.12 のテスト
  2. この組み合わせに追加の変数を補完coverage: true

include の典型的な使用シナリオ

  1. 実験的バージョンのテスト — Python 3.13 プレビュー版など、特定のシステムでのみテスト
  2. 特殊設定 — 一部の組み合わせは追加の環境変数やパラメータが必要
  3. エッジケースのカバー — 低頻度で使用される組み合わせ、全順列ではなく個別に追加

4.3 exclude と include を一緒に使う

実際のプロジェクトでは、exclude と include を同時に使用することがよくあります。例えば、すべての Python 3.9 の組み合わせを除外するが、Ubuntu + Python 3.9 の最小テストだけを個別に追加する場合:

strategy:
  matrix:
    os: [ubuntu-latest, windows-latest]
    python-version: ['3.9', '3.10', '3.11', '3.12']
    exclude:
      - python-version: '3.9'
    include:
      - os: ubuntu-latest
        python-version: '3.9'
        minimal: true

この設定の実行順序は:まずすべての組み合わせを生成 → exclude を適用して除外 → include を適用して追加。最終結果:Ubuntu で 4 つのバージョンを実行、Windows で 3 つのバージョンを実行(3.9 を除外)。

5. fail-fast 戦略:高速失敗 vs 完全デバッグ

Matrix タスクにはデフォルトの動作があります。1 つのタスクが失敗すると、実行中の他のタスクがキャンセルされます。この動作は fail-fast と呼ばれ、デフォルトで有効になっています。

strategy:
  fail-fast: true  # デフォルト値、省略可能
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

5.1 いつ fail-fast: true を使うか(デフォルト)

PR テストシナリオ — 開発者が PR を提出し、テストに問題がないかを素早く知りたい場合。1 つのタスクが失敗すれば、他のタスクも失敗する可能性が高い(同じコードの問題)ため、時間を無駄にする必要はありません。

コスト重視シナリオ — GitHub Actions の無料枠は限られており、セルフホストランナーのリソースも限られています。高速失敗は多くのコストを節約できます。

私の個人的な習慣は:PR テストでは fail-fast: true、main ブランチの完全テストでは fail-fast: false を使用することです。

5.2 いつ fail-fast: false を使うか

デバッグ段階 — Matrix タスクが頻繁に失敗する場合、具体的にどの組み合わせが失敗したか、失敗の理由がそれぞれ何かを知りたい場合。fail-fast: true だと、最初の失敗したタスクしか見えず、他のタスクはキャンセルされます。

互換性テスト — マルチバージョン・マルチプラットフォームの互換性テストを行い、各組み合わせのテスト結果を知りたい場合。あるバージョンに問題があっても、他のバージョンの情報を取得するのに影響しません。

完全レポートシナリオ — CI 終了後にすべての組み合わせの合格/失敗状態を含む完全なテストレポートを生成する必要がある場合。

strategy:
  fail-fast: false  # すべてのタスクを実行
  matrix:
    os: [ubuntu-latest, windows-latest, macos-latest]
    node: [18, 20, 22]

5.3 実際のケース

昨年、あるプロジェクトの CI 問題を調査しました。テストが Ubuntu + Node 18 で常に失敗するが、他の組み合わせは合格していました。デフォルトで fail-fast が有効だったため、每次 Ubuntu + Node 18 の失敗しか見えず、他のタスクはキャンセルされていました。その後、Windows + Node 18 も問題があるか知りたくて fail-fast: false に変更したところ、Windows では問題なく、Ubuntu だけに問題があることがわかりました。最終的に、ファイルパスの大文字小文字の互換性問題だと特定できました。

私の提案:開発・デバッグ段階では fail-fast: false を使用して、すべての問題を確認する。安定運用段階では fail-fast: true を使用して、コストと時間を節約する。

6. max-parallel:並列制御とコスト最適化

Matrix タスクはデフォルトで並列実行され、GitHub は可能な限り多くのタスクを同時に起動します。公開リポジトリの場合、GitHub ホストランナーの並列制限は 20 個です。プライベートリポジトリの場合、無料アカウントの並列制限は 2 個です。

しかし、時には並列数を手動で制御する必要があります。これが max-parallel の役割です。

6.1 いつ並列制限が必要か

セルフホストランナーのリソースが限られている — ランナーサーバーが 4 コア 8GB しかなく、8 個のタスクを同時に実行するとサーバーがダウンする。

外部サービスのレート制限 — テストがサードパーティ API を呼び出し、相手に QPS 制限がある場合、並列が高すぎるとブロックされる。

データベース接続プールの制限 — テストがデータベース接続を必要とし、接続プールが 10 接続しかない場合、タスクが多すぎると接続が枯渇する。

strategy:
  max-parallel: 4  # 最大で同時に 4 個のタスクを実行
  matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]

この設定は 6 個のタスクを生成しますが、最大で 4 個だけが同時に実行されます。1 つ完了してから、次を起動します。

6.2 コスト計算の例

あるプロジェクトがあり、毎回の CI 実行で 3 システム x 4 Node バージョン = 12 個のタスクをテストする必要があるとします。各タスクの平均実行時間は 10 分です。

並列制限なし(ランナーが十分あると仮定)

  • 12 個のタスクが同時に実行
  • 総所要時間は約 10 分
  • 総計算時間 = 12 x 10 = 120 分

max-parallel: 4 で制限

  • 12 個のタスクが 3 バッチで実行
  • 総所要時間は約 30 分
  • 総計算時間 = 12 x 10 = 120 分(変わらない)

わかりましたか?max-parallel は総計算時間を削減せず、総所要時間を延長するだけです。なぜ使うのか?

並列ピークコストリソース制限 があるからです。

GitHub Actions の課金単位は「分」ですが、セルフホストランナーを使う場合や、クラウドプロバイダーがピーク課金する場合、並列制御が重要です。例えば、12 個のタスクが同時に実行されると、データベースは 12 接続が必要です。バッチで実行すれば、4 接続だけで済みます。

私の実践経験

  • 公開リポジトリ、GitHub ホストランナー:max-parallel を気にせず、自動スケジュールさせる
  • プライベートリポジトリ、無料枠:max-parallel: 2 に制限し、ゆっくり実行して枠を超えない
  • セルフホストランナー:サーバー設定に応じて max-parallel を制限、4 コアの場合 2-4 並列を推奨

7. 動的 matrix:fromJSON 高度技術

これまで説明した Matrix は静的設定です。YAML でテストするバージョンを固定で記述します。しかし、シナリオによっては、コードの変化に応じてテスト組み合わせを動的に生成する必要があります。

例えば、monorepo があり、中に複数のサービスがあり、各サービスに独自のテスト設定があるとします。今回のコミットに関連するサービスだけをテストし、すべてのサービスをテストしないようにしたい場合です。

7.1 2 ステップ workflow で動的 matrix を実現

GitHub Actions には「動的 matrix」の直接サポート構文がありませんが、1 つの job で matrix 設定を生成し、別の job に渡すことで実現できます。鍵は fromJSON() 関数です。

jobs:
  # 第1ステップ:変更されたサービスを検出し、matrix 設定を生成
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2  # 前回のコミットを取得する必要がある

      - name: Detect changed services
        id: set-matrix
        run: |
          # 今回のコミットで変更されたファイルを取得
          CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)

          # どのサービスが変更されたかを判断
          SERVICES="[]"
          if echo "$CHANGED_FILES" | grep -q "services/auth/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["auth"]')
          fi
          if echo "$CHANGED_FILES" | grep -q "services/api/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["api"]')
          fi
          if echo "$CHANGED_FILES" | grep -q "services/web/"; then
            SERVICES=$(echo $SERVICES | jq '. + ["web"]')
          fi

          # サービスが変更されていない場合、デフォルトですべてのサービスをテスト
          if [ "$SERVICES" = "[]" ]; then
            SERVICES='["auth", "api", "web"]'
          fi

          echo "matrix={\"service\":$(echo $SERVICES)}" >> $GITHUB_OUTPUT

  # 第2ステップ:動的に生成された matrix を使用
  test:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4
      - name: Test ${{ matrix.service }}
        run: |
          cd services/${{ matrix.service }}
          npm install
          npm test

この workflow の動作原理:

  1. detect job が今回のコミットでどのディレクトリが変更されたかを検出
  2. 変更されたディレクトリに基づいて、JSON 形式の matrix 設定を動的に生成
  3. test job が fromJSON() を使ってこの設定を解析し、対応するタスクを生成

7.2 動的 matrix の典型的な使用シナリオ

Monorepo シナリオ — 変更されたサービスだけをテストし、CI 時間を節約

オンデマンドデプロイ — Dockerfile の変更を検出し、更新されたイメージだけをビルド・デプロイ

マトリックステストの最適化 — ファイルタイプに応じてテスト組み合わせを決定(例:package.json が変更された場合のみ完全なマルチバージョンテストを実行)

私が遭遇した落とし穴

  1. fromJSON()strategy.matrix の値にしか使えず、他の場所では使えない
  2. 生成された JSON は正当な matrix 形式である必要がある(例:{"service": ["auth", "api"]}
  3. 生成された matrix が空の場合、workflow は直接エラーになる — デフォルト値処理を追加することを忘れずに

8. 実践テンプレートライブラリ:5 つの本番用 workflow サンプル

次に、そのままコピーして使える 5 つの workflow テンプレートを提供します。個人プロジェクトからエンタープライズアプリケーションまで、様々なシナリオをカバーしています。

8.1 テンプレート 1:Node.js マルチバージョンテスト(基本)

適用シナリオ:Node.js ライブラリまたはアプリケーション、複数の Node バージョンに対応する必要がある

name: Node.js CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [18, 20, 22, 23]

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'npm'

      - run: npm ci
      - run: npm run build --if-present
      - run: npm test

      - name: Upload coverage
        if: matrix.node-version == 22
        uses: codecov/codecov-action@v4

ポイント

  • npm install ではなく npm ci を使用し、依存関係のバージョンをロック
  • Node 22 でのみカバレッジレポートをアップロードし、重複アップロードを回避
  • cache: 'npm' で npm キャッシュを有効にし、依存関係のインストールを高速化

8.2 テンプレート 2:Python マルチプラットフォーム・マルチバージョンテスト(中級)

適用シナリオ:Python プロジェクト、クロスプラットフォーム・クロスバージョンテストが必要

name: Python CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
        python-version: ['3.10', '3.11', '3.12']
        exclude:
          - os: windows-latest
            python-version: '3.10'  # 既知の互換性問題

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt

      - name: Run tests
        run: pytest -v

      - name: Lint check
        run: |
          pip install ruff
          ruff check .

ポイント

  • exclude を使用して既知の問題のある組み合わせを除外
  • cache: 'pip' で pip 依存関係のインストールを高速化
  • コードチェックツール ruff を統合

8.3 テンプレート 3:exclude/include 精密制御(上級)

適用シナリオ:テスト組み合わせを精密に制御し、特定の組み合わせを除外、特殊テストを追加

name: Advanced Matrix

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ${{ matrix.os }}
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest]
        python-version: ['3.10', '3.11', '3.12']
        exclude:
          # Windows + Python 3.10 を除外(既知の問題)
          - os: windows-latest
            python-version: '3.10'
        include:
          # 実験的テストを追加:Ubuntu + Python 3.13 プレビュー版
          - os: ubuntu-latest
            python-version: '3.13-dev'
            experimental: true
          # Python 3.12 にカバレッジレポートを追加
          - python-version: '3.12'
            coverage: true

    continue-on-error: ${{ matrix.experimental == true }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Python ${{ matrix.python-version }}
        uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'

      - run: pip install -r requirements.txt

      - name: Run tests
        run: |
          if [ "${{ matrix.coverage }}" = "true" ]; then
            pytest --cov=src --cov-report=xml
          else
            pytest
          fi
        shell: bash

ポイント

  • continue-on-error で実験的テストの失敗が全体のステータスに影響しないようにする
  • include は新しい組み合わせの追加と変数の補完を同時に行える
  • shell: bash を使用して Windows と Linux のコマンドを統一

8.4 テンプレート 4:動的 matrix + caching(上級)

適用シナリオ:Monorepo、ファイル変更に応じてテスト組み合わせを動的に生成

name: Dynamic Matrix CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  detect:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.set-matrix.outputs.matrix }}

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 2

      - name: Detect changed packages
        id: set-matrix
        run: |
          CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)

          PACKAGES="[]"
          for dir in packages/*/; do
            pkg=$(basename $dir)
            if echo "$CHANGED_FILES" | grep -q "^packages/$pkg/"; then
              PACKAGES=$(echo $PACKAGES | jq ". + [\"$pkg\"]")
            fi
          done

          # 変更がない場合、すべてのパッケージをテスト
          if [ "$PACKAGES" = "[]" ]; then
            PACKAGES='["core", "utils", "cli"]'
          fi

          echo "matrix={\"package\":$(echo $PACKAGES)}" >> $GITHUB_OUTPUT

  test:
    needs: detect
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.detect.outputs.matrix) }}

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build --if-present

      - name: Test ${{ matrix.package }}
        run: |
          cd packages/${{ matrix.package }}
          npm test

ポイント

  • fetch-depth: 2 で前回のコミットを取得して比較
  • jq コマンドで JSON 配列を操作
  • 変更がない場合のデフォルト値を提供し、空の matrix エラーを回避

8.5 テンプレート 5:セルフホストランナー + max-parallel(エンタープライズ)

適用シナリオ:セルフホストランナー、並列とリソースを厳密に制御

name: Enterprise CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: [self-hosted, linux, x64]
    strategy:
      fail-fast: true
      max-parallel: 4
      matrix:
        java-version: [11, 17, 21]
        database: [postgresql, mysql]

    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: postgres
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

      mysql:
        image: mysql:8
        env:
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306:3306
        options: >-
          --health-cmd "mysqladmin ping"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4

      - name: Setup Java ${{ matrix.java-version }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java-version }}
          distribution: 'temurin'
          cache: 'maven'

      - name: Run tests with ${{ matrix.database }}
        env:
          DB_TYPE: ${{ matrix.database }}
          DB_HOST: localhost
          DB_PORT: ${{ matrix.database == 'postgresql' && 5432 || 3306 }}
        run: mvn test -Dspring.profiles.active=${{ matrix.database }}

      - name: Archive test results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results-${{ matrix.java-version }}-${{ matrix.database }}
          path: target/surefire-reports

ポイント

  • runs-on: [self-hosted, linux, x64] でセルフホストランナーのラベルを指定
  • max-parallel: 4 で並列を制限し、ランナーサーバーを保護
  • services でテスト用データベースコンテナを起動
  • if: always() でテスト失敗時も結果を確実にアップロード

9. よくある落とし穴とベストプラクティス

Matrix を長く使ってきて、多くの落とし穴に遭遇しました。最も一般的なものをいくつかまとめます。

9.1 落とし穴 1:組み合わせ数の爆発

最も極端な設定を見たことがあります:4 オペレーティングシステム x 5 ランタイムバージョン x 3 データベース x 2 キャッシュ方式 = 120 個のタスク。毎回の CI が完了するまで 45 分かかり、請求書が爆発しました。

解決方法

  • main ブランチでのみ完全な Matrix を実行し、PR は主要な組み合わせのみ実行
  • exclude を使用してエッジケースを除外
  • 各次元の必要性を評価 — 本当に 4 つのオペレーティングシステムをテストする必要がありますか?
# PR は主要な組み合わせのみテスト
on:
  pull_request:
    branches: [main]

jobs:
  test:
    strategy:
      matrix:
        os: [ubuntu-latest]  # PR は Ubuntu のみテスト
        node: [20]            # PR は Node 20 のみテスト

9.2 落とし穴 2:fail-fast がデバッグを妨げる

デフォルトの fail-fast: true はデバッグ段階で厄介です。1 つのタスクが失敗すると他のタスクが全てキャンセルされ、完全な失敗レポートが見えません。

解決方法:デバッグ段階では手動で fail-fast: false に変更し、デバッグ完了後に戻す。

または環境変数で制御:

strategy:
  fail-fast: ${{ github.event_name == 'pull_request' }}

9.3 落とし穴 3:caching の欠如

Matrix は同じ job を複数回繰り返し実行します。毎回依存関係を再インストールすると、時間コストが高くなります。ある時、12 個の組み合わせをテストし、各インストールに 2 分かかり、インストールだけで 24 分かかりました。

解決方法:GitHub Actions キャッシュを使用するか、専用のキャッシュ action を使う。

- uses: actions/setup-node@v4
  with:
    node-version: ${{ matrix.node-version }}
    cache: 'npm'  # 重要:npm キャッシュを有効化

9.4 ベストプラクティスまとめ

プラクティス 1:PR は小型 Matrix + main は完全 Matrix

jobs:
  test:
    strategy:
      matrix:
        # PR は主要な組み合わせのみテスト
        ${{ github.event_name == 'pull_request' && fromJSON('{"os":["ubuntu-latest"],"node":[20]}') || fromJSON('{"os":["ubuntu-latest","windows-latest","macos-latest"],"node":[18,20,22]}') }}

プラクティス 2:exclude で既知の問題のある組み合わせを除外

特定の組み合わせの互換性問題に遭遇したら、まず exclude でスキップし、TODO を記録して後で修正する。

プラクティス 3:caching と組み合わせてインストール時間を短縮

各 job の依存関係インストールは最も時間のかかる工程の 1 つです。キャッシュを活用すれば時間を分単位から秒単位に削減できます。

プラクティス 4:組み合わせに意味のある名前を追加

デフォルトのタスク名は test (ubuntu-latest, 20) という形式ですが、name でカスタマイズできます:

jobs:
  test:
    name: Test (${{ matrix.os }}, Node ${{ matrix.node }})
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node: [18, 20, 22]

これで GitHub Actions インターフェースで各タスクを識別しやすくなります。

10. まとめ

GitHub Actions Matrix は、マルチプラットフォーム・マルチバージョンテストを処理する強力なツールです。核心は数えられるほどです:次元の定義、組み合わせの制御、並列の管理、動的生成。

あまりにも多くのプロジェクトがまだ手書きで重複した CI 設定を維持し、1 つのコマンドを変更するために十数箇所を修正しています。Matrix で数百行の設定を数十行に圧縮でき、メンテナンスコストは劇的に下がります。

重要ポイントの振り返り

  1. 基本構文matrix.osmatrix.node でデカルト積を展開
  2. 精密制御exclude で無効な組み合わせを除外、include で特殊設定を追加
  3. 戦略選択fail-fast はシナリオに応じて選択、デバッグは false、本番は true
  4. 並列制限max-parallel でセルフホストランナーを保護、コストを制御
  5. 動的生成fromJSON() でオンデマンドテストを実現、CI リソースを節約

記事内の 5 つのテンプレートはそのままプロジェクトで使えます。最もシンプルな Node.js マルチバージョンテストからエンタープライズ向けのセルフホストランナー設定まで、全てカバーしています。

Matrix を使い始めたばかりの場合は、テンプレート 1 から始めることをお勧めします。動作確認後、excludeinclude を追加し、最後に動的生成を試してみてください。一歩ずつ進め、いきなり複雑なものに取り組まないでください。

質問があればコメント欄に投稿するか、GitHub Actions 公式ドキュメントで詳細を確認してください。Matrix の使用経験がある方は、ぜひ共有してください。

GitHub Actions Matrix マルチプラットフォーム・マルチバージョンテストの設定

ゼロから Matrix 戦略を設定し、クロスプラットフォーム・クロスバージョンの自動テストを実現

⏱️ 目安時間: 30 分

  1. 1

    ステップ1: Matrix 次元を定義

    workflow の job に strategy.matrix 設定を追加:

    ```yaml
    strategy:
    matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]
    ```

    これで 2 x 3 = 6 個の並列タスクが生成されます。
  2. 2

    ステップ2: Matrix 変数を使用

    runs-on と steps で matrix 変数を参照:

    ```yaml
    runs-on: ${{ matrix.os }}
    steps:
    - uses: actions/setup-node@v4
    with:
    node-version: ${{ matrix.node }}
    ```

    各タスクは自動的に対応する os と node の値を取得します。
  3. 3

    ステップ3: 特定の組み合わせを除外(オプション)

    exclude を使用して既知の問題のある組み合わせを除外:

    ```yaml
    strategy:
    matrix:
    os: [ubuntu-latest, windows-latest]
    node: [18, 20, 22]
    exclude:
    - os: windows-latest
    node: 18
    ```

    Windows + Node 18 の組み合わせを除外し、最終的に 5 個のタスクを生成。
  4. 4

    ステップ4: 失敗戦略を設定

    シナリオに応じて fail-fast 戦略を選択:

    - PR テスト:fail-fast: true(高速失敗、コスト節約)
    - デバッグ段階:fail-fast: false(全ての失敗を確認)
    - main ブランチ:fail-fast: false(完全レポート)

    ```yaml
    strategy:
    fail-fast: false
    matrix:
    # ...
    ```
  5. 5

    ステップ5: 並列数を制限(オプション)

    セルフホストランナーやリソースが限られている場合、並列を制限:

    ```yaml
    strategy:
    max-parallel: 4
    matrix:
    # ...
    ```

    最大で同時に 4 個のタスクを実行し、ランナーの過負荷を防止。

FAQ

Matrix の組み合わせ数に上限はありますか?
GitHub は Matrix タスク数にソフト制限を設けています。公開リポジトリは最大 256 タスク、プライベートリポジトリはプランによって異なります。ただし、実際の使用では組み合わせ数を 20 以内に抑えることをお勧めします。CI 時間の長期化とコスト爆発を避けるためです。4 OS x 5 バージョン x 3 データベース = 60 タスクは既にかなり多いです。
fail-fast のデフォルト値は何ですか?
fail-fast のデフォルト値は true です。1 つのタスクが失敗すると、実行中の他のタスクはキャンセルされます。デバッグ段階では false に設定して、すべての失敗原因を確認することをお勧めします。本番環境ではデフォルト値を使用して、高速失敗でコストを節約します。
exclude と include の実行順序は?
実行順序:まずすべての組み合わせを生成 -> exclude を適用して除外 -> include を適用して追加。そのため、まずすべての Python 3.9 の組み合わせを除外し、include で Ubuntu + Python 3.9 の最小テストだけを個別に追加できます。
動的 matrix の fromJSON() はどこで使えますか?
fromJSON() は strategy.matrix の値にしか使えず、他の YAML フィールドでは使えません。生成される JSON は正当な matrix 形式である必要があります(例:{"os": ["ubuntu", "windows"]})。生成された matrix が空の場合、workflow はエラーになるため、デフォルト値処理を追加することを忘れないでください。
max-parallel は総計算時間を削減しますか?
いいえ。max-parallel は同時に実行されるタスク数を制御するだけで、総計算時間は削減しません。12 タスク x 10 分 = 120 分の計算時間は、並列数に関係なく変わりません。ただし、並列制限で:1)ピークリソース使用量を削減、2)データベース接続プールの枯渇を防止、3)セルフホストランナーの負荷を制御できます。
Matrix タスク間でキャッシュを共有できますか?
はい。GitHub Actions のキャッシュはリポジトリレベルで、すべての job がアクセスできます。setup-node や setup-python で cache パラメータを有効にするだけで、依存関係ディレクトリを自動的にキャッシュします。すべてのタスクでキャッシュを有効にすることをお勧めします。最初のタスクがキャッシュを作成し、後続のタスクは直接使用します。
Matrix タスクにカスタム名を追加するには?
job の name 属性と matrix 変数を組み合わせて使用:

• name: Test (${{ matrix.os }}, Node ${{ matrix.node }})

これで GitHub Actions インターフェースに "Test (ubuntu-latest, Node 20)" のような分かりやすい名前が表示され、各タスクを識別しやすくなります。

10 min read · 公開日: 2026年4月28日 · 更新日: 2026年4月29日

関連記事

コメント

GitHubアカウントでログインしてコメントできます