用户实测:执行到「同步程序文件到 /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 泄漏
286 lines
12 KiB
Bash
Executable File
286 lines
12 KiB
Bash
Executable File
#!/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
|