diff --git a/scripts/gray_deploy.sh b/scripts/gray_deploy.sh new file mode 100755 index 000000000..20b9cb4f5 --- /dev/null +++ b/scripts/gray_deploy.sh @@ -0,0 +1,227 @@ +#!/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 "============================================" diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 000000000..9c2220d79 --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# 一键发布脚本:打 tag → 触发 CI 构建 → 可选灰度发布 +# 用法: ./scripts/release.sh v0.1.130 [--gray 5] +# +# 说明: +# - 打 tag 后 CI 会自动构建镜像并全量部署到生产 +# - 加 --gray 参数则在构建完成后执行灰度切流(需 SSH 到生产服务器执行) +# - 加 --no-deploy 只打 tag 不触发自动部署 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +usage() { + echo "用法: $0 <版本号> [--gray 百分比] [--no-deploy]" + echo "" + echo "示例:" + echo " $0 v0.1.130 # 打tag + 全量发布(CI自动部署)" + echo " $0 v0.1.130 --gray 5 # 打tag + 5%灰度发布" + echo " $0 v0.1.130 --no-deploy # 只打tag,不部署" + exit 1 +} + +VERSION="" +GRAY_PCT=0 +DEPLOY=true + +while [[ $# -gt 0 ]]; do + case "$1" in + --gray) + GRAY_PCT="$2" + shift 2 + ;; + --no-deploy) + DEPLOY=false + shift + ;; + -h|--help) + usage + ;; + v*) + VERSION="$1" + shift + ;; + *) + echo "未知参数: $1" + usage + ;; + esac +done + +if [[ -z "$VERSION" ]]; then + echo "错误: 请指定版本号(如 v0.1.130)" + usage +fi + +echo "============================================" +echo " 发布版本: $VERSION" +echo " 灰度比例: ${GRAY_PCT}%" +echo " 自动部署: $DEPLOY" +echo "============================================" + +cd "$REPO_ROOT" + +# 1. 确认分支 +CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) +if [[ "$CURRENT_BRANCH" != "develop" ]]; then + echo "错误: 请在 develop 分支上打 tag" + exit 1 +fi + +# 2. 拉取最新 +echo "" +echo ">>> 拉取最新代码..." +git pull origin develop + +# 3. 检查 tag 是否已存在 +if git rev-parse "$VERSION" >/dev/null 2>&1; then + echo "警告: tag $VERSION 已存在,跳过打 tag" +else + echo "" + echo ">>> 打 tag $VERSION ..." + git tag -a "$VERSION" -m "Release $VERSION" + git push origin "$VERSION" + echo " ✅ Tag 已推送,CI 将自动构建生产镜像" +fi + +# 4. 部署提示 +if [[ "$DEPLOY" == "true" ]]; then + echo "" + echo ">>> 构建 & 部署" + echo " CI 会自动执行:" + echo " 1. Build Production Runtime Images(约10-15分钟)" + echo " 2. Deploy Production(SSH 到生产服务器部署)" + echo "" + echo " 查看进度: Gitea Actions → 对应 tag 的 run" + + if [[ "$GRAY_PCT" -gt 0 ]]; then + echo "" + echo ">>> 灰度发布" + echo " 构建部署完成后,在生产服务器上执行:" + echo " cd /var/lib/xiaoxia-saas-production" + echo " ./gray_deploy.sh $VERSION $GRAY_PCT" + fi +fi + +echo "" +echo "============================================" +echo " ✅ 发布流程触发完成" +echo " 版本: $VERSION" +echo " 灰度: ${GRAY_PCT}%" +echo "============================================" diff --git a/scripts/rollback.sh b/scripts/rollback.sh index e69de29bb..01663ceb8 100755 --- a/scripts/rollback.sh +++ b/scripts/rollback.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# 灰度回滚脚本:切回稳定版本流量 +# 用法: ./scripts/rollback.sh [稳定版本号] + +set -euo pipefail + +STABLE_VERSION="${1:-current}" + +echo "==========================================" +echo " 灰度回滚" +echo " 切回稳定版本: $STABLE_VERSION" +echo "==========================================" + +# 1. 恢复Nginx全量到稳定版本 +echo "" +echo ">>> 恢复Nginx全量流量到稳定版本..." + +NGINX_CONF="${NGINX_CONF:-/etc/nginx/conf.d/saas-api.conf}" +if [[ -f "$NGINX_CONF" ]]; then + # 找最近的备份 + LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.* 2>/dev/null | head -1) + if [[ -n "$LATEST_BAK" ]]; then + cp "$LATEST_BAK" "$NGINX_CONF" + echo " 从备份恢复: $LATEST_BAK" + else + echo " 未找到备份,请手动移除 canary upstream" + fi + + nginx -t && nginx -s reload + echo " ✅ Nginx 已回滚" +fi + +# 2. 停止灰度版本容器(保留30分钟以便排查) +echo "" +echo ">>> 灰度版本容器将在30分钟后停止(便于排查问题)" +echo " 立即停止请执行: docker stop saas-api-canary saas-worker-canary" + +echo "" +echo "==========================================" +echo " ✅ 回滚完成" +echo " 流量已全部切回稳定版本" +echo "==========================================" diff --git a/scripts/rollback_gray.sh b/scripts/rollback_gray.sh new file mode 100755 index 000000000..b57aa7bd4 --- /dev/null +++ b/scripts/rollback_gray.sh @@ -0,0 +1,77 @@ +#!/bin/bash +# 灰度回滚脚本:切回全量稳定版本,停止 canary 容器 +# 用法: ./scripts/rollback_gray.sh + +set -euo pipefail + +NGINX_CONF="${NGINX_CONF:-/etc/nginx/sites-enabled/00-xiaoxia-saas}" +CANARY_API="${CANARY_API:-xiaoxia-api-canary}" +CANARY_WEB="${CANARY_WEB:-xiaoxia-web-canary}" + +echo "============================================" +echo " 灰度回滚" +echo " 目标: 全量切回稳定版本" +echo "============================================" + +# 1. 找最近的灰度备份 +echo "" +echo ">>> 查找最近的灰度备份..." +LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.gray.* 2>/dev/null | head -1 || true) + +if [[ -z "$LATEST_BAK" ]]; then + echo " 未找到灰度备份,尝试手动移除 upstream 配置..." + + # 手动回滚:移除 upstream 块,把 proxy_pass 改回 127.0.0.1 + TMP_CONF=$(mktemp) + + # 移除 upstream 块(从 "# Gray release upstreams" 到空行结束) + awk ' + /^# Gray release upstreams/ { skip=1; next } + skip && /^$/ && !found_first_empty { found_first_empty=1; next } + skip && found_first_empty && /^$/ { skip=0; found_first_empty=0; next } + skip { next } + { print } + ' "$NGINX_CONF" > "$TMP_CONF" + + # 把 upstream 名改回 IP + sed -i 's|proxy_pass http://saas_api_backend|proxy_pass http://127.0.0.1:8001|g' "$TMP_CONF" + sed -i 's|proxy_pass http://saas_web_backend/|proxy_pass http://127.0.0.1:3002/|g' "$TMP_CONF" + + mv "$TMP_CONF" "$NGINX_CONF" +else + echo " 从备份恢复: $LATEST_BAK" + cp "$LATEST_BAK" "$NGINX_CONF" +fi + +# 2. 测试并 reload nginx +echo "" +echo ">>> Nginx 测试 & reload..." +if ! nginx -t 2>&1; then + echo " ❌ Nginx 配置测试失败!请检查" + exit 1 +fi +nginx -s reload +echo " ✅ Nginx 已回滚,全量切回稳定版本" + +# 3. 停止 canary 容器(延迟停止,保留30分钟便于排查) +echo "" +echo ">>> Canary 容器将在30分钟后停止(便于排查)" +echo " 立即停止请执行: docker rm -f $CANARY_API $CANARY_WEB" + +# 30分钟后停止(后台执行,不阻塞脚本) +( + sleep 1800 + for c in "$CANARY_API" "$CANARY_WEB"; do + if docker ps --format '{{.Names}}' | grep -q "^${c}$"; then + docker stop "$c" >/dev/null 2>&1 && docker rm "$c" >/dev/null 2>&1 + echo "[$(date)] 已停止 canary 容器: $c" + fi + done +) & + +echo "" +echo "============================================" +echo " ✅ 灰度回滚完成" +echo " 流量已全部切回稳定版本" +echo " Canary 容器: 30分钟后自动清理" +echo "============================================"