404 Not Found

404 Not Found


nginx

プロジェクトデプロイ—ShopMetrics SaaSプラットフォームのローンチ

ローンチは終わりではなく, 始まりに過ぎません。デプロイ後も監視, アラート設定, バックアップを行い, 24時間365日安定稼働を確保する必要があります。

1. 学ぶこと


2. ローンチ当日の実話

(1) 痛み:デプロイ後にデータベースがマイグレーションされていないことに気づいた

Bobが率いるShopMetricsチームが初デプロイを完了しました—コードはプッシュされ, Dockerは起動し, Nginxは設定されました。しかし, Aliceがダッシュボードを開くとすぐに500エラーが発生しました。Charlieがログを確認したところ, 新しく追加したanalytics_reportsテーブルがデータベースに存在しないことが判明しました—マイグレーションの実行を忘れていたのです。さらに悪いことに, デプロイ前にデータベースのバックアップも取っていなかったため, 本番環境でマイグレーションを実行するしかありませんでした。

(2) デプロイプロセスの体系化による解決策

デプロイは単に「コードを本番にプッシュすること」ではありません—15ステップのチェックリストであり, 各ステップは検証を通過してから次に進みます。CI/CDがこのうち10ステップを自動化し, 残りの5ステップ (ヘルスチェックとビジネス検証)のみを手動で確認します。

TEXT
チェックリスト:15ステップ, CI/CDが10を自動化, 人間が5を検証
結果:0ステップ見落とし, 0デプロイ後インシデント

(3) 成果

Bobの2回目のデプロイでは, 体系的なプロセスが使用され—完全自動化デプロイとヘルスチェック—3分で完了し, エラーはゼロでした。


3. Docker Compose本番オーケストレーション

(1) 本番アーキテクチャ概要

100%
flowchart TB
    subgraph Internet["インターネット"]
        USER[ユーザー]
        STRIPE[Stripe Webhooks]
    end

    subgraph LB["ロードバランサー / CDN"]
        NGINX[Nginxリバースプロキシ]
    end

    subgraph App["アプリケーション層"]
        APP1[Appコンテナ 1]
        APP2[Appコンテナ 2]
    end

    subgraph Worker["バックグラウンド層"]
        Q1[キューワーカー - High]
        Q2[キューワーカー - Default]
        SCHED[スケジューラー]
    end

    subgraph Data["データ層"]
        MASTER[(MySQL Primary)]
        REPLICA[(MySQL Replica)]
        REDIS[(Redis Cluster)]
        S3[(S3ストレージ)]
    end

    subgraph Monitor["オブザーバビリティ"]
        SENTRY[Sentry]
        TELESCOPE[Telescope]
        LOGS[ログ集約]
    end

    USER --> NGINX
    STRIPE --> NGINX
    NGINX --> APP1
    NGINX --> APP2
    APP1 --> MASTER
    APP1 --> REDIS
    APP2 --> REDIS
    APP1 --> S3
    Q1 --> MASTER
    Q1 --> REDIS
    Q1 --> S3
    SCHED --> Q1
    APP1 --> REPLICA
    APP2 --> REPLICA
    APP1 --> SENTRY
    APP1 --> TELESCOPE

(2) Docker Compose本番設定

