コンテンツへスキップ
·15分で読めます

GitHub Actions CI/CD:本当に動くゼロダウンタイムデプロイ

完全なGitHub Actionsセットアップ:並列テストジョブ、Dockerビルドキャッシュ、VPSへのSSHデプロイ、PM2によるゼロダウンタイム、シークレット管理、2年間磨き上げたワークフローパターン。

シェア:X / TwitterLinkedIn

これまで携わったすべてのプロジェクトは、いずれ同じ転換点に達する:デプロイプロセスが手動でやるには辛すぎるようになる。テストを実行し忘れる。ローカルでビルドしたがバージョンを上げ忘れる。本番にSSHして、前回デプロイした人が古い.envファイルを残していたことに気づく。

GitHub Actionsは2年前にこの問題を解決してくれた。初日から完璧だったわけではない — 最初に書いたワークフローは200行のYAML悪夢で、半分の確率でタイムアウトし、何もキャッシュしなかった。しかしイテレーションを重ねるごとに、このサイトを確実に、ダウンタイムゼロで、4分以内にデプロイする仕組みにたどり着いた。

それがこのワークフローだ。セクションごとに解説する。ドキュメント版ではない。本番環境との接触に耐えるバージョンだ。

ビルディングブロックを理解する#

フルパイプラインに入る前に、GitHub Actionsの仕組みについて明確なメンタルモデルが必要だ。JenkinsやCircleCIを使ったことがあるなら、知っていることのほとんどを忘れろ。概念は大まかにマッピングできるが、実行モデルは足をすくわれるほど異なる。

トリガー:ワークフローが実行されるタイミング#

yaml
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: "0 6 * * 1" # 毎週月曜日UTC午前6時
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        default: "staging"
        type: choice
        options:
          - staging
          - production

4つのトリガー、それぞれ異なる目的を持つ:

  • pushmainへ — 本番デプロイのトリガー。コードがマージされたら出荷しろ。
  • pull_request — すべてのPRでCIチェックを実行する。lint、型チェック、テストがここに入る。
  • schedule — リポジトリのcron。週次の依存関係監査スキャンと古いキャッシュのクリーンアップに使っている。
  • workflow_dispatch — GitHub UIに手動の「デプロイ」ボタンと入力パラメータを提供する。コード変更なしでステージングをデプロイしたいとき — 環境変数を更新した、またはベースのDockerイメージを再取得する必要があるときに非常に有用だ。

人がハマりがちなこと:pull_requestマージコミットに対して実行され、PRブランチのHEADではない。つまり、CIはマージのコードの姿をテストしている。これは実際に望ましい動作だが、グリーンだったブランチがリベース後にレッドになると驚く人がいる。

ジョブ、ステップ、ランナー#

yaml
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint

ジョブはデフォルトで並列実行される。各ジョブは新しいVM(「ランナー」)を取得する。ubuntu-latestはそれなりにパワフルなマシンを提供する — 2026年時点で4 vCPU、16 GB RAM。パブリックリポジトリでは無料、プライベートでは月2000分。

ステップはジョブ内で逐次実行される。各uses:ステップはマーケットプレイスから再利用可能なアクションを取り込む。各run:ステップはシェルコマンドを実行する。

--frozen-lockfileフラグは重要だ。これがないと、pnpm installがCI中にロックファイルを更新する可能性がある。つまり、開発者がコミットしたのと同じ依存関係をテストしていないことになる。開発者のマシンのロックファイルがすでに正しいために、ローカルでは消える不可解なテスト失敗を見たことがある。

環境変数 vs シークレット#

yaml
env:
  NODE_ENV: production
  NEXT_TELEMETRY_DISABLED: 1
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
        run: |
          echo "$SSH_PRIVATE_KEY" > key.pem
          chmod 600 key.pem
          ssh -i key.pem deploy@$DEPLOY_HOST "cd /var/www/app && ./deploy.sh"

ワークフローレベルでenv:で設定する環境変数はプレーンテキストで、ログに表示される。非機密の設定に使え:NODE_ENV、テレメトリフラグ、フィーチャートグル。

シークレット${{ secrets.X }})は保存時に暗号化され、ログではマスクされ、同じリポジトリのワークフローでのみ利用可能だ。Settings > Secrets and variables > Actionsで設定する。

environment: productionの行は重要だ。GitHub Environmentsを使えば、シークレットを特定のデプロイターゲットにスコープできる。ステージングのSSH鍵と本番のSSH鍵は、ジョブがどの環境をターゲットにするかによって、どちらもSSH_PRIVATE_KEYという名前で異なる値を持てる。これにより必須レビュアーも解放される — 本番デプロイを手動承認の背後にゲートできる。

フルCIパイプライン#

CIパイプラインの構成はこうだ。目標:あらゆるカテゴリのエラーを最速で検出する。

yaml
name: CI
 
on:
  pull_request:
    branches: [main]
  push:
    branches: [main]
 
concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm test -- --coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  build:
    name: Build
    needs: [lint, typecheck, test]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: "pnpm"
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .next/
          retention-days: 1

なぜこの構造か#

lint、typecheck、testは並列実行される。 互いに依存関係がない。型エラーがあってもlintの実行はブロックされないし、テスト失敗が型チェッカーを待つ必要もない。通常の実行では、同時に実行しながら3つすべてが30〜60秒で完了する。

buildは3つすべてを待つ。 needs: [lint, typecheck, test]の行は、lint、typecheck、テストの3つすべてがパスした場合にのみビルドジョブが開始されることを意味する。lintエラーや型の失敗があるプロジェクトをビルドする意味はない。

**concurrencycancel-in-progress: true**は大幅な時間節約になる。短い間隔で2つのコミットをプッシュすると、最初のCI実行がキャンセルされる。これがないと、古い実行が分数予算を消費し、チェックUIを散らかす。

