← Blog
·10 blog.minutes

モダンCI/CDパイプラインの構築:コミットから本番まで

高速で信頼性が高く安全なCI/CDパイプラインを設計するための実践ガイド——GitHub Actions、GitLab CI、キャッシング、デプロイ戦略、本番監視をカバー。

すべてのソフトウェアチームはCI/CDを実行している。しかし、うまく実行しているチームは少ない。デリバリーを加速させるパイプラインと、全員を遅くするパイプラインの違いは、設計に帰着する:ステージの構造化方法、障害の処理方法、環境の管理方法、デプロイ方法である。適切に設計されたパイプラインは、すべてのコミットを本番への安全で再現可能な経路に変える。不適切に設計されたものは、すべてのデプロイを火災訓練に変える。

この記事では、モダンなCI/CDパイプラインが実際にどのようなものかを説明する。プロバイダー選定、ステージ設計、キャッシング、シークレット管理、デプロイ戦略、そしてそれらを統合する監視について順を追って見ていく。各セクションには、自身のスタックに適用できる実際のワークフロー例が含まれている。

モダンなCI/CDパイプラインが重要な理由

CI/CDの目的は、自動化そのものではない。コードを書いてから、そのコードが本番で機能するかどうかを知るまでのフィードバックループを短縮することである。そのループで節約された毎分は、開発者が20分前に失敗したビルドにコンテキストスイッチして戻る代わりに、次の問題に費やすことができる時間である。

モダンなCI/CDは、古典的なJenkins-and-Travis時代とはいくつかの重要な点で異なる。第一に、パイプラインは現在、コードとして定義されている。.github/workflows/deploy.ymlファイルは、デプロイするアプリケーションと同じリポジトリにあり、それとともにバージョン管理され、一緒にレビューされる。パイプライン設定はもはや別のチームが管理する別個の成果物ではない。コードベースの一部であり、他の変更と同じレビュープロセスの対象となる。

第二に、モダンなパイプラインは速度を重視して設計されている。キャッシング、並列ジョブ実行、マトリックスビルド、条件付きステージスキップを使用して、数十分ではなく数分で完了する。開発者のコンテキスト保持ウィンドウ——おおよそ10〜15分——よりも長くかかるパイプラインは、その主要な目的を達成できていない。

第三に、モダンなパイプラインは設計上セキュリティを意識している。シークレットは専用のシークレットストアからランタイムに注入され、設定ファイルに焼き付けられることはない。サプライチェーン攻撃は依存関係の固定、ロックファイル、署名検証によって軽減される。デプロイ認証情報は環境ごとにスコープされ、自動的にローテーションされる。

これら3つの特性——パイプライン・アズ・コード、速度優先設計、セキュリティ意識のデフォルト——がモダンなCI/CDの姿を定義する。それ以外はすべて実装の詳細である。

CI/CDプロバイダーの選択と品質ゲートの設定

プロバイダーの状況は3つの主要なオプションに収束しており、それぞれに明確なトレードオフがある。GitHub ActionsはすでにGitHub上のチームにとって最も人気のある選択肢である。緊密なGitHub統合により、プルリクエストチェック、マージキュー、デプロイメント環境がすべてそのまま機能する。アクションマーケットプレイスはエコシステム内のほぼすべてのツールの事前構築済みステップを提供し、ホステッドランナーにはmacOS、Windows、ARM、GPUインスタンスが特殊なビルド用に含まれている。

GitLab CIは、特にソース管理、CI/CD、コンテナレジストリ、アーティファクトストレージのための単一プラットフォームを望むチームにとって、最も強力な代替案である。GitLabのパイプライン設定は、いくつかの点でGitHub Actionsよりも表現力豊かである——有向非巡回グラフ(DAG)パイプライン、子パイプラインと親パイプライン、Kubernetes用GitLabエージェントによる組み込みのカナリアデプロイのネイティブサポート。GitLabはまた、主要プロバイダーの中で最も強力な無料ティアCI分を提供している。