YAML
# docker-compose.prod.yml
services:
  app:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 512M
          cpus: '1.0'
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:8080/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    networks:
      - internal

  queue-high:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    command: php artisan queue:work --queue=high --sleep=3 --tries=3 --max-time=3600
    deploy:
      replicas: 2
      resources:
        limits:
          memory: 256M
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage
    networks:
      - internal

  queue-default:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    command: php artisan queue:work --queue=default --sleep=3 --tries=3 --max-time=3600
    deploy:
      replicas: 3
      resources:
        limits:
          memory: 256M
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    volumes:
      - storage:/var/www/html/storage
    networks:
      - internal

  scheduler:
    image: ghcr.io/bob/shopmetrics:latest
    restart: unless-stopped
    command: php artisan schedule:work --verbose
    env_file:
      - .env.production
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - internal

  mysql:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_DATABASE: ${DB_DATABASE}
      MYSQL_USER: ${DB_USERNAME}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p${DB_ROOT_PASSWORD}"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: >
      redis-server
      --requirepass ${REDIS_PASSWORD}
      --maxmemory 512mb
      --maxmemory-policy allkeys-lru
      --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
    networks:
      - internal

  nginx:
    image: nginx:alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./docker/nginx/production.conf:/etc/nginx/conf.d/default.conf:ro
      - ./docker/nginx/ssl:/etc/nginx/ssl:ro
    depends_on:
      app:
        condition: service_healthy
    networks:
      - internal

volumes:
  mysql_data:
  redis_data:
  storage:

networks:
  internal:
    driver: bridge

(1) ▶ サンプル:ShopMetrics .env.production設定

BASH
# .env.production
APP_NAME=ShopMetrics
APP_ENV=production
APP_KEY=base64:xxx
APP_DEBUG=false
APP_URL=https://shopmetrics.io
APP_VERSION=1.0.0

DB_CONNECTION=mysql
DB_HOST=mysql
DB_PORT=3306
DB_DATABASE=shopmetrics
DB_USERNAME=shopmetrics
DB_PASSWORD=${DB_PASSWORD}
DB_ROOT_PASSWORD=${DB_ROOT_PASSWORD}

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
REDIS_HOST=redis
REDIS_PASSWORD=${REDIS_PASSWORD}

FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=shopmetrics-reports

STRIPE_KEY=pk_live_xxx
STRIPE_SECRET=sk_live_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx

SENTRY_LARAVEL_DSN=https://xxx@sentry.io/xxx
SENTRY_TRACES_SAMPLE_RATE=0.2

LOG_CHANNEL=daily
LOG_LEVEL=warning

出力:

TEXT
# コマンド実行成功

4. CI/CDパイプライン

(1) 完全なGitHub Actionsワークフロー

YAML
# .github/workflows/deploy-production.yml
name: Deploy to Production

on:
  push:
    branches: [main]
  workflow_dispatch:  # 手動トリガーオプション

concurrency: production  # 同時デプロイは1つのみ

env:
  REGISTRY: ghcr.io
  IMAGE: ${{ github.repository }}

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_DATABASE: shopmetrics_test
          MYSQL_USER: test
          MYSQL_PASSWORD: test
          MYSQL_ROOT_PASSWORD: test
        ports: ['3306:3306']
      redis:
        image: redis:7-alpine
        ports: ['6379:6379']
    steps:
      - uses: actions/checkout@v4
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
          coverage: xdebug
      - name: Install & Test
        run: |
          composer install --no-progress
          cp .env.example .env
          php artisan key:generate
          php artisan test --parallel --coverage-text=coverage.txt
      - name: Upload coverage
        uses: actions/upload-artifact@v4
        with:
          name: coverage
          path: coverage.txt

  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: composer install --no-dev
      - name: Security audit
        run: composer audit

  build:
    needs: [test, security]
    runs-on: ubuntu-latest
    permissions:
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE }}:latest
            ${{ env.REGISTRY }}/${{ env.IMAGE }}:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    needs: build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.PROD_HOST }}
          username: deploy
          key: ${{ secrets.PROD_SSH_KEY }}
          script: |
            cd /opt/shopmetrics
            export IMAGE_TAG=${{ github.sha }}

            # 最新イメージをプル
            docker compose -f docker-compose.prod.yml pull app queue-high queue-default scheduler

            # ローリングアップデート:新しいコンテナを開始
            docker compose -f docker-compose.prod.yml up -d --no-deps --scale app=3 app
            sleep 10

            # 新しいコンテナのヘルスチェック
            for i in $(seq 1 5); do
              if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
                echo "✓ Health check passed"
                break
              fi
              echo "Waiting for health check... ($i/5)"
              sleep 5
            done

            # マイグレーションの実行
            docker compose -f docker-compose.prod.yml exec -T app php artisan migrate --force

            # ワーカーの再起動 (グレースフル)
            docker compose -f docker-compose.prod.yml exec -T app php artisan queue:restart

            # 古いコンテナをスケールダウン
            docker compose -f docker-compose.prod.yml up -d --scale app=2

            # キャッシュのクリアと再構築
            docker compose -f docker-compose.prod.yml exec -T app php artisan optimize:clear
            docker compose -f docker-compose.prod.yml exec -T app php artisan optimize

            echo "✓ Deploy complete: $IMAGE_TAG"

  verify:
    needs: deploy
    runs-on: ubuntu-latest
    steps:
      - name: Smoke test production
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://shopmetrics.io/health)
          if [ "$STATUS" != "200" ]; then
            echo "✗ Health check failed: HTTP $STATUS"
            exit 1
          fi
          echo "✓ Production health check passed"

      - name: Notify Slack on success
        if: success()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text":"✅ ShopMetrics deployed successfully: ${{ github.sha }}"}

      - name: Notify Slack on failure
        if: failure()
        uses: slackapi/slack-github-action@v1
        with:
          payload: |
            {"text":"🚨 ShopMetrics deployment FAILED: ${{ github.sha }}"}

