Laravel: 项目部署 — ShopMetrics SaaS平台上线

最后更新:2026-08-26

上线不是结束,是开始——部署后还要监控、告警、备份,保证 7x24 小时稳定运行。

1. 你将学到


2. 一个上线日的真实故事

(1) 痛点:上线后才发现数据库没迁移

Bob 带着 ShopMetrics 团队第一次上线——代码推上去了,Docker 跑起来了,Nginx 配好了。但 Alice 一打开仪表盘就 500 错误。Charlie 查日志发现:新加的 analytics_reports 表在数据库里不存在——忘了跑 migrate。更糟的是,上线前没做数据库备份,只能硬着头皮在生产环境跑迁移。

(2) 系统化上线流程的解法

上线不是"把代码推上去"——是一份 15 步的检查清单,每步必须验证通过才能继续。CI/CD 自动化其中 10 步,人工只需确认健康检查和业务验证。

TEXT 📖 仅展示
Checklist: 15 steps, CI/CD automates 10, human verifies 5
Result: 0 missed steps, 0 post-deploy incidents

(3) 收益

Bob 第二次上线用了系统化流程——全自动化部署 + 健康检查,3 分钟完成上线,0 错误。


3. Docker Compose 生产编排

(1) 生产架构总览

100%
flowchart TB
    subgraph Internet["Internet"]
        USER[Users]
        STRIPE[Stripe Webhooks]
    end

    subgraph LB["Load Balancer / CDN"]
        NGINX[Nginx Reverse Proxy]
    end

    subgraph App["Application Tier"]
        APP1[App Container 1]
        APP2[App Container 2]
    end

    subgraph Worker["Background Tier"]
        Q1[Queue Worker - High]
        Q2[Queue Worker - Default]
        SCHED[Scheduler]
    end

    subgraph Data["Data Tier"]
        MASTER[(MySQL Primary)]
        REPLICA[(MySQL Replica)]
        REDIS[(Redis Cluster)]
        S3[(S3 Storage)]
    end

    subgraph Monitor["Observability"]
        SENTRY[Sentry]
        TELESCOPE[Telescope]
        LOGS[Log Aggregation]
    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

▶ 示例: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:  # Manual trigger option

concurrency: production  # Only one deploy at a time

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 }}

            # Pull latest image
            docker compose -f docker-compose.prod.yml pull app queue-high queue-default scheduler

            # Rolling update: start new containers
            docker compose -f docker-compose.prod.yml up -d --no-deps --scale app=3 app
            sleep 10

            # Health check new containers
            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

            # Run migrations
            docker compose -f docker-compose.prod.yml exec -T app php artisan migrate --force

            # Restart workers (graceful)
            docker compose -f docker-compose.prod.yml exec -T app php artisan queue:restart

            # Scale down old containers
            docker compose -f docker-compose.prod.yml up -d --scale app=2

            # Clear and rebuild caches
            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 }}"}

▶ 示例:ShopMetrics 数据库迁移策略

PHP
// Safe migration patterns for production

// ✅ SAFE: Add column (non-breaking)
Schema::table('orders', function (Blueprint $table) {
    $table->string('shipping_method')->nullable()->after('status');
    $table->index('shipping_method');
});

// ✅ SAFE: Add index (concurrent in PostgreSQL)
// MySQL: Use ALGORITHM=INPLACE for non-blocking index creation
DB::statement('ALTER TABLE orders ADD INDEX idx_orders_shipping (shipping_method) ALGORITHM=INPLACE');

// ⚠️ CAUTION: Remove column (two-step migration)
// Step 1: Mark as deprecated (deploy first, remove code references)
Schema::table('orders', function (Blueprint $table) {
    // Keep old column, add new column
    $table->renameColumn('shipping_method', 'deprecated_shipping_method');
});

// Step 2: Remove after code no longer references it (next deploy)
Schema::table('orders', function (Blueprint $table) {
    $table->dropColumn('deprecated_shipping_method');
});