if: always()付きのカバレッジアップロードは、テストが失敗してもカバレッジレポートが得られることを意味する。デバッグに便利だ — どのテストが失敗し、何をカバーしていたかを確認できる。

フェイルファスト vs すべて実行#

デフォルトでは、マトリックス内の1つのジョブが失敗すると、GitHubは他をキャンセルする。CIでは、実はこの動作を望んでいる — lintが失敗したなら、テスト結果は気にしない。まずlintを直せ。

しかしテストマトリックス(例えばNode 20とNode 22でテスト)では、すべての失敗を一度に見たいかもしれない:

yaml
test:
  strategy:
    fail-fast: false
    matrix:
      node-version: [20, 22]
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v4
    - uses: actions/setup-node@v4
      with:
        node-version: ${{ matrix.node-version }}
        cache: "pnpm"
    - run: pnpm install --frozen-lockfile
    - run: pnpm test

fail-fast: falseは両方のマトリックスレッグを完了させる。Node 22が失敗してもNode 20がパスすれば、再実行を待たずにすぐにその情報が得られる。

速度のためのキャッシュ#

CI速度に対して最も大きな改善となるのがキャッシュだ。中規模プロジェクトでのコールドなpnpm installは30〜45秒かかる。ウォームキャッシュがあれば3〜5秒だ。これを4つの並列ジョブに掛け算すると、実行ごとに2分節約できる。

pnpmストアキャッシュ#

yaml
- uses: actions/setup-node@v4
  with:
    node-version: 22
    cache: "pnpm"

この1行でpnpmストア(~/.local/share/pnpm/store)をキャッシュする。キャッシュヒット時、pnpm install --frozen-lockfileはダウンロードの代わりにストアからハードリンクするだけだ。これだけで繰り返し実行時のインストール時間が80%短縮される。

OSでもキャッシュしたいなど、もっと制御が必要なら、actions/cacheを直接使え:

yaml
- uses: actions/cache@v4
  with:
    path: |
      ~/.local/share/pnpm/store
      node_modules
    key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
    restore-keys: |
      pnpm-${{ runner.os }}-

restore-keysのフォールバックは重要だ。pnpm-lock.yamlが変更されると(新しい依存関係)、正確なキーはマッチしないが、プレフィックスマッチがキャッシュされたパッケージの大部分を復元する。差分のみがダウンロードされる。

Next.jsビルドキャッシュ#

Next.jsは.next/cacheに独自のビルドキャッシュを持つ。実行間でこれをキャッシュすることでインクリメンタルビルドが可能になる — 変更されたページとコンポーネントのみが再コンパイルされる。

yaml
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
      nextjs-${{ runner.os }}-

この3レベルキー戦略の意味:

  1. 完全一致:同じ依存関係かつ同じソースファイル。フルキャッシュヒット、ビルドはほぼ瞬時。
  2. 部分一致(依存関係):依存関係は同じだがソースが変更。変更されたファイルのみ再コンパイル。
  3. 部分一致(OSのみ):依存関係が変更。可能なものを再利用。

私のプロジェクトでの実数値:コールドビルドは約55秒、キャッシュ済みビルドは約15秒。73%の削減だ。

Dockerレイヤーキャッシュ#

Dockerビルドはキャッシュが本当に効果を発揮する場所だ。Next.jsの完全なDockerビルド — OS依存関係のインストール、ソースのコピー、pnpm installの実行、next buildの実行 — はコールドで3〜4分かかる。レイヤーキャッシュがあれば30〜60秒だ。

yaml
- uses: docker/build-push-action@v6
  with:
    context: .
    push: true
    tags: ghcr.io/${{ github.repository }}:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max

type=ghaはGitHub Actionsの組み込みキャッシュバックエンドを使用する。mode=maxは最終レイヤーだけでなくすべてのレイヤーをキャッシュする。これはマルチステージビルドで中間レイヤー(pnpm installのような)が最もリビルドにコストがかかる場合に重要だ。

Turborepoリモートキャッシュ#

Turborepoを使ったモノレポにいるなら、リモートキャッシュは変革的だ。最初のビルドがタスク出力をキャッシュにアップロードする。以降のビルドは再計算の代わりにダウンロードする。

yaml
- run: pnpm turbo build --remote-only
  env:
    TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
    TURBO_TEAM: ${{ vars.TURBO_TEAM }}

TurboリモートキャッシュでモノレポのCI時間が8分から90秒に短縮されるのを見たことがある。注意点:VercelアカウントまたはセルフホストのTurboサーバーが必要だ。単一アプリのリポジトリではオーバーキル。

Dockerビルドとプッシュ#

VPS(または任意のサーバー)にデプロイするなら、Dockerが再現可能なビルドを提供する。CIで動くのと同じイメージが本番で動く。「自分のマシンでは動く」がなくなる。なぜならマシンそのものがイメージだからだ。

マルチステージDockerfile#

ワークフローに入る前に、Next.js用のDockerfileがこれだ:

dockerfile
# ステージ1:依存関係
FROM node:22-alpine AS deps
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod=false
 
# ステージ2:ビルド
FROM node:22-alpine AS builder
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
 
# ステージ3:本番
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
 
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
 
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
 
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

3ステージ、明確な分離。最終イメージはすべてをコピーした場合の約1.2GBではなく約150MBだ。本番アーティファクトのみがランナーステージに到達する。

ビルド&プッシュワークフロー#

yaml
name: Build and Push Docker Image
 
on:
  push:
    branches: [main]
 
env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
 
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=ref,event=branch
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

重要な決定を解説しよう。

GitHub Container Registry(ghcr.io)#

Docker Hubの代わりにghcr.ioを使う理由は3つ:

  1. 認証が無料。 GITHUB_TOKENはすべてのワークフローで自動的に利用可能 — Docker Hubの認証情報を保存する必要がない。
  2. 近接性。 イメージはCIが実行されるのと同じインフラからプルされる。CI中のプルが速い。
  3. 可視性。 イメージはGitHub UIでリポジトリにリンクされる。Packagesタブで確認できる。