(1) ▶ サンプル:ShopMetricsデータベースマイグレーション戦略

PHP
// 本番環境で安全なマイグレーションパターン

// ✅ 安全:カラムの追加 (非破壊的)
Schema::table('orders', function (Blueprint $table) {
    $table->string('shipping_method')->nullable()->after('status');
    $table->index('shipping_method');
});

// ✅ 安全:インデックスの追加 (PostgreSQLでは同時実行可能)
// MySQL:ALGORITHM=INPLACEで非ブロッキングインデックス作成
DB::statement('ALTER TABLE orders ADD INDEX idx_orders_shipping (shipping_method) ALGORITHM=INPLACE');

// ⚠️ 注意:カラムの削除 (2ステップマイグレーション)
// ステップ1:非推奨としてマーク (先にデプロイ, コードの参照を削除)
Schema::table('orders', function (Blueprint $table) {
    // 古いカラムを保持, 新しいカラムを追加
    $table->renameColumn('shipping_method', 'deprecated_shipping_method');
});

// ステップ2:コードが参照しなくなった後に削除 (次回デプロイ)
Schema::table('orders', function (Blueprint $table) {
    $table->dropColumn('deprecated_shipping_method');
});

// ❌ 危険:カラム型の変更 (テーブルがロックされる)
// 2ステップで実施:新しいカラムを追加 → データ移行 → 古いカラムを削除
Schema::table('orders', function (Blueprint $table) {
    $table->unsignedBigInteger('total_cents_new')->nullable()->after('total_cents');
});

// 別マイグレーションでのデータ移行
DB::statement('UPDATE orders SET total_cents_new = total_cents WHERE total_cents_new IS NULL');

Schema::table('orders', function (Blueprint $table) {
    $table->dropColumn('total_cents');
    $table->renameColumn('total_cents_new', 'total_cents');
});

出力:

TEXT
// 実行成功

5. ヘルスチェックと監視

(1) ヘルスチェックエンドポイント

