a9aface0b8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m46s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m47s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 2m8s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m20s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m29s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Unit Tests (push) Failing after 4m59s
CI/CD Pipeline / Build Staging API Image (push) Failing after 6m35s
CI/CD Pipeline / Build Staging Worker Image (push) Failing after 6m7s
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (push) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
金丝雀发布流水线:5%→20%→50%→100%分阶段灰度,自动健康检查+异常回滚,main分支push自动触发。
364 lines
12 KiB
Bash
Executable File
364 lines
12 KiB
Bash
Executable File
#!/bin/bash
|
|
# ===========================================
|
|
# 金丝雀发布脚本 - 分阶段灰度到全量
|
|
# ===========================================
|
|
# 在 CI Runner 上执行,通过 SSH 控制生产服务器执行灰度发布。
|
|
# 流程:5%灰度 → 20%灰度 → 50%灰度 → 100%全量
|
|
# 每阶段自动健康检查,失败自动回滚。
|
|
#
|
|
# 用法:
|
|
# IMAGE_TAG=v0.1.130 ./scripts/ci/canary_release.sh
|
|
#
|
|
# 环境变量:
|
|
# IMAGE_TAG - 新版本镜像标签 (必填)
|
|
# CANARY_STAGES - 灰度阶段配置,格式: "百分比:等待秒数" 用逗号分隔
|
|
# 默认: "5:600,20:900,50:1200"
|
|
# PROD_API_URL - Production API 公网地址
|
|
# PROD_WEB_URL - Production Web 公网地址
|
|
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
|
|
# PRODUCTION_SSH_USER - SSH 用户名
|
|
# PRODUCTION_SSH_PORT - SSH 端口
|
|
# PRODUCTION_SSH_KEY - SSH 私钥内容
|
|
# ACR_USERNAME - 容器镜像仓库用户名
|
|
# ACR_PASSWORD - 容器镜像仓库密码
|
|
# CI_NOTIFY_WEBHOOK - 通知 Webhook
|
|
# SKIP_ROLLBACK - 失败时不自动回滚 (调试用)
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
|
|
# 配置
|
|
IMAGE_TAG="${IMAGE_TAG:-}"
|
|
CANARY_STAGES="${CANARY_STAGES:-5:600,20:900,50:1200}"
|
|
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
|
|
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
|
|
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
|
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
|
|
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
|
|
# gray_deploy.sh 的镜像命名格式是 ${REGISTRY}-component:tag
|
|
# 需要与 ACR 镜像名 xiaoxia-registry.../xiaoxiakeji/xiaoxia-saas-api:tag 匹配
|
|
GRAY_REGISTRY="${GRAY_REGISTRY:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com/xiaoxiakeji/xiaoxia-saas}"
|
|
ACR_REGISTRY_HOST="${ACR_REGISTRY_HOST:-xiaoxia-registry.cn-hangzhou.cr.aliyuncs.com}"
|
|
ACR_USERNAME="${ACR_USERNAME:-}"
|
|
ACR_PASSWORD="${ACR_PASSWORD:-}"
|
|
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
|
|
|
if [[ -z "$IMAGE_TAG" ]]; then
|
|
echo "ERROR: IMAGE_TAG is required"
|
|
exit 1
|
|
fi
|
|
|
|
# 颜色
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m'
|
|
|
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
|
log_step() { echo -e "${BLUE}[STEP]${NC} $1"; }
|
|
|
|
# ===========================================
|
|
# SSH 配置
|
|
# ===========================================
|
|
SSH_KEY_PATH=""
|
|
|
|
setup_ssh() {
|
|
if [ -f /root/.ssh/xiaoxia_runtime_builder ]; then
|
|
SSH_KEY_PATH="/root/.ssh/xiaoxia_runtime_builder"
|
|
elif [ -f "$HOME/.ssh/xiaoxia_runtime_builder" ]; then
|
|
SSH_KEY_PATH="$HOME/.ssh/xiaoxia_runtime_builder"
|
|
elif [ -n "${PRODUCTION_SSH_KEY:-}" ]; then
|
|
SSH_KEY_PATH="$HOME/.ssh/canary_deploy_key"
|
|
mkdir -p "$HOME/.ssh"
|
|
printf '%s\n' "$PRODUCTION_SSH_KEY" > "$SSH_KEY_PATH"
|
|
chmod 600 "$SSH_KEY_PATH"
|
|
else
|
|
log_error "没有可用的 SSH 密钥"
|
|
return 1
|
|
fi
|
|
|
|
ssh-keyscan -p "$PRODUCTION_SSH_PORT" -H "$PRODUCTION_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null || true
|
|
log_info "SSH 已配置: ${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}:${PRODUCTION_SSH_PORT}"
|
|
}
|
|
|
|
run_ssh() {
|
|
local cmd="$1"
|
|
ssh -p "$PRODUCTION_SSH_PORT" -i "$SSH_KEY_PATH" -o StrictHostKeyChecking=no \
|
|
"${PRODUCTION_SSH_USER}@${PRODUCTION_SSH_HOST}" "$cmd"
|
|
}
|
|
|
|
# ===========================================
|
|
# 上传脚本 + Docker登录
|
|
# ===========================================
|
|
prepare_server() {
|
|
log_step "准备生产服务器环境"
|
|
|
|
# 创建临时目录
|
|
run_ssh "mkdir -p /tmp/canary-release"
|
|
|
|
# 上传 gray_deploy.sh
|
|
local gray_script="$REPO_ROOT/scripts/gray_deploy.sh"
|
|
if [[ -f "$gray_script" ]]; then
|
|
cat "$gray_script" | run_ssh "cat > /tmp/canary-release/gray_deploy.sh && chmod +x /tmp/canary-release/gray_deploy.sh"
|
|
log_info " gray_deploy.sh 已上传"
|
|
else
|
|
log_error "找不到 gray_deploy.sh: $gray_script"
|
|
return 1
|
|
fi
|
|
|
|
# 上传 rollback_gray.sh
|
|
local rollback_script="$REPO_ROOT/scripts/rollback_gray.sh"
|
|
if [[ -f "$rollback_script" ]]; then
|
|
cat "$rollback_script" | run_ssh "cat > /tmp/canary-release/rollback_gray.sh && chmod +x /tmp/canary-release/rollback_gray.sh"
|
|
log_info " rollback_gray.sh 已上传"
|
|
else
|
|
log_warn "找不到 rollback_gray.sh"
|
|
fi
|
|
|
|
# 上传 ci_production_deploy.sh
|
|
local prod_deploy="$REPO_ROOT/scripts/ci_production_deploy.sh"
|
|
if [[ -f "$prod_deploy" ]]; then
|
|
cat "$prod_deploy" | run_ssh "cat > /tmp/canary-release/ci_production_deploy.sh && chmod +x /tmp/canary-release/ci_production_deploy.sh"
|
|
log_info " ci_production_deploy.sh 已上传"
|
|
else
|
|
log_error "找不到 ci_production_deploy.sh: $prod_deploy"
|
|
return 1
|
|
fi
|
|
|
|
# Docker 登录到 ACR
|
|
if [[ -n "$ACR_USERNAME" && -n "$ACR_PASSWORD" ]]; then
|
|
log_info " Docker 登录到 ACR..."
|
|
run_ssh "docker login '$ACR_REGISTRY_HOST' -u '$ACR_USERNAME' -p '$ACR_PASSWORD' 2>/dev/null" || \
|
|
log_warn " Docker login 失败(可能已有凭证),将尝试直接 pull"
|
|
fi
|
|
|
|
log_info "✅ 服务器环境准备完成"
|
|
}
|
|
|
|
# ===========================================
|
|
# 健康检查(公网访问)
|
|
# ===========================================
|
|
health_check() {
|
|
local stage_name="$1"
|
|
local timeout="${2:-120}"
|
|
local interval=5
|
|
local elapsed=0
|
|
|
|
log_step "健康检查 - $stage_name (超时 ${timeout}s)"
|
|
|
|
while [ $elapsed -lt $timeout ]; do
|
|
local api_ok=false
|
|
local web_ok=false
|
|
|
|
# 检查 API
|
|
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
--connect-timeout 5 --max-time 10 \
|
|
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
|
|
if [[ "$api_code" == "200" ]]; then
|
|
api_ok=true
|
|
fi
|
|
|
|
# 检查 Web
|
|
local web_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
--connect-timeout 5 --max-time 10 \
|
|
"$PROD_WEB_URL" 2>/dev/null || echo "000")
|
|
if [[ "$web_code" == "200" || "$web_code" == "301" || "$web_code" == "302" ]]; then
|
|
web_ok=true
|
|
fi
|
|
|
|
if $api_ok && $web_ok; then
|
|
log_info "✅ 健康检查通过 (API=$api_code, Web=$web_code)"
|
|
return 0
|
|
fi
|
|
|
|
log_warn " 等待中... API=$api_code, Web=$web_code (${elapsed}s/${timeout}s)"
|
|
sleep $interval
|
|
elapsed=$((elapsed + interval))
|
|
done
|
|
|
|
log_error "❌ 健康检查超时"
|
|
return 1
|
|
}
|
|
|
|
# ===========================================
|
|
# 灰度发布
|
|
# ===========================================
|
|
gray_deploy() {
|
|
local pct="$1"
|
|
log_step "灰度发布 ${pct}% - $IMAGE_TAG"
|
|
|
|
run_ssh "cd /tmp/canary-release && \
|
|
REGISTRY='$GRAY_REGISTRY' \
|
|
./gray_deploy.sh '$IMAGE_TAG' '$pct'"
|
|
}
|
|
|
|
# ===========================================
|
|
# 全量部署
|
|
# ===========================================
|
|
full_deploy() {
|
|
log_step "全量部署 - $IMAGE_TAG"
|
|
|
|
run_ssh "cd /tmp/canary-release && \
|
|
IMAGE_TAG='$IMAGE_TAG' \
|
|
ACR_USERNAME='$ACR_USERNAME' \
|
|
ACR_PASSWORD='$ACR_PASSWORD' \
|
|
sh ./ci_production_deploy.sh"
|
|
}
|
|
|
|
# ===========================================
|
|
# 灰度回滚
|
|
# ===========================================
|
|
rollback_gray() {
|
|
log_error "执行灰度回滚..."
|
|
if [[ "$SKIP_ROLLBACK" == "true" ]]; then
|
|
log_warn "SKIP_ROLLBACK=true,跳过回滚"
|
|
return
|
|
fi
|
|
|
|
if run_ssh "test -f /tmp/canary-release/rollback_gray.sh"; then
|
|
run_ssh "cd /tmp/canary-release && ./rollback_gray.sh" || \
|
|
log_error "回滚脚本执行失败,请手动处理"
|
|
else
|
|
# 内联回滚逻辑
|
|
log_warn "使用内联回滚逻辑"
|
|
run_ssh '
|
|
NGINX_CONF="/etc/nginx/sites-enabled/00-xiaoxia-saas"
|
|
LATEST_BAK=$(ls -t "${NGINX_CONF}".bak.gray.* 2>/dev/null | head -1 || true)
|
|
if [[ -n "$LATEST_BAK" ]]; then
|
|
cp "$LATEST_BAK" "$NGINX_CONF"
|
|
else
|
|
sed -i "s|proxy_pass http://saas_api_backend|proxy_pass http://127.0.0.1:8001|g" "$NGINX_CONF"
|
|
sed -i "s|proxy_pass http://saas_web_backend/|proxy_pass http://127.0.0.1:3002/|g" "$NGINX_CONF"
|
|
fi
|
|
nginx -t && nginx -s reload
|
|
docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true
|
|
' || log_error "回滚失败,请手动处理"
|
|
fi
|
|
}
|
|
|
|
# ===========================================
|
|
# 通知
|
|
# ===========================================
|
|
notify_status() {
|
|
local status="$1"
|
|
local message="$2"
|
|
if [ -n "${CI_NOTIFY_WEBHOOK:-}" ]; then
|
|
NOTIFY_MODE="$status" JOB_NAME="Canary Release - $message" \
|
|
python3 "$REPO_ROOT/scripts/ci_notify.py" 2>/dev/null || true
|
|
fi
|
|
}
|
|
|
|
# ===========================================
|
|
# 清理
|
|
# ===========================================
|
|
cleanup() {
|
|
log_step "清理生产服务器临时文件"
|
|
run_ssh "rm -rf /tmp/canary-release" 2>/dev/null || true
|
|
}
|
|
|
|
# ===========================================
|
|
# 主流程
|
|
# ===========================================
|
|
main() {
|
|
echo "==========================================="
|
|
echo " 🐦 金丝雀发布"
|
|
echo " 版本: $IMAGE_TAG"
|
|
echo " 阶段: $CANARY_STAGES"
|
|
echo "==========================================="
|
|
echo ""
|
|
|
|
setup_ssh
|
|
prepare_server
|
|
trap cleanup EXIT
|
|
|
|
# 解析灰度阶段
|
|
IFS=',' read -ra STAGES <<< "$CANARY_STAGES"
|
|
local total_stages=${#STAGES[@]}
|
|
local current_stage=0
|
|
|
|
# 逐阶段灰度
|
|
for stage in "${STAGES[@]}"; do
|
|
current_stage=$((current_stage + 1))
|
|
local pct=$(echo "$stage" | cut -d: -f1)
|
|
local wait_time=$(echo "$stage" | cut -d: -f2)
|
|
|
|
echo ""
|
|
echo "--- 阶段 $current_stage/$total_stages: ${pct}% 灰度 ---"
|
|
|
|
# 执行灰度发布
|
|
if ! gray_deploy "$pct"; then
|
|
log_error "灰度发布 ${pct}% 失败"
|
|
rollback_gray
|
|
notify_status "failure" "Stage ${pct}% Deploy Failed"
|
|
exit 1
|
|
fi
|
|
|
|
# 健康检查
|
|
if ! health_check "${pct}%灰度"; then
|
|
log_error "${pct}%灰度健康检查失败"
|
|
rollback_gray
|
|
notify_status "failure" "Stage ${pct}% Health Check Failed"
|
|
exit 1
|
|
fi
|
|
|
|
# 观察期
|
|
log_info "⏳ 观察期 ${wait_time}s,监控流量稳定性..."
|
|
local waited=0
|
|
local check_interval=60
|
|
while [ $waited -lt $wait_time ]; do
|
|
sleep $check_interval
|
|
waited=$((waited + check_interval))
|
|
# 每隔一段时间做一次快速健康检查
|
|
local api_code=$(curl -s -o /dev/null -w "%{http_code}" \
|
|
--connect-timeout 5 --max-time 10 \
|
|
"${PROD_API_URL}/health" 2>/dev/null || echo "000")
|
|
if [[ "$api_code" != "200" ]]; then
|
|
log_error "❌ 观察期内 API 异常 (HTTP $api_code),触发回滚"
|
|
rollback_gray
|
|
notify_status "failure" "Stage ${pct}% Watch Period Failed"
|
|
exit 1
|
|
fi
|
|
log_info " 观察中... ${waited}s/${wait_time}s (API=$api_code)"
|
|
done
|
|
|
|
log_info "✅ ${pct}%灰度阶段完成,稳定运行 ${wait_time}s"
|
|
done
|
|
|
|
# 全量部署
|
|
echo ""
|
|
echo "--- 最终阶段: 100% 全量部署 ---"
|
|
|
|
if ! full_deploy; then
|
|
log_error "全量部署失败"
|
|
notify_status "failure" "Full Deploy Failed"
|
|
exit 1
|
|
fi
|
|
|
|
# 最终健康检查
|
|
if ! health_check "全量部署" "180"; then
|
|
log_error "全量部署后健康检查失败"
|
|
notify_status "failure" "Full Deploy Health Check Failed"
|
|
exit 1
|
|
fi
|
|
|
|
# 清理 canary 容器
|
|
log_step "清理 Canary 容器"
|
|
run_ssh "docker rm -f xiaoxia-api-canary xiaoxia-web-canary 2>/dev/null || true" || true
|
|
|
|
echo ""
|
|
echo "==========================================="
|
|
echo " ✅ 金丝雀发布完成"
|
|
echo " 版本: $IMAGE_TAG"
|
|
echo " 状态: 100%全量运行"
|
|
echo "==========================================="
|
|
|
|
notify_status "success" "$IMAGE_TAG Fully Deployed"
|
|
}
|
|
|
|
main "$@"
|