From b2982ae8f7affd0207669efb2f54fced90f027f6 Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 29 Jul 2026 15:50:59 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20deploy.sh=20=E6=94=AF=E6=8C=81=E4=B8=80?= =?UTF-8?q?=E9=94=AE=E9=83=A8=E7=BD=B2=E4=B8=8E=E5=B0=B1=E7=BB=AA=E6=8E=A2?= =?UTF-8?q?=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 up 子命令:build + start + watchdog 一键强制重来 - start 改为轮询 /api/health 就绪才返回,消除启动竞态误报 - start 增加端口占用守卫:非本项目进程占用时明确报错 - watchdog 增加启动宽限(WATCHDOG_STARTUP_GRACE),避开启动窗口误重启 - watchdog 支持 launchd 模式(WATCHDOG_RESTART_MODE),用 launchctl kickstart 重启以避免与 KeepAlive 抢端口 - 抽取 _spawn_server/_wait_ready/foreign_port_holder 公共函数 Co-Authored-By: Claude Opus 4.8 --- deploy.sh | 171 +++++++++++++++++++++++++++----------- deploy/install-service.sh | 2 + 2 files changed, 123 insertions(+), 50 deletions(-) diff --git a/deploy.sh b/deploy.sh index a4a113e..b0ce074 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,6 +1,6 @@ #!/bin/bash # iOS 自动打包服务 — 部署与管理脚本 -# 用法: ./deploy.sh {build|start|stop|restart|status|watchdog|watchdog-stop} +# 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop} set -e @@ -23,6 +23,11 @@ WATCHDOG_INTERVAL="${WATCHDOG_INTERVAL:-30}" # 健康检查间隔(秒) WATCHDOG_TIMEOUT="${WATCHDOG_TIMEOUT:-10}" # 健康检查超时(秒) MAX_RESTART_ATTEMPTS="${MAX_RESTART_ATTEMPTS:-3}" # 连续重启上限,超过则冷却 COOLDOWN_SECONDS="${COOLDOWN_SECONDS:-300}" # 冷却时间(秒) +WATCHDOG_STARTUP_GRACE="${WATCHDOG_STARTUP_GRACE:-10}" # watchdog 首次检查前的宽限(秒),避开正常启动窗口 +STARTUP_READY_TIMEOUT="${STARTUP_READY_TIMEOUT:-20}" # 启动后等待 /api/health 就绪的最长时间(秒) +# 重启方式:nohup(默认,PID 文件托管)| launchd(交回 launchctl kickstart 托管,避免与 KeepAlive 抢端口) +WATCHDOG_RESTART_MODE="${WATCHDOG_RESTART_MODE:-nohup}" +APP_LAUNCHD_LABEL="${APP_LAUNCHD_LABEL:-com.readoor.buildserver}" # ========== 颜色输出 ========== RED='\033[0;31m' @@ -64,6 +69,52 @@ get_server_pid() { return 1 } +# 返回占用目标端口、但命令不属于本项目的进程 PID(若有),用于区分“端口冲突”与“本服务在跑”。 +foreign_port_holder() { + local pid + for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do + if ! is_server_process "$pid"; then + echo "$pid" + return 0 + fi + done + return 1 +} + +# 启动 uvicorn 后端进程(单点维护启动参数,供 start / watchdog 复用),PID 写入 PID_FILE。 +_spawn_server() { + setup_venv + mkdir -p "$LOG_DIR" + nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ + --host "$BIND_HOST" \ + --port "$BACKEND_PORT" \ + --proxy-headers \ + --forwarded-allow-ips 127.0.0.1 \ + --workers 1 \ + >> "$LOG_DIR/server.log" 2>&1 & + echo $! > "$PID_FILE" +} + +# 轮询 /api/health 直到返回 200 或超时;就绪返回 0,否则返回 1。 +# 健康检查固定走 127.0.0.1(与 check_health 一致),不受 BIND_HOST 影响。 +_wait_ready() { + local timeout="${1:-$STARTUP_READY_TIMEOUT}" + local deadline=$(( $(date +%s) + timeout )) + while [ "$(date +%s)" -lt "$deadline" ]; do + # 进程若已退出则无需再等 + if [ -f "$PID_FILE" ] && ! kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + return 1 + fi + local code + code=$(curl -s -o /dev/null -w "%{http_code}" \ + --connect-timeout 2 --max-time 3 \ + "http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000") + [ "$code" = "200" ] && return 0 + sleep 1 + done + return 1 +} + # 日志同时写文件和终端 log_to_file() { local level="$1"; shift @@ -143,11 +194,23 @@ do_build() { do_start() { check_env + # 端口被非本项目进程占用:明确报错,避免 uvicorn 静默绑定失败。 + local foreign + foreign=$(foreign_port_holder || true) + if [ -n "$foreign" ]; then + error "端口 $BACKEND_PORT 被非本项目进程占用 (PID: $foreign):" + ps -p "$foreign" -o pid,command= 2>/dev/null | sed 's/^/ /' + error "请先释放该端口,或在 .env 修改 BACKEND_PORT" + return 1 + fi + + # 本项目实例已在运行:强制重来(用户选定语义)。 + # 注意:这会中断正在进行的打包任务。 local existing_pid existing_pid=$(get_server_pid || true) if [ -n "$existing_pid" ]; then - warn "服务已在运行 (PID: $existing_pid)" - return 0 + warn "检测到旧服务 (PID: $existing_pid),强制重启(若有打包任务将被中断)..." + do_stop fi # 确保前端已构建 @@ -156,28 +219,15 @@ do_start() { do_build fi - setup_venv - - mkdir -p "$LOG_DIR" - info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..." - nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ - --host "$BIND_HOST" \ - --port "$BACKEND_PORT" \ - --proxy-headers \ - --forwarded-allow-ips 127.0.0.1 \ - --workers 1 \ - > "$LOG_DIR/server.log" 2>&1 & + _spawn_server - echo $! > "$PID_FILE" - sleep 1 - - if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then - info "服务已启动 (PID: $(cat "$PID_FILE"))" + if _wait_ready; then + info "服务已就绪 (PID: $(cat "$PID_FILE"))" info "访问地址: http://$BIND_HOST:$BACKEND_PORT" else - error "启动失败,请查看日志: $LOG_DIR/server.log" - rm -f "$PID_FILE" + error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log" + tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /' return 1 fi } @@ -252,12 +302,15 @@ check_health() { # ========== Watchdog(前台模式,用于调试) ========== do_watchdog_fg() { - info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s" + info "Watchdog 启动(前台模式),检查间隔: ${WATCHDOG_INTERVAL}s,启动宽限: ${WATCHDOG_STARTUP_GRACE}s" info "按 Ctrl+C 停止" local restart_count=0 local last_restart_time=0 + # 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。 + sleep "$WATCHDOG_STARTUP_GRACE" + while true; do local result result=$(check_health) @@ -338,7 +391,10 @@ do_watchdog_loop() { local restart_count=0 local last_restart_time=0 - log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s" + log_to_file "INFO" "Watchdog started, interval=${WATCHDOG_INTERVAL}s, grace=${WATCHDOG_STARTUP_GRACE}s, mode=${WATCHDOG_RESTART_MODE}" + + # 启动宽限:避开服务正常启动/重启的就绪窗口,防止误判触发重启。 + sleep "$WATCHDOG_STARTUP_GRACE" while true; do local result @@ -366,35 +422,33 @@ do_watchdog_loop() { restart_count=$((restart_count + 1)) last_restart_time=$(date +%s) - log_to_file "WARN" "Restarting service (attempt: $restart_count)" + log_to_file "WARN" "Restarting service (attempt: $restart_count, mode: $WATCHDOG_RESTART_MODE)" - # 停止旧进程 - if [ -f "$PID_FILE" ]; then - local old_pid - old_pid=$(cat "$PID_FILE") - kill "$old_pid" 2>/dev/null - sleep 2 - kill -9 "$old_pid" 2>/dev/null || true - rm -f "$PID_FILE" + if [ "$WATCHDOG_RESTART_MODE" = "launchd" ]; then + # launchd 托管模式:交回 launchctl 重启,避免与 KeepAlive 抢占端口。 + if launchctl kickstart -k "gui/$(id -u)/$APP_LAUNCHD_LABEL" 2>/dev/null; then + log_to_file "INFO" "launchctl kickstart issued for $APP_LAUNCHD_LABEL" + else + log_to_file "ERROR" "launchctl kickstart failed for $APP_LAUNCHD_LABEL" + fi + else + # nohup 托管模式:停掉旧进程后重新拉起。 + if [ -f "$PID_FILE" ]; then + local old_pid + old_pid=$(cat "$PID_FILE") + kill "$old_pid" 2>/dev/null + sleep 2 + kill -9 "$old_pid" 2>/dev/null || true + rm -f "$PID_FILE" + fi + _spawn_server fi - # 重新启动 - setup_venv - mkdir -p "$LOG_DIR" - nohup "$VENV_DIR/bin/python3" -m uvicorn backend.main:app \ - --host "$BIND_HOST" \ - --port "$BACKEND_PORT" \ - --proxy-headers \ - --forwarded-allow-ips 127.0.0.1 \ - --workers 1 \ - >> "$LOG_DIR/server.log" 2>&1 & - echo $! > "$PID_FILE" - - sleep 3 - if kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then - log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE"))" + # 无论哪种模式,都等待 /api/health 就绪再继续,避免连锁误判。 + if _wait_ready; then + log_to_file "INFO" "Service restarted successfully (PID: $(cat "$PID_FILE" 2>/dev/null))" else - log_to_file "ERROR" "Service restart failed" + log_to_file "ERROR" "Service restart failed or not ready in ${STARTUP_READY_TIMEOUT}s" fi fi @@ -457,8 +511,21 @@ do_test() { fi } +# ========== 一键部署(强制重来)========== +# build → start(强制重启并等待就绪)→ watchdog,最后打印状态。 +do_up() { + info "一键部署(强制重来):build → start → watchdog" + do_watchdog_stop >/dev/null 2>&1 || true # 先停监听,避免它在重启窗口内干预 + do_build + do_start # do_start 内部会强制停旧服务并等待 health 就绪 + do_watchdog + echo "" + do_status +} + # ========== 主入口 ========== case "${1:-}" in + up) do_up ;; build) do_build ;; start) do_start ;; stop) do_stop; do_watchdog_stop ;; @@ -470,10 +537,11 @@ case "${1:-}" in watchdog-fg) do_watchdog_fg ;; _watchdog_loop) do_watchdog_loop ;; *) - echo "用法: $0 {build|start|stop|restart|status|test|watchdog|watchdog-stop}" + echo "用法: $0 {up|build|start|stop|restart|status|test|watchdog|watchdog-stop}" echo "" + echo " up 一键部署:build + start + watchdog(强制重来)" echo " build 构建前端、安装依赖" - echo " start 启动服务(后台运行)" + echo " start 启动服务(强制重启并等待就绪;端口被占用会报错)" echo " stop 停止服务(同时停止 watchdog)" echo " restart 重启服务" echo " status 查看运行状态" @@ -485,6 +553,9 @@ case "${1:-}" in echo "环境变量(可在 .env 中配置):" echo " WATCHDOG_INTERVAL 健康检查间隔(秒,默认 30)" echo " WATCHDOG_TIMEOUT 健康检查超时(秒,默认 10)" + echo " WATCHDOG_STARTUP_GRACE watchdog 首次检查前宽限(秒,默认 10)" + echo " STARTUP_READY_TIMEOUT 启动后等待 health 就绪的最长时间(秒,默认 20)" + echo " WATCHDOG_RESTART_MODE 重启方式 nohup|launchd(默认 nohup)" echo " MAX_RESTART_ATTEMPTS 连续重启上限(默认 3)" echo " COOLDOWN_SECONDS 超过重启上限后冷却时间(秒,默认 300)" exit 1 diff --git a/deploy/install-service.sh b/deploy/install-service.sh index 94d1cd8..83842b6 100755 --- a/deploy/install-service.sh +++ b/deploy/install-service.sh @@ -95,6 +95,8 @@ EOF PATH $VENV_DIR/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + WATCHDOG_RESTART_MODE + launchd RunAtLoad