PHP
// routes/web.php
Route::get('/health', function () {
    $start = microtime(true);
    $checks = [];

    // データベースチェック
    try {
        DB::connection()->getPdo();
        $checks['database'] = 'ok';
    } catch (\Throwable $e) {
        $checks['database'] = 'fail: ' . $e->getMessage();
    }

    // Redisチェック
    try {
        Cache::put('health_check', 'ok', 10);
        $checks['redis'] = Cache::get('health_check') === 'ok' ? 'ok' : 'fail';
    } catch (\Throwable $e) {
        $checks['redis'] = 'fail: ' . $e->getMessage();
    }

    // S3チェック
    try {
        Storage::disk('s3')->put('health_check.txt', 'ok');
        $checks['storage'] = Storage::disk('s3')->get('health_check.txt') === 'ok' ? 'ok' : 'fail';
    } catch (\Throwable $e) {
        $checks['storage'] = 'fail: ' . $e->getMessage();
    }

    // キューチェック
    try {
        $size = Queue::size('default');
        $checks['queue'] = $size < 10000 ? "ok (size: {$size})" : "warn (size: {$size})";
    } catch (\Throwable $e) {
        $checks['queue'] = 'fail: ' . $e->getMessage();
    }

    $latency = round((microtime(true) - $start) * 1000, 2);
    $allOk = collect($checks)->every(fn ($v) => str_starts_with($v, 'ok'));

    return response()->json([
        'status' => $allOk ? 'healthy' : 'unhealthy',
        'checks' => $checks,
        'latency_ms' => $latency,
        'version' => config('app.version'),
        'timestamp' => now()->toIso8601String(),
    ], $allOk ? 200 : 503);
});

(2) 監視ツールの比較

ツール タイプ 無料枠 用途
Sentry エラー追跡 月5Kイベント 例外キャプチャ + パフォーマンス追跡
Laravel Telescope デバッグパネル 無料 開発/デバッグ (本番ではアクセス制限)
Prometheus + Grafana メトリクス監視 無料自己ホスト システムメトリクス + カスタムダッシュボード
UptimeRobot 可用性監視 50モニター HTTPヘルスチェックアラート
Algolia/Meilisearch ログ検索 限定無料 ログ集約と検索

(1) ▶ サンプル:ShopMetrics Sentry設定とカスタムコンテキスト

PHP
// config/sentry.php
return [
    'dsn' => env('SENTRY_LARAVEL_DSN'),
    'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE', 0.2),
    'send_default_pii' => false,
    'environment' => app()->environment(),
    'release' => config('app.version'),
];

// app/Exceptions/Handler.php
class Handler extends ExceptionHandler
{
    public function report(Throwable $e): void
    {
        if (app()->bound('sentry') && $this->shouldReport($e)) {
            \Sentry\configureScope(function (\Sentry\State\Scope $scope) {
                if ($user = auth()->user()) {
                    $scope->setUser([
                        'id' => $user->id,
                        'email' => $user->email,
                        'tenant_id' => $user->tenant_id,
                    ]);
                }

                $scope->setTag('app_version', config('app.version'));
                $scope->setExtra('request_url', request()->url());
                $scope->setExtra('request_method', request()->method());
            });
        }

        parent::report($e);
    }
}

// app/Providers/AppServiceProvider.php
class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        if (app()->environment('local')) {
            Telescope::listenStorageNotifications();
        }

        Telescope::filter(function (IncomingEntry $entry) {
            if (app()->environment('local')) return true;
            return $entry->isReportableException() ||
                   $entry->isFailedJob() ||
                   $entry->isScheduledTask() ||
                   $entry->hasMonitoredTag();
        });
    }
}

出力:

TEXT
// 実行成功

6. データベースバックアッププラン

(1) バックアップ戦略

バックアップタイプ 頻度 保持期間 保存先 復旧時間
フルバックアップ 毎日午前3時 30日 S3 (オフサイト) 30〜60分
差分バックアップ 毎時 7日 S3 (オフサイト) 60〜120分
Binlog リアルタイム 7日 ローカル + S3 任意の時点へのPITR

(2) 自動バックアップスクリプト

BASH
#!/bin/bash
# backup-mysql.sh — MySQLの毎日S3バックアップ
set -e

DB_NAME="shopmetrics"
S3_BUCKET="s3://shopmetrics-backups/mysql"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="/tmp/shopmetrics_${DATE}.sql.gz"

echo "→ バックアップ開始:${DATE}"

