diff --git a/README.txt b/README.txt index c688ed9..c5f3ccb 100644 --- a/README.txt +++ b/README.txt @@ -16,9 +16,11 @@ OpenDance Studio —— Web 管理服务端 v0.1.0 使用说明 Node 进程由 systemd 托管,只监听 127.0.0.1,外部只能经 nginx 访问。 前置要求 - - Linux 服务器(Debian/Ubuntu 或 RHEL 系均可),有 root 权限 + - Linux 服务器,有 root 权限(不需要 sudo,直接以 root 执行即可) - Node.js 22 或更高版本 - nginx(未安装时脚本会跳过站点配置并给出提示) + - 进程管理:systemd / OpenRC / SysVinit 均可;**三者都没有也能装** + (自动降级为 nohup 后台进程 + PID 文件 + crontab @reboot 开机自启) - 一个可连通的现有 MySQL / MariaDB 实例,并已创建数据库与账号: CREATE DATABASE IF NOT EXISTS dpendance @@ -67,11 +69,24 @@ Node 进程由 systemd 托管,只监听 127.0.0.1,外部只能经 nginx 访 数据库始终由部署方自行管理,卸载脚本不做任何改动。 -常用命令 - systemctl status opendance-api # 服务状态 - journalctl -u opendance-api -f # 实时日志 - systemctl restart opendance-api # 重启 - nginx -t && systemctl reload nginx # 校验并重载 nginx +常用命令(按安装时自动检测到的初始化系统而定) + systemd: + systemctl status opendance-api # 服务状态 + journalctl -u opendance-api -f # 实时日志 + systemctl restart opendance-api # 重启 + + OpenRC: + rc-service opendance-api status / restart + tail -f /var/log/opendance-api.log + + SysVinit: + /etc/init.d/opendance-api status / restart + + 无 init 系统(降级模式): + bash /opt/opendance/deploy/ctl.sh status | restart | logs + + nginx 配置校验: + nginx -t && (systemctl reload nginx || rc-service nginx reload || nginx -s reload) ---------------------------------------------------------------- 二、本地开发 diff --git a/deploy/ctl.sh b/deploy/ctl.sh new file mode 100755 index 0000000..bb2f72b --- /dev/null +++ b/deploy/ctl.sh @@ -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 diff --git a/deploy/initd/opendance-api b/deploy/initd/opendance-api new file mode 100755 index 0000000..256d308 --- /dev/null +++ b/deploy/initd/opendance-api @@ -0,0 +1,35 @@ +#!/bin/sh +### BEGIN INIT INFO +# Provides: opendance-api +# Required-Start: $network $remote_fs $local_fs +# Required-Stop: $network $remote_fs $local_fs +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: OpenDance Studio Web 管理服务端 +# Description: OpenDance Studio Web 管理服务端(NestJS + TypeORM) +### END INIT INFO +# +# SysVinit / LSB 启动脚本。实际启停逻辑复用 deploy/ctl.sh, +# 避免 systemd / SysV / 纯进程三种模式各写一份。 +# +# 也可以直接手动调用: +# /etc/init.d/opendance-api start|stop|restart|status + +APP_DIR="__APP_DIR__" +SERVICE_USER="__SERVICE_USER__" + +export APP_DIR SERVICE_USER + +case "$1" in + start|stop|restart|status) + ;; + reload|force-reload) + set -- restart + ;; + *) + echo "用法: $0 {start|stop|restart|status}" >&2 + exit 2 + ;; +esac + +exec /bin/bash "$APP_DIR/deploy/ctl.sh" "$1" diff --git a/deploy/install.sh b/deploy/install.sh index 5e376e7..48faa84 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -92,8 +92,6 @@ API_PORT="${PORT:-3000}" [ -n "${DB_PASS:-}" ] || die "deploy/.env 中 DB_PASS 未设置" [ -n "${DB_NAME:-}" ] || die "deploy/.env 中 DB_NAME 未设置" -command -v systemctl >/dev/null 2>&1 || die "未找到 systemctl。本部署依赖 systemd 托管 Node 进程; - 若目标系统没有 systemd(如容器/WSL1),请改用 SKIP_NGINX=1 手动以进程方式运行,或换一台服务器。" 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]')" @@ -233,19 +231,81 @@ as_service_user env \ "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" +# ---------- 4. 安装为系统服务(按可用的初始化系统降级) ---------- +# systemd → OpenRC → SysVinit → 纯后台进程(nohup + PID 文件) +# “运行在 Linux 上”不应等于“必须有 systemd”:容器 / LXC / 精简系统都很常见。 +INIT_SYSTEM="" +if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + INIT_SYSTEM=systemd +elif command -v rc-service >/dev/null 2>&1 && command -v rc-update >/dev/null 2>&1; then + INIT_SYSTEM=openrc +elif [ -d /etc/init.d ] && { command -v update-rc.d >/dev/null 2>&1 || command -v chkconfig >/dev/null 2>&1; }; then + INIT_SYSTEM=sysvinit +else + INIT_SYSTEM=none +fi +log "初始化系统:$INIT_SYSTEM" -systemctl daemon-reload -systemctl enable opendance-api >/dev/null -systemctl restart opendance-api +case "$INIT_SYSTEM" in + 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 + ;; + + openrc) + log "安装 OpenRC 服务" + sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \ + -e "s|__APP_DIR__|$APP_DIR|g" \ + -e "s|__NODE_BIN__|$NODE_BIN|g" \ + "$APP_DIR/deploy/openrc/opendance-api" > /etc/init.d/opendance-api + chmod 755 /etc/init.d/opendance-api + rc-update add opendance-api default >/dev/null 2>&1 || warn "rc-update add 失败,请手动加入默认运行级" + rc-service opendance-api restart 2>/dev/null || rc-service opendance-api start + ;; + + sysvinit) + log "安装 SysVinit 服务" + sed -e "s|__SERVICE_USER__|$SERVICE_USER|g" \ + -e "s|__APP_DIR__|$APP_DIR|g" \ + "$APP_DIR/deploy/initd/opendance-api" > /etc/init.d/opendance-api + chmod 755 /etc/init.d/opendance-api + if command -v update-rc.d >/dev/null 2>&1; then + update-rc.d opendance-api defaults >/dev/null 2>&1 || warn "update-rc.d 失败,请手动建立启动链接" + elif command -v chkconfig >/dev/null 2>&1; then + chkconfig --add opendance-api >/dev/null 2>&1 || true + fi + /etc/init.d/opendance-api restart + ;; + + none) + warn "未检测到 systemd / OpenRC / SysVinit,改用后台进程方式运行(nohup + PID 文件)" + chmod +x "$APP_DIR/deploy/ctl.sh" + APP_DIR="$APP_DIR" SERVICE_USER="$SERVICE_USER" bash "$APP_DIR/deploy/ctl.sh" restart + + # 尝试用 cron 的 @reboot 实现开机自启(没有 cron 就只提示) + if command -v crontab >/dev/null 2>&1; then + CRON_LINE="@reboot /bin/bash $APP_DIR/deploy/ctl.sh start >/dev/null 2>&1" + if crontab -l 2>/dev/null | grep -qF "$APP_DIR/deploy/ctl.sh start"; then + log "开机自启已存在(crontab @reboot)" + else + ( crontab -l 2>/dev/null; echo "$CRON_LINE" ) | crontab - + log "已写入开机自启:crontab @reboot" + fi + else + warn "未找到 crontab,无法配置开机自启。重启后请手动执行:" + warn " bash $APP_DIR/deploy/ctl.sh start" + fi + ;; +esac log "等待 API 就绪…" READY=0 @@ -303,6 +363,31 @@ fi # ---------- 6. 完成 ---------- HOST_HINT="${DEPLOY_HOST:-<服务器IP>}" + +# 按实际使用的初始化系统给出对应的运维命令(用变量拼装,避免嵌套 heredoc 出错) +case "$INIT_SYSTEM" in + systemd) + CMD_HINT=" systemctl status opendance-api # 服务状态 + journalctl -u opendance-api -f # 实时日志 + systemctl restart opendance-api # 重启" + ;; + openrc) + CMD_HINT=" rc-service opendance-api status # 服务状态 + rc-service opendance-api restart # 重启 + tail -f /var/log/opendance-api.log # 实时日志" + ;; + sysvinit) + CMD_HINT=" /etc/init.d/opendance-api status # 服务状态 + /etc/init.d/opendance-api restart # 重启 + tail -f /var/log/opendance-api.log # 实时日志" + ;; + *) + CMD_HINT=" bash ${APP_DIR}/deploy/ctl.sh status # 服务状态 + bash ${APP_DIR}/deploy/ctl.sh restart # 重启 + bash ${APP_DIR}/deploy/ctl.sh logs # 实时日志" + ;; +esac + cat <&2; exit 1; } [ "$(id -u)" -eq 0 ] || die "需要 root 权限。请以 root 身份执行,例如: su -c 'bash deploy/uninstall.sh' 或 sudo bash deploy/uninstall.sh" -log "停止并禁用 opendance-api 服务" -systemctl stop opendance-api 2>/dev/null || true -systemctl disable opendance-api 2>/dev/null || true -rm -f /etc/systemd/system/opendance-api.service -systemctl daemon-reload +log "停止并移除 opendance-api 服务" +# 与 install.sh 的四层降级一一对应,避免在非 systemd 系统上留下仍在运行的进程 +if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then + systemctl stop opendance-api 2>/dev/null || true + systemctl disable opendance-api 2>/dev/null || true + rm -f /etc/systemd/system/opendance-api.service + systemctl daemon-reload +elif command -v rc-service >/dev/null 2>&1 && [ -f /etc/init.d/opendance-api ]; then + rc-service opendance-api stop 2>/dev/null || true + rc-update del opendance-api default 2>/dev/null || true + rm -f /etc/init.d/opendance-api +elif [ -f /etc/init.d/opendance-api ]; then + /etc/init.d/opendance-api stop 2>/dev/null || true + command -v update-rc.d >/dev/null 2>&1 && update-rc.d -f opendance-api remove 2>/dev/null || true + command -v chkconfig >/dev/null 2>&1 && chkconfig --del opendance-api 2>/dev/null || true + rm -f /etc/init.d/opendance-api +fi + +# 降级模式(nohup 后台进程)的停止与开机自启清理 +if [ -f "$APP_DIR/deploy/ctl.sh" ]; then + APP_DIR="$APP_DIR" SERVICE_USER="$SERVICE_USER" bash "$APP_DIR/deploy/ctl.sh" stop 2>/dev/null || true +fi +if command -v crontab >/dev/null 2>&1; then + if crontab -l 2>/dev/null | grep -qF "$APP_DIR/deploy/ctl.sh start"; then + crontab -l 2>/dev/null | grep -vF "$APP_DIR/deploy/ctl.sh start" | crontab - + log "已移除 crontab 开机自启" + fi +fi if command -v nginx >/dev/null 2>&1; then log "移除 nginx 站点" diff --git a/tasks/010-deploy.md b/tasks/010-deploy.md index 12a6fb5..4e27a93 100644 --- a/tasks/010-deploy.md +++ b/tasks/010-deploy.md @@ -51,9 +51,30 @@ deploy/nginx/opendance.conf | `deploy/uninstall.sh` | 卸载;默认保留上传数据,`--purge` 才删除 | | `deploy/.env.example` | 部署环境变量样例(含 `HOST=127.0.0.1`) | | `deploy/systemd/opendance-api.service` | systemd 单元,含 `ProtectSystem=strict` 等安全加固 | +| `deploy/openrc/opendance-api` | OpenRC 服务脚本 | +| `deploy/initd/opendance-api` | SysVinit / LSB 启动脚本(转调 ctl.sh) | +| `deploy/ctl.sh` | 无 init 系统时的进程控制(nohup + PID 文件),start/stop/restart/status/logs | | `deploy/nginx/opendance.conf` | 站点配置:SPA 回退、API 反代、上传上限 512 MiB、gzip | | `web-server/admin/nginx.conf` | 管理端容器内配置(保留给需要独立静态托管的场景) | +## 进程管理:四层降级 + +"运行在 Linux 上"不应等于"必须有 systemd"。安装时按以下顺序探测并选择: + +``` +1. systemd /run/systemd/system 存在且 systemctl 可用 +2. OpenRC rc-service + rc-update 可用 +3. SysVinit /etc/init.d 存在且 update-rc.d 或 chkconfig 可用 +4. 都没有 nohup 后台进程 + PID 文件(deploy/ctl.sh) + 并写 crontab @reboot 实现开机自启 +``` + +三种 init 方式最终都调用同一个 Node 进程,只是守护方式不同;第 4 种由 +`deploy/ctl.sh` 提供 start/stop/restart/status/logs。 + +**不使用 sudo**:脚本已要求 root,降权到服务用户用 `runuser → setpriv → su` +依次回退(部分服务器根本没装 sudo)。 + ## 关键实现 - **Node 进程只监听 `127.0.0.1`**(`HOST=127.0.0.1`),外部只能经 nginx 访问;