Files
opendance-web/deploy/install.sh
T
zijunchiang 7cd607ecde fix(deploy): 修复 install.sh 同步文件时的长时间卡死
用户实测:执行到「同步程序文件到 /opt/opendance」后卡死。

根因:在源码检出目录执行时,web-server/ 下有 node_modules(236MB /
27359 个文件,admin 下还有 122MB),原实现用 cp -r 全量复制,
随后又对整个目录 chown -R,处理三万个文件表现为卡死。

修复:
- 复制改用 copy_tree(),排除 node_modules/.git/storage/dist-release 等
  运行期可再生目录;有 rsync 用 rsync -a --delete,否则用 tar 管道回退
- tar 回退的排除模式同时给出 './x' 与 '*/x':只写 './x' 无法匹配嵌套的
  './admin/node_modules'(实测会漏掉 122MB,等于没修)
- 增加目录安全检查:安装目录与源目录相同、或二者互相包含时直接报错退出,
  避免 rm -rf 删掉源文件 / cp 无限递归
- 支持两种目录布局:发布包 admin/ 在根目录,源码检出在
  web-server/admin/dist(缺失则现场构建),此前从源码目录执行必然在
  「缺少 admin/ 目录」处失败
- 每个目录打印复制结果与文件数,npm 安装前给出预计耗时提示,
  不再有长时间无输出的「假卡死」
- 失败时 trap 打印行号

实测(用脚本中真实代码块驱动的用例):
  源码检出目录 → /tmp:rsync 路径 1 秒 / 209 文件;tar 回退路径同样 209 文件
  发布包解压目录 → /tmp:0 秒,admin 与 dist/main.js 均正确
  同目录 / 嵌套目录两种危险布局:均被拒绝并给出可操作提示
  两条路径均无 node_modules 泄漏
2026-09-15 00:14:06 +08:00