# 一貫性スナップショットでダンプ (ロックなし)
mysqldump \
  --host=mysql \
  --user=${DB_USERNAME} \
  --password=${DB_PASSWORD} \
  --single-transaction \
  --routines \
  --triggers \
  --quick \
  ${DB_NAME} | gzip > ${BACKUP_FILE}

# S3にアップロード
aws s3 cp ${BACKUP_FILE} ${S3_BUCKET}/daily/shopmetrics_${DATE}.sql.gz

# ローカルファイルをクリーンアップ
rm -f ${BACKUP_FILE}

# 30日以上古いバックアップをクリーンアップ
aws s3 rm ${S3_BUCKET}/daily/ --recursive --exclude "*" --include "shopmetrics_*" \
  --query "Contents[?LastModified<='$(date -d '-30 days' +%Y-%m-%d)'].Key"

echo "✓ バックアップ完了:shopmetrics_${DATE}.sql.gz"

# バックアップの整合性を検証
LATEST=$(aws s3 ls ${S3_BUCKET}/daily/ | tail -1 | awk '{print $4}')
aws s3 cp ${S3_BUCKET}/daily/${LATEST} /tmp/verify.sql.gz
if gzip -t /tmp/verify.sql.gz; then
    echo "✓ バックアップ整合性検証成功"
else
    echo "✗ バックアップ整合性検証失敗"
    exit 1
fi
rm -f /tmp/verify.sql.gz

(1) ▶ サンプル:ShopMetrics自動バックアップスケジュール

PHP
// routes/console.php
use Illuminate\Support\Facades\Schedule;

// 毎日のデータベースバックアップ
Schedule::command('shopmetrics:backup-database')
    ->dailyAt('03:00')
    ->onOneServer()
    ->withoutOverlapping()
    ->emailOutputOnFailure('ops@shopmetrics.io');

// 毎時のS3ストレージ同期 (レポートファイル用)
Schedule::command('shopmetrics:sync-storage-backup')
    ->hourly()
    ->onOneServer();

// 毎週のバックアップ整合性テスト
Schedule::command('shopmetrics:verify-backup')
    ->weeklyOn(Schedule::SUNDAY, '04:00')
    ->onOneServer()
    ->emailOutputOnFailure('ops@shopmetrics.io');

// app/Console/Commands/BackupDatabase.php
class BackupDatabase extends Command
{
    protected $signature = 'shopmetrics:backup-database';
    protected $description = 'Backup MySQL database to S3';

    public function handle(): int
    {
        $filename = 'shopmetrics_' . now()->format('Ymd_His') . '.sql.gz';
        $tempPath = storage_path('app/backups/' . $filename);

        $this->info('Starting database backup...');

        $command = sprintf(
            'mysqldump --host=%s --user=%s --password=%s --single-transaction %s | gzip > %s',
            config('database.connections.mysql.host'),
            config('database.connections.mysql.username'),
            config('database.connections.mysql.password'),
            config('database.connections.mysql.database'),
            $tempPath
        );

        $exitCode = Process::run($command)->exitCode();

        if ($exitCode !== 0) {
            $this->error('Backup failed!');
            return self::FAILURE;
        }

        $s3Path = 'mysql/daily/' . $filename;
        Storage::disk('s3-backup')->put($s3Path, file_get_contents($tempPath));
        unlink($tempPath);

        $this->info("Backup uploaded: {$s3Path}");
        return self::SUCCESS;
    }
}

出力:

TEXT
// 実行成功

7. デプロイチェックリストとロールバックプラン

(1) ローンチチェックリスト