マルチプラットフォームビルド#

yaml
platforms: linux/amd64,linux/arm64

この行はビルドに90秒ほど追加するが、価値がある。ARM64イメージはネイティブで動作する:

  • Docker DesktopでのApple Siliconマシン(M1/M2/M3/M4)によるローカル開発
  • AWS Gravitonインスタンス(x86相当より20〜40%安価)
  • Oracle Cloudの無料ARMティア

これがなければ、MシリーズMacの開発者はRosettaエミュレーション経由でx86イメージを実行することになる。動作はするが、目に見えて遅く、時折奇妙なアーキテクチャ固有のバグが表面化する。

QEMUがクロスコンパイルレイヤーを提供する。Buildxがマルチアーキテクチャビルドをオーケストレーションし、マニフェストリストをプッシュするのでDockerが自動的に正しいアーキテクチャをプルする。

タグ戦略#

yaml
tags: |
  type=sha,prefix=
  type=ref,event=branch
  type=raw,value=latest,enable={{is_default_branch}}

すべてのイメージに3つのタグ:

  • abc1234(コミットSHA):イミュータブル。常に正確なコミットをデプロイできる。
  • main(ブランチ名):ミュータブル。そのブランチからの最新ビルドを指す。
  • latest:ミュータブル。デフォルトブランチでのみ設定。サーバーがプルするもの。

SHAもどこかに記録せずに本番でlatestをデプロイするな。何かが壊れたとき、どのlatestかを知る必要がある。デプロイされたSHAをサーバー上のファイルに保存し、ヘルスエンドポイントが読み取るようにしている。

VPSへのSSHデプロイ#

すべてがここで結実する。CIが通り、Dockerイメージがビルドされプッシュされ、あとはサーバーに新しいイメージをプルしてリスタートするよう伝えるだけだ。

SSHアクション#

yaml
deploy:
  name: Deploy to Production
  needs: [build-and-push]
  runs-on: ubuntu-latest
  environment: production
 
  steps:
    - name: Deploy via SSH
      uses: appleboy/ssh-action@v1
      with:
        host: ${{ secrets.DEPLOY_HOST }}
        username: ${{ secrets.DEPLOY_USER }}
        key: ${{ secrets.SSH_PRIVATE_KEY }}
        port: ${{ secrets.SSH_PORT }}
        script_stop: true
        script: |
          set -euo pipefail
 
          APP_DIR="/var/www/akousa.net"
          IMAGE="ghcr.io/${{ github.repository }}:latest"
          DEPLOY_SHA="${{ github.sha }}"
 
          echo "=== Deploying $DEPLOY_SHA ==="
 
          # 最新イメージをプル
          docker pull "$IMAGE"
 
          # 古いコンテナを停止して削除
          docker stop akousa-app || true
          docker rm akousa-app || true
 
          # 新しいコンテナを起動
          docker run -d \
            --name akousa-app \
            --restart unless-stopped \
            --network host \
            -e NODE_ENV=production \
            -e DATABASE_URL="${DATABASE_URL}" \
            -p 3000:3000 \
            "$IMAGE"
 
          # ヘルスチェックを待つ
          echo "Waiting for health check..."
          for i in $(seq 1 30); do
            if curl -sf http://localhost:3000/api/health > /dev/null 2>&1; then
              echo "Health check passed on attempt $i"
              break
            fi
            if [ "$i" -eq 30 ]; then
              echo "Health check failed after 30 attempts"
              exit 1
            fi
            sleep 2
          done
 
          # デプロイされたSHAを記録
          echo "$DEPLOY_SHA" > "$APP_DIR/.deployed-sha"
 
          # 古いイメージを削除
          docker image prune -af --filter "until=168h"
 
          echo "=== Deploy complete ==="

デプロイスクリプトの代替案#

単純なプル&リスタート以上のことには、ワークフローにインラインで書くのではなく、サーバー上のスクリプトにロジックを移す:

bash
#!/bin/bash
# /var/www/akousa.net/deploy.sh
set -euo pipefail
 
APP_DIR="/var/www/akousa.net"
LOG_FILE="$APP_DIR/deploy.log"
IMAGE="ghcr.io/akousa/akousa-net:latest"
 
log() {
  echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"
}
 
log "Starting deployment..."
 
# GHCRにログイン
echo "$GHCR_TOKEN" | docker login ghcr.io -u akousa --password-stdin
 
# リトライ付きプル
for attempt in 1 2 3; do
  if docker pull "$IMAGE"; then
    log "Image pulled successfully on attempt $attempt"
    break
  fi
  if [ "$attempt" -eq 3 ]; then
    log "ERROR: Failed to pull image after 3 attempts"
    exit 1
  fi
  log "Pull attempt $attempt failed, retrying in 5s..."
  sleep 5
done
 
# ヘルスチェック関数
health_check() {
  local port=$1
  local max_attempts=30
  for i in $(seq 1 $max_attempts); do
    if curl -sf "http://localhost:$port/api/health" > /dev/null 2>&1; then
      return 0
    fi
    sleep 2
  done
  return 1
}
 
# 代替ポートで新しいコンテナを起動
docker run -d \
  --name akousa-app-new \
  --env-file "$APP_DIR/.env.production" \
  -p 3001:3000 \
  "$IMAGE"
 
# 新しいコンテナが正常か検証
if ! health_check 3001; then
  log "ERROR: New container failed health check. Rolling back."
  docker stop akousa-app-new || true
  docker rm akousa-app-new || true
  exit 1
fi
 
log "New container healthy. Switching traffic..."
 
# Nginxのupstreamを切り替え
sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# 古いコンテナを停止
docker stop akousa-app || true
docker rm akousa-app || true
 