その他のオプションは特定のニッチを担っている。Jenkinsは、広範なプラグイン投資とオンプレミス要件を持つ組織にとって依然として関連性があるが、設定・アズ・コードのストーリーは弱い。CircleCIはインテリジェントなキャッシングと並列処理による高速ビルド時間を提供し、その設定形式はクリーンで読みやすい。Buildkiteは興味深いハイブリッドな立場を占めている:インフラは自分で提供し、Buildkiteがオーケストレーション層を提供する。これにより、CIサーバーを管理せずに実行環境を完全に制御できる。

  • GitHub Actions:GitHubネイティブのチームに最適、最大の事前構築アクションエコシステム、パブリックリポジトリは無料。
  • GitLab CI:エンドツーエンドDevOpsプラットフォームに最適、最も強力なKubernetes統合、充実した無料ティア。
  • CircleCI:生の速度を優先するチームに最適、優れたキャッシングと並列処理。
  • Buildkite:マネージドオーケストレーションでカスタムインフラが必要なチームに最適。
  • Jenkins:既存のプラグイン投資を持つレガシーエンタープライズ環境に最適。

この記事では、最も広く採用されているため、例にGitHub Actionsを使用する。パターンは最小限の構文変更で他のプロバイダーに直接変換できる。

プロバイダーを選択したら、設計する最初のパイプラインステージは品質ゲートである。リンティングとテストは、すべての変更がデプロイに向かう前にクリアしなければならない最低限の基準である。重要な設計上の決定は、これらのゲートをマージ前(プルリクエストの必須チェック)とマージ後(マージ後検証)のどちらで実行するかである。本番に出荷するチームにとっての正しい答えは両方だが、マージ前ゲートがmainブランチを保護するものである。

リンティングは高速であるべきだ。TypeScriptの型チェック、ESLint、Prettier——これらは数分ではなく数秒で完了すべきである。リンティングステージに60秒以上かかる場合、生成ファイルをリントしているか、自動修正すべきでチェックすべきではないルールを実行しているかのどちらかである。リンティングは、自動修正できない正確性とスタイルの強制に焦点を当て、フォーマッターはpre-commitフックとして実行する。

テストにはより慎重なステージ設計が必要である。単体テストスイートはすべてのプッシュで実行される——高速でなければならない。統合テストスイートは単体テストが合格したときに実行される——徹底的でなければならない。エンドツーエンドテストスイートは統合テストが合格したときに実行される——信頼性が高くなければならない。テストをこれらの層に整理し、条件付きで実行することで、総パイプライン時間を予測可能に保ちながら、カバレッジの深さを維持する。

name: CI — Lint and Test

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

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm run typecheck
      - run: npm run lint

  unit:
    needs: [lint]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm run test:unit -- --coverage

  integration:
    needs: [unit]
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16-alpine
        env:
          POSTGRES_DB: app_test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: "npm"
      - run: npm ci
      - run: npm run test:integration
        env:
          DATABASE_URL: postgres://postgres:test@localhost:5432/app_test

このワークフローで注目すべき3つのパターンがある。第一に、cancel-in-progressによる並行性制御:開発者が最初の実行中に2つ目のコミットをプッシュすると、最初の実行は自動的にキャンセルされる。これにより無駄な計算を防ぎ、フィードバックを高速に保つ。第二に、needsによる依存関係チェーン:統合テストは単体テストが合格した場合のみ実行される。第三に、統合テスト依存関係のためのサービスコンテナ。これは外部インフラなしでクリーンなデータベースを起動する。

ステージ1:ビルドとキャッシュ——デプロイの単位としてのアーティファクト

テストが合格したら、次のステージでデプロイ可能なアーティファクトを生成する。アーティファクトは1回だけビルドされ、環境間でプロモーションされるべきである。各環境で再ビルドすることは、不要なリスクを導入する一般的なアンチパターンである:テストに合格したコードが、ビルドプロセスの環境固有の違いにより本番に到達するコードと異なる可能性がある。

ビルドステージはキャッシングが最も効果を発揮する場所である。依存関係——npmパッケージ、Dockerレイヤー、Pythonホイール——は実行間でキャッシュでき、ビルド時間を桁違いに削減できる。キャッシュキーの戦略が重要である:ロックファイルのハッシュを含めてパッケージ更新でキャッシュを無効にするが、コミットハッシュは含めない。すべてのビルドがキャッシュミスになるからだ。