# 確認項目 コマンド/アクション 自動化 合格基準
1 コードがテスト合格 php artisan test CI 0失敗
2 セキュリティ監査合格 composer audit CI 0脆弱性
3 Dockerイメージビルド docker build CI ビルド成功
4 最新イメージをプル docker compose pull CD イメージ存在
5 新しいコンテナを開始 docker compose up -d CD コンテナ実行中
6 ヘルスチェック合格 curl /health CD HTTP 200
7 データベースマイグレーション migrate --force CD 0エラー
8 キャッシュウォームアップ config:cache + route:cache CD コマンド成功
9 キューワーカー再起動 queue:restart CD ワーカー再起動
10 古いコンテナをクリーンアップ docker image prune CD ディスク容量正常
11 ビジネススモークテスト 主要フローの手動テスト 手動 機能正常
12 監視確認 Sentry/Telescopeに異常なし 手動 5分間0エラー
13 ログ確認 ERRORなし 手動 新しい例外なし
14 パフォーマンス検証 ダッシュボード読み込み < 500ms 手動 p95 < 500ms
15 チーム通知 Slack/メール通知 自動 送信済み

(2) ロールバックプラン

シナリオ 検出方法 ロールバック手順 復旧時間
ヘルスチェック失敗 CI/CD自動検出 docker compose pull <previous-tag> + up < 1分
マイグレーション失敗 CI/CDログ migrate:rollback + コードのロールバック < 5分
ビジネスロジックエラー ユーザーフィードバック/監視 Blue-Greenで前のスロットにロールバック < 30秒
データベース破損 監視アラート S3バックアップ復元 + Binlogリプレイ 30〜120分

(1) ▶ サンプル:ShopMetricsロールバックスクリプト

BASH
#!/bin/bash
# rollback.sh — 緊急ロールバックスクリプト
set -e

COMPOSE_FILE="docker-compose.prod.yml"
BACKUP_DIR="/opt/shopmetrics/backups"

echo "=== 緊急ロールバック ==="

# 前回のイメージタグを取得
PREVIOUS_TAG=$(cat ${BACKUP_DIR}/last_successful_deploy_tag.txt)
CURRENT_TAG=$(cat ${BACKUP_DIR}/current_deploy_tag.txt)

echo "現在:${CURRENT_TAG}"
echo "ロールバック先:${PREVIOUS_TAG}"

# ステップ1:イメージタグを前バージョンに設定
export IMAGE_TAG=${PREVIOUS_TAG}

# ステップ2:前バージョンのイメージをプル
docker compose -f ${COMPOSE_FILE} pull app queue-high queue-default scheduler

# ステップ3:前バージョンのコンテナを開始
docker compose -f ${COMPOSE_FILE} up -d --no-deps --scale app=2 app
docker compose -f ${COMPOSE_FILE} up -d --no-deps queue-high queue-default scheduler

# ステップ4:待機と検証
sleep 10
if curl -sf http://localhost:8080/health > /dev/null; then
    echo "✓ ロールバック後のヘルスチェック合格"
else
    echo "✗ ロールバック後のヘルスチェック失敗 — 手動介入が必要"
    exit 1
fi

# ステップ5:DBマイグレーションのロールバックが必要か確認
echo "⚠ データベースマイグレーションのロールバックが必要か確認:"
echo "  実行:docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate:status"
echo "  必要な場合:docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate:rollback --step=N"

# ステップ6:現在のタグを更新
echo "${PREVIOUS_TAG}" > ${BACKUP_DIR}/current_deploy_tag.txt

# ステップ7:チームに通知
echo "⚠ ロールバック完了 — チームに即座に通知してください"
echo "  ${CURRENT_TAG}から${PREVIOUS_TAG}にロールバックしました"

出力:

TEXT
CONTAINER ID   IMAGE     STATUS    
abc123         latest    Up 2 hours

8. 総合サンプル:ShopMetricsローンチの全プロセス

BASH
#!/bin/bash
# ============================================
# 総合:ShopMetrics完全デプロイスクリプト
# 対象:バックアップ → デプロイ → マイグレーション → 検証 → ロールバックプラン
# ============================================

set -euo pipefail

