Files
iOSBuildServer/deploy.sh
T
shenleiandClaude Opus 4.8 b2982ae8f7 feat: deploy.sh 支持一键部署与就绪探测
- 新增 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 <noreply@anthropic.com>
2026-07-29 15:50:59 +09:00

564 lines
18 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
# iOS 自动打包服务 — 部署与管理脚本
# 用法: ./deploy.sh {up|build|start|stop|restart|status|watchdog|watchdog-stop}
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$SCRIPT_DIR"
PID_FILE="$SCRIPT_DIR/.server.pid"
WATCHDOG_PID_FILE="$SCRIPT_DIR/.watchdog.pid"
LOG_DIR="$SCRIPT_DIR/logs"
VENV_DIR="$SCRIPT_DIR/.venv"
# 加载 .env
if [ -f .env ]; then
set -a; source .env; set +a
fi
BACKEND_PORT="${BACKEND_PORT:-8000}"
BIND_HOST="${BIND_HOST:-127.0.0.1}"
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'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
error() { echo -e "${RED}[ERROR]${NC} $*"; }
# 在 PID 文件丢失或进程 PID 被复用时,仍能安全识别本服务。
is_server_process() {
local pid="$1"
local command
command=$(ps -p "$pid" -o command= 2>/dev/null || true)
[[ "$command" == *"uvicorn backend.main:app"* ]]
}
get_server_pid() {
local pid
if [ -f "$PID_FILE" ]; then
pid=$(cat "$PID_FILE")
if kill -0 "$pid" 2>/dev/null && is_server_process "$pid"; then
echo "$pid"
return 0
fi
rm -f "$PID_FILE"
fi
# 仅认领监听当前配置端口、且命令明确属于本项目的 uvicorn 进程。
for pid in $(lsof -tiTCP:"$BACKEND_PORT" -sTCP:LISTEN 2>/dev/null); do
if is_server_process "$pid"; then
echo "$pid" > "$PID_FILE"
echo "$pid"
return 0
fi
done
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
echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" >> "$LOG_DIR/watchdog.log"
}
# ========== 环境检查 ==========
check_env() {
if [ ! -f .env ]; then
error ".env 文件不存在"
echo " 请执行: cp .env.example .env && vim .env"
exit 1
fi
if ! command -v python3 &>/dev/null; then
error "未找到 python3"
exit 1
fi
# 运行已构建的前端不需要 Node;仅在缺少静态资源时才需要它重新构建。
if [ ! -d "$SCRIPT_DIR/backend/static" ] && ! command -v node &>/dev/null; then
error "未找到 node,且前端尚未构建"
exit 1
fi
# 检查 Xcode
if ! command -v xcodebuild &>/dev/null; then
warn "未找到 xcodebuild,打包功能将不可用"
fi
}
# ========== Python 虚拟环境 ==========
setup_venv() {
# 项目目录移动后,venv 内 pip 的 shebang 会指向旧路径,需要重建。
if [ -d "$VENV_DIR" ] && ! "$VENV_DIR/bin/pip" --version >/dev/null 2>&1; then
warn "检测到失效的 Python 虚拟环境,正在重建..."
rm -rf "$VENV_DIR"
fi
if [ ! -d "$VENV_DIR" ]; then
info "创建 Python 虚拟环境..."
python3 -m venv "$VENV_DIR"
fi
"$VENV_DIR/bin/python3" -m pip install -r requirements.txt -q
}
# ========== 构建前端 ==========
do_build() {
# build 只需要 Python 和 Node,不要求 .env
if ! command -v python3 &>/dev/null; then
error "未找到 python3"; exit 1
fi
if ! command -v node &>/dev/null; then
error "未找到 node"; exit 1
fi
# 首次部署时自动创建 .env
if [ ! -f .env ]; then
info "首次部署,创建 .env 配置文件..."
cp .env.example .env
warn "已创建 .env,请按需修改配置(当前为默认值)"
fi
info "安装前端依赖..."
cd "$SCRIPT_DIR/frontend"
npm install --silent
info "构建前端..."
npm run build
cd "$SCRIPT_DIR"
setup_venv
info "构建完成"
}
# ========== 启动服务 ==========
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),强制重启(若有打包任务将被中断)..."
do_stop
fi
# 确保前端已构建
if [ ! -d "$SCRIPT_DIR/backend/static" ]; then
warn "前端未构建,先执行 build..."
do_build
fi
info "启动后端服务 ($BIND_HOST:$BACKEND_PORT)..."
_spawn_server
if _wait_ready; then
info "服务已就绪 (PID: $(cat "$PID_FILE"))"
info "访问地址: http://$BIND_HOST:$BACKEND_PORT"
else
error "服务启动后 ${STARTUP_READY_TIMEOUT}s 内未就绪,请查看日志: $LOG_DIR/server.log"
tail -20 "$LOG_DIR/server.log" 2>/dev/null | sed 's/^/ /'
return 1
fi
}
# ========== 停止服务 ==========
do_stop() {
local pid
pid=$(get_server_pid || true)
if [ -z "$pid" ]; then
warn "服务未在运行"
return 0
fi
info "停止服务 (PID: $pid)..."
kill "$pid"
# 等待进程退出
for i in $(seq 1 10); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.5
done
# 还没退出就强杀
if kill -0 "$pid" 2>/dev/null; then
warn "进程未响应,强制终止..."
kill -9 "$pid"
fi
info "服务已停止"
rm -f "$PID_FILE"
}
# ========== 查看状态 ==========
do_status() {
local pid
pid=$(get_server_pid || true)
if [ -n "$pid" ]; then
info "服务运行中 (PID: $pid), 端口: $BACKEND_PORT"
else
warn "服务未运行"
fi
if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then
info "Watchdog 运行中 (PID: $(cat "$WATCHDOG_PID_FILE")), 检查间隔: ${WATCHDOG_INTERVAL}s"
else
warn "Watchdog 未运行"
rm -f "$WATCHDOG_PID_FILE" 2>/dev/null
fi
}
# ========== 健康检查 ==========
check_health() {
# 检查进程是否存在
local pid
pid=$(get_server_pid || true)
if [ -z "$pid" ]; then
echo "process_down"
return
fi
# 检查 HTTP 是否响应
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout "$WATCHDOG_TIMEOUT" \
--max-time "$WATCHDOG_TIMEOUT" \
"http://127.0.0.1:$BACKEND_PORT/api/health" 2>/dev/null || echo "000")
if [ "$http_code" != "200" ]; then
echo "http_$http_code"
return
fi
echo "ok"
}
# ========== Watchdog(前台模式,用于调试) ==========
do_watchdog_fg() {
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)
if [ "$result" = "ok" ]; then
restart_count=0
else
local now
now=$(date +%s)
warn "健康检查失败: $result"
# 冷却判断:连续重启超过上限,暂停一段时间
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
local elapsed=$((now - last_restart_time))
if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then
local wait=$((COOLDOWN_SECONDS - elapsed))
warn "连续重启 ${restart_count} 次,冷却中,${wait}s 后重试..."
log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s"
sleep "$wait"
continue
else
warn "冷却结束,重新尝试..."
restart_count=0
fi
fi
warn "正在重启服务..."
log_to_file "WARN" "Restarting service (cause: $result, attempt: $((restart_count+1)))"
do_stop
sleep 2
do_start
restart_count=$((restart_count + 1))
last_restart_time=$(date +%s)
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
error "已连续重启 ${restart_count} 次,进入冷却..."
log_to_file "ERROR" "Entered cooldown after $restart_count restarts"
fi
fi
sleep "$WATCHDOG_INTERVAL"
done
}
# ========== Watchdog(后台守护) ==========
do_watchdog() {
mkdir -p "$LOG_DIR"
if [ -f "$WATCHDOG_PID_FILE" ] && kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then
warn "Watchdog 已在运行 (PID: $(cat "$WATCHDOG_PID_FILE"))"
return 0
fi
info "启动 Watchdog(后台模式),检查间隔: ${WATCHDOG_INTERVAL}s"
nohup bash -c "
cd '$SCRIPT_DIR'
source .env 2>/dev/null
exec bash '$(realpath "$0")' _watchdog_loop
" >> "$LOG_DIR/watchdog.log" 2>&1 &
echo $! > "$WATCHDOG_PID_FILE"
sleep 1
if kill -0 "$(cat "$WATCHDOG_PID_FILE")" 2>/dev/null; then
info "Watchdog 已启动 (PID: $(cat "$WATCHDOG_PID_FILE"))"
else
error "Watchdog 启动失败"
rm -f "$WATCHDOG_PID_FILE"
return 1
fi
}
# 内部循环入口(由后台 watchdog 进程调用)
do_watchdog_loop() {
local restart_count=0
local last_restart_time=0
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
result=$(check_health)
if [ "$result" = "ok" ]; then
restart_count=0
else
local now
now=$(date +%s)
log_to_file "WARN" "Health check failed: $result"
if [ "$restart_count" -ge "$MAX_RESTART_ATTEMPTS" ]; then
local elapsed=$((now - last_restart_time))
if [ "$elapsed" -lt "$COOLDOWN_SECONDS" ]; then
local wait=$((COOLDOWN_SECONDS - elapsed))
log_to_file "WARN" "Cooldown: ${restart_count} restarts, waiting ${wait}s"
sleep "$wait"
continue
else
log_to_file "INFO" "Cooldown ended, resetting counter"
restart_count=0
fi
fi
restart_count=$((restart_count + 1))
last_restart_time=$(date +%s)
log_to_file "WARN" "Restarting service (attempt: $restart_count, mode: $WATCHDOG_RESTART_MODE)"
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
# 无论哪种模式,都等待 /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 or not ready in ${STARTUP_READY_TIMEOUT}s"
fi
fi
sleep "$WATCHDOG_INTERVAL"
done
}
# ========== 停止 Watchdog ==========
do_watchdog_stop() {
if [ ! -f "$WATCHDOG_PID_FILE" ]; then
warn "Watchdog 未在运行"
return 0
fi
local pid
pid=$(cat "$WATCHDOG_PID_FILE")
if kill -0 "$pid" 2>/dev/null; then
info "停止 Watchdog (PID: $pid)..."
kill "$pid"
sleep 1
kill -9 "$pid" 2>/dev/null || true
info "Watchdog 已停止"
else
warn "Watchdog 进程已不存在"
fi
rm -f "$WATCHDOG_PID_FILE"
}
# ========== 运行测试 ==========
do_test() {
local failed=0
# 确保依赖已安装
setup_venv
"$VENV_DIR/bin/python3" -m pip install -r requirements-dev.txt -q
if [ ! -d "frontend/node_modules" ]; then
cd frontend && npm install --silent && cd ..
fi
# 后端测试
info "运行后端测试..."
echo "────────────────────────────────────────"
"$VENV_DIR/bin/python3" -m pytest tests/ -v "$@" || failed=1
echo ""
# 前端测试
info "运行前端测试..."
echo "────────────────────────────────────────"
cd frontend && npx vitest run "$@" || failed=1
cd "$SCRIPT_DIR"
echo ""
# 结果
if [ "$failed" -eq 0 ]; then
info "全部测试通过"
else
error "存在失败的测试"
exit 1
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 ;;
restart) do_stop; do_start ;;
status) do_status ;;
test) shift; do_test "$@" ;;
watchdog) do_watchdog ;;
watchdog-stop) do_watchdog_stop ;;
watchdog-fg) do_watchdog_fg ;;
_watchdog_loop) do_watchdog_loop ;;
*)
echo "用法: $0 {up|build|start|stop|restart|status|test|watchdog|watchdog-stop}"
echo ""
echo " up 一键部署:build + start + watchdog(强制重来)"
echo " build 构建前端、安装依赖"
echo " start 启动服务(强制重启并等待就绪;端口被占用会报错)"
echo " stop 停止服务(同时停止 watchdog"
echo " restart 重启服务"
echo " status 查看运行状态"
echo " test 运行全部测试(后端 + 前端)"
echo " watchdog 启动健康监测(后台守护,自动重启故障服务)"
echo " watchdog-stop 停止健康监测"
echo " watchdog-fg 前台运行健康监测(调试用)"
echo ""
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
;;
esac