484 lines
16 KiB
Bash
484 lines
16 KiB
Bash
#!/bin/bash
|
||
# ===========================================
|
||
# CI Production 健康检查 + 自动回滚脚本(SSH 部署模式)
|
||
# ===========================================
|
||
#
|
||
# 在 CI Runner 上执行,通过公网 URL 检查 Production 部署健康状态。
|
||
# 不健康则通过 SSH 自动回滚到上一个版本的镜像。
|
||
#
|
||
# 用法:
|
||
# ./ci_production_healthcheck.sh
|
||
#
|
||
# 环境变量:
|
||
# PROD_API_URL - Production API 公网地址 (默认 https://api.xiaoxiajianji.com)
|
||
# PROD_WEB_URL - Production Web 公网地址 (默认 https://saas.xiaoxiajianji.com)
|
||
# HEALTH_CHECK_TIMEOUT - 健康检查总超时秒数 (默认 180)
|
||
# SKIP_ROLLBACK - 失败时不自动回滚 (true/false, 默认 false)
|
||
# SKIP_NOTIFY - 跳过通知 (true/false, 默认 false)
|
||
# CI_NOTIFY_WEBHOOK - 通知 Webhook URL
|
||
#
|
||
# PRODUCTION_SSH_HOST - 生产服务器 SSH 地址
|
||
# PRODUCTION_SSH_USER - SSH 用户名 (默认 root)
|
||
# PRODUCTION_SSH_PORT - SSH 端口 (默认 22222)
|
||
# PRODUCTION_SSH_KEY - SSH 私钥内容
|
||
# REGISTRY_TOKEN - Registry Token(回滚时拉取旧镜像需要)
|
||
#
|
||
# GITHUB_SHA - 当前 commit SHA
|
||
# GITHUB_REF_NAME - tag 名 (如 v0.1.100)
|
||
# GITHUB_RUN_ID - CI Run ID
|
||
# GITHUB_REPOSITORY - 仓库名
|
||
# GITHUB_ACTOR - 提交者
|
||
|
||
set -eu
|
||
|
||
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||
|
||
# 配置
|
||
PROD_API_URL="${PROD_API_URL:-https://api.xiaoxiajianji.com}"
|
||
PROD_WEB_URL="${PROD_WEB_URL:-https://saas.xiaoxiajianji.com}"
|
||
HEALTH_CHECK_TIMEOUT="${HEALTH_CHECK_TIMEOUT:-180}"
|
||
SKIP_ROLLBACK="${SKIP_ROLLBACK:-false}"
|
||
SKIP_NOTIFY="${SKIP_NOTIFY:-false}"
|
||
|
||
PRODUCTION_SSH_HOST="${PRODUCTION_SSH_HOST:-47.98.113.167}"
|
||
PRODUCTION_SSH_USER="${PRODUCTION_SSH_USER:-root}"
|
||
PRODUCTION_SSH_PORT="${PRODUCTION_SSH_PORT:-22222}"
|
||
|
||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||
|
||
# 颜色
|
||
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() {
|
||
# 查找或创建 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/prod_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"
|
||
}
|
||
|
||
# ===========================================
|
||
# 1. 记录部署前各服务的镜像版本(用于回滚)
|
||
# ===========================================
|
||
ROLLBACK_API_TAG=""
|
||
ROLLBACK_WORKER_TAG=""
|
||
ROLLBACK_WEB_TAG=""
|
||
|
||
save_rollback_target() {
|
||
log_step "记录当前生产环境各服务镜像版本(回滚目标)..."
|
||
|
||
# 通过 SSH 获取当前运行的容器镜像
|
||
local api_image worker_image web_image
|
||
api_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-api-production 2>/dev/null || echo ''")
|
||
worker_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-worker-production 2>/dev/null || echo ''")
|
||
web_image=$(run_ssh "docker inspect --format '{{.Config.Image}}' xiaoxia-web-production 2>/dev/null || echo ''")
|
||
|
||
# 提取 tag(镜像名是 xiaoxia-saas-api:v0.1.100 格式)
|
||
ROLLBACK_API_TAG=$(echo "$api_image" | sed 's/.*://' || echo "")
|
||
ROLLBACK_WORKER_TAG=$(echo "$worker_image" | sed 's/.*://' || echo "")
|
||
ROLLBACK_WEB_TAG=$(echo "$web_image" | sed 's/.*://' || echo "")
|
||
|
||
log_info " API: ${ROLLBACK_API_TAG:-未知}"
|
||
log_info " Worker: ${ROLLBACK_WORKER_TAG:-未知}"
|
||
log_info " Web: ${ROLLBACK_WEB_TAG:-未知}"
|
||
|
||
# 验证三个服务版本是否一致
|
||
if [ -n "$ROLLBACK_API_TAG" ] && [ -n "$ROLLBACK_WORKER_TAG" ] && [ -n "$ROLLBACK_WEB_TAG" ]; then
|
||
if [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WORKER_TAG" ] && [ "$ROLLBACK_API_TAG" = "$ROLLBACK_WEB_TAG" ]; then
|
||
log_info " ✅ 三个服务版本一致: $ROLLBACK_API_TAG"
|
||
export ROLLBACK_TAG="$ROLLBACK_API_TAG"
|
||
else
|
||
log_warn " ⚠️ 三个服务版本不一致,回滚时将分别使用各自版本"
|
||
export ROLLBACK_API_TAG ROLLBACK_WORKER_TAG ROLLBACK_WEB_TAG
|
||
export ROLLBACK_TAG_MIXED="true"
|
||
fi
|
||
else
|
||
log_warn " ⚠️ 未能获取全部服务版本,回滚功能可能受限"
|
||
fi
|
||
}
|
||
|
||
# ===========================================
|
||
# 2. 健康检查(公网视角)
|
||
# ===========================================
|
||
health_check() {
|
||
local timeout="$HEALTH_CHECK_TIMEOUT"
|
||
local start_time
|
||
start_time=$(date +%s)
|
||
|
||
log_step "公网健康检查(超时 ${timeout}s)..."
|
||
log_info " API: ${PROD_API_URL}/health"
|
||
log_info " Web: ${PROD_WEB_URL}/"
|
||
|
||
local api_ok=false
|
||
local web_ok=false
|
||
local api_docs_ok=false
|
||
local login_api_ok=false
|
||
|
||
while [ $(( $(date +%s) - start_time )) -lt "$timeout" ]; do
|
||
# 检查 API health
|
||
if [ "$api_ok" = false ] && curl -sf --max-time 10 "${PROD_API_URL}/health" >/dev/null 2>&1; then
|
||
log_info "✅ API 健康检查通过"
|
||
api_ok=true
|
||
fi
|
||
|
||
# 检查 Web 首页
|
||
if [ "$web_ok" = false ] && curl -sf --max-time 10 "$PROD_WEB_URL/" >/dev/null 2>&1; then
|
||
log_info "✅ Web 前端检查通过"
|
||
web_ok=true
|
||
fi
|
||
|
||
# 检查 API docs
|
||
if [ "$api_docs_ok" = false ]; then
|
||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "${PROD_API_URL}/docs" 2>/dev/null || echo "000")
|
||
if [ "$HTTP_CODE" = "200" ]; then
|
||
log_info "✅ API Docs 检查通过"
|
||
api_docs_ok=true
|
||
fi
|
||
fi
|
||
|
||
# 检查登录 API
|
||
if [ "$login_api_ok" = false ]; then
|
||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -X POST \
|
||
"${PROD_API_URL}/api/v1/auth/login" \
|
||
-H "Content-Type: application/json" \
|
||
-d '{"email":"smoke@test.com","password":"wrong"}' 2>/dev/null || echo "000")
|
||
if [ "$HTTP_CODE" = "401" ] || [ "$HTTP_CODE" = "422" ]; then
|
||
log_info "✅ 登录 API 检查通过(HTTP $HTTP_CODE,符合预期)"
|
||
login_api_ok=true
|
||
fi
|
||
fi
|
||
|
||
# 都通过了就退出
|
||
if [ "$api_ok" = true ] && [ "$web_ok" = true ] && [ "$api_docs_ok" = true ] && [ "$login_api_ok" = true ]; then
|
||
log_info "🎉 所有健康检查通过!"
|
||
return 0
|
||
fi
|
||
|
||
sleep 5
|
||
done
|
||
|
||
# 超时了
|
||
log_error "❌ 健康检查超时 (${timeout}s)"
|
||
[ "$api_ok" = false ] && log_error " - API health 未通过"
|
||
[ "$web_ok" = false ] && log_error " - Web 前端未通过"
|
||
[ "$api_docs_ok" = false ] && log_error " - API Docs 未通过"
|
||
[ "$login_api_ok" = false ] && log_error " - 登录 API 未通过"
|
||
|
||
return 1
|
||
}
|
||
|
||
# ===========================================
|
||
# 3. 执行回滚(SSH 重新部署旧版本)
|
||
# ===========================================
|
||
do_rollback() {
|
||
log_step "执行回滚:通过 SSH 重新部署旧版本镜像..."
|
||
|
||
local rollback_tag="${ROLLBACK_TAG:-}"
|
||
if [ -z "$rollback_tag" ] && [ "${ROLLBACK_TAG_MIXED:-}" != "true" ]; then
|
||
log_error "没有可回滚的版本记录,无法自动回滚"
|
||
return 1
|
||
fi
|
||
|
||
# 如果版本不一致,用 API 的版本作为回滚目标
|
||
if [ -z "$rollback_tag" ]; then
|
||
rollback_tag="$ROLLBACK_API_TAG"
|
||
fi
|
||
|
||
if [ -z "$rollback_tag" ]; then
|
||
log_error "无法确定回滚版本"
|
||
return 1
|
||
fi
|
||
|
||
log_info "回滚目标版本: $rollback_tag"
|
||
|
||
# 通过 SSH 在生产服务器上执行回滚部署
|
||
# 复用 Registry 方式部署脚本的逻辑,用旧版本 tag 重新部署
|
||
local rollback_script=$(cat << 'ROLLBACK_EOF'
|
||
#!/bin/sh
|
||
set -eu
|
||
|
||
IMAGE_TAG="$1"
|
||
REGISTRY_TOKEN="$2"
|
||
REGISTRY="${REGISTRY:-git.xiaoxiajianji.com/xiaoxia/xiaoxia-saas}"
|
||
REGISTRY_USER="${REGISTRY_USER:-xiaoxia}"
|
||
|
||
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}"
|
||
|
||
echo "=== Rollback to $IMAGE_TAG ==="
|
||
|
||
# 登录 Registry
|
||
if [ -n "$REGISTRY_TOKEN" ]; then
|
||
REGISTRY_HOST=$(echo "$REGISTRY" | cut -d/ -f1)
|
||
printf %s "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" -u "$REGISTRY_USER" --password-stdin 2>/dev/null || true
|
||
fi
|
||
|
||
# Pull 旧版本镜像
|
||
LOCAL_API="xiaoxia-saas-api:${IMAGE_TAG}"
|
||
LOCAL_WORKER="xiaoxia-saas-worker:${IMAGE_TAG}"
|
||
LOCAL_WEB="xiaoxia-saas-web:${IMAGE_TAG}"
|
||
|
||
docker pull "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}"
|
||
docker pull "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}"
|
||
docker pull "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}"
|
||
|
||
docker tag "${REGISTRY}/xiaoxia-saas-api:${IMAGE_TAG}" "$LOCAL_API"
|
||
docker tag "${REGISTRY}/xiaoxia-saas-worker:${IMAGE_TAG}" "$LOCAL_WORKER"
|
||
docker tag "${REGISTRY}/xiaoxia-saas-web:${IMAGE_TAG}" "$LOCAL_WEB"
|
||
|
||
echo "Rollback images pulled."
|
||
|
||
# 停止当前容器
|
||
docker rm -f xiaoxia-api-production 2>/dev/null || true
|
||
docker rm -f xiaoxia-worker-production 2>/dev/null || true
|
||
docker rm -f xiaoxia-web-production 2>/dev/null || true
|
||
|
||
LOG_OPTS="--log-driver json-file --log-opt max-size=50m --log-opt max-file=3"
|
||
|
||
# 启动 API(回滚不跑 migration,因为新版本可能加了字段,回滚后代码是旧的但数据还在)
|
||
echo "Starting API (rollback)..."
|
||
docker run -d \
|
||
--name xiaoxia-api-production \
|
||
--env-file "$ENV_FILE" \
|
||
--network xiaoxia-net-production \
|
||
-p 127.0.0.1:8001:8000 \
|
||
-e APP_ENV=production \
|
||
-e APP_VERSION="$IMAGE_TAG" \
|
||
-e GENERATED_FILES_DIR=/app/generated \
|
||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||
-v "$GENERATED_DIR:/app/generated" \
|
||
--restart unless-stopped \
|
||
--cpus 2 \
|
||
--memory 2g \
|
||
--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_OPTS \
|
||
"$LOCAL_API"
|
||
|
||
# 启动 Worker
|
||
echo "Starting Worker (rollback)..."
|
||
docker run -d \
|
||
--name xiaoxia-worker-production \
|
||
--env-file "$ENV_FILE" \
|
||
--network xiaoxia-net-production \
|
||
-e APP_ENV=production \
|
||
-e APP_VERSION="$IMAGE_TAG" \
|
||
-e WORKER_CONCURRENCY=1 \
|
||
-e WORKER_MAX_TASKS_PER_CHILD=100 \
|
||
-e GENERATED_FILES_DIR=/app/generated \
|
||
-e GENERATED_FILES_URL_PREFIX=/generated-files \
|
||
-e PUBLIC_API_BASE_URL=https://api.xiaoxiajianji.com \
|
||
-v "$GENERATED_DIR:/app/generated" \
|
||
--restart unless-stopped \
|
||
--cpus 2 \
|
||
--memory 2g \
|
||
--health-cmd "sh -c \"grep -q celery /proc/1/cmdline || exit 1\"" \
|
||
--health-interval 30s \
|
||
--health-timeout 10s \
|
||
--health-retries 3 \
|
||
--health-start-period 30s \
|
||
$LOG_OPTS \
|
||
"$LOCAL_WORKER"
|
||
|
||
# 启动 Web
|
||
LEGACY_VOLUME=""
|
||
if [ -d "$LEGACY_ASSETS_DIR" ] && [ "$(ls -A "$LEGACY_ASSETS_DIR" 2>/dev/null)" ]; then
|
||
LEGACY_VOLUME="-v ${LEGACY_ASSETS_DIR}:/usr/share/nginx/html/assets-legacy/assets:ro"
|
||
fi
|
||
|
||
echo "Starting Web (rollback)..."
|
||
docker run -d \
|
||
--name xiaoxia-web-production \
|
||
--network xiaoxia-net-production \
|
||
-p 127.0.0.1:3002:80 \
|
||
--restart unless-stopped \
|
||
--cpus 0.5 \
|
||
--memory 512m \
|
||
$LEGACY_VOLUME \
|
||
--health-cmd "wget --spider -q http://127.0.0.1:80" \
|
||
--health-interval 30s \
|
||
--health-timeout 5s \
|
||
--health-retries 3 \
|
||
$LOG_OPTS \
|
||
"$LOCAL_WEB"
|
||
|
||
# 等待 API 健康
|
||
echo "Waiting for API (rollback)..."
|
||
i=0
|
||
while [ "$i" -lt 40 ]; do
|
||
if curl -sf --max-time 5 http://127.0.0.1:8001/health >/dev/null 2>&1; then
|
||
echo "API healthy (rollback)."
|
||
break
|
||
fi
|
||
i=$((i + 1))
|
||
sleep 3
|
||
done
|
||
|
||
# 等待 Web 健康
|
||
echo "Waiting for Web (rollback)..."
|
||
i=0
|
||
while [ "$i" -lt 15 ]; do
|
||
if curl -sf --max-time 5 http://127.0.0.1:3002/ >/dev/null 2>&1; then
|
||
echo "Web healthy (rollback)."
|
||
break
|
||
fi
|
||
i=$((i + 1))
|
||
sleep 2
|
||
done
|
||
|
||
echo "=== Rollback complete: $IMAGE_TAG ==="
|
||
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Image}}" | grep production
|
||
ROLLBACK_EOF
|
||
)
|
||
|
||
# 将脚本 base64 编码后通过 SSH 执行
|
||
local script_b64
|
||
script_b64=$(echo "$rollback_script" | base64 -w 0)
|
||
|
||
log_info "在生产服务器上执行回滚脚本..."
|
||
if run_ssh "echo '$script_b64' | base64 -d | sh -s -- '$rollback_tag' '${REGISTRY_TOKEN:-}'" 2>&1; then
|
||
log_info "✅ 回滚命令执行完成"
|
||
return 0
|
||
else
|
||
log_error "❌ 回滚命令执行失败"
|
||
return 1
|
||
fi
|
||
}
|
||
|
||
# ===========================================
|
||
# 4. 发送通知
|
||
# ===========================================
|
||
send_notification() {
|
||
local status="$1" # success / failure / rollback
|
||
local detail="$2"
|
||
|
||
if [ "${SKIP_NOTIFY:-false}" = "true" ]; then
|
||
log_info "跳过通知(SKIP_NOTIFY=true)"
|
||
return 0
|
||
fi
|
||
|
||
local webhook="${CI_NOTIFY_WEBHOOK:-}"
|
||
if [ -z "$webhook" ]; then
|
||
log_warn "未配置 CI_NOTIFY_WEBHOOK,跳过通知"
|
||
return 0
|
||
fi
|
||
|
||
if [ -f "$SCRIPT_DIR/deploy_notify.py" ]; then
|
||
python3 "$SCRIPT_DIR/deploy_notify.py" \
|
||
--status "$status" \
|
||
--detail "$detail" \
|
||
--webhook "$webhook" \
|
||
--env production \
|
||
2>/dev/null || log_warn "通知发送失败(非致命)"
|
||
else
|
||
log_warn "找不到 deploy_notify.py,跳过通知"
|
||
fi
|
||
|
||
# 标记:通知已由健康检查脚本发出,避免 CI 兜底通知重复发送
|
||
echo "$status" > /tmp/prod_deploy_notification_sent
|
||
}
|
||
|
||
# ===========================================
|
||
# 主流程
|
||
# ===========================================
|
||
main() {
|
||
echo ""
|
||
echo "=========================================="
|
||
echo " CI Production 健康检查 + 自动回滚"
|
||
echo "=========================================="
|
||
echo ""
|
||
|
||
local deploy_status="success"
|
||
local deploy_detail=""
|
||
|
||
# 1. 设置 SSH
|
||
if ! setup_ssh; then
|
||
log_error "SSH 配置失败,无法执行回滚"
|
||
fi
|
||
|
||
# 2. 记录部署前状态(回滚目标)
|
||
save_rollback_target || true
|
||
|
||
# 3. 健康检查(公网视角)
|
||
if ! health_check; then
|
||
log_error "健康检查失败"
|
||
deploy_status="failure"
|
||
deploy_detail="公网健康检查超时,部署后服务未正常响应"
|
||
|
||
# 自动回滚
|
||
if [ "${SKIP_ROLLBACK:-false}" != "true" ]; then
|
||
log_warn "开始自动回滚..."
|
||
if do_rollback; then
|
||
deploy_status="rollback"
|
||
deploy_detail="健康检查失败,已自动回滚到上一版本 (${ROLLBACK_TAG:-未知})"
|
||
|
||
# 回滚后再检查一下公网状态
|
||
log_info "回滚完成,重新检查公网健康状态..."
|
||
if health_check; then
|
||
log_info "✅ 回滚后服务已恢复"
|
||
deploy_detail="${deploy_detail},回滚后服务已恢复"
|
||
else
|
||
log_error "⚠️ 回滚后健康检查仍未通过,请手动排查"
|
||
deploy_detail="${deploy_detail},但回滚后仍未恢复,请紧急排查"
|
||
fi
|
||
else
|
||
deploy_detail="健康检查失败且回滚失败,请手动排查"
|
||
fi
|
||
fi
|
||
|
||
send_notification "$deploy_status" "$deploy_detail"
|
||
|
||
# 失败时退出非零,让 CI Job 标记为失败
|
||
exit 1
|
||
fi
|
||
|
||
# 4. 成功
|
||
log_info ""
|
||
log_info "=================================="
|
||
log_info " ✅ Production 部署成功!"
|
||
log_info "=================================="
|
||
|
||
deploy_detail="部署成功,所有健康检查通过 (${GITHUB_REF_NAME:-未知版本})"
|
||
send_notification "success" "$deploy_detail"
|
||
}
|
||
|
||
main "$@"
|