Skip to main content
infrastructure2025年12月7日7分で読めます

本番環境で実際に機能するCI/CDパイプラインのセットアップ

実際のプロジェクトで実証済みのCI/CDパターン — GitHub Actionsワークフローから、金曜日に本番環境を壊さないデプロイ戦略まで。

cicdgithub-actionsdevops
本番環境で実際に機能するCI/CDパイプラインのセットアップ

私が本番プロジェクトで初めてセットアップしたCI/CDパイプラインは、npm testを実行し、その後SSH経由でVPSにデプロイする単一のGitHub Actionsワークフローでした。それは機能していましたが、ある時機能しなくなりました — デプロイの失敗により、金曜日の夜にサーバーが半更新状態になり、私は週末をファイルのロールバックに費やしました。その経験から、デプロイパイプラインは単に「テストを実行してからデプロイする」だけではないことを学びました。それは、git pushからユーザーが新しいコードを見るまでの間に存在する、チェック、ゲート、およびロールバックメカニズムのシステム全体なのです。

この記事では、私が複数の本番プロジェクトで洗練させてきたパイプラインアーキテクチャを、適応可能な具体的なGitHub Actionsの例とともに解説します。

パイプラインアーキテクチャ

本番パイプラインには明確なステージがあり、各ステージには特定の目的があります。ステージをスキップすると、今は数分節約できますが、後で数時間かかることになります。

ステージ

Code Push
  │
  ├─→ Stage 1: Validation (lint, format, type-check)
  │
  ├─→ Stage 2: Testing (unit, integration)
  │
  ├─→ Stage 3: Build (compile, bundle, containerize)
  │
  ├─→ Stage 4: Deploy to Staging
  │
  ├─→ Stage 5: Smoke Tests / E2E on Staging
  │
  └─→ Stage 6: Deploy to Production

各ステージはゲートとして機能します。バリデーションが失敗すれば、テストは実行されません。テストが失敗すれば、ビルドは開始されません。これにより、計算時間を節約し、迅速なフィードバックを提供します — 開発者は、リンターの実行を忘れた場合、テストスイートが失敗するまで8分待つのではなく、30秒以内にそれを知ることができます。

トリガ戦略

すべてのプッシュに完全なパイプラインが必要なわけではありません。私が使用するトリガ設定は次のとおりです。

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
    types: [opened, synchronize, reopened]

プルリクエストはステージ1〜3(バリデーション、テスト、ビルド)を実行します。developへのマージはステージングにデプロイします。mainへのマージは本番環境にデプロイします。これにより、PRのフィードバックを迅速に保ちつつ、デプロイが保護されたブランチからのみ行われるようにします。

ステージ1: バリデーション

バリデーションは、テストが実行される前にフォーマットの不整合や型エラーを検出します。これらのチェックは高速(30秒未満)で、最も一般的な問題を捕捉します。

jobs:
  validate:
    name: Validate
    runs-on: ubuntu-latest
    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: Check formatting
        run: npx prettier --check "src/**/*.{ts,tsx,js,jsx,json,css}"

      - name: Lint
        run: npx eslint src/ --max-warnings 0

      - name: Type check
        run: npx tsc --noEmit

--max-warnings 0ルール

ESLintはエラー(プロセスを失敗させる)と警告(失敗させない)を区別します。--max-warnings 0がないと、チームは誰もが無視する何百もの警告を蓄積してしまいます。CIで警告をエラーとして扱うことで、チームはそれらを修正するか、明示的にルールを無効にするかのどちらかを強制されます。中間はありません。

CIチェックとしてのフォーマット、単なる提案ではない

CIでPrettierを実行する(--writeではなく--checkを使用)ことで、すべての開発者が適切なエディタ拡張機能を持っていることに頼ることなく、一貫したフォーマットを保証します。CIでフォーマットが失敗した場合、開発者はローカルでnpx prettier --write .を実行し、修正をコミットします。これは譲れない点です — フォーマットに関する議論は、ツールが決定を下すときに終わります。

ステージ2: テスト