# 新しいコンテナをリネーム
docker rename akousa-app-new akousa-app
 
log "Deployment complete."

ワークフローは単一のSSHコマンドになる:

yaml
script: |
  cd /var/www/akousa.net && ./deploy.sh

これが良い理由:(1) デプロイロジックがサーバー上でバージョン管理される、(2) デバッグのためにSSH経由で手動実行できる、(3) YAML内のYAML内のbashをエスケープする必要がない。

ゼロダウンタイム戦略#

「ゼロダウンタイム」はマーケティング用語のように聞こえるが、正確な意味がある:デプロイ中にいかなるリクエストもコネクション拒否や502を受けない。最もシンプルなものから最も堅牢なものまで、3つの実際のアプローチがある。

戦略1:PM2クラスターモードリロード#

Node.jsを直接実行している場合(Docker内ではなく)、PM2のクラスターモードが最も簡単なゼロダウンタイムの道を提供する。

bash
# ecosystem.config.jsにはすでに以下がある:
#   instances: 2
#   exec_mode: "cluster"
 
pm2 reload akousa --update-env

pm2 reloadrestartではなく)はローリングリスタートを行う。新しいワーカーを起動し、準備完了を待ち、古いワーカーを一つずつ終了させる。ゼロワーカーがトラフィックを処理している瞬間は一度もない。

--update-envフラグはエコシステム設定から環境変数をリロードする。これがないと、.envを変更したデプロイの後でも古い環境変数が残る。

ワークフローでは:

yaml
- name: Deploy and reload PM2
  uses: appleboy/ssh-action@v1
  with:
    host: ${{ secrets.DEPLOY_HOST }}
    username: ${{ secrets.DEPLOY_USER }}
    key: ${{ secrets.SSH_PRIVATE_KEY }}
    script: |
      cd /var/www/akousa.net
      git pull origin main
      pnpm install --frozen-lockfile
      pnpm build
      pm2 reload ecosystem.config.js --update-env

これがこのサイトで使っているものだ。シンプルで信頼性が高く、ダウンタイムは文字通りゼロ — デプロイ中に100リクエスト/秒の負荷ジェネレーターでテストした。5xxは一つもなし。

戦略2:Nginx Upstreamによるブルー/グリーン#

Dockerデプロイでは、ブルー/グリーンが古いバージョンと新しいバージョンの間にクリーンな分離を提供する。

コンセプト:古いコンテナ(「ブルー」)をポート3000で、新しいコンテナ(「グリーン」)をポート3001で実行する。Nginxはブルーを指す。グリーンを起動し、正常か検証し、Nginxをグリーンに切り替え、ブルーを停止する。

Nginx upstream設定:

nginx
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
    server 127.0.0.1:3000;
}
nginx
# /etc/nginx/sites-available/akousa.net
server {
    listen 443 ssl http2;
    server_name akousa.net;
 
    location / {
        proxy_pass http://app_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

切り替えスクリプト:

bash
#!/bin/bash
set -euo pipefail
 
CURRENT_PORT=$(grep -oP 'server 127\.0\.0\.1:\K\d+' /etc/nginx/conf.d/upstream.conf)
 
if [ "$CURRENT_PORT" = "3000" ]; then
  NEW_PORT=3001
  OLD_PORT=3000
else
  NEW_PORT=3000
  OLD_PORT=3001
fi
 
echo "Current: $OLD_PORT -> New: $NEW_PORT"
 
# 代替ポートで新しいコンテナを起動
docker run -d \
  --name "akousa-app-$NEW_PORT" \
  --env-file /var/www/akousa.net/.env.production \
  -p "$NEW_PORT:3000" \
  "ghcr.io/akousa/akousa-net:latest"
 
# ヘルスチェックを待つ
for i in $(seq 1 30); do
  if curl -sf "http://localhost:$NEW_PORT/api/health" > /dev/null; then
    echo "New container healthy on port $NEW_PORT"
    break
  fi
  [ "$i" -eq 30 ] && { echo "Health check failed"; docker stop "akousa-app-$NEW_PORT"; docker rm "akousa-app-$NEW_PORT"; exit 1; }
  sleep 2
done
 
# Nginxを切り替え
sudo sed -i "s/server 127.0.0.1:$OLD_PORT/server 127.0.0.1:$NEW_PORT/" /etc/nginx/conf.d/upstream.conf
sudo nginx -t && sudo nginx -s reload
 
# 古いコンテナを停止
sleep 5  # 処理中のリクエストが完了するのを待つ
docker stop "akousa-app-$OLD_PORT" || true
docker rm "akousa-app-$OLD_PORT" || true
 
echo "Switched from :$OLD_PORT to :$NEW_PORT"

Nginxリロード後の5秒のスリープは怠慢ではない — これはグレースタイムだ。Nginxのリロードはグレースフル(既存のコネクションは維持される)だが、ロングポーリング接続やストリーミングレスポンスの中には完了に時間が必要なものがある。

戦略3:ヘルスチェック付きDocker Compose#

よりstructuredなアプローチとして、Docker Composeがブルー/グリーンの切り替えを管理できる:

yaml
# docker-compose.yml
services:
  app:
    image: ghcr.io/akousa/akousa-net:latest
    restart: unless-stopped
    env_file: .env.production
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
    deploy:
      replicas: 2
      update_config:
        parallelism: 1
        delay: 10s
        order: start-first
        failure_action: rollback
      rollback_config:
        parallelism: 0
        order: stop-first
    ports:
      - "3000:3000"

order: start-firstがキーラインだ。「古いコンテナを停止する前に新しいコンテナを起動する」という意味だ。parallelism: 1と組み合わせることで、ローリングアップデートが実現する — 一度に1つのコンテナ、常にキャパシティを維持。

デプロイ:

bash
docker compose pull
docker compose up -d --remove-orphans

Docker Composeはヘルスチェックを監視し、パスするまで新しいコンテナにトラフィックをルーティングしない。ヘルスチェックが失敗すると、failure_action: rollbackが自動的に前のバージョンに戻す。これは単一VPSで得られるKubernetes風ローリングデプロイに最も近いものだ。

シークレット管理#

シークレット管理は「ほぼ正しく」やるのは簡単だが、残りのエッジケースで壊滅的に間違えるものの一つだ。

GitHubシークレット:基本#

yaml
# GitHub UIで設定:Settings > Secrets and variables > Actions
 
steps:
  - name: Use a secret
    env:
      DB_URL: ${{ secrets.DATABASE_URL }}
    run: |
      # 値はログでマスクされる
      echo "Connecting to database..."
      # これはログで "Connecting to ***" と出力される
      echo "Connecting to $DB_URL"

GitHubはログ出力からシークレット値を自動的にマスクする。シークレットがp@ssw0rd123で、いずれかのステップがその文字列を出力すると、ログには***と表示される。これはうまく機能するが、一つ注意点がある:シークレットが短い場合(4桁のPINなど)、無害な文字列にマッチする可能性があるため、GitHubがマスクしないかもしれない。シークレットは適度に複雑にしておけ。

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

yaml
jobs:
  deploy-staging:
    environment: staging
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = staging.akousa.net
 
  deploy-production:
    environment: production
    steps:
      - run: echo "Deploying to ${{ secrets.DEPLOY_HOST }}"
      # DEPLOY_HOST = akousa.net

同じシークレット名、環境ごとに異なる値。ジョブのenvironmentフィールドがどのシークレットセットが注入されるかを決定する。

本番環境では必須レビュアーを有効にすべきだ。これはmainへのプッシュがワークフローをトリガーし、CIは自動で実行されるが、デプロイジョブは一時停止してGitHub UIで誰かが「承認」をクリックするのを待つという意味だ。ソロプロジェクトではオーバーヘッドに感じるかもしれない。ユーザーがいるものなら、誤って壊れたものをマージした最初のとき、命の恩人になる。

OIDC:静的認証情報はもう不要#

GitHubシークレットに保存された静的認証情報(AWSアクセスキー、GCPサービスアカウントのJSONファイル)はリスクだ。期限切れにならず、特定のワークフロー実行にスコープできず、漏洩したら手動でローテーションする必要がある。

OIDC(OpenID Connect)がこれを解決する。GitHub Actionsがアイデンティティプロバイダーとして機能し、クラウドプロバイダーがその場で短期間の認証情報を発行することを信頼する:

yaml
jobs:
  deploy:
    runs-on: ubuntu-latest
    permissions:
      id-token: write  # OIDCに必要
      contents: read
 
    steps:
      - name: Configure AWS Credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
          aws-region: eu-central-1
 
      - name: Push to ECR
        run: |
          aws ecr get-login-password --region eu-central-1 | \
            docker login --username AWS --password-stdin 123456789012.dkr.ecr.eu-central-1.amazonaws.com

アクセスキーなし。シークレットキーなし。configure-aws-credentialsアクションがGitHubのOIDCトークンを使ってAWS STSから一時トークンをリクエストする。トークンは特定のリポジトリ、ブランチ、環境にスコープされる。ワークフロー実行後に期限切れになる。

AWS側での設定にはIAM OIDCアイデンティティプロバイダーとロール信頼ポリシーが必要だ:

json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:akousa/akousa-net:ref:refs/heads/main"
        }
      }
    }
  ]
}

sub条件は極めて重要だ。これがなければ、OIDCプロバイダーの詳細を何らかの方法で入手したどんなリポジトリでもそのロールを引き受けることができる。これがあれば、特定のリポジトリのmainブランチのみが可能だ。

GCPにはWorkload Identity Federationによる同等のセットアップがある。Azureにはフェデレーテッド認証情報がある。クラウドがOIDCをサポートしているなら、使え。2026年に静的なクラウド認証情報を保存する理由はない。

デプロイ用SSHキー#

SSH経由のVPSデプロイには、専用のキーペアを生成する:

bash
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""

公開鍵を制限付きでサーバーの~/.ssh/authorized_keysに追加:

restrict,command="/var/www/akousa.net/deploy.sh" ssh-ed25519 AAAA... github-actions-deploy

restrictプレフィックスはポートフォワーディング、エージェントフォワーディング、PTY割り当て、X11フォワーディングを無効にする。command=プレフィックスはこのキーがデプロイスクリプトのみを実行できることを意味する。秘密鍵が漏洩しても、攻撃者はデプロイスクリプトを実行できるだけで他には何もできない。

秘密鍵をGitHubシークレットにSSH_PRIVATE_KEYとして追加する。これは私が受け入れる唯一の静的認証情報だ — 強制コマンド付きSSHキーは非常に限定された影響範囲を持つ。

PRワークフロー:プレビューデプロイ#

すべてのPRにプレビュー環境があるべきだ。ユニットテストでは見逃すビジュアルバグをキャッチし、デザイナーがコードをチェックアウトせずにレビューでき、QAの仕事が劇的に楽になる。

PRオープン時にプレビューをデプロイ#

yaml
name: Preview Deploy
 
on:
  pull_request:
    types: [opened, synchronize, reopened]
 