// ❌ DANGEROUS: Change column type (locks table)
// Use two-step: add new column → migrate data → drop old column
Schema::table('orders', function (Blueprint $table) {
    $table->unsignedBigInteger('total_cents_new')->nullable()->after('total_cents');
});

// Data migration in a separate migration
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 = [];

    // Database check
    try {
        DB::connection()->getPdo();
        $checks['database'] = 'ok';
    } catch (\Throwable $e) {
        $checks['database'] = 'fail: ' . $e->getMessage();
    }

    // Redis check
    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 check
    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();
    }

    // Queue check
    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 events/月 异常捕获+性能追踪
Laravel Telescope 调试面板 免费 开发/调试(生产限权限)
Prometheus + Grafana 指标监控 自托管免费 系统指标+自定义仪表盘
UptimeRobot 可用性监控 50 monitors HTTP 健康检查告警
Algolia/Meilisearch 日志搜索 有限免费 日志聚合与搜索

▶ 示例: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:00 AM 30 天 S3 (异地) 30-60 分钟
增量备份 每小时 7 天 S3 (异地) 60-120 分钟
Binlog 实时 7 天 本地 + S3 PITR 任意时间点

(2) 自动备份脚本

BASH
#!/bin/bash
# backup-mysql.sh — Daily MySQL backup to 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 "→ Starting backup: ${DATE}"

# Dump with consistent snapshot (no lock)
mysqldump \
  --host=mysql \
  --user=${DB_USERNAME} \
  --password=${DB_PASSWORD} \
  --single-transaction \
  --routines \
  --triggers \
  --quick \
  ${DB_NAME} | gzip > ${BACKUP_FILE}

# Upload to S3
aws s3 cp ${BACKUP_FILE} ${S3_BUCKET}/daily/shopmetrics_${DATE}.sql.gz

# Cleanup local file
rm -f ${BACKUP_FILE}

# Cleanup backups older than 30 days
aws s3 rm ${S3_BUCKET}/daily/ --recursive --exclude "*" --include "shopmetrics_*" \
  --query "Contents[?LastModified<='$(date -d '-30 days' +%Y-%m-%d)'].Key"

echo "✓ Backup complete: shopmetrics_${DATE}.sql.gz"

# Verify backup integrity
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 "✓ Backup integrity verified"
else
    echo "✗ Backup integrity check FAILED"
    exit 1
fi
rm -f /tmp/verify.sql.gz

▶ 示例:ShopMetrics 自动备份调度

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

// Daily database backup
Schedule::command('shopmetrics:backup-database')
    ->dailyAt('03:00')
    ->onOneServer()
    ->withoutOverlapping()
    ->emailOutputOnFailure('ops@shopmetrics.io');

// Hourly S3 storage sync (for report files)
Schedule::command('shopmetrics:sync-storage-backup')
    ->hourly()
    ->onOneServer();

// Weekly backup integrity test
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 failures
2 安全审计通过 composer audit CI 0 vulnerabilities
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 errors
8 缓存预热 config:cache + route:cache CD 命令成功
9 队列 Worker 重启 queue:restart CD Worker 重新启动
10 旧容器清理 docker image prune CD 磁盘空间正常
11 业务冒烟测试 手动测试关键流程 人工 功能正常
12 监控确认 Sentry/Telescope 无异常 人工 0 errors in 5min
13 日志检查 hilog 无 ERROR 人工 无新异常
14 性能确认 仪表盘加载 < 500ms 人工 p95 < 500ms
15 团队通知 Slack/邮件通知 自动 已发送

(2) 回滚预案

场景 检测方式 回滚操作 恢复时间
健康检查失败 CI/CD 自动检测 docker compose pull <previous-tag> + up < 1 分钟
迁移失败 CI/CD 日志 migrate:rollback + 回滚代码 < 5 分钟
业务逻辑错误 用户反馈/监控 Blue-Green 切回旧 slot < 30 秒
数据库损坏 监控告警 S3 备份恢复 + binlog 回放 30-120 分钟

