fix(deploy): 支持无 systemd 的环境,并修复降级路径的两个真实缺陷

用户实测:服务器没有 systemctl,install.sh 直接报错退出。

原错误信息本身也是错的(建议 SKIP_NGINX=1,但那只跳过 nginx,仍走 systemctl)。

进程管理改为四层降级:
  systemd → OpenRC → SysVinit → nohup 后台进程(deploy/ctl.sh)
- 新增 deploy/ctl.sh:start/stop/restart/status/logs,PID 文件 + 进程名双重判定
- 新增 deploy/openrc/opendance-api、deploy/initd/opendance-api
- 无 init 系统时写 crontab @reboot 实现开机自启
- uninstall.sh 同步支持四种模式清理(原先只处理 systemd,会留下运行中的进程)
- 汇总输出按实际初始化系统给出对应运维命令

测试中发现并修复降级路径的两个真实缺陷:

1. nohup 无法调用 shell 函数
    实测退出码 127 且什么都不执行
   (nohup 是外部命令,只能执行可执行文件)。
   这会让无 systemd 的降级路径完全失效——服务根本起不来。
   改为按 runuser / setpriv / su 显式拼接成外部命令后再 nohup。
   另加启动后 1 秒存活检测,失败立即打印日志,不再白等 30 秒。

2. find_pids 在无进程时返回一个空格
    在空输入下仍会执行一次 echo,输出空行,
   最终得到 " ";调用方 [ -n ... ] 会判为「有进程」,
   导致 start 直接跳过、status 谎报运行中。改为纯 bash 拼接,空时返回空串。

实测(真实 API 进程,端口 3100):
  start → 健康检查 200,PID 正确
  重复 start → 识别已在运行,进程数保持 1
  status → 运行中
  restart → 新 PID,健康检查 200
  stop → 无残留进程,端口释放
  无 process 时 find_pids 返回空串,is_running=false