286 lines
12 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.
#!/bin/bash
# ============================================================
# OpenDance Studio Web 服务端 —— Linux 原生站点部署脚本
#
# 部署形态(不使用 Docker):
# nginx ── 静态托管管理端 + 反向代理 API
# └──▶ systemd 托管的 Node 进程(NestJS,仅监听 127.0.0.1)
#
# 数据库仍是部署方提供的**现有 MySQL / MariaDB 实例**,本脚本不安装、不管理数据库。
#
# 用法(在解压后的发布目录内,需要 root):
# cp deploy/.env.example deploy/.env && vi deploy/.env
# sudo bash deploy/install.sh
#
# 可用环境变量覆盖:
# APP_DIR 安装目录(默认 /opt/opendance)
# SERVICE_USER 运行服务的系统用户(默认 opendance)
# STORAGE_DIR 上传文件目录(默认 /var/lib/opendance/storage)
# SERVER_NAME nginx server_name(默认 _)
# SKIP_NGINX=1 只装 API 服务,不配置 nginx
# ============================================================
set -euo pipefail
# 失败时打印出错行号,避免只看到半截输出
trap 'printf "\033[1;31m[install]\033[0m 第 %d 行执行失败(退出码 %d)\n" "$LINENO" "$?" >&2' ERR
SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)"
cd "$SRC_DIR"
APP_DIR="${APP_DIR:-/opt/opendance}"
SERVICE_USER="${SERVICE_USER:-opendance}"
STORAGE_DIR="${STORAGE_DIR:-/var/lib/opendance/storage}"
SERVER_NAME="${SERVER_NAME:-_}"
API_HOST="127.0.0.1"
API_PORT="${PORT:-3000}"
log() { printf '\033[1;34m[install]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*"; }
die() { printf '\033[1;31m[error]\033[0m %s\n' "$*" >&2; exit 1; }
# ---------- 0. 前置检查 ----------
[ "$(id -u)" -eq 0 ] || die "需要 root 权限(安装 systemd 服务与 nginx 站点):sudo bash deploy/install.sh"
[ -f deploy/.env ] || die "缺少 deploy/.env,请先:
cp deploy/.env.example deploy/.env && vi deploy/.env"
set -a
# shellcheck disable=SC1091
. ./deploy/.env
set +a
API_PORT="${PORT:-3000}"
[ -n "${DB_HOST:-}" ] || die "deploy/.env 中 DB_HOST 未设置"
[ -n "${DB_USER:-}" ] || die "deploy/.env 中 DB_USER 未设置"
[ -n "${DB_PASS:-}" ] || die "deploy/.env 中 DB_PASS 未设置"
[ -n "${DB_NAME:-}" ] || die "deploy/.env 中 DB_NAME 未设置"
command -v node >/dev/null 2>&1 || die "未找到 node。请安装 Node.js 22 或更高版本。"
NODE_BIN="$(command -v node)"
NODE_MAJOR="$(node -p 'process.versions.node.split(".")[0]')"
[ "$NODE_MAJOR" -ge 22 ] 2>/dev/null || die "Node.js 版本过低(当前 $(node -v)),需要 22+"
command -v npm >/dev/null 2>&1 || die "未找到 npm"
log "Node.js:$("$NODE_BIN" -v)($NODE_BIN)"
log "安装目录:$APP_DIR"
log "运行用户:$SERVICE_USER"
log "上传目录:$STORAGE_DIR"
log "目标数据库:${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}(现有实例,本脚本不会创建或管理数据库)"
# ---------- 1. 系统用户与目录 ----------
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
log "创建系统用户 $SERVICE_USER"
useradd --system --shell /usr/sbin/nologin --home-dir "$APP_DIR" --create-home "$SERVICE_USER" 2>/dev/null \
|| useradd -r -s /sbin/nologin -d "$APP_DIR" -m "$SERVICE_USER"
fi
# ---- 目录安全检查 ----
# 若安装目录与源目录相同或互相包含,后面的 rm -rf / cp 会删掉源文件或无限递归。
SRC_REAL="$(readlink -f "$SRC_DIR")"
mkdir -p "$APP_DIR"
APP_REAL="$(readlink -f "$APP_DIR")"
if [ "$SRC_REAL" = "$APP_REAL" ]; then
die "安装目录与源目录相同($APP_REAL)。
本脚本会先清空安装目录下的 web-server/ admin/ 再复制,直接在这里执行会删掉源文件。
请把发布包解压到别处,或用 APP_DIR=... 指定另一个安装目录:
sudo APP_DIR=/opt/opendance bash $0"
fi
case "$APP_REAL/" in
"$SRC_REAL"/*)
die "安装目录位于源目录内部($APP_REAL ⊂ $SRC_REAL),复制会无限递归。
请改用源目录之外的 APP_DIR。" ;;
esac
case "$SRC_REAL/" in
"$APP_REAL"/*)
die "源目录位于安装目录内部($SRC_REAL ⊂ $APP_REAL)。
安装时会先清空该子目录,导致源文件被删除。请把发布包解压到 $APP_REAL 之外。" ;;
esac
# ---- 复制(排除 node_modules / .git / storage 等大目录)----
# 说明:在 git 检出目录直接执行时,web-server/ 里可能已有 200 MB+ 的
# node_modules(数万个小文件)。原实现用 cp -r 全量复制,会长时间无输出,
# 看起来像卡死。这里统一排除,运行期依赖稍后由 npm ci 重新安装。
COPY_EXCLUDES=(--exclude=node_modules --exclude=.git --exclude=storage
--exclude=dist-release --exclude=coverage --exclude=.gradle)
copy_tree() {
local src="$1" dst="$2"
mkdir -p "$dst"
if command -v rsync >/dev/null 2>&1; then
rsync -a --delete "${COPY_EXCLUDES[@]}" "$src/" "$dst/"
else
# 无 rsync 时用 tar 管道,同样支持排除。
# 注意:tar 的 --exclude 按完整成员名匹配,只写 "./node_modules" 无法匹配
# 嵌套的 "./admin/node_modules"(实测会漏掉 admin 下的 122MB 依赖)。
# 因此每个名字都要同时给出顶层与任意层级的模式。
local tar_excludes=()
for pattern in node_modules .git storage dist-release coverage .gradle; do
tar_excludes+=(--exclude="./$pattern" --exclude="*/$pattern")
done
( cd "$src" && tar "${tar_excludes[@]}" -cf - . ) | ( cd "$dst" && tar -xf - )
fi
}
log "同步程序文件到 $APP_DIR"
for item in web-server contracts; do
[ -e "$SRC_DIR/$item" ] || die "缺少 $item/ 目录(既不在发布包里,也不在源码根目录)"
printf ' 复制 %-12s …' "$item"
rm -rf "${APP_DIR:?}/$item"
copy_tree "$SRC_DIR/$item" "$APP_DIR/$item"
printf ' %s 个文件\n' "$(find "$APP_DIR/$item" -type f | wc -l)"
done
# ---- 管理端产物:两种目录布局都要支持 ----
# 发布包: <root>/admin/ (已构建)
# 源码检出:<root>/web-server/admin/dist/ (需已构建,缺失则现场构建)
ADMIN_SRC=""
if [ -d "$SRC_DIR/admin" ]; then
ADMIN_SRC="$SRC_DIR/admin"
elif [ -d "$SRC_DIR/web-server/admin/dist" ]; then
ADMIN_SRC="$SRC_DIR/web-server/admin/dist"
elif [ -d "$SRC_DIR/web-server/admin" ]; then
warn "未找到管理端构建产物,正在构建 web-server/admin(需要 devDependencies)"
if [ -f "$SRC_DIR/web-server/admin/package-lock.json" ]; then
( cd "$SRC_DIR/web-server/admin" && npm ci --no-audit --no-fund --loglevel=error )
else
( cd "$SRC_DIR/web-server/admin" && npm install --no-audit --no-fund --loglevel=error )
fi
( cd "$SRC_DIR/web-server/admin" && npm run build )
ADMIN_SRC="$SRC_DIR/web-server/admin/dist"
fi
[ -n "$ADMIN_SRC" ] || die "找不到管理端:既没有 admin/ 目录,也没有 web-server/admin/"
printf ' 复制 %-12s …' "admin"
rm -rf "${APP_DIR:?}/admin"
copy_tree "$ADMIN_SRC" "$APP_DIR/admin"
printf ' %s 个文件(来源:%s)\n' "$(find "$APP_DIR/admin" -type f | wc -l)" "$ADMIN_SRC"
mkdir -p "$APP_DIR/deploy"
cp -r "$SRC_DIR/deploy/." "$APP_DIR/deploy/"
if [ -f "$SRC_DIR/THIRD_PARTY.md" ]; then cp "$SRC_DIR/THIRD_PARTY.md" "$APP_DIR/"; fi
if [ -f "$SRC_DIR/README.txt" ]; then cp "$SRC_DIR/README.txt" "$APP_DIR/"; fi
mkdir -p "$STORAGE_DIR"
log "设置目录属主($SERVICE_USER)"
chown -R "$SERVICE_USER:$SERVICE_USER" "$APP_DIR" "$STORAGE_DIR"
chmod 750 "$STORAGE_DIR"
# ---------- 2. 安装运行期依赖(必要时构建) ----------
cd "$APP_DIR/web-server"
log "安装运行期依赖(仅生产依赖,可能需要 1-3 分钟…)"
if [ -f package-lock.json ]; then
sudo -u "$SERVICE_USER" npm ci --omit=dev --no-audit --no-fund --loglevel=error
else
sudo -u "$SERVICE_USER" npm install --omit=dev --no-audit --no-fund --loglevel=error
fi
if [ ! -f dist/main.js ]; then
warn "未找到构建产物 dist/main.js,改为在服务器上构建(需要 devDependencies)"
sudo -u "$SERVICE_USER" npm install --no-audit --no-fund
sudo -u "$SERVICE_USER" npm run build
fi
[ -f dist/main.js ] || die "构建产物缺失:$APP_DIR/web-server/dist/main.js"
[ -f "$APP_DIR/admin/index.html" ] || die "管理端构建产物缺失:$APP_DIR/admin/index.html"
# ---------- 3. 数据库迁移 ----------
log "执行 TypeORM 迁移(不使用 DB_SYNCHRONIZE)"
set -a; . "$APP_DIR/deploy/.env"; set +a
cd "$APP_DIR/web-server"
sudo -u "$SERVICE_USER" env \
"PATH=$(dirname "$NODE_BIN"):/usr/local/bin:/usr/bin:/bin" \
"DB_HOST=${DB_HOST}" "DB_PORT=${DB_PORT:-3306}" "DB_USER=${DB_USER}" \
"DB_PASS=${DB_PASS}" "DB_NAME=${DB_NAME}" "DB_LOGGING=${DB_LOGGING:-false}" \
"$NODE_BIN" scripts/migrate.mjs run
# ---------- 4. systemd 服务 ----------
log "安装 systemd 服务"
SERVICE_FILE=/etc/systemd/system/opendance-api.service
sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \
-e "s|__APP_DIR__|$APP_DIR|g" \
-e "s|__NODE_BIN__|$NODE_BIN|g" \
-e "s|__STORAGE_PARENT__|$(dirname "$STORAGE_DIR")|g" \
"$APP_DIR/deploy/systemd/opendance-api.service" > "$SERVICE_FILE"
chmod 644 "$SERVICE_FILE"
systemctl daemon-reload
systemctl enable opendance-api >/dev/null
systemctl restart opendance-api
log "等待 API 就绪…"
READY=0
for _ in $(seq 1 30); do
if curl -fsS -m 3 "http://${API_HOST}:${API_PORT}/health" >/dev/null 2>&1; then READY=1; break; fi
sleep 1
done
if [ "$READY" -ne 1 ]; then
warn "API 未在 30 秒内就绪,最近日志:"
journalctl -u opendance-api -n 40 --no-pager || true
die "API 启动失败"
fi
log "API 已就绪:$(curl -fsS -m 5 "http://${API_HOST}:${API_PORT}/health" | head -c 200)"
# ---------- 5. nginx ----------
if [ "${SKIP_NGINX:-0}" = "1" ]; then
warn "SKIP_NGINX=1,跳过 nginx 配置。API 仅在 ${API_HOST}:${API_PORT} 提供。"
else
if ! command -v nginx >/dev/null 2>&1; then
warn "未安装 nginx,跳过站点配置。"
warn " 安装后重新执行本脚本,或手动把 deploy/nginx/opendance.conf 放到 nginx 配置目录。"
else
log "配置 nginx 站点"
NGINX_AVAILABLE=/etc/nginx/sites-available
NGINX_ENABLED=/etc/nginx/sites-enabled
# RHEL 系发行版没有 sites-available 约定
if [ ! -d "$NGINX_AVAILABLE" ]; then
NGINX_AVAILABLE=/etc/nginx/conf.d
NGINX_ENABLED=/etc/nginx/conf.d
fi
DEST="$NGINX_AVAILABLE/opendance.conf"
sed -e "s|__APP_DIR__|$APP_DIR|g" \
-e "s|__SERVER_NAME__|$SERVER_NAME|g" \
-e "s|__API_HOST__|$API_HOST|g" \
-e "s|__API_PORT__|$API_PORT|g" \
"$APP_DIR/deploy/nginx/opendance.conf" > "$DEST"
if [ "$NGINX_ENABLED" != "$NGINX_AVAILABLE" ]; then
ln -sf "$DEST" "$NGINX_ENABLED/opendance.conf"
if [ -e "$NGINX_ENABLED/default" ]; then
rm -f "$NGINX_ENABLED/default"
log "已移除 nginx 默认站点(避免抢占 80 端口)"
fi
fi
if nginx -t; then
systemctl reload nginx 2>/dev/null || systemctl restart nginx 2>/dev/null || nginx -s reload
log "nginx 已重载"
else
die "nginx 配置校验失败,请检查 $DEST"
fi
fi
fi
# ---------- 6. 完成 ----------
HOST_HINT="${DEPLOY_HOST:-<服务器IP>}"
cat <<EOF
============================================================
OpenDance Studio Web 服务端已部署(Linux 原生站点,无 Docker)
管理端首页: http://${HOST_HINT}/
API 前缀: http://${HOST_HINT}/api
健康检查: http://${HOST_HINT}/health
契约快照: http://${HOST_HINT}/contracts/
数据库: 使用现有实例 ${DB_HOST}:${DB_PORT:-3306}/${DB_NAME}
上传目录: ${STORAGE_DIR}
程序目录: ${APP_DIR}
Node 进程: 仅监听 ${API_HOST}:${API_PORT}(systemd 托管)
常用命令:
systemctl status opendance-api # 服务状态
journalctl -u opendance-api -f # 实时日志
systemctl restart opendance-api # 重启
nginx -t && systemctl reload nginx # 校验并重载 nginx
============================================================
EOF