▶ 示例:ShopMetrics 回滚脚本

BASH
#!/bin/bash
# rollback.sh — Emergency rollback script
set -e

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

echo "=== EMERGENCY ROLLBACK ==="

# Get previous image tag
PREVIOUS_TAG=$(cat ${BACKUP_DIR}/last_successful_deploy_tag.txt)
CURRENT_TAG=$(cat ${BACKUP_DIR}/current_deploy_tag.txt)

echo "Current: ${CURRENT_TAG}"
echo "Rolling back to: ${PREVIOUS_TAG}"

# Step 1: Set image tag to previous version
export IMAGE_TAG=${PREVIOUS_TAG}

# Step 2: Pull previous image
docker compose -f ${COMPOSE_FILE} pull app queue-high queue-default scheduler

# Step 3: Start previous version containers
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

# Step 4: Wait and verify
sleep 10
if curl -sf http://localhost:8080/health > /dev/null; then
    echo "✓ Health check passed after rollback"
else
    echo "✗ Health check FAILED after rollback — MANUAL INTERVENTION REQUIRED"
    exit 1
fi

# Step 5: Check if DB migration rollback is needed
echo "⚠ Check if database migrations need rollback:"
echo "  Run: docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate:status"
echo "  If needed: docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate:rollback --step=N"

# Step 6: Update current tag
echo "${PREVIOUS_TAG}" > ${BACKUP_DIR}/current_deploy_tag.txt

# Step 7: Notify team
echo "⚠ ROLLBACK COMPLETE — Notify team immediately"
echo "  Rolled back from ${CURRENT_TAG} to ${PREVIOUS_TAG}"

输出:

TEXT 📖 仅展示
CONTAINER ID   IMAGE     STATUS    
abc123         latest    Up 2 hours

8. 综合示例:ShopMetrics 上线全流程

BASH
#!/bin/bash
# ============================================
# Comprehensive: ShopMetrics full deployment script
# Covers: backup → deploy → migrate → verify → rollback plan
# ============================================

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; }

# Pre-deploy: Backup
log "→ Step 1/10: Database backup"
php artisan shopmetrics:backup-database
log "✓ Backup complete"

# Pre-deploy: Save current state
log "→ Step 2/10: Saving current state"
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 "✓ State saved (rolling back to: $(cat ${BACKUP_DIR}/last_successful_deploy_tag.txt 2>/dev/null || echo 'N/A'))"

# Deploy: Pull and start
log "→ Step 3/10: Pulling image ${DEPLOY_TAG}"
export IMAGE_TAG=${DEPLOY_TAG}
docker compose -f ${COMPOSE_FILE} pull app queue-high queue-default scheduler

log "→ Step 4/10: Starting new containers"
docker compose -f ${COMPOSE_FILE} up -d --no-deps --remove-orphans app queue-high queue-default scheduler

# Health check with retry
log "→ Step 5/10: Health check"
for i in $(seq 1 6); do
    if curl -sf http://localhost:8080/health > /dev/null 2>&1; then
        log "✓ Health check passed"
        break
    fi
    if [ $i -eq 6 ]; then
        log "✗ Health check failed after 30s — ROLLING BACK"
        bash /opt/shopmetrics/rollback.sh
        notify "🚨 ShopMetrics deploy FAILED (health check) — rolled back"
        exit 1
    fi
    log "  Waiting... ($i/6)"
    sleep 5
done

# Migrate
log "→ Step 6/10: Running migrations"
docker compose -f ${COMPOSE_FILE} exec -T app php artisan migrate --force
if [ $? -ne 0 ]; then
    log "✗ Migration failed — ROLLING BACK"
    bash /opt/shopmetrics/rollback.sh
    notify "🚨 ShopMetrics deploy FAILED (migration) — rolled back"
    exit 1
