プロジェクトデプロイ—ShopMetrics SaaSプラットフォームのローンチ
ローンチは終わりではなく, 始まりに過ぎません。デプロイ後も監視, アラート設定, バックアップを行い, 24時間365日安定稼働を確保する必要があります。
1. 学ぶこと
- Docker Compose本番オーケストレーション:app/worker/scheduler/nginx/redis/mysql
- GitHub Actions CI/CD:テスト → ビルド → デプロイの完全自動化
- ヘルスチェックと監視:Sentryエラー追跡 / Laravel Telescope
- データベースマイグレーション戦略とバックアッププラン
- デプロイチェックリストとロールバックプラン
2. ローンチ当日の実話
(1) 痛み:デプロイ後にデータベースがマイグレーションされていないことに気づいた
Bobが率いるShopMetricsチームが初デプロイを完了しました—コードはプッシュされ, Dockerは起動し, Nginxは設定されました。しかし, Aliceがダッシュボードを開くとすぐに500エラーが発生しました。Charlieがログを確認したところ, 新しく追加したanalytics_reportsテーブルがデータベースに存在しないことが判明しました—マイグレーションの実行を忘れていたのです。さらに悪いことに, デプロイ前にデータベースのバックアップも取っていなかったため, 本番環境でマイグレーションを実行するしかありませんでした。
(2) デプロイプロセスの体系化による解決策
デプロイは単に「コードを本番にプッシュすること」ではありません—15ステップのチェックリストであり, 各ステップは検証を通過してから次に進みます。CI/CDがこのうち10ステップを自動化し, 残りの5ステップ (ヘルスチェックとビジネス検証)のみを手動で確認します。
チェックリスト:15ステップ, CI/CDが10を自動化, 人間が5を検証
結果:0ステップ見落とし, 0デプロイ後インシデント
(3) 成果
Bobの2回目のデプロイでは, 体系的なプロセスが使用され—完全自動化デプロイとヘルスチェック—3分で完了し, エラーはゼロでした。
3. Docker Compose本番オーケストレーション
(1) 本番アーキテクチャ概要
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本番設定
# 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設定
# .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
出力:
# コマンド実行成功
4. CI/CDパイプライン
(1) 完全なGitHub Actionsワークフロー
# .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データベースマイグレーション戦略
// 本番環境で安全なマイグレーションパターン
// ✅ 安全:カラムの追加 (非破壊的)
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');
});
出力:
// 実行成功
5. ヘルスチェックと監視
(1) ヘルスチェックエンドポイント
// 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設定とカスタムコンテキスト
// 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();
});
}
}
出力:
// 実行成功
6. データベースバックアッププラン
(1) バックアップ戦略
| バックアップタイプ | 頻度 | 保持期間 | 保存先 | 復旧時間 |
|---|---|---|---|---|
| フルバックアップ | 毎日午前3時 | 30日 | S3 (オフサイト) | 30〜60分 |
| 差分バックアップ | 毎時 | 7日 | S3 (オフサイト) | 60〜120分 |
| Binlog | リアルタイム | 7日 | ローカル + S3 | 任意の時点へのPITR |
(2) 自動バックアップスクリプト
#!/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自動バックアップスケジュール
// 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;
}
}
出力:
// 実行成功
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ロールバックスクリプト
#!/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}にロールバックしました"
出力:
CONTAINER ID IMAGE STATUS
abc123 latest Up 2 hours
8. 総合サンプル:ShopMetricsローンチの全プロセス
#!/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 "=== デプロイ完了 ==="
❓ よくある質問
📖 まとめ
- Docker Compose本番オーケストレーションの6サービス:app(x2)/queue(x5)/scheduler/mysql/redis/nginx
- GitHub Actions CI/CDの5ステージ:テスト → セキュリティ → ビルド → デプロイ → 検証
- ヘルスチェックエンドポイントはDB/Redis/S3/Queueをカバー。CI/CDで自動検証
- Sentryエラー追跡 + Telescopeデバッグパネル:ダブルセーフティネット
- 毎日のデータベースバックアップはS3に + リアルタイムBinlogバックアップ, 30日間保持
- 15ステップのデプロイチェックリスト + 4つのロールバックシナリオ:デプロイはもはや運任せではありません
📝 練習問題
-
基本問題 (⭐):ShopMetricsのDocker Compose本番設定を記述し, app, queue-worker, scheduler, mysql, redis, nginxの6サービスを含めてください。全サービスにヘルスチェックと再起動ポリシーを含めてください。
-
応用問題 (⭐⭐):完全なGitHub Actionsワークフローを記述してください:テスト → セキュリティ監査 → Dockerビルド → デプロイ → スモークテスト → Slack通知。ヘルスチェック失敗時の自動ロールバックロジックを含めてください。
-
チャレンジ (⭐⭐⭐):ShopMetricsの包括的な運用保守システムを設計してください—(1) DB/Redis/S3/Queue/S3-backupをカバーするヘルスチェックエンドポイント (2) 自動データベースバックアップ + 復元スクリプト + 月次復元訓練 (3) Sentryエラー追跡 + カスタムコンテキスト (4) 自動化された15ステップのデプロイチェックリストスクリプト (5) ロールバックスクリプト (コード + データベース)。Markdown形式で運用マニュアルを記述してください。