This commit is contained in:
2026-09-15 00:34:52 +08:00
parent 1ffe3059af
commit c092b833fb
7 changed files with 421 additions and 30 deletions
Executable
+178
View File
@@ -0,0 +1,178 @@
#!/bin/bash
# ============================================================
# OpenDance Studio Web API —— 进程控制(不依赖 systemd / OpenRC / SysV)
#
# 适用于没有 systemd 的环境(容器、LXC、精简系统等)。
# 以 nohup 后台运行 Node 进程并记录 PID,日志写入 LOG_FILE。
#
# 用法(需 root):
# bash deploy/ctl.sh start|stop|restart|status|logs
#
# 配置来源:$APP_DIR/deploy/.env
# 可用环境变量覆盖:APP_DIR / SERVICE_USER / LOG_FILE
# ============================================================
set -uo pipefail
APP_DIR="${APP_DIR:-/opt/opendance}"
SERVICE_USER="${SERVICE_USER:-opendance}"
LOG_FILE="${LOG_FILE:-/var/log/opendance-api.log}"
RUN_DIR="${RUN_DIR:-/run}"
PID_FILE="${PID_FILE:-$RUN_DIR/opendance-api.pid}"
MAIN_JS="$APP_DIR/web-server/dist/main.js"
die() { printf '\033[1;31m[ctl]\033[0m %s\n' "$*" >&2; exit 1; }
log() { printf '\033[1;34m[ctl]\033[0m %s\n' "$*"; }
[ "$(id -u)" -eq 0 ] || die "需要 root 权限"
# 以服务用户身份执行;不使用 sudo(本脚本已要求 root)
as_service_user() {
if command -v runuser >/dev/null 2>&1; then
runuser -u "$SERVICE_USER" -- "$@"
elif command -v setpriv >/dev/null 2>&1; then
setpriv --reuid="$SERVICE_USER" --regid="$SERVICE_USER" --init-groups -- "$@"
else
su -s /bin/bash "$SERVICE_USER" -c "$(printf '%q ' "$@")"
fi
}
# 后台启动服务进程,回显启动器 PID。
# 注意:这里**不能**写成 `nohup as_service_user ...`。
# nohup 是外部命令,只能执行可执行文件,无法调用 shell 函数
# (实测直接退出码 127 且什么都不运行),会让无 systemd 的降级路径完全失效。
# 因此三种降权工具各自显式拼接成外部命令。
spawn_service_process() {
local node_bin="$1" script="$2"
if command -v runuser >/dev/null 2>&1; then
nohup runuser -u "$SERVICE_USER" -- "$node_bin" "$script" >>"$LOG_FILE" 2>&1 &
elif command -v setpriv >/dev/null 2>&1; then
nohup setpriv --reuid="$SERVICE_USER" --regid="$SERVICE_USER" --init-groups -- \
"$node_bin" "$script" >>"$LOG_FILE" 2>&1 &
else
nohup su -s /bin/bash "$SERVICE_USER" -c "$(printf '%q ' "$node_bin" "$script")" \
>>"$LOG_FILE" 2>&1 &
fi
echo $!
}
node_bin() {
command -v node 2>/dev/null || die "未找到 node"
}
# 按 pid 文件与命令行双重判定,避免 PID 记录失准。
# 返回以空格分隔的 PID 列表;**没有进程时返回空串**。
# 注意:不能再用 `echo "$pids" | xargs -n1` 去重 —— xargs 在空输入下仍会
# 执行一次 echo,输出一个空行,最终得到一个空格。调用方 `[ -n "$(...)" ]`
# 会把 " " 判为“有进程”,导致 start 直接跳过、服务根本没起来。
find_pids() {
local pids=""
if [ -f "$PID_FILE" ]; then
local pid
pid="$(cat "$PID_FILE" 2>/dev/null || true)"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
pids="$pid"
fi
fi
# 兜底:命令行里带 main.js 绝对路径的进程(启动时即用绝对路径,故匹配精确)
local extra
extra="$(pgrep -f -- "$MAIN_JS" 2>/dev/null || true)"
for p in $extra; do
case " $pids " in
*" $p "*) ;;
*) pids="${pids:+$pids }$p" ;;
esac
done
printf '%s' "$pids"
}
is_running() { [ -n "$(find_pids)" ]; }
start() {
[ -f "$MAIN_JS" ] || die "找不到 $MAIN_JS,请先执行 deploy/install.sh"
if is_running; then
log "已在运行(PID:$(find_pids)),跳过启动"
return 0
fi
# 载入部署环境变量
if [ -f "$APP_DIR/deploy/.env" ]; then
set -a; . "$APP_DIR/deploy/.env"; set +a
else
die "缺少 $APP_DIR/deploy/.env"
fi
mkdir -p "$(dirname "$LOG_FILE")" "$RUN_DIR"
touch "$LOG_FILE"
chown "$SERVICE_USER:$SERVICE_USER" "$LOG_FILE" 2>/dev/null || true
local NODE; NODE="$(node_bin)"
log "启动:$MAIN_JS(用户 $SERVICE_USER)"
cd "$APP_DIR/web-server" || die "无法进入 $APP_DIR/web-server"
# 用绝对路径启动,便于 stop 时精确匹配命令行
local launcher_pid
launcher_pid="$(spawn_service_process "$NODE" "$MAIN_JS")"
echo "$launcher_pid" > "$PID_FILE"
# 先确认启动器没有立刻失败(如降权工具不可用、node 路径错误)
sleep 1
if ! is_running; then
warn "进程未能启动,日志如下:"
tail -20 "$LOG_FILE" >&2 || true
rm -f "$PID_FILE"
return 1
fi
# 等待端口就绪
local port="${PORT:-3000}"
for _ in $(seq 1 30); do
if command -v curl >/dev/null 2>&1; then
curl -fsS -m 2 "http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { log "已就绪(PID:$(find_pids))"; return 0; }
else
"$NODE" -e 'fetch(process.argv[1]).then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))' \
"http://127.0.0.1:${port}/health" >/dev/null 2>&1 && { log "已就绪(PID:$(find_pids))"; return 0; }
fi
sleep 1
done
log "警告:30 秒内未通过健康检查,请查看 $LOG_FILE"
tail -20 "$LOG_FILE" >&2 || true
return 1
}
stop() {
local pids; pids="$(find_pids)"
if [ -z "$pids" ]; then
log "未在运行"
rm -f "$PID_FILE"
return 0
fi
log "停止:$pids"
for p in $pids; do kill -TERM "$p" 2>/dev/null || true; done
for _ in $(seq 1 15); do is_running || break; sleep 1; done
pids="$(find_pids)"
if [ -n "$pids" ]; then
log "优雅退出超时,强制结束:$pids"
for p in $pids; do kill -KILL "$p" 2>/dev/null || true; done
fi
rm -f "$PID_FILE"
log "已停止"
}
status() {
if is_running; then
log "运行中(PID:$(find_pids))"
return 0
fi
log "未运行"
return 3
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
logs) tail -n "${2:-80}" -f "$LOG_FILE" ;;
*) die "用法:bash deploy/ctl.sh start|stop|restart|status|logs" ;;
esac