DEPLOY_TAG=${1:-$(git rev-parse --short HEAD)}
COMPOSE_FILE="docker-compose.prod.yml"
BACKUP_DIR="/opt/shopmetrics/backups"
SLACK_WEBHOOK="${SLACK_WEBHOOK_URL}"

log() { echo "[$(date +%H:%M:%S)] $1"; }
notify() { curl -s -X POST "${SLACK_WEBHOOK}" -H 'Content-type: application/json' --data "{\"text\":\"$1\"}" > /dev/null 2>&1 || true; }

# デプロイ前:バックアップ
log "→ Step 1/10: データベースバックアップ"
php artisan shopmetrics:backup-database
log "✓ バックアップ完了"

# デプロイ前:現在の状態を保存
log "→ Step 2/10: 現在の状態を保存"
cat ${BACKUP_DIR}/current_deploy_tag.txt > ${BACKUP_DIR}/last_successful_deploy_tag.txt 2>/dev/null || true
echo "${DEPLOY_TAG}" > ${BACKUP_DIR}/current_deploy_tag.txt
log "✓ 状態保存完了 (ロールバック先:$(cat ${BACKUP_DIR}/last_successful_deploy_tag.txt 2>/dev/null || echo 'N/A'))"

# デプロイ:プルと開始
log "→ Step 3/10: イメージ${DEPLOY_TAG}をプル"
export IMAGE_TAG=${DEPLOY_TAG}
docker compose -f ${COMPOSE_FILE} pull app queue-high queue-default scheduler

log "→ Step 4/10: 新しいコンテナを開始"
docker compose -f ${COMPOSE_FILE} up -d --no-deps --remove-orphans app queue-high queue-default scheduler

# リトライ付きヘルスチェック
log "→ Step 5/10: ヘルスチェック"
for i in $(seq 1 6); do
    if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
        log "✓ ヘルスチェック合格"
        break
    fi
    if [ $i -eq 6 ]; then
        log "✗ 30秒後もヘルスチェック失敗 — ロールバック中"
        bash /opt/shopmetrics/rollback.sh
        notify "🚨 ShopMetricsデプロイ失敗 (ヘルスチェック)— ロールバック済み"
        exit 1
    fi
    log "  待機中... ($i/6)"
    sleep 5
done

# マイグレーション
log "→ Step 6/10: マイグレーション実行"
docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate --force
if [ $? -ne 0 ]; then
    log "✗ マイグレーション失敗 — ロールバック中"
    bash /opt/shopmetrics/rollback.sh
    notify "🚨 ShopMetricsデプロイ失敗 (マイグレーション)— ロールバック済み"
    exit 1
fi
log "✓ マイグレーション完了"

# 最適化
log "→ Step 7/10: キャッシュ構築"
docker compose -f ${COMPOSE_FILE} exec -T app php artisan optimize:clear
docker compose -f ${COMPOSE_FILE} exec -T app php artisan optimize
docker compose -f ${COMPOSE_FILE} exec -T app php artisan queue:restart
log "✓ キャッシュ再構築完了"

# デプロイ後検証
log "→ Step 8/10: スモークテスト"
SMOKE_PASS=true
for endpoint in "/health" "/api/v1/plans"; do
    STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://shopmetrics.io${endpoint}")
    if [ "$STATUS" -ge 400 ]; then
        log "✗ スモークテスト失敗:${endpoint} → HTTP ${STATUS}"
        SMOKE_PASS=false
    fi
done
if [ "$SMOKE_PASS" = false ]; then
    log "✗ スモークテスト失敗 — ロールバック中"
    bash /opt/shopmetrics/rollback.sh
    notify "🚨 ShopMetricsデプロイ失敗 (スモークテスト)— ロールバック済み"
    exit 1
fi
log "✓ スモークテスト合格"

# クリーンアップ
log "→ Step 9/10: クリーンアップ"
docker image prune -f > /dev/null
log "✓ 古いイメージをクリーンアップ"