テストはパイプラインのバックボーンです。より迅速なフィードバックのために、テストをタイプに基づいて並列ジョブに分割しています。

  unit-tests:
    name: Unit Tests
    runs-on: ubuntu-latest
    needs: validate
    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: Run unit tests
        run: npx vitest run --coverage --reporter=verbose

      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/

  integration-tests:
    name: Integration Tests
    runs-on: ubuntu-latest
    needs: validate
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7
        ports:
          - 6379:6379
    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: Run database migrations
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb

      - name: Run integration tests
        run: npx vitest run --config vitest.integration.config.ts
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb
          REDIS_URL: redis://localhost:6379

統合テストのためのサービスコンテナ

GitHub Actionsのサービスコンテナはあまり活用されていません。統合テストでデータベースをモックする(コードではなくモックをテストすることになる)代わりに、実際のPostgreSQLインスタンスを起動します。servicesブロックはライフサイクル管理を処理します — コンテナはテストの前に起動し、テストの後に停止します。

これにより、コンテナの起動に約15〜20秒がジョブに追加されますが、実際のデータベースに対してテストを行うことで得られる信頼性はそれだけの価値があります。

並列テスト実行

単体テストと統合テストは並列で実行されます(両方ともneeds: validateであり、needs: unit-testsではありません)。これにより、パイプラインの合計時間が短縮されます。単体テストに2分、統合テストに4分かかる場合、並列実行では6分ではなく4分待つことになります。

ステージ3: ビルド

ビルドステージは、プロジェクトがコンパイルされ、デプロイ可能なアーティファクトが生成されることを検証します。

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    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 application
        run: npm run build
        env:
          NODE_ENV: production

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 7

ビルドキャッシュ

Dockerベースのデプロイの場合、レイヤーキャッシュはビルドを劇的に高速化します。

  build-docker:
    name: Build Docker Image
    runs-on: ubuntu-latest
    needs: [unit-tests, integration-tests]
    steps:
      - uses: actions/checkout@v4

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

      - name: Login to Container Registry
        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 != 'pull_request' }}
          tags: |
            ghcr.io/${{ github.repository }}:${{ github.sha }}
            ghcr.io/${{ github.repository }}:latest
          cache-from: type=gha
          cache-to: type=gha,mode=max

cache-from: type=ghaは、GitHub Actionsキャッシュを使用して、実行間でDockerレイヤーを保存します。一般的なNode.jsアプリケーションの場合、これにより、依存関係のみの変更に対するビルド時間が3〜4分から30〜60秒に短縮されます。

CIのためのDockerfileのベストプラクティス

Dockerfileの構造は、ビルドキャッシュの効率に直接影響します。レイヤーを、変更頻度が低いものから高いものの順に並べます。

FROM node:20-alpine AS base

# System dependencies (rarely changes)
RUN apk add --no-cache libc6-compat

# Package manifests (changes when dependencies change)
WORKDIR /app
COPY package.json package-lock.json ./

# Install dependencies (cached unless manifests change)
FROM base AS deps
RUN npm ci --production

FROM base AS build
RUN npm ci
COPY . .
RUN npm run build

# Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./

EXPOSE 3000
CMD ["node", "dist/server.js"]

マルチステージビルドにより、最終イメージは小さく保たれ(開発依存関係やソースコードなし)、レイヤーの順序付けにより、npm cipackage.jsonまたはpackage-lock.jsonが変更された場合にのみ実行されることが保証されます。

ステージ4: ステージングへのデプロイ

ステージングデプロイは、developブランチへのマージ時に自動的に行われます。ステージング環境は、可能な限り本番環境をミラーリングする必要があります — 同じインフラストラクチャ、同じ環境変数(異なる値)、同じスケーリング設定。

  deploy-staging:
    name: Deploy to Staging
    runs-on: ubuntu-latest
    needs: build
    if: github.ref == 'refs/heads/develop' && github.event_name == 'push'
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - uses: actions/checkout@v4

      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: dist/

      - name: Deploy to Cloud Run (Staging)
        uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: my-api-staging
          region: us-central1
          image: ghcr.io/${{ github.repository }}:${{ github.sha }}
          env_vars: |
            NODE_ENV=staging
            DATABASE_URL=${{ secrets.STAGING_DATABASE_URL }}