fi
log "✓ Migrations complete"

# Optimize
log "→ Step 7/10: Caching"
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 "✓ Caches rebuilt"

# Post-deploy verification
log "→ Step 8/10: Smoke tests"
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 "✗ Smoke test failed: ${endpoint} → HTTP ${STATUS}"
        SMOKE_PASS=false
    fi
done
if [ "$SMOKE_PASS" = false ]; then
    log "✗ Smoke tests failed — ROLLING BACK"
    bash /opt/shopmetrics/rollback.sh
    notify "🚨 ShopMetrics deploy FAILED (smoke test) — rolled back"
    exit 1
fi
log "✓ Smoke tests passed"

# Cleanup
log "→ Step 9/10: Cleanup"
docker image prune -f > /dev/null
log "✓ Old images cleaned"

# Success
log "→ Step 10/10: Final verification"
sleep 5
ERRORS=$(docker compose -f ${COMPOSE_FILE} logs --since 2m app 2>&1 | grep -c "ERROR" || true)
if [ "${ERRORS}" -gt 5 ]; then
    log "⚠ High error rate detected: ${ERRORS} errors in 2 minutes"
    notify "⚠️ ShopMetrics deployed but high error rate: ${ERRORS} errors"
else
    log "✓ Deployment successful: ${DEPLOY_TAG}"
    notify "✅ ShopMetrics deployed successfully: ${DEPLOY_TAG}"
fi

log "=== DEPLOY COMPLETE ==="

❓ 常见问题

Q 生产环境用 Docker Compose 还是 Kubernetes?
A 5 台服务器以内用 Docker Compose 足够,运维简单。超过 5 台或需要自动扩缩容时迁移到 Kubernetes。ShopMetrics 初期用 Docker Compose,用户过 10 千再考虑 K8s。
Q 迁移在生产环境失败怎么办?
A CI/CD 中迁移失败自动阻断部署、不切流量。回滚代码版本即可(数据库结构还是旧版本兼容的,因为迁移遵循"先加后删"原则)。
Q Sentry 免费层够用吗?
A 5K events/月对初期够用。超过后按量付费或自托管 Sentry(开源免费,需自己维护服务器)。建议生产环境始终开启 Sentry。
Q 数据库备份恢复要多长时间?
A 10GB 数据库全量恢复约 30 分钟(从 S3 下载+导入)。开启 binlog 可实现任意时间点恢复(PITR),恢复时间约 60-120 分钟。每月至少做一次恢复演练。
Q 怎么知道什么时候该扩容?
A 监控指标:CPU > 70% 持续 5 分钟、队列积压 > 1000、p95 延迟 > 500ms。设定告警阈值,触发时先排查是否可优化,确认是容量问题再扩容。
Q 回滚后数据库怎么办?
A 遵循"先加后删"迁移原则——新版本只加列不改列,回滚代码后数据库结构兼容。删列操作至少延迟一个版本再执行。这样回滚只涉及代码,不涉及数据库。

📖 小节


📝 作业

  1. 基础题(⭐):为 ShopMetrics 编写 Docker Compose 生产配置,包含 app + queue-worker + scheduler + mysql + redis + nginx 六个服务,所有服务带 healthcheck 和 restart 策略。

  2. 进阶题(⭐⭐):编写完整的 GitHub Actions 工作流:test → security audit → build Docker → deploy → smoke test → Slack 通知。包含健康检查失败自动回滚逻辑。

  3. 挑战题(⭐⭐⭐):设计 ShopMetrics 完整运维体系——(1) 健康检查端点覆盖 DB/Redis/S3/Queue/S3-backup (2) 数据库自动备份+恢复脚本+每月恢复演练 (3) Sentry 错误追踪+自定义上下文 (4) 上线检查清单15步自动化脚本 (5) 回滚脚本(代码+数据库)。写出运维手册 Markdown 文档。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