jobs:
  preview:
    runs-on: ubuntu-latest
    environment:
      name: preview-${{ github.event.number }}
      url: ${{ steps.deploy.outputs.url }}
 
    steps:
      - uses: actions/checkout@v4
 
      - name: Build preview image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:pr-${{ github.event.number }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
      - name: Deploy preview
        id: deploy
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            PORT=$((4000 + PR_NUM))
            IMAGE="ghcr.io/${{ github.repository }}:pr-${PR_NUM}"
 
            docker pull "$IMAGE"
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
 
            docker run -d \
              --name "preview-${PR_NUM}" \
              --restart unless-stopped \
              -e NODE_ENV=preview \
              -p "${PORT}:3000" \
              "$IMAGE"
 
            echo "url=https://pr-${PR_NUM}.preview.akousa.net" >> "$GITHUB_OUTPUT"
 
      - name: Comment PR with preview URL
        uses: actions/github-script@v7
        with:
          script: |
            const url = `https://pr-${{ github.event.number }}.preview.akousa.net`;
            const body = `### Preview Deployment
 
            | Status | URL |
            |--------|-----|
            | :white_check_mark: Deployed | [${url}](${url}) |
 
            _Last updated: ${new Date().toISOString()}_
            _Commit: \`${{ github.sha }}\`_`;
 
            // 既存のコメントを検索
            const comments = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
 
            const botComment = comments.data.find(c =>
              c.user.type === 'Bot' && c.body.includes('Preview Deployment')
            );
 
            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }

ポート計算(4000 + PR_NUM)は実用的なハックだ。PR #42はポート4042を得る。数百以上のオープンPRがない限り、衝突はない。Nginxのワイルドカード設定がpr-*.preview.akousa.netを正しいポートにルーティングする。

PRクローズ時のクリーンアップ#

クリーンアップされないプレビュー環境はディスクとメモリを食う。クリーンアップジョブを追加しろ:

yaml
name: Cleanup Preview
 
on:
  pull_request:
    types: [closed]
 
jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Remove preview container
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PREVIEW_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            PR_NUM=${{ github.event.number }}
            docker stop "preview-${PR_NUM}" || true
            docker rm "preview-${PR_NUM}" || true
            docker rmi "ghcr.io/${{ github.repository }}:pr-${PR_NUM}" || true
            echo "Preview for PR #${PR_NUM} cleaned up."
 
      - name: Deactivate environment
        uses: actions/github-script@v7
        with:
          script: |
            const deployments = await github.rest.repos.listDeployments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              environment: `preview-${{ github.event.number }}`,
            });
 
            for (const deployment of deployments.data) {
              await github.rest.repos.createDeploymentStatus({
                owner: context.repo.owner,
                repo: context.repo.repo,
                deployment_id: deployment.id,
                state: 'inactive',
              });
            }

必須ステータスチェック#

リポジトリ設定(Settings > Branches > Branch protection rules)で、マージ前にこれらのチェックを必須にしろ:

  • lint — lintエラーなし
  • typecheck — 型エラーなし
  • test — すべてのテストがパス
  • build — プロジェクトが正常にビルドされる

これがなければ、誰かが失敗したチェック付きのPRをマージする。悪意ではなく — 「4つ中2つのチェックがパス」と見て、他の2つはまだ実行中だと思い込む。ロックダウンしろ。

また**「マージ前にブランチを最新にすることを必須にする」**も有効にしろ。これにより、最新のmainにリベースした後にCIの再実行が強制される。2つのPRが個別にはCIをパスするが、組み合わせると競合するケースをキャッチする。

通知#

誰も知らないデプロイは、誰も信頼しないデプロイだ。通知がフィードバックループを閉じる。

Slack Webhook#

yaml
- name: Notify Slack
  if: always()
  uses: slackapi/slack-github-action@v2
  with:
    webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
    webhook-type: incoming-webhook
    payload: |
      {
        "blocks": [
          {
            "type": "header",
            "text": {
              "type": "plain_text",
              "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
            }
          },
          {
            "type": "section",
            "fields": [
              {
                "type": "mrkdwn",
                "text": "*Repository:*\n${{ github.repository }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Branch:*\n${{ github.ref_name }}"
              },
              {
                "type": "mrkdwn",
                "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|${{ github.sha }}>"
              },
              {
                "type": "mrkdwn",
                "text": "*Triggered by:*\n${{ github.actor }}"
              }
            ]
          },
          {
            "type": "actions",
            "elements": [
              {
                "type": "button",
                "text": {
                  "type": "plain_text",
                  "text": "View Run"
                },
                "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
              }
            ]
          }
        ]
      }

if: always()は重要だ。これがないと、デプロイが失敗したときに通知ステップがスキップされる — まさに最も必要なときにだ。

GitHub Deployments API#

よりリッチなデプロイ追跡のために、GitHub Deployments APIを使え。リポジトリUIにデプロイ履歴を表示し、ステータスバッジを有効にする:

yaml
- name: Create GitHub Deployment
  id: deployment
  uses: actions/github-script@v7
  with:
    script: |
      const deployment = await github.rest.repos.createDeployment({
        owner: context.repo.owner,
        repo: context.repo.repo,
        ref: context.sha,
        environment: 'production',
        auto_merge: false,
        required_contexts: [],
        description: `Deploying ${context.sha.substring(0, 7)} to production`,
      });
      return deployment.data.id;
 
- name: Deploy
  run: |
    # ... 実際のデプロイステップ ...
 
- name: Update deployment status
  if: always()
  uses: actions/github-script@v7
  with:
    script: |
      const deploymentId = ${{ steps.deployment.outputs.result }};
      await github.rest.repos.createDeploymentStatus({
        owner: context.repo.owner,
        repo: context.repo.repo,
        deployment_id: deploymentId,
        state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
        environment_url: 'https://akousa.net',
        log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
        description: '${{ job.status }}' === 'success'
          ? 'Deployment succeeded'
          : 'Deployment failed',
      });

これでGitHubのEnvironmentsタブに完全なデプロイ履歴が表示される:誰が何をいつデプロイし、成功したかどうか。

失敗時のみメール#

クリティカルなデプロイでは、失敗時にメールもトリガーする。GitHub Actions組み込みのメール(ノイズが多すぎる)ではなく、ターゲットを絞ったWebhook経由で:

yaml
- name: Alert on failure
  if: failure()
  run: |
    curl -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
      -H "Content-Type: application/json" \
      -d '{
        "subject": "DEPLOY FAILED: ${{ github.repository }}",
        "body": "Commit: ${{ github.sha }}\nActor: ${{ github.actor }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
      }'

これは最後の防衛線だ。Slackは素晴らしいがノイズも多い — みんなチャンネルをミュートする。「DEPLOY FAILED」のメールとランへのリンクは注目を集める。

完全なワークフローファイル#

すべてを単一の本番対応ワークフローにまとめたものがこれだ。このサイトを実際にデプロイしているものに非常に近い。

yaml
name: CI/CD Pipeline
 
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
    inputs:
      skip_tests:
        description: "Skip tests (emergency deploy)"
        required: false
        type: boolean
        default: false
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
 
env:
  NODE_VERSION: "22"
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}
 
jobs:
  # ============================================================
  # CI:lint、型チェック、テストを並列実行
  # ============================================================
 
  lint:
    name: Lint
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run ESLint
        run: pnpm lint
 
  typecheck:
    name: Type Check
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run TypeScript compiler
        run: pnpm tsc --noEmit
 
  test:
    name: Unit Tests
    if: ${{ !inputs.skip_tests }}
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Run tests with coverage
        run: pnpm test -- --coverage
 
      - name: Upload coverage report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage/
          retention-days: 7
 
  # ============================================================
  # ビルド:CIがパスした後のみ
  # ============================================================
 
  build:
    name: Build Application
    needs: [lint, typecheck, test]
    if: always() && !cancelled() && needs.lint.result == 'success' && needs.typecheck.result == 'success' && (needs.test.result == 'success' || needs.test.result == 'skipped')
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Setup pnpm
        uses: pnpm/action-setup@v4
 
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: ${{ env.NODE_VERSION }}
          cache: "pnpm"
 
      - name: Install dependencies
        run: pnpm install --frozen-lockfile
 
      - name: Cache Next.js build
        uses: actions/cache@v4
        with:
          path: .next/cache
          key: nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('src/**/*.ts', 'src/**/*.tsx') }}
          restore-keys: |
            nextjs-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}-
            nextjs-${{ runner.os }}-
 
      - name: Build Next.js application
        run: pnpm build
 
  # ============================================================
  # Docker:イメージのビルドとプッシュ(mainブランチのみ)
  # ============================================================
 
  docker:
    name: Build Docker Image
    needs: [build]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    outputs:
      image_tag: ${{ steps.meta.outputs.tags }}
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Set up QEMU for multi-platform builds
        uses: docker/setup-qemu-action@v3
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
 
      - name: Extract image metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=
            type=raw,value=latest,enable={{is_default_branch}}
 
      - name: Build and push Docker image
        uses: docker/build-push-action@v6
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
 
  # ============================================================
  # デプロイ:VPSにSSHして更新
  # ============================================================
 
  deploy:
    name: Deploy to Production
    needs: [docker]
    if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://akousa.net
 
    steps:
      - name: Create GitHub Deployment
        id: deployment
        uses: actions/github-script@v7
        with:
          script: |
            const deployment = await github.rest.repos.createDeployment({
              owner: context.repo.owner,
              repo: context.repo.repo,
              ref: context.sha,
              environment: 'production',
              auto_merge: false,
              required_contexts: [],
              description: `Deploy ${context.sha.substring(0, 7)}`,
            });
            return deployment.data.id;
 
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          port: ${{ secrets.SSH_PORT }}
          script_stop: true
          command_timeout: 5m
          script: |
            set -euo pipefail
 
            APP_DIR="/var/www/akousa.net"
            IMAGE="ghcr.io/${{ github.repository }}:latest"
            SHA="${{ github.sha }}"
 
            echo "=== Deploy $SHA started at $(date) ==="
 
            # 新しいイメージをプル
            docker pull "$IMAGE"
 
            # 代替ポートで新しいコンテナを起動
            docker run -d \
              --name akousa-app-new \
              --env-file "$APP_DIR/.env.production" \
              -p 3001:3000 \
              "$IMAGE"
 
            # ヘルスチェック
            echo "Running health check..."
            for i in $(seq 1 30); do
              if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
                echo "Health check passed (attempt $i)"
                break
              fi
              if [ "$i" -eq 30 ]; then
                echo "ERROR: Health check failed"
                docker logs akousa-app-new --tail 50
                docker stop akousa-app-new && docker rm akousa-app-new
                exit 1
              fi
              sleep 2
            done
 
            # トラフィック切り替え
            sudo sed -i 's/server 127.0.0.1:3000/server 127.0.0.1:3001/' /etc/nginx/conf.d/upstream.conf
            sudo nginx -t && sudo nginx -s reload
 
            # 処理中のリクエストのためのグレース期間
            sleep 5
 
            # 古いコンテナを停止
            docker stop akousa-app || true
            docker rm akousa-app || true
 
            # リネームしてポートをリセット
            docker rename akousa-app-new akousa-app
            sudo sed -i 's/server 127.0.0.1:3001/server 127.0.0.1:3000/' /etc/nginx/conf.d/upstream.conf
            # 注意:ここではNginxをリロードしない。コンテナ名が変わっただけで
            # ポートは変わっていないため。次のデプロイで正しいポートを使用する。
 
            # デプロイを記録
            echo "$SHA" > "$APP_DIR/.deployed-sha"
            echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $SHA" >> "$APP_DIR/deploy.log"
 
            # 古いイメージをクリーンアップ(7日以上前)
            docker image prune -af --filter "until=168h"
 
            echo "=== Deploy complete at $(date) ==="
 
      - name: Update deployment status
        if: always()
        uses: actions/github-script@v7
        with:
          script: |
            const deploymentId = ${{ steps.deployment.outputs.result }};
            await github.rest.repos.createDeploymentStatus({
              owner: context.repo.owner,
              repo: context.repo.repo,
              deployment_id: deploymentId,
              state: '${{ job.status }}' === 'success' ? 'success' : 'failure',
              environment_url: 'https://akousa.net',
              log_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
            });
 
      - name: Notify Slack
        if: always()
        uses: slackapi/slack-github-action@v2
        with:
          webhook: ${{ secrets.SLACK_DEPLOY_WEBHOOK }}
          webhook-type: incoming-webhook
          payload: |
            {
              "blocks": [
                {
                  "type": "header",
                  "text": {
                    "type": "plain_text",
                    "text": "${{ job.status == 'success' && 'Deploy Successful' || 'Deploy Failed' }}"
                  }
                },
                {
                  "type": "section",
                  "fields": [
                    {
                      "type": "mrkdwn",
                      "text": "*Commit:*\n<${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}|`${{ github.sha }}`>"
                    },
                    {
                      "type": "mrkdwn",
                      "text": "*Actor:*\n${{ github.actor }}"
                    }
                  ]
                },
                {
                  "type": "actions",
                  "elements": [
                    {
                      "type": "button",
                      "text": { "type": "plain_text", "text": "View Run" },
                      "url": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
                    }
                  ]
                }
              ]
            }
 
      - name: Alert on failure
        if: failure()
        run: |
          curl -sf -X POST "${{ secrets.ALERT_WEBHOOK_URL }}" \
            -H "Content-Type: application/json" \
            -d '{
              "subject": "DEPLOY FAILED: ${{ github.repository }}",
              "body": "Commit: ${{ github.sha }}\nRun: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
            }' || true