GitHub Environments

ワークフローのenvironmentキーは、GitHubの環境保護ルールを有効にします。手動承認を要求したり、デプロイできるブランチを制限したり、環境固有のシークレットを設定したりできます。ステージングの場合、通常は承認を必要としません(自動デプロイ)。本番環境の場合、少なくとも1人のレビューアを要求します。

ステージ5: スモークテスト

ステージングにデプロイした後、デプロイされたアプリケーションに対して基本的なテストセットを実行し、実際の環境で実際に機能することを確認します。

  smoke-tests:
    name: Smoke Tests
    runs-on: ubuntu-latest
    needs: deploy-staging
    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: Wait for deployment
        run: |
          for i in {1..30}; do
            status=$(curl -s -o /dev/null -w "%{http_code}" https://staging.example.com/health)
            if [ "$status" = "200" ]; then
              echo "Service is healthy"
              exit 0
            fi
            echo "Waiting for service... (attempt $i)"
            sleep 10
          done
          echo "Service did not become healthy"
          exit 1

      - name: Run smoke tests
        run: npx playwright test --config=playwright.smoke.config.ts
        env:
          BASE_URL: https://staging.example.com

スモークテストは完全なE2Eテストではありません。それらは重要なパスを検証します:ホームページがロードできるか、ユーザーがログインできるか、主要なAPIエンドポイントがデータを返すか。2分未満で完了する5〜10のシナリオです。スモークテストが失敗した場合、ステージングデプロイはロールバックされ、本番デプロイは進行しません。

ステージ6: 本番デプロイ

本番デプロイは、デプロイ戦略が最も重要になる場所です。いくつかの異なるアプローチがあり、それぞれに異なるトレードオフがあります。

ローリングデプロイ

最もシンプルな戦略です。古いインスタンスがドレインされる間に、新しいインスタンスが起動されます。デプロイ中、いつでも一部のリクエストは古いバージョンにヒットし、一部は新しいバージョンにヒットします。これはほとんどのコンテナプラットフォームのデフォルトです。

長所: シンプル、追加のインフラコストなし。 短所: 2つのバージョンが同時にトラフィックを処理するため、データベーススキーマの変更やAPI契約の変更で問題が発生する可能性があります。

ブルー/グリーンデプロイ

2つの同一の環境(ブルーとグリーン)が存在します。一方がトラフィックを処理し、もう一方はアイドル状態です。アイドル状態の環境にデプロイし、それが機能することを確認してから、ルーターを切り替えます。何か問題が発生した場合は、元に戻します。

  deploy-production:
    name: Deploy to Production
    runs-on: ubuntu-latest
    needs: smoke-tests
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    environment:
      name: production
      url: https://api.example.com
    steps:
      - name: Deploy new revision
        uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: my-api-production
          region: us-central1
          image: ghcr.io/${{ github.repository }}:${{ github.sha }}
          flags: '--no-traffic'

      - name: Run production health check
        run: |
          REVISION_URL=$(gcloud run revisions describe my-api-production-${{ github.sha }} \
            --region us-central1 --format='value(status.url)')
          status=$(curl -s -o /dev/null -w "%{http_code}" "$REVISION_URL/health")
          if [ "$status" != "200" ]; then
            echo "Health check failed"
            exit 1
          fi

      - name: Migrate traffic
        run: |
          gcloud run services update-traffic my-api-production \
            --region us-central1 \
            --to-latest

--no-trafficフラグは、新しいリビジョンにトラフィックを送信せずにデプロイします。ヘルスチェックがパスした後、トラフィックは新しいリビジョンにシフトされます。ヘルスチェックが失敗した場合、ワークフローは停止し、古いリビジョンが引き続きサービスを提供します。

カナリアデプロイ

トラフィックのごく一部(5〜10%)を新しいバージョンにルーティングし、エラー率、レイテンシ、主要なビジネスメトリクスを監視します。定義された期間後にメトリクスが良好に見える場合、徐々にトラフィックを増やします。メトリクスが悪化した場合、ロールバックします。

      - name: Canary - route 10% of traffic
        run: |
          gcloud run services update-traffic my-api-production \
            --region us-central1 \
            --to-revisions=my-api-production-${{ github.sha }}=10

      - name: Monitor canary (5 minutes)
        run: |
          sleep 300
          # Check error rate for the canary revision
          ERROR_RATE=$(curl -s "$MONITORING_API/error-rate?revision=${{ github.sha }}")
          if (( $(echo "$ERROR_RATE > 1.0" | bc -l) )); then
            echo "Error rate too high: $ERROR_RATE%. Rolling back."
            gcloud run services update-traffic my-api-production \
              --region us-central1 \
              --to-latest
            exit 1
          fi

      - name: Promote canary to 100%
        run: |
          gcloud run services update-traffic my-api-production \
            --region us-central1 \
            --to-latest

カナリアデプロイを使用するタイミング: 10%のトラフィックでも統計的に意味のあるエラー率を生成するのに十分なトラフィックがある場合。1分あたり100リクエストを処理するサービスの場合、10%は1分あたり10リクエストとなり、数分以内に上昇したエラー率を検出するのに十分です。1分あたり10リクエストを処理するサービスの場合、カナリアデプロイは意味がありません。

ロールバックメカニズム

すべてのデプロイには、文書化されたロールバックパスが必要です。「古いバージョンを再デプロイする」はロールバック戦略ですが、遅いです。より良いオプションは次のとおりです。

トラフィックシフトによる即時ロールバック

--no-trafficでデプロイし、トラフィックをシフトする場合、ロールバックは単一のコマンドです。

# Roll back to the previous revision
gcloud run services update-traffic my-api-production \
  --region us-central1 \
  --to-revisions=PREVIOUS_REVISION=100

古いリビジョンがまだ実行されているため、これは数秒で有効になります。

自動ロールバック

エラー率が急増した場合に自動的にロールバックするデプロイ後の監視ステップを追加します。

      - name: Post-deploy monitor
        run: |
          for i in {1..10}; do
            sleep 60
            ERROR_RATE=$(curl -s "$MONITORING_API/error-rate?window=5m")
            if (( $(echo "$ERROR_RATE > 2.0" | bc -l) )); then
              echo "Error rate $ERROR_RATE% exceeds threshold. Rolling back."
              gcloud run services update-traffic my-api-production \
                --region us-central1 \
                --to-revisions=$PREVIOUS_REVISION=100
              exit 1
            fi
            echo "Minute $i: Error rate $ERROR_RATE% — OK"
          done

データベースマイグレーションのロールバック

これが最も難しい部分です。デプロイにデータベーススキーマの変更が含まれる場合、データベースをロールバックせずにアプリケーションをロールバックすると、不整合が生じます。解決策は拡張・縮小マイグレーションです。

  1. 拡張: 古い列を削除せずに新しい列/テーブルを追加します。新しい列はNULL許容にするか、デフォルト値を設定します。
  2. デプロイ: 新しいコードは古い列と新しい列の両方に書き込みます。新しい列から読み取り、古い列にフォールバックします。
  3. データ移行: 古いデータから新しい列をバックフィルします。
  4. 縮小: 検証後、新しい列のみを使用するコードをデプロイします。その後、古い列を削除します。

これにより、すべてのステップが独立して元に戻せるようになります。

シークレット管理

シークレットをハードコードしないでください。.envファイルをコミットしないでください。GitHub Actionsでのシークレットの扱い方は次のとおりです。

GitHub Secrets

ほとんどの場合、GitHubの組み込みシークレットで十分です。これらは暗号化され、ログに公開されることはなく、リポジトリまたは組織にスコープされます。

env:
  DATABASE_URL: ${{ secrets.DATABASE_URL }}
  API_KEY: ${{ secrets.API_KEY }}

環境スコープのシークレット

ステージングと本番環境で異なるシークレット。

deploy-staging:
  environment: staging
  # ${{ secrets.DATABASE_URL }} resolves to the staging value

deploy-production:
  environment: production
  # ${{ secrets.DATABASE_URL }} resolves to the production value

外部シークレットマネージャー

大規模なチームやより厳格なコンプライアンス要件の場合、AWS Secrets Manager、Google Secret Manager、またはHashiCorp Vaultを使用します。アプリケーションは、環境変数として受け取るのではなく、実行時にシークレットをフェッチします。

import { SecretManagerServiceClient } from '@google-cloud/secret-manager';

const client = new SecretManagerServiceClient();

async function getSecret(name: string): Promise<string> {
  const [version] = await client.accessSecretVersion({
    name: `projects/my-project/secrets/${name}/versions/latest`,
  });
  return version.payload?.data?.toString() || '';
}

デプロイの監視

デプロイはコードが稼働したときに完了するわけではありません。コードが機能していることを確認したときに完了します。

デプロイ通知

関連するコンテキストとともにデプロイイベントをSlackに送信します。

      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "text": "${{ job.status == 'success' && 'Deployed' || 'Failed to deploy' }} `${{ github.sha }}` to production",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "*${{ job.status == 'success' && ':white_check_mark: Deployment Successful' || ':x: Deployment Failed' }}*\nCommit: `${{ github.sha }}`\nAuthor: ${{ github.actor }}\nMessage: ${{ github.event.head_commit.message }}"
                  }
                }
              ]
            }

