Files
Hulumath-Web/scripts/deploy_production.sh
T
Jacky 7c9e0303b9
CI / test (pull_request) Successful in 3m27s
ci: add rollback-aware production smoke checks
2026-08-09 00:10:35 +08:00

311 lines
10 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -Eeuo pipefail
umask 027
PROJECT_DIR="${PROJECT_DIR:-/www/wwwroot/Hulumath-Web}"
PYTHON_BIN="${PYTHON_BIN:-/www/server/pyporject_evn/versions/3.12.13/bin/python3}"
VENV_DIR="${VENV_DIR:-$PROJECT_DIR/.venv-production}"
ENV_FILE="${ENV_FILE:-$PROJECT_DIR/.env.production}"
SERVICE_NAME="${SERVICE_NAME:-hulumath-web}"
BACKUP_DIR="${BACKUP_DIR:-/www/backup/hulumath}"
MYSQLDUMP_BIN="${MYSQLDUMP_BIN:-}"
MYSQL_CONTAINER="${MYSQL_CONTAINER:-}"
PREVIOUS_REVISION="${PREVIOUS_REVISION:-}"
HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}"
SMOKE_BASE_URL="${DEPLOY_SMOKE_BASE_URL:-}"
SMOKE_WS_URL="${DEPLOY_SMOKE_WS_URL:-}"
ROLLBACK_ENABLED=0
MYSQL_CREDENTIALS_FILE=""
MYSQL_CONTAINER_CREDENTIALS_PATH=""
log() {
printf '\n[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}
fail() {
printf '\n[ERROR] %s\n' "$*" >&2
if [ "$ROLLBACK_ENABLED" -eq 1 ]; then
rollback_application 1
fi
exit 1
}
if [ "$(id -u)" -eq 0 ]; then
SUDO=""
elif sudo -n true 2>/dev/null; then
SUDO="sudo"
else
fail "部署用户必须是 root,或具备免密 sudo 权限"
fi
DEPLOY_OWNER="$(id -un):$(id -gn)"
rollback_application() {
local exit_code="${1:-$?}"
trap - ERR
if [ -n "$MYSQL_CONTAINER_CREDENTIALS_PATH" ] && [ -n "$MYSQL_CONTAINER" ]; then
docker exec "$MYSQL_CONTAINER" rm -f "$MYSQL_CONTAINER_CREDENTIALS_PATH" \
>/dev/null 2>&1 || true
fi
if [ -n "$MYSQL_CREDENTIALS_FILE" ]; then
rm -f "$MYSQL_CREDENTIALS_FILE"
fi
log "部署失败,开始应用代码回退"
if [ -z "$PREVIOUS_REVISION" ]; then
log "未提供 PREVIOUS_REVISION,跳过自动回退"
exit "$exit_code"
fi
if ! git cat-file -e "${PREVIOUS_REVISION}^{commit}" 2>/dev/null; then
log "上一版本 $PREVIOUS_REVISION 不存在,跳过自动回退"
exit "$exit_code"
fi
git reset --hard "$PREVIOUS_REVISION"
if [ ! -f "$PROJECT_DIR/backend/manage.py" ]; then
log "上一版本不是 Django 版本,无法自动恢复;数据库备份仍保留在 $BACKUP_DIR"
exit "$exit_code"
fi
$SUDO chown -R "$DEPLOY_OWNER" "$VENV_DIR"
if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then
$SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles"
fi
"$VENV_DIR/bin/pip" install --disable-pip-version-check -r "$PROJECT_DIR/requirements.txt"
(
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
cd "$PROJECT_DIR/backend"
"$VENV_DIR/bin/python" manage.py collectstatic --noinput
)
$SUDO chown -R www:www "$PROJECT_DIR/backend/staticfiles"
$SUDO chown -R www:www "$VENV_DIR"
$SUDO systemctl restart "$SERVICE_NAME"
log "应用代码已回退到 $PREVIOUS_REVISION;数据库未执行反向迁移"
exit "$exit_code"
}
trap rollback_application ERR
ROLLBACK_ENABLED=1
cd "$PROJECT_DIR"
[ -d .git ] || fail "$PROJECT_DIR 不是 Git 仓库"
[ -f requirements.txt ] || fail "缺少 requirements.txt"
[ -f backend/manage.py ] || fail "缺少 backend/manage.py"
[ -f "$ENV_FILE" ] || fail "缺少 $ENV_FILE,请先按 .env.production.example 配置"
if [ ! -x "$PYTHON_BIN" ] && [ -x "${PYTHON_BIN%3}" ]; then
PYTHON_BIN="${PYTHON_BIN%3}"
fi
[ -x "$PYTHON_BIN" ] || fail "Python 不存在或不可执行: $PYTHON_BIN"
set -a
# .env.production 必须使用 KEY=value 格式;包含特殊字符的值请用单引号包裹。
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
if [ -z "$HEALTH_HOST" ]; then
HEALTH_HOST="${DEPLOY_HEALTH_HOST:-}"
fi
[ "${DJANGO_DEBUG:-}" = "false" ] || fail "生产环境必须设置 DJANGO_DEBUG=false"
[[ "${DATABASE_URL:-}" == mysql://* ]] \
|| fail "DATABASE_URL 必须是 MySQL 地址"
[[ "${REDIS_URL:-}" == redis://* || "${REDIS_URL:-}" == rediss://* ]] \
|| fail "REDIS_URL 必须是 Redis 地址"
[ -n "${DJANGO_SECRET_KEY:-}" ] || fail "缺少 DJANGO_SECRET_KEY"
[ -n "${DJANGO_ALLOWED_HOSTS:-}" ] || fail "缺少 DJANGO_ALLOWED_HOSTS"
if [ -z "$HEALTH_HOST" ]; then
HEALTH_HOST="${DJANGO_ALLOWED_HOSTS%%,*}"
fi
log "创建或更新生产虚拟环境"
if [ ! -x "$VENV_DIR/bin/python" ]; then
"$PYTHON_BIN" -m venv "$VENV_DIR"
fi
$SUDO chown -R "$DEPLOY_OWNER" "$VENV_DIR"
"$VENV_DIR/bin/pip" install --disable-pip-version-check --upgrade pip
"$VENV_DIR/bin/pip" install --disable-pip-version-check -r requirements.txt
log "备份 MySQL"
if [ -n "$MYSQL_CONTAINER" ]; then
command -v docker >/dev/null 2>&1 || fail "配置了 MYSQL_CONTAINER,但服务器没有 docker 命令"
docker inspect "$MYSQL_CONTAINER" >/dev/null 2>&1 \
|| fail "MySQL 容器不存在: $MYSQL_CONTAINER"
docker exec "$MYSQL_CONTAINER" mysqldump --version \
| grep -Eq '(Ver|Distrib)[[:space:]]+8\.' \
|| fail "容器内 mysqldump 不是 MySQL 8: $MYSQL_CONTAINER"
else
if [ -z "$MYSQLDUMP_BIN" ]; then
MYSQLDUMP_BIN="$(command -v mysqldump || true)"
fi
if [ -z "$MYSQLDUMP_BIN" ]; then
for candidate in \
/www/server/mysql/bin/mysqldump \
/usr/bin/mysqldump; do
if [ -x "$candidate" ]; then
MYSQLDUMP_BIN="$candidate"
break
fi
done
fi
[ -x "$MYSQLDUMP_BIN" ] \
|| fail "未找到 mysqldumpDocker MySQL 请设置 MYSQL_CONTAINER"
"$MYSQLDUMP_BIN" --version | grep -Eq '(Ver|Distrib)[[:space:]]+8\.' \
|| fail "宿主机 mysqldump 不是 MySQL 8;请设置 MYSQL_CONTAINER 使用容器内客户端"
fi
$SUDO mkdir -p "$BACKUP_DIR"
$SUDO chown "$(id -un):$(id -gn)" "$BACKUP_DIR"
BACKUP_FILE="$BACKUP_DIR/hulumath_$(date '+%Y%m%d_%H%M%S').sql.gz"
MYSQL_CREDENTIALS_FILE="$(mktemp)"
chmod 600 "$MYSQL_CREDENTIALS_FILE"
MYSQL_DATABASE_NAME="$("$VENV_DIR/bin/python" - "$MYSQL_CREDENTIALS_FILE" <<'PY'
import os
import sys
from urllib.parse import unquote, urlparse
parsed = urlparse(os.environ["DATABASE_URL"])
database = unquote(parsed.path.lstrip("/"))
values = {
"host": parsed.hostname or "127.0.0.1",
"port": str(parsed.port or 3306),
"user": unquote(parsed.username or ""),
"password": unquote(parsed.password or ""),
"database": database,
}
if not values["user"] or not database:
raise SystemExit("DATABASE_URL 缺少用户名或数据库名")
if any("\n" in value or "\r" in value for value in values.values()):
raise SystemExit("DATABASE_URL 包含非法换行")
def quote(value):
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
with open(sys.argv[1], "w", encoding="utf-8") as target:
target.write("[client]\n")
if not os.environ.get("MYSQL_CONTAINER"):
target.write(f"host={quote(values['host'])}\n")
target.write(f"port={values['port']}\n")
target.write(f"user={quote(values['user'])}\n")
target.write(f"password={quote(values['password'])}\n")
target.write("default-character-set=utf8mb4\n")
print(database)
PY
)"
if [ -n "$MYSQL_CONTAINER" ]; then
MYSQL_CONTAINER_CREDENTIALS_PATH="/tmp/hulumath-dump-$$.cnf"
docker cp "$MYSQL_CREDENTIALS_FILE" \
"$MYSQL_CONTAINER:$MYSQL_CONTAINER_CREDENTIALS_PATH"
docker exec "$MYSQL_CONTAINER" mysqldump \
--defaults-extra-file="$MYSQL_CONTAINER_CREDENTIALS_PATH" \
--single-transaction \
--quick \
--triggers \
--hex-blob \
--no-tablespaces \
--set-gtid-purged=OFF \
"$MYSQL_DATABASE_NAME" | gzip -c > "$BACKUP_FILE"
docker exec "$MYSQL_CONTAINER" rm -f "$MYSQL_CONTAINER_CREDENTIALS_PATH"
MYSQL_CONTAINER_CREDENTIALS_PATH=""
else
"$MYSQLDUMP_BIN" \
--defaults-extra-file="$MYSQL_CREDENTIALS_FILE" \
--single-transaction \
--quick \
--triggers \
--hex-blob \
--no-tablespaces \
--set-gtid-purged=OFF \
"$MYSQL_DATABASE_NAME" | gzip -c > "$BACKUP_FILE"
fi
rm -f "$MYSQL_CREDENTIALS_FILE"
MYSQL_CREDENTIALS_FILE=""
test -s "$BACKUP_FILE" || fail "数据库备份为空: $BACKUP_FILE"
find "$BACKUP_DIR" -type f -name 'hulumath_*.sql.gz' -mtime +14 -delete
log "数据库备份完成: $BACKUP_FILE"
log "执行 Django 发布检查、迁移和静态资源收集"
if [ -d "$PROJECT_DIR/backend/staticfiles" ]; then
$SUDO chown -R "$DEPLOY_OWNER" "$PROJECT_DIR/backend/staticfiles"
fi
cd "$PROJECT_DIR/backend"
if [ "${DJANGO_USE_HTTPS:-true}" = "true" ]; then
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level WARNING --database default
else
log "临时 HTTP 模式:部署安全检查只阻断 ERROR;绑定域名后应启用 HTTPS"
"$VENV_DIR/bin/python" manage.py check --deploy --fail-level ERROR --database default
fi
"$VENV_DIR/bin/python" manage.py check_mysql
"$VENV_DIR/bin/python" manage.py migrate --noinput
"$VENV_DIR/bin/python" manage.py collectstatic --noinput
cd "$PROJECT_DIR"
log "安装或更新 systemd 服务"
$SUDO install -m 0644 deploy/hulumath-web.service /etc/systemd/system/hulumath-web.service
$SUDO systemctl daemon-reload
$SUDO systemctl enable "$SERVICE_NAME"
if ! $SUDO systemctl is-active --quiet "$SERVICE_NAME"; then
if command -v ss >/dev/null 2>&1 && ss -ltn | grep -q ':8000 '; then
fail "8000 端口仍被旧 Flask/Gunicorn 占用。首次切换需先停止宝塔中的旧 Flask 进程"
fi
fi
log "修正运行目录权限并重启 ASGI 服务"
$SUDO chown -R www:www "$PROJECT_DIR/backend/staticfiles"
$SUDO chown -R www:www "$VENV_DIR"
$SUDO systemctl restart "$SERVICE_NAME"
$SUDO systemctl is-active --quiet "$SERVICE_NAME"
log "检查 HTTP 健康状态"
HTTP_OK=0
for _ in $(seq 1 20); do
if curl --fail --silent --show-error \
--header "Host: $HEALTH_HOST" \
--header "X-Forwarded-Proto: https" \
http://127.0.0.1:8000/health/ | grep -q '"status": "ok"'; then
HTTP_OK=1
break
fi
sleep 2
done
[ "$HTTP_OK" -eq 1 ] || fail "HTTP 健康检查失败"
log "检查 WebSocket 与 Redis Channel Layer"
"$VENV_DIR/bin/python" - <<'PY'
import asyncio
import json
from websockets.asyncio.client import connect
async def check():
async with connect("ws://127.0.0.1:8000/ws/health/", open_timeout=5) as websocket:
payload = json.loads(await asyncio.wait_for(websocket.recv(), timeout=5))
if payload != {"status": "ok", "channel_layer": "ok"}:
raise RuntimeError(f"unexpected WebSocket health payload: {payload}")
asyncio.run(check())
PY
if [ -n "$SMOKE_BASE_URL" ] || [ -n "$SMOKE_WS_URL" ]; then
[ -n "$SMOKE_BASE_URL" ] && [ -n "$SMOKE_WS_URL" ] \
|| fail "DEPLOY_SMOKE_BASE_URL 和 DEPLOY_SMOKE_WS_URL 必须同时设置"
[ -f "$PROJECT_DIR/scripts/smoke_production.py" ] \
|| fail "缺少生产冒烟脚本: scripts/smoke_production.py"
log "检查 Nginx、业务页面与公网 WebSocket"
"$VENV_DIR/bin/python" "$PROJECT_DIR/scripts/smoke_production.py" \
--base-url "$SMOKE_BASE_URL" \
--ws-url "$SMOKE_WS_URL"
fi
trap - ERR
ROLLBACK_ENABLED=0
log "部署完成,当前版本: $(git rev-parse --short HEAD)"
log "备份文件: $BACKUP_FILE"