# 成功
log "→ Step 10/10: 最終検証"
sleep 5
ERRORS=$(docker compose -f ${COMPOSE_FILE} logs --since 2m app 2>&1 | grep -c "ERROR" || true)
if [ "${ERRORS}" -gt 5 ]; then
    log "⚠ 高エラー率検出:2分間に${ERRORS}件のエラー"
    notify "⚠️ ShopMetricsデプロイ済みですが高エラー率:${ERRORS}件のエラー"
else
    log "✓ デプロイ成功:${DEPLOY_TAG}"
    notify "✅ ShopMetricsデプロイ成功:${DEPLOY_TAG}"
fi

log "=== デプロイ完了 ==="

❓ よくある質問

Q 本番環境ではDocker ComposeとKubernetesのどちらを使うべきですか?
A サーバー5台まではDocker Composeで十分で, 運用がシンプルです。サーバーが5台を超える場合や自動スケーリングが必要な場合はKubernetesへの移行を検討してください。ShopMetricsはDocker Composeで開始し, ユーザー数が10,000を超えたらKubernetesを検討します。
Q 本番環境でマイグレーションが失敗した場合はどうすればよいですか?
A CI/CDでは, マイグレーションの失敗は自動的にデプロイをブロックし, トラフィックは切り替わりません。前のコードバージョンにロールバックするだけです (マイグレーションは「先に追加, 後に削除」の原則に従うため, データベーススキーマは旧バージョンと互換性があります)。
Q Sentryの無料枠で十分ですか?
A 月5,000イベントは初期段階では十分です。制限を超えたら, 従量課金プランに切り替えるか, Sentryを自己ホスト (オープンソースで無料, ただしサーバーの保守が必要)できます。本番環境では常にSentryを有効にしておくことをお勧めします。
Q データベースのバックアップと復元にはどのくらいかかりますか?
A 10GBのデータベースのフル復元は約30分です (S3からのダウンロードとインポートを含む)。Binlogを有効にすればポイントインタイムリカバリ (PITR)が可能で, 復元時間は約60〜120分です。少なくとも月1回は復元訓練を実施してください。
Q スケールアップのタイミングはどうやって知りますか?
A 次の指標を監視してください:CPUが5分連続で70%超, キューのバックログが1,000超, p95レイテンシが500ms超。アラート閾値を設定し, トリガーされたらまず最適化可能か調査します。キャパシティ問題と確認後に初めてスケールアップします。
Q ロールバック後, データベースはどうなりますか?
A マイグレーションの「先に追加, 後に削除」原則に従います—新バージョンはカラムを追加するだけで既存のものは変更しないため, コードをロールバックしてもデータベース構造は互換性を維持します。削除操作は少なくとも1バージョン後に延期します。これにより, ロールバックはコードのみに影響し, データベースには影響しません。

📖 まとめ


📝 練習問題

  1. 基本問題 (⭐):ShopMetricsのDocker Compose本番設定を記述し, app, queue-worker, scheduler, mysql, redis, nginxの6サービスを含めてください。全サービスにヘルスチェックと再起動ポリシーを含めてください。

  2. 応用問題 (⭐⭐):完全なGitHub Actionsワークフローを記述してください:テスト → セキュリティ監査 → Dockerビルド → デプロイ → スモークテスト → Slack通知。ヘルスチェック失敗時の自動ロールバックロジックを含めてください。

  3. チャレンジ (⭐⭐⭐):ShopMetricsの包括的な運用保守システムを設計してください—(1) DB/Redis/S3/Queue/S3-backupをカバーするヘルスチェックエンドポイント (2) 自動データベースバックアップ + 復元スクリプト + 月次復元訓練 (3) Sentryエラー追跡 + カスタムコンテキスト (4) 自動化された15ステップのデプロイチェックリストスクリプト (5) ロールバックスクリプト (コード + データベース)。Markdown形式で運用マニュアルを記述してください。

Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%