デプロイ後のヘルスチェック

すべての本番デプロイ後、重要なエンドポイントが正しく応答することを確認します。

      - name: Verify production health
        run: |
          endpoints=("/" "/api/health" "/api/v1/status")
          for endpoint in "${endpoints[@]}"; do
            status=$(curl -s -o /dev/null -w "%{http_code}" "https://api.example.com$endpoint")
            if [ "$status" != "200" ]; then
              echo "FAILED: $endpoint returned $status"
              exit 1
            fi
            echo "OK: $endpoint returned $status"
          done

完全なパイプライン例

これらすべてをまとめると、Cloud RunにデプロイされたNode.jsアプリケーションの凝縮された、しかし完全なパイプラインは次のようになります。

name: CI/CD Pipeline
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx prettier --check "src/**/*.{ts,tsx}"
      - run: npx eslint src/ --max-warnings 0
      - run: npx tsc --noEmit

  test:
    needs: validate
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: testdb }
        ports: ['5432:5432']
        options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 20, cache: 'npm' }
      - run: npm ci
      - run: npx vitest run --coverage
        env:
          DATABASE_URL: postgresql://test:test@localhost:5432/testdb

  build-and-push:
    needs: test
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    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 }}
      - uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build-and-push
    runs-on: ubuntu-latest
    environment:
      name: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}
    steps:
      - uses: google-github-actions/auth@v2
        with:
          credentials_json: ${{ secrets.GCP_SA_KEY }}
      - uses: google-github-actions/deploy-cloudrun@v2
        with:
          service: my-api-${{ github.ref == 'refs/heads/main' && 'prod' || 'staging' }}
          region: us-central1
          image: ghcr.io/${{ github.repository }}:${{ github.sha }}

concurrencyブロックは注目に値します — プルリクエストで同じブランチの進行中の実行をキャンセルするため、CIがまだ実行中に修正をプッシュしても、2つのパイプラインがキューに登録されることはありません。

やり直すならこうする

もし今日、ゼロからパイプラインをセットアップするなら、バリデーションとテストステージのみから始めます — デプロイの自動化は行いません。コードベースが安定するまでの最初の数週間は、手動で(またはシンプルなデプロイスクリプトで)デプロイします。手動プロセスがボトルネックになったときに初めてデプロイの自動化を追加します。それ以前ではありません。時期尚早なパイプラインの最適化は、時期尚早なコードの最適化と同じくらい現実的であり、午前2時に壊れたデプロイパイプラインをデバッグすることは、手動で./deploy.shを実行するよりもはるかに悪いことです。

DU

Danil Ulmashev

Full Stack Developer

一緒にお仕事しませんか?