フローのウォークスルー#

mainにプッシュすると:

  1. lint、型チェック、テストが同時に開始される。3つのランナー、3つの並列ジョブ。いずれかが失敗するとパイプラインは停止する。
  2. ビルドは3つすべてがパスした場合のみ実行される。アプリケーションがコンパイルされ、動作する出力が生成されることを検証する。
  3. Dockerが本番イメージをビルドしghcr.ioにプッシュする。マルチプラットフォーム、レイヤーキャッシュ済み。
  4. デプロイがVPSにSSHし、新しいイメージをプルし、新しいコンテナを起動し、ヘルスチェックし、Nginxを切り替え、クリーンアップする。
  5. 通知は結果に関係なく発火する。Slackにメッセージが送られる。GitHub Deploymentsが更新される。失敗した場合はアラートメールが送信される。

PRをオープンすると:

  1. lint、型チェック、テストが実行される。同じ品質ゲート。
  2. ビルドが実行されプロジェクトがコンパイルされることを検証する。
  3. Dockerとデプロイはスキップされるif条件でmainブランチのみにゲートされている)。

緊急デプロイが必要なとき(テストスキップ):

  1. Actionsタブで**「Run workflow」**をクリック。
  2. skip_tests: trueを選択。
  3. lintと型チェックは引き続き実行される(自分をそこまで信頼していない)。
  4. テストはスキップされ、ビルドが実行され、Dockerがビルドされ、デプロイが発火する。

これが2年間のワークフローだ。サーバー移行、Node.jsメジャーバージョンアップグレード、npmからpnpmへの移行、このサイトへの15ツールの追加を乗り越えてきた。プッシュから本番までのエンドツーエンド合計時間:平均3分40秒。最も遅いステップはマルチプラットフォームDockerビルドの約90秒。それ以外はすべてキャッシュによりほぼ瞬時だ。

2年間のイテレーションからの教訓#

私が犯した失敗を紹介するので、あなたは繰り返さずに済む。

アクションバージョンをピン留めしろ。 uses: actions/checkout@v4は問題ないが、本番環境ではuses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11(完全なSHA)を検討しろ。侵害されたアクションがシークレットを流出させる可能性がある。2025年のtj-actions/changed-files事件がこれは理論上の話ではないことを証明した。

すべてをキャッシュするな。 かつてnode_modulesを直接(pnpmストアだけでなく)キャッシュして、古いネイティブバインディングが原因の不可解なビルド失敗のデバッグに2時間費やした。パッケージマネージャーのストアをキャッシュしろ、インストールされたモジュールではなく。

タイムアウトを設定しろ。 すべてのジョブにtimeout-minutesを設定すべきだ。デフォルトは360分(6時間)。SSH接続が切れてデプロイがハングした場合、6時間後に月間の分数を使い果たしたところで気づきたくないだろう。

yaml
jobs:
  deploy:
    timeout-minutes: 15
    runs-on: ubuntu-latest

concurrencyを賢く使え。 PRではcancel-in-progress: trueが常に正解 — すでにフォースプッシュで上書きされたコミットのCI結果を気にする人はいない。本番デプロイではfalseに設定しろ。素早い追加コミットがロールアウト中のデプロイをキャンセルすることは望ましくない。

ワークフローファイルをテストしろ。 acthttps://github.com/nektos/act)を使ってワークフローをローカルで実行できる。すべてをキャッチするわけではない(シークレットは利用不可で、ランナー環境も異なる)が、プッシュ前にYAML構文エラーと明らかなロジックバグをキャッチできる。

CIコストを監視しろ。 GitHub Actionsの分数はパブリックリポジトリでは無料、プライベートでは安価だが、積み重なる。マルチプラットフォームDockerビルドは2倍の分数(プラットフォームごとに1つ)。マトリックステスト戦略は実行時間を乗算する。課金ページに注意を払え。

最高のCI/CDパイプラインは、あなたが信頼できるものだ。信頼は信頼性、可観測性、段階的な改善から生まれる。シンプルなlint-test-buildパイプラインから始めろ。再現性が必要になったらDockerを追加しろ。自動化が必要になったらSSHデプロイを追加しろ。自信が必要になったら通知を追加しろ。初日にフルパイプラインを構築するな — 抽象化を間違える。

今日必要なパイプラインを構築し、プロジェクトとともに成長させろ。

関連記事