Dockerベースのデプロイは追加のキャッシュ層を追加する。Dockerレイヤーキャッシング(DLC)は中間イメージレイヤーをキャッシュし、アプリケーションファイルの変更がシステムパッケージの再インストールを必要としないようにする。GitHub Actionsはdocker/build-push-actionのcache-fromとcache-toパラメータをレジストリまたはGitHub Actionsキャッシュに向けることでDLCをサポートしている。

name: Build and Push

on:
  push:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to container registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract short SHA for tagging
        id: vars
        run: echo "sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
 
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ghcr.io/myorg/myapp:latest
            ghcr.io/myorg/myapp:${{ steps.vars.outputs.sha_short }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

結果として得られるアーティファクト——latestとコミットSHAの両方でタグ付けされたDockerイメージ——はデプロイの単一の単位である。SHAタグは正確なトレーサビリティを提供する:実行中のコンテナがあれば、そのイメージタグを見てどのコミットがそれを生成したかを正確に知ることができる。latestタグは開発環境に便利な参照を提供する。本番は常に明示的なSHAでデプロイし、決してlatestではデプロイしない。

ステージ2:環境管理とシークレット——妥協しない設定

環境管理は、ほとんどのパイプライン設計ミスが発生する場所である。ナイーブなアプローチ——開発ブランチ、ステージングブランチ、本番ブランチをそれぞれ独自のパイプライン設定で維持する——は連鎖的な問題を生み出す。マージ競合、設定のドリフト、環境をスキップするホットフィックスは、環境がブランチに結び付けられている場合に避けられない。

モダンなアプローチは、環境・パー・デプロイであり、環境・パー・ブランチではない。単一のmainブランチが単一のアーティファクトを生成する。そのアーティファクトは検証のために開発にデプロイされ、本番前検証のためにステージングにプロモーションされ、準備ができたら本番にプロモーションされる。アーティファクトが開発では合格してもステージングで失敗した場合、修正は品質ゲートをバイパスするホットフィックスではなく、次のコミットに入る。環境は同じアーティファクトの別々のデプロイであり、別々のコードベースではない。

シークレット処理も同じ原則に従う:注入し、焼き付けない。シークレットがDockerイメージ、リポジトリ内の環境ファイル、パイプラインログに表示されることは決してあってはならない。GitHub Actionsはリポジトリと環境レベルで暗号化されたシークレットを提供し、GITHUB_TOKENは個人アクセストークンを保存せずにGitHub APIアクセスのための短期間の認証情報を提供する。

組み込みのシークレット管理を超えた組織には、HashiCorp Vault、AWS Secrets Manager、Dopplerなどの外部シークレットストアが追加機能を提供する:自動ローテーション、監査ログ、きめ細かなアクセスポリシー。パイプラインはパイプライン設定にシークレットを保存するのではなく、認証されたリクエストを通じてランタイムにシークレットを取得する。

name: Deploy to Environment

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        type: choice
        options:
          - staging
          - production
      tag:
        description: "Docker image tag (commit SHA)"
        required: true

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    concurrency:
      group: deploy-${{ inputs.environment }}
      cancel-in-progress: false
    steps:
      - name: Deploy to Kubernetes
        uses: actions-hub/kubectl@master
        env:
          KUBE_CONFIG: ${{ secrets[format('KUBE_CONFIG_{0}', inputs.environment)] }}
        with:
          args: |-
            set image deployment/myapp \
              myapp=ghcr.io/myorg/myapp:${{ inputs.tag }} \
              -n ${{ inputs.environment }}

      - name: Verify deployment
        run: |
          kubectl rollout status deployment/myapp \
            -n ${{ inputs.environment }} \
            --timeout=5m

      - name: Notify on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {
              "text": "Deploy to ${{ inputs.environment }} failed for tag ${{ inputs.tag }}"
            }
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}

