#!/bin/bash # 灰度发布脚本:在生产服务器上启动 canary 版本,通过 Nginx 权重切流 # 用法: ./scripts/gray_deploy.sh <版本号> <灰度百分比> # # 前提: # - 在生产服务器上执行(或通过 SSH 管道执行) # - 当前已有全量运行的 production 容器 # - Nginx 配置在 /etc/nginx/sites-enabled/00-xiaoxia-saas # # 灰度范围:API + Web(Worker 暂时全量升级,队列消费无法按比例切流) set -euo pipefail VERSION="${1:-}" GRAY_PCT="${2:-10}" if [[ -z "$VERSION" ]]; then echo "用法: $0 <版本号> [灰度百分比]" echo "示例: $0 v0.1.130 5" exit 1 fi REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}" NGINX_CONF="${NGINX_CONF:-/etc/nginx/sites-enabled/00-xiaoxia-saas}" ENV_FILE="${ENV_FILE:-/var/lib/xiaoxia-saas-production/.env}" GENERATED_DIR="${GENERATED_DIR:-/var/lib/xiaoxia-saas-production/generated}" LEGACY_ASSETS_DIR="${LEGACY_ASSETS_DIR:-/var/lib/xiaoxia-saas-production/legacy-assets}" # Canary 端口(与 production 错开) CANARY_API_PORT=18001 CANARY_WEB_PORT=13002 echo "============================================" echo " 灰度发布" echo " 新版本: $VERSION" echo " 灰度比例: ${GRAY_PCT}%" echo " Canary API 端口: $CANARY_API_PORT" echo " Canary Web 端口: $CANARY_WEB_PORT" echo "============================================" # 1. 检查环境 if [[ ! -f "$ENV_FILE" ]]; then echo "错误: 环境文件不存在: $ENV_FILE" exit 1 fi if [[ ! -f "$NGINX_CONF" ]]; then echo "错误: Nginx 配置不存在: $NGINX_CONF" exit 1 fi # 2. 拉取新版本镜像 echo "" echo ">>> 拉取新版本镜像..." for component in api web worker; do echo " 拉取 $component:$VERSION ..." docker pull "${REGISTRY}-${component}:${VERSION}" 2>&1 | tail -1 done echo " ✅ 镜像拉取完成" # 3. 启动 API Canary echo "" echo ">>> 启动 API Canary 容器..." CANARY_API="xiaoxia-api-canary" if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_API}$"; then echo " 停止旧 canary..." docker rm -f "$CANARY_API" >/dev/null 2>&1 fi docker run -d \ --name "$CANARY_API" \ --env-file "$ENV_FILE" \ --network xiaoxia-net-production \ -p "127.0.0.1:${CANARY_API_PORT}:8000" \ -e APP_ENV=production \ -e APP_VERSION="$VERSION-canary" \ -e GENERATED_FILES_DIR=/app/generated \ -e GENERATED_FILES_URL_PREFIX=/generated-files \ -e PUBLIC_API_BASE_URL=https://saas-api.xiaoxiajianji.com \ -v "${GENERATED_DIR}:/app/generated" \ --restart unless-stopped \ --cpus 1 \ --memory 1g \ --health-cmd "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health', timeout=5)\"" \ --health-interval 30s \ --health-timeout 10s \ --health-retries 3 \ --health-start-period 40s \ --log-driver json-file \ --log-opt max-size=50m \ --log-opt max-file=3 \ "${REGISTRY}-api:${VERSION}" >/dev/null echo " ✅ API Canary 已启动(端口 $CANARY_API_PORT)" # 4. 启动 Web Canary echo "" echo ">>> 启动 Web Canary 容器..." CANARY_WEB="xiaoxia-web-canary" if docker ps -a --format '{{.Names}}' | grep -q "^${CANARY_WEB}$"; then echo " 停止旧 canary..." docker rm -f "$CANARY_WEB" >/dev/null 2>&1 fi LEGACY_VOLUME="" if [[ -d "$LEGACY_ASSETS_DIR" ]] && [[ -n "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]]; then LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro" fi docker run -d \ --name "$CANARY_WEB" \ --network xiaoxia-net-production \ -p "127.0.0.1:${CANARY_WEB_PORT}:80" \ --restart unless-stopped \ --cpus 0.5 \ --memory 256m \ $LEGACY_VOLUME \ --health-cmd "wget --spider -q http://127.0.0.1:80" \ --health-interval 30s \ --health-timeout 5s \ --health-retries 3 \ --health-start-period 10s \ --log-driver json-file \ --log-opt max-size=50m \ --log-opt max-file=3 \ "${REGISTRY}-web:${VERSION}" >/dev/null echo " ✅ Web Canary 已启动(端口 $CANARY_WEB_PORT)" # 5. 等待健康检查 echo "" echo ">>> 等待 Canary 容器健康..." for i in $(seq 1 40); do api_healthy=$(docker inspect --format='{{.State.Health.Status}}' "$CANARY_API" 2>/dev/null || echo "starting") web_healthy=$(docker inspect --format='{{.State.Health.Status}}' "$CANARY_WEB" 2>/dev/null || echo "starting") if [[ "$api_healthy" == "healthy" && "$web_healthy" == "healthy" ]]; then echo " ✅ API + Web Canary 均健康(用时 ${i}s)" break fi if [[ "$api_healthy" == "unhealthy" ]]; then echo " ❌ API Canary 健康检查失败" docker logs --tail 30 "$CANARY_API" exit 1 fi if [[ "$web_healthy" == "unhealthy" ]]; then echo " ❌ Web Canary 健康检查失败" docker logs --tail 20 "$CANARY_WEB" exit 1 fi sleep 3 done # 6. 更新 Nginx 配置 - 添加 upstream 权重 echo "" echo ">>> 更新 Nginx 权重(稳定: $((100-GRAY_PCT))% / 灰度: ${GRAY_PCT}%)..." # 备份 BAK_FILE="${NGINX_CONF}.bak.gray.$(date +%Y%m%d%H%M%S)" cp "$NGINX_CONF" "$BAK_FILE" echo " 已备份: $BAK_FILE" # 生成 upstream 块 UPSTREAM_BLOCK=" # Gray release upstreams(自动生成 - gray_deploy.sh) upstream saas_api_backend { server 127.0.0.1:8001 weight=$((100-GRAY_PCT)); server 127.0.0.1:${CANARY_API_PORT} weight=${GRAY_PCT}; } upstream saas_web_backend { server 127.0.0.1:3002 weight=$((100-GRAY_PCT)); server 127.0.0.1:${CANARY_WEB_PORT} weight=${GRAY_PCT}; } " # 在文件最前面插入 upstream 块 TMP_CONF=$(mktemp) { echo "$UPSTREAM_BLOCK" cat "$NGINX_CONF" } > "$TMP_CONF" # 替换 proxy_pass 指向 upstream # API: proxy_pass http://127.0.0.1:8001 -> proxy_pass http://saas_api_backend sed -i 's|proxy_pass http://127\.0\.0\.1:8001|proxy_pass http://saas_api_backend|g' "$TMP_CONF" # Web: proxy_pass http://127.0.0.1:3002/ -> proxy_pass http://saas_web_backend/ sed -i 's|proxy_pass http://127\.0\.0\.1:3002/|proxy_pass http://saas_web_backend/|g' "$TMP_CONF" # 测试配置 mv "$TMP_CONF" "$NGINX_CONF" if ! nginx -t 2>&1; then echo " ❌ Nginx 配置测试失败,回滚..." cp "$BAK_FILE" "$NGINX_CONF" nginx -t exit 1 fi nginx -s reload echo " ✅ Nginx 已 reload,灰度生效" # 7. 验证灰度流量 echo "" echo ">>> 验证灰度流量..." gray_hits=0 total_hits=20 for i in $(seq 1 $total_hits); do resp=$(curl -s -o /dev/null -w "%{http_code}" -H "X-Gray-Test: 1" http://127.0.0.1:${CANARY_API_PORT}/health 2>/dev/null || echo "000") if [[ "$resp" == "200" ]]; then gray_hits=$((gray_hits + 1)) fi sleep 0.1 done echo " Canary 健康验证: $gray_hits/$total_hits 请求成功" echo "" echo "============================================" echo " ✅ 灰度发布完成" echo " 版本: $VERSION (${GRAY_PCT}%流量)" echo " API: 127.0.0.1:$CANARY_API_PORT" echo " Web: 127.0.0.1:$CANARY_WEB_PORT" echo " Nginx 备份: $BAK_FILE" echo " 回滚: ./scripts/rollback.sh" echo " Worker: 暂不灰度(队列消费无法按比例切流)" echo "============================================"