GitHub Actions CI/CD:本当に動くゼロダウンタイムデプロイ
完全なGitHub Actionsセットアップ:並列テストジョブ、Dockerビルドキャッシュ、VPSへのSSHデプロイ、PM2によるゼロダウンタイム、シークレット管理、2年間磨き上げたワークフローパターン。
これまで携わったすべてのプロジェクトは、いずれ同じ転換点に達する:デプロイプロセスが手動でやるには辛すぎるようになる。テストを実行し忘れる。ローカルでビルドしたがバージョンを上げ忘れる。本番にSSHして、前回デプロイした人が古い.envファイルを残していたことに気づく。
GitHub Actionsは2年前にこの問題を解決してくれた。初日から完璧だったわけではない — 最初に書いたワークフローは200行のYAML悪夢で、半分の確率でタイムアウトし、何もキャッシュしなかった。しかしイテレーションを重ねるごとに、このサイトを確実に、ダウンタイムゼロで、4分以内にデプロイする仕組みにたどり着いた。
それがこのワークフローだ。セクションごとに解説する。ドキュメント版ではない。本番環境との接触に耐えるバージョンだ。
ビルディングブロックを理解する#
フルパイプラインに入る前に、GitHub Actionsの仕組みについて明確なメンタルモデルが必要だ。JenkinsやCircleCIを使ったことがあるなら、知っていることのほとんどを忘れろ。概念は大まかにマッピングできるが、実行モデルは足をすくわれるほど異なる。
トリガー:ワークフローが実行されるタイミング#
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
- production4つのトリガー、それぞれ異なる目的を持つ:
pushをmainへ — 本番デプロイのトリガー。コードがマージされたら出荷しろ。pull_request— すべてのPRでCIチェックを実行する。lint、型チェック、テストがここに入る。schedule— リポジトリのcron。週次の依存関係監査スキャンと古いキャッシュのクリーンアップに使っている。workflow_dispatch— GitHub UIに手動の「デプロイ」ボタンと入力パラメータを提供する。コード変更なしでステージングをデプロイしたいとき — 環境変数を更新した、またはベースのDockerイメージを再取得する必要があるときに非常に有用だ。
人がハマりがちなこと:pull_requestはマージコミットに対して実行され、PRブランチのHEADではない。つまり、CIはマージ後のコードの姿をテストしている。これは実際に望ましい動作だが、グリーンだったブランチがリベース後にレッドになると驚く人がいる。
ジョブ、ステップ、ランナー#
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 シークレット#
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パイプラインの構成はこうだ。目標:あらゆるカテゴリのエラーを最速で検出する。
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エラーや型の失敗があるプロジェクトをビルドする意味はない。
**concurrencyとcancel-in-progress: true**は大幅な時間節約になる。短い間隔で2つのコミットをプッシュすると、最初のCI実行がキャンセルされる。これがないと、古い実行が分数予算を消費し、チェックUIを散らかす。
if: always()付きのカバレッジアップロードは、テストが失敗してもカバレッジレポートが得られることを意味する。デバッグに便利だ — どのテストが失敗し、何をカバーしていたかを確認できる。
フェイルファスト vs すべて実行#
デフォルトでは、マトリックス内の1つのジョブが失敗すると、GitHubは他をキャンセルする。CIでは、実はこの動作を望んでいる — lintが失敗したなら、テスト結果は気にしない。まずlintを直せ。
しかしテストマトリックス(例えばNode 20とNode 22でテスト)では、すべての失敗を一度に見たいかもしれない:
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 testfail-fast: falseは両方のマトリックスレッグを完了させる。Node 22が失敗してもNode 20がパスすれば、再実行を待たずにすぐにその情報が得られる。
速度のためのキャッシュ#
CI速度に対して最も大きな改善となるのがキャッシュだ。中規模プロジェクトでのコールドなpnpm installは30〜45秒かかる。ウォームキャッシュがあれば3〜5秒だ。これを4つの並列ジョブに掛け算すると、実行ごとに2分節約できる。
pnpmストアキャッシュ#
- 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を直接使え:
- 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に独自のビルドキャッシュを持つ。実行間でこれをキャッシュすることでインクリメンタルビルドが可能になる — 変更されたページとコンポーネントのみが再コンパイルされる。
- 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レベルキー戦略の意味:
- 完全一致:同じ依存関係かつ同じソースファイル。フルキャッシュヒット、ビルドはほぼ瞬時。
- 部分一致(依存関係):依存関係は同じだがソースが変更。変更されたファイルのみ再コンパイル。
- 部分一致(OSのみ):依存関係が変更。可能なものを再利用。
私のプロジェクトでの実数値:コールドビルドは約55秒、キャッシュ済みビルドは約15秒。73%の削減だ。
Dockerレイヤーキャッシュ#
Dockerビルドはキャッシュが本当に効果を発揮する場所だ。Next.jsの完全なDockerビルド — OS依存関係のインストール、ソースのコピー、pnpm installの実行、next buildの実行 — はコールドで3〜4分かかる。レイヤーキャッシュがあれば30〜60秒だ。
- 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=maxtype=ghaはGitHub Actionsの組み込みキャッシュバックエンドを使用する。mode=maxは最終レイヤーだけでなくすべてのレイヤーをキャッシュする。これはマルチステージビルドで中間レイヤー(pnpm installのような)が最もリビルドにコストがかかる場合に重要だ。
Turborepoリモートキャッシュ#
Turborepoを使ったモノレポにいるなら、リモートキャッシュは変革的だ。最初のビルドがタスク出力をキャッシュにアップロードする。以降のビルドは再計算の代わりにダウンロードする。
- 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がこれだ:
# ステージ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だ。本番アーティファクトのみがランナーステージに到達する。
ビルド&プッシュワークフロー#
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つ:
- 認証が無料。
GITHUB_TOKENはすべてのワークフローで自動的に利用可能 — Docker Hubの認証情報を保存する必要がない。 - 近接性。 イメージはCIが実行されるのと同じインフラからプルされる。CI中のプルが速い。
- 可視性。 イメージはGitHub UIでリポジトリにリンクされる。Packagesタブで確認できる。
マルチプラットフォームビルド#
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が自動的に正しいアーキテクチャをプルする。
タグ戦略#
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アクション#
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 ==="デプロイスクリプトの代替案#
単純なプル&リスタート以上のことには、ワークフローにインラインで書くのではなく、サーバー上のスクリプトにロジックを移す:
#!/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コマンドになる:
script: |
cd /var/www/akousa.net && ./deploy.shこれが良い理由:(1) デプロイロジックがサーバー上でバージョン管理される、(2) デバッグのためにSSH経由で手動実行できる、(3) YAML内のYAML内のbashをエスケープする必要がない。
ゼロダウンタイム戦略#
「ゼロダウンタイム」はマーケティング用語のように聞こえるが、正確な意味がある:デプロイ中にいかなるリクエストもコネクション拒否や502を受けない。最もシンプルなものから最も堅牢なものまで、3つの実際のアプローチがある。
戦略1:PM2クラスターモードリロード#
Node.jsを直接実行している場合(Docker内ではなく)、PM2のクラスターモードが最も簡単なゼロダウンタイムの道を提供する。
# ecosystem.config.jsにはすでに以下がある:
# instances: 2
# exec_mode: "cluster"
pm2 reload akousa --update-envpm2 reload(restartではなく)はローリングリスタートを行う。新しいワーカーを起動し、準備完了を待ち、古いワーカーを一つずつ終了させる。ゼロワーカーがトラフィックを処理している瞬間は一度もない。
--update-envフラグはエコシステム設定から環境変数をリロードする。これがないと、.envを変更したデプロイの後でも古い環境変数が残る。
ワークフローでは:
- 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設定:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
server 127.0.0.1:3000;
}# /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;
}
}切り替えスクリプト:
#!/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がブルー/グリーンの切り替えを管理できる:
# 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つのコンテナ、常にキャパシティを維持。
デプロイ:
docker compose pull
docker compose up -d --remove-orphansDocker Composeはヘルスチェックを監視し、パスするまで新しいコンテナにトラフィックをルーティングしない。ヘルスチェックが失敗すると、failure_action: rollbackが自動的に前のバージョンに戻す。これは単一VPSで得られるKubernetes風ローリングデプロイに最も近いものだ。
シークレット管理#
シークレット管理は「ほぼ正しく」やるのは簡単だが、残りのエッジケースで壊滅的に間違えるものの一つだ。
GitHubシークレット:基本#
# 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がマスクしないかもしれない。シークレットは適度に複雑にしておけ。
環境スコープのシークレット#
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がアイデンティティプロバイダーとして機能し、クラウドプロバイダーがその場で短期間の認証情報を発行することを信頼する:
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アイデンティティプロバイダーとロール信頼ポリシーが必要だ:
{
"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デプロイには、専用のキーペアを生成する:
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オープン時にプレビューをデプロイ#
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クローズ時のクリーンアップ#
クリーンアップされないプレビュー環境はディスクとメモリを食う。クリーンアップジョブを追加しろ:
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#
- 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にデプロイ履歴を表示し、ステータスバッジを有効にする:
- 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経由で:
- 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」のメールとランへのリンクは注目を集める。
完全なワークフローファイル#
すべてを単一の本番対応ワークフローにまとめたものがこれだ。このサイトを実際にデプロイしているものに非常に近い。
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にプッシュすると:
- lint、型チェック、テストが同時に開始される。3つのランナー、3つの並列ジョブ。いずれかが失敗するとパイプラインは停止する。
- ビルドは3つすべてがパスした場合のみ実行される。アプリケーションがコンパイルされ、動作する出力が生成されることを検証する。
- Dockerが本番イメージをビルドしghcr.ioにプッシュする。マルチプラットフォーム、レイヤーキャッシュ済み。
- デプロイがVPSにSSHし、新しいイメージをプルし、新しいコンテナを起動し、ヘルスチェックし、Nginxを切り替え、クリーンアップする。
- 通知は結果に関係なく発火する。Slackにメッセージが送られる。GitHub Deploymentsが更新される。失敗した場合はアラートメールが送信される。
PRをオープンすると:
- lint、型チェック、テストが実行される。同じ品質ゲート。
- ビルドが実行されプロジェクトがコンパイルされることを検証する。
- Dockerとデプロイはスキップされる(
if条件でmainブランチのみにゲートされている)。
緊急デプロイが必要なとき(テストスキップ):
- Actionsタブで**「Run workflow」**をクリック。
skip_tests: trueを選択。- lintと型チェックは引き続き実行される(自分をそこまで信頼していない)。
- テストはスキップされ、ビルドが実行され、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時間後に月間の分数を使い果たしたところで気づきたくないだろう。
jobs:
deploy:
timeout-minutes: 15
runs-on: ubuntu-latestconcurrencyを賢く使え。 PRではcancel-in-progress: trueが常に正解 — すでにフォースプッシュで上書きされたコミットのCI結果を気にする人はいない。本番デプロイではfalseに設定しろ。素早い追加コミットがロールアウト中のデプロイをキャンセルすることは望ましくない。
ワークフローファイルをテストしろ。 act(https://github.com/nektos/act)を使ってワークフローをローカルで実行できる。すべてをキャッチするわけではない(シークレットは利用不可で、ランナー環境も異なる)が、プッシュ前にYAML構文エラーと明らかなロジックバグをキャッチできる。
CIコストを監視しろ。 GitHub Actionsの分数はパブリックリポジトリでは無料、プライベートでは安価だが、積み重なる。マルチプラットフォームDockerビルドは2倍の分数(プラットフォームごとに1つ)。マトリックステスト戦略は実行時間を乗算する。課金ページに注意を払え。
最高のCI/CDパイプラインは、あなたが信頼できるものだ。信頼は信頼性、可観測性、段階的な改善から生まれる。シンプルなlint-test-buildパイプラインから始めろ。再現性が必要になったらDockerを追加しろ。自動化が必要になったらSSHデプロイを追加しろ。自信が必要になったら通知を追加しろ。初日にフルパイプラインを構築するな — 抽象化を間違える。
今日必要なパイプラインを構築し、プロジェクトとともに成長させろ。