このワークフローは環境スコープのシークレットを使用する:KUBE_CONFIG_STAGINGとKUBE_CONFIG_PRODUCTIONは別々に保存され、パイプラインはターゲット環境に基づいて正しい方を選択する。環境レベルの並行性グループにより、一度に1つのデプロイのみが実行され、ロールアウト中のレースコンディションを防ぐ。ロールアウトステータスコマンドは、Kubernetesがデプロイの正常を報告するまでブロックし、このチェックが失敗した場合にのみ障害通知が発生する。

ステージ3:デプロイ戦略——ブルーグリーン、カナリア、ロールバック

デプロイの方法は、何をデプロイするかと同じくらい重要である。最もシンプルな戦略——古いバージョンをシャットダウンし、新しいバージョンを起動する——は、起動時間が速くステートレスな低リスクのサービスに適している。それ以外の場合、デプロイ戦略は、悪いリリースがすべてのユーザーに影響するか一部かの別、ロールバックに数秒か数分かの別、チームが金曜の午後にデプロイするか火曜の朝だけかの別を決定する。

ブルーグリーンデプロイは2つの同一の環境を維持する。常に一方の環境(ブルー)が本番トラフィックを処理し、もう一方(グリーン)が新しいバージョンを実行する。グリーン環境がヘルスチェックに合格すると、ロードバランサーがトラフィックをブルーからグリーンに切り替える。ブルー環境は即時ロールバックのために準備完了のままとなる:新しいバージョンが失敗した場合、トラフィックを戻すのは単一のDNSまたはロードバランサーの変更で済む。トレードオフはコストである——2つの完全な環境は、切り替えウィンドウ中に2倍のインフラ支出を意味する。

カナリアデプロイは、古いバージョンが大部分を処理している間に、新しいバージョンにトラフィックの小%をルーティングする。カナリアの%は、可観測性が新しいバージョンの正常性を確認するにつれて段階的に増加する:1%、次に5%、次に25%、そして100%。この戦略は、完全なロールアウトの前に実際のトラフィックで問題を表面化させ、問題発生時の影響範囲を制限する。トレードオフは複雑さである——カナリアデプロイには、洗練されたトラフィックルーティング、可観測性統合、メトリクスに基づく自動プロモーションまたはロールバックが必要である。

ロールバック自動化は、すべてのパイプラインが必要とし、ほとんどのパイプラインが欠いているセーフティネットである。ロールバックは、以前のアーティファクトを再デプロイする手動プロセスではなく、単一のコマンドまたはボタンプレスであるべきだ。重要な設計要件は、以前のアーティファクトが利用可能であることである:イメージがコミットSHAでタグ付けされ、参照されていないイメージをガベージコレクションしないレジストリに保存されている場合、ロールバックは最後に正常だったSHAを再デプロイすることを意味する。イメージが一時的にタグ付けされている場合——latestなど——ロールバックには以前のコミットからの再ビルドが必要であり、これは遅く、再ビルドが異なる出力を生成するリスクを導入する。

name: Rollback

on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Environment to roll back"
        required: true
        type: choice
        options:
          - staging
          - production
      target-tag:
        description: "Tag to roll back to (leave empty for previous deploy)"
        required: false

jobs:
  rollback:
    runs-on: ubuntu-latest
    environment: ${{ inputs.environment }}
    steps:
      - name: Get previous deployment tag
        id: previous
        if: inputs.target-tag == ''
        run: |
          PREVIOUS=$(kubectl rollout history deployment/myapp \
            -n ${{ inputs.environment }} \
            --revision=1 2>/dev/null | grep -oP 'ghcr.io/myorg/myapp:K[a-f0-9]+')
          echo "tag=$PREVIOUS" >> $GITHUB_OUTPUT

      - name: Roll back to target
        run: |
          TAG="${{ inputs.target-tag || steps.previous.outputs.tag }}"
          kubectl set image deployment/myapp \
            myapp=ghcr.io/myorg/myapp:$TAG \
            -n ${{ inputs.environment }}
          kubectl rollout status deployment/myapp \
            -n ${{ inputs.environment }} \
            --timeout=3m

