Bash treated the full-width comma after $COMPOSE_FILE as part of the variable name under UTF-8 locale, causing an unbound variable error at the Next.js container rebuild step.
450 lines
14 KiB
Bash
Executable File
450 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ============================================================
|
||
# Novalon 统一发布脚本
|
||
#
|
||
# 用法:
|
||
# ./scripts/deploy.sh [命令] [选项]
|
||
#
|
||
# 命令:
|
||
# build 构建静态产物 (npm run build / npm run build:clean)
|
||
# deploy 构建并发布到生产服务器 (默认命令)
|
||
# rollback 回滚到最近一次远程备份
|
||
# status 查看生产环境发布状态
|
||
# help 显示帮助
|
||
#
|
||
# 选项:
|
||
# --skip-build 跳过本地构建,直接使用现有 dist/
|
||
# --auto-rollback 部署/验证失败时自动回滚到最近备份
|
||
# --clean 构建前清理 .next 与 dist
|
||
# --server-ip IP 服务器地址 (默认 139.155.109.62)
|
||
# --server-user USER SSH 用户 (默认 root)
|
||
# --deploy-root PATH 服务器部署根目录 (默认 /home/novalon/docker-app)
|
||
# --project-name NAME 服务器项目目录名 (默认 novalon-website)
|
||
# --nginx-container C Nginx 容器名 (默认 novalon-nginx-secure)
|
||
# --domain URL 验证域名 (默认 https://novalon.cn)
|
||
# --backup-retention N 远程备份保留份数 (默认 3)
|
||
# --dist-dir DIR 本地构建产物目录 (默认 dist)
|
||
# --dry-run 只打印远程命令,不实际执行
|
||
# -h, --help 显示帮助
|
||
#
|
||
# 环境变量覆盖:
|
||
# SERVER_IP SERVER_USER DEPLOY_ROOT PROJECT_NAME NGINX_CONTAINER
|
||
# NEXTJS_CONTAINER COMPOSE_FILE COMPOSE_CMD
|
||
# DOMAIN BACKUP_RETENTION_COUNT DIST_DIR
|
||
# ============================================================
|
||
|
||
set -euo pipefail
|
||
|
||
# ---------- 默认配置 ----------
|
||
SERVER_IP="${SERVER_IP:-139.155.109.62}"
|
||
SERVER_USER="${SERVER_USER:-root}"
|
||
DEPLOY_ROOT="${DEPLOY_ROOT:-/home/novalon/docker-app}"
|
||
PROJECT_NAME="${PROJECT_NAME:-novalon-website}"
|
||
NGINX_CONTAINER="${NGINX_CONTAINER:-novalon-nginx-secure}"
|
||
NEXTJS_CONTAINER="${NEXTJS_CONTAINER:-novalon-website}"
|
||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.server.yml}"
|
||
COMPOSE_CMD="${COMPOSE_CMD:-docker-compose}"
|
||
DOMAIN="${DOMAIN:-https://novalon.cn}"
|
||
BACKUP_RETENTION="${BACKUP_RETENTION_COUNT:-${BACKUP_RETENTION:-3}}"
|
||
DIST_DIR="${DIST_DIR:-dist}"
|
||
|
||
REMOTE_PROJECT_DIR="$DEPLOY_ROOT/$PROJECT_NAME"
|
||
REMOTE_DIST_DIR="$REMOTE_PROJECT_DIR/dist"
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||
LOG_DIR="$PROJECT_ROOT/logs"
|
||
LOG_FILE="$LOG_DIR/deploy_$(date +%Y%m%d_%H%M%S).log"
|
||
|
||
# ---------- 运行时状态 ----------
|
||
COMMAND="${1:-deploy}"
|
||
SKIP_BUILD=0
|
||
AUTO_ROLLBACK=0
|
||
CLEAN_BUILD=0
|
||
DRY_RUN=0
|
||
DEPLOY_STARTED=0
|
||
|
||
# ---------- 工具函数 ----------
|
||
usage() {
|
||
awk '
|
||
NR == 1 { next }
|
||
/^# =+$/ { c++; if (c == 2) exit }
|
||
{ line = $0; sub(/^# ?/, "", line); print line }
|
||
' "$0"
|
||
}
|
||
|
||
log() {
|
||
echo -e "$@"
|
||
}
|
||
|
||
require_local_cmd() {
|
||
local cmd="$1"
|
||
if ! command -v "$cmd" >/dev/null 2>&1; then
|
||
log "❌ 缺少本地命令: $cmd"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
run_remote() {
|
||
# 用法: run_remote <远程命令字符串>
|
||
# 远程命令会在目标服务器的 bash 中执行
|
||
local remote_cmd="$1"
|
||
if [[ "$DRY_RUN" == "1" ]]; then
|
||
log "🔍 [dry-run] ssh $SERVER_USER@$SERVER_IP \"$remote_cmd\""
|
||
return 0
|
||
fi
|
||
ssh -o ConnectTimeout=8 -o BatchMode=yes "$SERVER_USER@$SERVER_IP" "$remote_cmd"
|
||
}
|
||
|
||
verify_remote() {
|
||
log ""
|
||
log "=========================================="
|
||
log "📋 步骤 7: 验证生产环境"
|
||
log "=========================================="
|
||
|
||
local pages=("/" "/about" "/contact" "/products" "/services")
|
||
local failed=0
|
||
|
||
for page in "${pages[@]}"; do
|
||
local url="${DOMAIN}${page}"
|
||
local status
|
||
status=$(curl -fsS -o /dev/null -w '%{http_code}' -L --connect-timeout 8 --max-time 15 "$url" 2>/dev/null || echo "000")
|
||
if [[ "$status" == "200" ]]; then
|
||
log " ✅ $page ($status)"
|
||
else
|
||
log " ❌ $page ($status)"
|
||
failed=1
|
||
fi
|
||
done
|
||
|
||
if [[ "$failed" == "1" ]]; then
|
||
log "❌ 生产环境验证失败"
|
||
return 1
|
||
fi
|
||
log "✅ 生产环境验证通过"
|
||
}
|
||
|
||
# ---------- 命令实现 ----------
|
||
cmd_build() {
|
||
log "=========================================="
|
||
log "📋 构建静态站点"
|
||
log "=========================================="
|
||
cd "$PROJECT_ROOT"
|
||
if [[ "$CLEAN_BUILD" == "1" ]]; then
|
||
npm run build:clean
|
||
else
|
||
npm run build
|
||
fi
|
||
|
||
if [[ ! -d "$DIST_DIR" ]]; then
|
||
log "❌ 构建失败:$DIST_DIR 目录不存在"
|
||
exit 1
|
||
fi
|
||
|
||
# standalone 构建不会把 public/ 输出到 dist 根目录;
|
||
# Nginx 以 dist 作为站点根目录,因此手动同步 public 静态资源(logo、favicon、fonts、images 等)
|
||
if [[ -d "$PROJECT_ROOT/public" ]]; then
|
||
cp -R "$PROJECT_ROOT/public/." "$DIST_DIR/"
|
||
log "✅ public 静态资源已同步到 $DIST_DIR"
|
||
fi
|
||
|
||
# standalone 构建的客户端静态资源位于 dist/static,而 HTML 引用 /_next/static/...;
|
||
# 复制到 dist/_next/static 供 Nginx 直接服务
|
||
if [[ -d "$DIST_DIR/static" ]]; then
|
||
mkdir -p "$DIST_DIR/_next"
|
||
cp -R "$DIST_DIR/static" "$DIST_DIR/_next/static"
|
||
log "✅ _next/static 已从 static 同步到 $DIST_DIR/_next/static"
|
||
fi
|
||
|
||
log "✅ 构建完成,产物大小: $(du -sh "$DIST_DIR" | cut -f1)"
|
||
}
|
||
|
||
cmd_deploy() {
|
||
log "=========================================="
|
||
log "🚀 Novalon 统一发布"
|
||
log " 目标: $SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR"
|
||
log " 域名: $DOMAIN"
|
||
log "=========================================="
|
||
|
||
if [[ "$SKIP_BUILD" != "1" ]]; then
|
||
cmd_build
|
||
elif [[ ! -d "$DIST_DIR" ]]; then
|
||
log "❌ $DIST_DIR 目录不存在,请先构建或去掉 --skip-build"
|
||
exit 1
|
||
fi
|
||
|
||
require_local_cmd ssh
|
||
require_local_cmd rsync
|
||
require_local_cmd curl
|
||
|
||
# 步骤 1: 检查本地产物
|
||
log ""
|
||
log "📋 步骤 1: 检查构建产物"
|
||
DIST_SIZE=$(du -sh "$DIST_DIR" | cut -f1)
|
||
log "✅ $DIST_DIR 目录存在,大小: $DIST_SIZE"
|
||
|
||
# 步骤 2: 验证 SSH 连接
|
||
log ""
|
||
log "📋 步骤 2: 验证 SSH 连接"
|
||
if ! run_remote "exit"; then
|
||
log "❌ 无法连接到服务器 $SERVER_IP"
|
||
exit 1
|
||
fi
|
||
log "✅ SSH 连接验证成功"
|
||
|
||
# 步骤 3: 远程备份旧版本
|
||
log ""
|
||
log "📋 步骤 3: 备份服务器上的旧版本"
|
||
run_remote "
|
||
set -e
|
||
PROJECT_DIR='$REMOTE_PROJECT_DIR'
|
||
DIST_PATH='$REMOTE_DIST_DIR'
|
||
if [ -d \"\$DIST_PATH\" ]; then
|
||
TIMESTAMP=\$(date +%Y%m%d_%H%M%S)
|
||
BACKUP_PATH=\"\$PROJECT_DIR/dist_backup_\$TIMESTAMP\"
|
||
cp -r \"\$DIST_PATH\" \"\$BACKUP_PATH\"
|
||
echo \"✅ 备份完成: \$BACKUP_PATH\"
|
||
else
|
||
echo \"ℹ️ 首次部署,无需备份\"
|
||
fi
|
||
"
|
||
|
||
DEPLOY_STARTED=1
|
||
|
||
# 步骤 4: 清理旧备份(保留最近 N 份)
|
||
log ""
|
||
log "📋 步骤 4: 清理旧备份(保留最近 ${BACKUP_RETENTION} 份)"
|
||
run_remote "
|
||
set -e
|
||
PROJECT_DIR='$REMOTE_PROJECT_DIR'
|
||
cd \"\$PROJECT_DIR\" || exit 0
|
||
ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | tail -n +$((BACKUP_RETENTION + 1)) | xargs rm -rf 2>/dev/null || true
|
||
echo \"✅ 清理完成\"
|
||
"
|
||
|
||
# 步骤 5: 上传 dist 目录
|
||
log ""
|
||
log "📋 步骤 5: 上传 dist 目录"
|
||
if [[ "$DRY_RUN" == "1" ]]; then
|
||
log "🔍 [dry-run] rsync -avz --delete \"$DIST_DIR/\" $SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR/"
|
||
else
|
||
ssh "$SERVER_USER@$SERVER_IP" "mkdir -p '$REMOTE_DIST_DIR'"
|
||
rsync -avz --delete "$DIST_DIR/" "$SERVER_USER@$SERVER_IP:$REMOTE_DIST_DIR/"
|
||
fi
|
||
log "✅ dist 目录已上传"
|
||
|
||
# 步骤 6: 设置权限并重载 Nginx
|
||
log ""
|
||
log "📋 步骤 6: 设置权限并重载 Nginx"
|
||
run_remote "
|
||
set -e
|
||
DIST_PATH='$REMOTE_DIST_DIR'
|
||
chown -R www-data:www-data \"\$DIST_PATH\" 2>/dev/null || true
|
||
chmod -R 755 \"\$DIST_PATH\"
|
||
echo \"✅ 权限设置完成\"
|
||
"
|
||
run_remote "
|
||
set -e
|
||
if docker exec $NGINX_CONTAINER nginx -t 2>/dev/null; then
|
||
docker exec $NGINX_CONTAINER nginx -s reload
|
||
echo \"✅ Nginx 重载成功\"
|
||
else
|
||
echo \"⚠️ Nginx 配置检测失败,重启容器...\"
|
||
docker restart $NGINX_CONTAINER
|
||
sleep 3
|
||
echo \"✅ Nginx 容器已重启\"
|
||
fi
|
||
"
|
||
|
||
# 步骤 6.5: 同步 public 并重建 Next.js 容器
|
||
# 混合渲染下,Nginx 提供新客户端资源,Next.js 容器也必须运行同一份构建,
|
||
# 否则会出现 /_next/static 404 或 Server Reference ID 不匹配。
|
||
log ""
|
||
log "📋 步骤 6.5: 重建 Next.js 容器 ($NEXTJS_CONTAINER)"
|
||
if [[ -d "$PROJECT_ROOT/public" ]]; then
|
||
if [[ "$DRY_RUN" == "1" ]]; then
|
||
log "🔍 [dry-run] rsync -az --delete \"$PROJECT_ROOT/public/\" $SERVER_USER@$SERVER_IP:$REMOTE_PROJECT_DIR/public/"
|
||
else
|
||
rsync -az --delete "$PROJECT_ROOT/public/" "$SERVER_USER@$SERVER_IP:$REMOTE_PROJECT_DIR/public/"
|
||
fi
|
||
fi
|
||
run_remote "
|
||
set -e
|
||
cd '$REMOTE_PROJECT_DIR'
|
||
if [ -f '$COMPOSE_FILE' ]; then
|
||
$COMPOSE_CMD -f '$COMPOSE_FILE' build
|
||
$COMPOSE_CMD -f '$COMPOSE_FILE' up -d
|
||
echo \"✅ Next.js 容器已重建\"
|
||
else
|
||
echo \"⚠️ 未找到 ${COMPOSE_FILE},跳过 Next.js 容器重建\"
|
||
fi
|
||
"
|
||
|
||
# 步骤 7: 验证生产环境
|
||
if ! verify_remote; then
|
||
if [[ "$AUTO_ROLLBACK" == "1" ]]; then
|
||
log ""
|
||
log "🔄 自动回滚到最近备份..."
|
||
cmd_rollback
|
||
fi
|
||
exit 1
|
||
fi
|
||
|
||
log ""
|
||
log "🎉 部署成功!"
|
||
log "📋 访问地址: $DOMAIN"
|
||
}
|
||
|
||
cmd_rollback() {
|
||
log "=========================================="
|
||
log "🔄 回滚到最近一次远程备份"
|
||
log "=========================================="
|
||
|
||
require_local_cmd ssh
|
||
require_local_cmd curl
|
||
|
||
if ! run_remote "exit"; then
|
||
log "❌ 无法连接到服务器 $SERVER_IP"
|
||
exit 1
|
||
fi
|
||
|
||
log "📋 查找最近备份..."
|
||
LATEST_BACKUP=$(run_remote "
|
||
ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | head -1
|
||
" | tr -d '\r')
|
||
|
||
if [[ -z "$LATEST_BACKUP" ]]; then
|
||
log "❌ 未找到任何备份,无法回滚"
|
||
exit 1
|
||
fi
|
||
log "✅ 最近备份: $LATEST_BACKUP"
|
||
|
||
run_remote "
|
||
set -e
|
||
PROJECT_DIR='$REMOTE_PROJECT_DIR'
|
||
DIST_PATH='$REMOTE_DIST_DIR'
|
||
BACKUP_PATH='$LATEST_BACKUP'
|
||
rm -rf \"\$DIST_PATH\"
|
||
cp -r \"\$BACKUP_PATH\" \"\$DIST_PATH\"
|
||
chown -R www-data:www-data \"\$DIST_PATH\" 2>/dev/null || true
|
||
chmod -R 755 \"\$DIST_PATH\"
|
||
echo \"✅ 已从备份恢复: \$BACKUP_PATH\"
|
||
"
|
||
|
||
run_remote "
|
||
set -e
|
||
if docker exec $NGINX_CONTAINER nginx -t 2>/dev/null; then
|
||
docker exec $NGINX_CONTAINER nginx -s reload
|
||
echo \"✅ Nginx 重载成功\"
|
||
else
|
||
docker restart $NGINX_CONTAINER
|
||
sleep 3
|
||
echo \"✅ Nginx 容器已重启\"
|
||
fi
|
||
"
|
||
|
||
if verify_remote; then
|
||
log ""
|
||
log "🎉 回滚成功!"
|
||
else
|
||
log "❌ 回滚后验证失败,请手动介入"
|
||
exit 1
|
||
fi
|
||
}
|
||
|
||
cmd_status() {
|
||
log "=========================================="
|
||
log "📋 生产环境发布状态"
|
||
log "=========================================="
|
||
require_local_cmd ssh
|
||
|
||
if ! run_remote "exit"; then
|
||
log "❌ 无法连接到服务器 $SERVER_IP"
|
||
exit 1
|
||
fi
|
||
|
||
run_remote "
|
||
echo \"--- Nginx 容器 ---\"
|
||
docker ps --filter name=$NGINX_CONTAINER --format '{{.Names}} {{.Status}}' || echo '❌ 容器未运行'
|
||
echo
|
||
echo \"--- 远程 dist 目录 ---\"
|
||
ls -ld '$REMOTE_DIST_DIR' 2>/dev/null || echo '❌ 目录不存在'
|
||
echo \"文件数: \$(find '$REMOTE_DIST_DIR' -type f 2>/dev/null | wc -l)\"
|
||
echo
|
||
echo \"--- 最近修改 ---\"
|
||
find '$REMOTE_DIST_DIR' -maxdepth 1 -type f -printf '%T@ %f\n' 2>/dev/null | sort -rn | head -5
|
||
echo
|
||
echo \"--- 可用备份 ---\"
|
||
ls -dt ${REMOTE_PROJECT_DIR}/dist_backup_* 2>/dev/null | head -${BACKUP_RETENTION} || echo '无备份'
|
||
"
|
||
}
|
||
|
||
on_error() {
|
||
local rc=$?
|
||
if [[ "$COMMAND" == "deploy" && "$AUTO_ROLLBACK" == "1" && "$DEPLOY_STARTED" == "1" ]]; then
|
||
log ""
|
||
log "❌ 部署失败(exit=$rc),执行自动回滚..."
|
||
cmd_rollback >&2 || true
|
||
fi
|
||
exit "$rc"
|
||
}
|
||
trap on_error ERR
|
||
|
||
# ---------- 参数解析 ----------
|
||
case "$COMMAND" in
|
||
build|deploy|rollback|status|help|-h|--help) ;;
|
||
*)
|
||
log "❌ 未知命令: $COMMAND"
|
||
usage
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
if [[ "$COMMAND" == "help" || "$COMMAND" == "-h" || "$COMMAND" == "--help" ]]; then
|
||
usage
|
||
exit 0
|
||
fi
|
||
|
||
shift || true
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--skip-build) SKIP_BUILD=1; shift ;;
|
||
--auto-rollback) AUTO_ROLLBACK=1; shift ;;
|
||
--clean) CLEAN_BUILD=1; shift ;;
|
||
--dry-run) DRY_RUN=1; shift ;;
|
||
--server-ip) SERVER_IP="$2"; shift 2 ;;
|
||
--server-user) SERVER_USER="$2"; shift 2 ;;
|
||
--deploy-root) DEPLOY_ROOT="$2"; shift 2 ;;
|
||
--project-name) PROJECT_NAME="$2"; shift 2 ;;
|
||
--nginx-container) NGINX_CONTAINER="$2"; shift 2 ;;
|
||
--domain) DOMAIN="$2"; shift 2 ;;
|
||
--backup-retention) BACKUP_RETENTION="$2"; shift 2 ;;
|
||
--dist-dir) DIST_DIR="$2"; shift 2 ;;
|
||
-h|--help) usage; exit 0 ;;
|
||
*)
|
||
log "❌ 未知参数: $1"
|
||
usage
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
REMOTE_PROJECT_DIR="$DEPLOY_ROOT/$PROJECT_NAME"
|
||
REMOTE_DIST_DIR="$REMOTE_PROJECT_DIR/dist"
|
||
|
||
# ---------- 日志 ----------
|
||
mkdir -p "$LOG_DIR"
|
||
exec > >(tee -a "$LOG_FILE") 2>&1
|
||
|
||
log "📋 部署日志: $LOG_FILE"
|
||
log ""
|
||
|
||
# ---------- 执行 ----------
|
||
case "$COMMAND" in
|
||
build) cmd_build ;;
|
||
deploy) cmd_deploy ;;
|
||
rollback) cmd_rollback ;;
|
||
status) cmd_status ;;
|
||
esac
|