上記のロールバックワークフローは、特定のターゲットが提供されない場合にKubernetesのロールアウト履歴をクエリして以前のリビジョンを見つけ、そのイメージを再デプロイする。実際には、ほとんどのチームはデプロイ追跡システムに最近の正常なデプロイのリストも維持している。これにより、ロールバックがKubernetesの内部履歴に依存することがなくなる。Kubernetesの内部履歴は、設定可能な数のリビジョン後にガベージコレクションされる可能性がある。

ステージ4:監視と継続的改善

パイプラインがグリーンになったときがデプロイの終わりではない。チームがデプロイされたバージョンが本番で正常であることを確認したときが終わりである。これには、すべてのパイプラインステージでの監視統合が必要である:デプロイ前に環境の正常性を確認するプレデプロイチェック、ロールアウト後数分以内に新しいバージョンを確認するポストデプロイチェック、カナリアのプロモーションとロールバックの決定のためのデータを提供する継続的な可観測性。

パイプラインの効果に関する最も重要な監視メトリクスはデプロイ頻度である。月に1回しかデプロイしない場合、パイプラインはデリバリー能力を向上させていない——それはまれなリリースを自動化しただけである。CI/CDパイプライン設計の目標は自動化されたデプロイではなく、継続的デリバリーである:任意のコミットをいつでも安全かつ自信を持ってリリースできる能力。デプロイ頻度は、パイプラインがこの目標を達成しているかどうかの最良の代理指標である。

2番目に重要なメトリクスは平均復旧時間(MTTR)である。本番問題の検出から修正のデプロイまでどれくらいの時間がかかるか?最速の復旧パスはロールバック——数秒または数分である。次に速いのは完全なパイプラインをバイパスするホットフィックス——数分または数時間である。最も遅いのはコミットからデプロイまでの完全なパイプライン——それでも30分未満であるべきだ。MTTRがデプロイ頻度を超えている場合、パイプライン設計に問題がある。

デプロイ頻度は、パイプラインが十分に高速かどうかを教えてくれる。平均復旧時間は、パイプラインが十分に安全かどうかを教えてくれる。どちらかの数値が悪化しているなら、パイプライン設計は間違った方向に進んでいる。
  • デプロイ頻度とMTTRを、虚栄のメトリクスではなく、パイプラインの健全性の先行指標として追跡する。
  • エラーレート、レイテンシ、トラフィックを環境別ではなくバージョン別に表示するポストデプロイ監視ダッシュボードを設定する。
  • エラーバジェットに基づいてカナリアプロモーションを自動化する:エラーレートがN分間閾値を下回ったらプロモーション、超えたら自動ロールバック。
  • 失敗したデプロイごとに非難のないポストモーテムを実行し、再発を防ぐためにパイプラインを更新する。
  • パイプラインの期間を毎週レビューする。どのステージが必要以上に長い場合は、パイプラインに機能を追加する前に、キャッシング、並列処理、またはステージの再設計に投資する。

パイプライン自体も他の製品と同じ改善サイクルに従うべきである。定期的なパイプレインレビューをスケジュールし、チームがパイプラインの期間、失敗率、摩擦点を調査する。遅いステージをアプリケーションバグと同じ優先度のバグとして扱う。開発者がパイプラインが遅すぎると言ったら、信じる——パイプラインは開発者にサービスを提供するために存在するのであり、その逆ではない。

適切に設計されたCI/CDパイプラインは見えない。コミットは開発者が考えることなくパイプラインを流れる。デプロイは1日に複数回、問題なく発生する。ロールバックは必要なときに高速で、何事もなかったように行われる。パイプラインは開発作業の背景に溶け込む。なぜなら、信頼性が高く予測可能に機能するからである。この不可視性こそが優れた設計の証である——パイプラインが単純だからではなく、複雑さが十分に管理されており、開発者がそれについて考える必要がないからである。

そのようなパイプラインを構築するには、意図的な設計、反復、投資が必要である。しかしその見返り——より高速なフィードバック、より安全なデプロイ、より高い開発者の自信——はすべてのコミットとともに複利的に成長する。