start-test.sh 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  1. #!/bin/bash
  2. # ============================================
  3. # 陇耘鲜达 Backend 测试环境启动脚本
  4. # 适配: python3.9+(推荐 venv;显式不使用 python2)
  5. #
  6. # 测试服务器目录示例: /root/zkxw/backend
  7. # 默认监听: 127.0.0.1:10002(裸机 Nginx 反代;勿与生产 8111 冲突)
  8. # 旧双机拓扑 0.0.0.0:8112 已废弃;手动 start/restart 时也走本默认,避免 Nginx 502。
  9. #
  10. # 首次部署:
  11. # cd /root/zkxw/backend
  12. # cp .env.test.example .env.test # 或从 .env.example 复制后按测试库填写
  13. # python3.9 -m venv .venv && ./.venv/bin/pip install -r requirements.txt
  14. # ./start-test.sh activate # source .venv/bin/activate
  15. # ./start-test.sh start
  16. # ============================================
  17. set -euo pipefail
  18. BASE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  19. cd "$BASE_DIR"
  20. APP_MODULE="${APP_MODULE:-app.main:app}"
  21. HOST="${HOST:-127.0.0.1}"
  22. PORT="${PORT:-10002}"
  23. WORKERS="${WORKERS:-1}"
  24. LOG_LEVEL="${LOG_LEVEL:-debug}"
  25. ENV_FILE="${ENV_FILE:-$BASE_DIR/.env.test}"
  26. VENV_DIR="${VENV_DIR:-$BASE_DIR/.venv}"
  27. ACTIVATE_SCRIPT="$VENV_DIR/bin/activate"
  28. PYTHON_BIN="${PYTHON_BIN:-$VENV_DIR/bin/python}"
  29. MIN_PYTHON_MAJOR=3
  30. MIN_PYTHON_MINOR=9
  31. SYSTEM_PYTHON=""
  32. LOG_DIR="$BASE_DIR/logs"
  33. LOG_FILE="$LOG_DIR/backend-test.log"
  34. PID_FILE="$BASE_DIR/backend-test.pid"
  35. RED='\033[0;31m'
  36. GREEN='\033[0;32m'
  37. YELLOW='\033[1;33m'
  38. NC='\033[0m'
  39. log_info() {
  40. echo -e "${GREEN}[INFO]${NC} $1"
  41. }
  42. log_warn() {
  43. echo -e "${YELLOW}[WARN]${NC} $1"
  44. }
  45. log_error() {
  46. echo -e "${RED}[ERROR]${NC} $1"
  47. }
  48. show_help() {
  49. cat << 'EOF'
  50. 用法:
  51. ./start-test.sh [start|stop|restart|status|logs|activate]
  52. 命令:
  53. start 启动服务(默认)
  54. stop 停止服务
  55. restart 重启服务
  56. status 查看状态
  57. logs 查看日志(最近100行)
  58. activate 激活 .venv 并进入交互 shell(等价 source .venv/bin/activate)
  59. 可选环境变量:
  60. HOST=127.0.0.1
  61. PORT=10002
  62. WORKERS=1
  63. LOG_LEVEL=debug
  64. APP_MODULE=app.main:app
  65. VENV_DIR=./.venv
  66. PYTHON_FOR_VENV=python3.9
  67. ENV_FILE=./.env.test
  68. 测试部署目录(示例):
  69. /root/zkxw/backend
  70. cd /root/zkxw/backend && ./start-test.sh start
  71. 说明:
  72. - 读取 .env.test(非 .env.production)
  73. - 默认 127.0.0.1:10002(Nginx 反代);生产 start-prod.sh 使用 8111
  74. - 可与生产共用 venv,但 PID/日志文件独立,勿混用 start/stop 命令
  75. - stop/restart 除 PID 文件外,还会按端口清理本应用 uvicorn,避免僵尸进程占端口导致发版假成功
  76. EOF
  77. }
  78. ensure_dirs() {
  79. mkdir -p "$LOG_DIR"
  80. }
  81. python_version_ge() {
  82. local ver="$1"
  83. local major="${ver%%.*}"
  84. local minor="${ver#*.}"
  85. if [ "$major" -gt "$MIN_PYTHON_MAJOR" ]; then
  86. return 0
  87. fi
  88. if [ "$major" -eq "$MIN_PYTHON_MAJOR" ] && [ "$minor" -ge "$MIN_PYTHON_MINOR" ]; then
  89. return 0
  90. fi
  91. return 1
  92. }
  93. resolve_system_python() {
  94. local candidate ver
  95. if [ -n "${PYTHON_FOR_VENV:-}" ] && command -v "$PYTHON_FOR_VENV" >/dev/null 2>&1; then
  96. ver="$("$PYTHON_FOR_VENV" -c 'import sys; print("{}.{}".format(sys.version_info[0], sys.version_info[1]))')"
  97. if python_version_ge "$ver"; then
  98. echo "$PYTHON_FOR_VENV"
  99. return 0
  100. fi
  101. log_error "PYTHON_FOR_VENV=$PYTHON_FOR_VENV 版本为 $ver,需要 >= ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}"
  102. exit 1
  103. fi
  104. for candidate in python3.11 python3.10 python3.9 python3; do
  105. if command -v "$candidate" >/dev/null 2>&1; then
  106. ver="$("$candidate" -c 'import sys; print("{}.{}".format(sys.version_info[0], sys.version_info[1]))')"
  107. if python_version_ge "$ver"; then
  108. echo "$candidate"
  109. return 0
  110. fi
  111. fi
  112. done
  113. return 1
  114. }
  115. check_python() {
  116. if ! SYSTEM_PYTHON="$(resolve_system_python)"; then
  117. log_error "未找到 python${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR}+,请安装 python39 或设置 PYTHON_FOR_VENV=python3.9"
  118. exit 1
  119. fi
  120. log_info "系统 Python: $SYSTEM_PYTHON ($("$SYSTEM_PYTHON" --version 2>&1))"
  121. }
  122. ensure_venv_python_version() {
  123. local ver
  124. ver="$("$PYTHON_BIN" -c 'import sys; print("{}.{}".format(sys.version_info[0], sys.version_info[1]))')"
  125. if python_version_ge "$ver"; then
  126. log_info "虚拟环境 Python: $ver ($VENV_DIR)"
  127. return 0
  128. fi
  129. log_error "虚拟环境 Python 版本过低: $ver(需要 >= ${MIN_PYTHON_MAJOR}.${MIN_PYTHON_MINOR})"
  130. log_error "当前虚拟环境目录: $VENV_DIR"
  131. log_warn "请执行: rm -rf \"$VENV_DIR\" && $SYSTEM_PYTHON -m venv \"$VENV_DIR\" && \"$VENV_DIR/bin/pip\" install -r requirements.txt"
  132. exit 1
  133. }
  134. has_uvicorn() {
  135. "$PYTHON_BIN" -c "import uvicorn" >/dev/null 2>&1
  136. }
  137. ensure_venv_and_runtime() {
  138. log_info "虚拟环境目录: $VENV_DIR"
  139. if [ ! -d "$VENV_DIR" ]; then
  140. log_warn "未找到虚拟环境,开始自动创建: $VENV_DIR"
  141. if ! "$SYSTEM_PYTHON" -m venv "$VENV_DIR" >/dev/null 2>&1; then
  142. log_error "自动创建 venv 失败,请先安装 python3-venv 或手动创建虚拟环境"
  143. log_warn "可尝试: $SYSTEM_PYTHON -m venv \"$VENV_DIR\""
  144. exit 1
  145. fi
  146. fi
  147. if [ ! -x "$PYTHON_BIN" ]; then
  148. log_error "虚拟环境 python 不存在: $PYTHON_BIN"
  149. exit 1
  150. fi
  151. ensure_venv_python_version
  152. if [ ! -x "$VENV_DIR/bin/pip" ]; then
  153. log_warn "虚拟环境 pip 不存在,尝试引导安装"
  154. "$PYTHON_BIN" -m ensurepip --upgrade >/dev/null 2>&1 || true
  155. fi
  156. if ! has_uvicorn; then
  157. log_warn "未检测到 uvicorn,开始安装依赖 requirements.txt"
  158. if [ ! -x "$VENV_DIR/bin/pip" ]; then
  159. log_error "pip 仍不可用,请手动执行依赖初始化"
  160. exit 1
  161. fi
  162. "$VENV_DIR/bin/pip" install --upgrade pip
  163. "$VENV_DIR/bin/pip" install -r "$BASE_DIR/requirements.txt"
  164. if ! has_uvicorn; then
  165. log_error "依赖安装后仍无法 import uvicorn,请检查: $PYTHON_BIN -m pip install -r requirements.txt"
  166. exit 1
  167. fi
  168. fi
  169. }
  170. # 读取 PID 文件中的进程号;必须存活且确认为本应用 uvicorn(防止脏 PID/PID=1 误杀)
  171. read_pid_file() {
  172. if [ ! -f "$PID_FILE" ]; then
  173. return 0
  174. fi
  175. local pid
  176. pid="$(tr -d '[:space:]' < "$PID_FILE" 2>/dev/null || true)"
  177. if [ -z "$pid" ] || ! [[ "$pid" =~ ^[0-9]+$ ]]; then
  178. return 0
  179. fi
  180. # 拒绝系统关键 PID 与明显非法值
  181. if [ "$pid" -le 1 ]; then
  182. return 0
  183. fi
  184. if ! ps -p "$pid" >/dev/null 2>&1; then
  185. return 0
  186. fi
  187. if ! is_our_uvicorn_pid "$pid"; then
  188. return 0
  189. fi
  190. echo "$pid"
  191. }
  192. # 进程命令行是否像本目录下的测试 uvicorn
  193. is_our_uvicorn_pid() {
  194. local pid="$1"
  195. local cmd=""
  196. if [ ! -r "/proc/$pid/cmdline" ]; then
  197. return 1
  198. fi
  199. cmd="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)"
  200. case "$cmd" in
  201. *"${BASE_DIR}"*"uvicorn"*"${APP_MODULE}"*) return 0 ;;
  202. *"${VENV_DIR}/bin/python"*"uvicorn"*"${APP_MODULE}"*"--port ${PORT}"*) return 0 ;;
  203. *"${VENV_DIR}/bin/python"*"uvicorn"*"${APP_MODULE}"*"--port=${PORT}"*) return 0 ;;
  204. esac
  205. # 兼容无绝对路径、仅按 port + app 模块匹配(仍限制在本机测试端口)
  206. case "$cmd" in
  207. *"uvicorn"*"${APP_MODULE}"*"--port ${PORT}"*|*"uvicorn"*"${APP_MODULE}"*"--port=${PORT}"*)
  208. return 0
  209. ;;
  210. esac
  211. return 1
  212. }
  213. # 列出占用 PORT 的监听进程 PID
  214. list_listen_pids_on_port() {
  215. local pids=""
  216. if command -v ss >/dev/null 2>&1; then
  217. pids="$(ss -lptn "sport = :${PORT}" 2>/dev/null | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' | sort -u)"
  218. fi
  219. if [ -z "$pids" ] && command -v lsof >/dev/null 2>&1; then
  220. pids="$(lsof -tiTCP:"${PORT}" -sTCP:LISTEN 2>/dev/null | sort -u || true)"
  221. fi
  222. if [ -z "$pids" ] && command -v fuser >/dev/null 2>&1; then
  223. pids="$(fuser "${PORT}/tcp" 2>/dev/null | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' | sort -u || true)"
  224. fi
  225. echo "$pids"
  226. }
  227. # 收集应停止的 PID:PID 文件 + 占用本端口的本应用进程
  228. collect_managed_pids() {
  229. local pid p seen=""
  230. pid="$(read_pid_file || true)"
  231. if [ -n "${pid:-}" ]; then
  232. echo "$pid"
  233. seen=" $pid "
  234. fi
  235. for p in $(list_listen_pids_on_port); do
  236. if [ -z "$p" ]; then
  237. continue
  238. fi
  239. case "$seen" in
  240. *" $p "*) continue ;;
  241. esac
  242. if is_our_uvicorn_pid "$p"; then
  243. echo "$p"
  244. seen="${seen}${p} "
  245. fi
  246. done
  247. }
  248. port_is_in_use() {
  249. local p
  250. p="$(list_listen_pids_on_port | head -n 1)"
  251. [ -n "$p" ]
  252. }
  253. wait_port_free() {
  254. local i
  255. for i in $(seq 1 20); do
  256. if ! port_is_in_use; then
  257. return 0
  258. fi
  259. sleep 1
  260. done
  261. return 1
  262. }
  263. wait_health_ok() {
  264. local i url="http://127.0.0.1:${PORT}/health"
  265. for i in $(seq 1 30); do
  266. if curl -fsS --connect-timeout 1 --max-time 2 "$url" >/dev/null 2>&1; then
  267. return 0
  268. fi
  269. sleep 1
  270. done
  271. return 1
  272. }
  273. is_running() {
  274. local pid
  275. pid="$(read_pid_file || true)"
  276. if [ -n "${pid:-}" ]; then
  277. return 0
  278. fi
  279. # PID 文件失效时,仍以「本端口上的本应用进程」为准,避免误判已停止
  280. for pid in $(list_listen_pids_on_port); do
  281. if is_our_uvicorn_pid "$pid"; then
  282. return 0
  283. fi
  284. done
  285. return 1
  286. }
  287. # 仅扩展「已确认属于本应用」的进程树;禁止对 PID<=1 或未校验进程做树展开
  288. expand_our_pid_tree() {
  289. local pid="$1"
  290. local c
  291. if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
  292. return 0
  293. fi
  294. if ! ps -p "$pid" >/dev/null 2>&1; then
  295. return 0
  296. fi
  297. if ! is_our_uvicorn_pid "$pid"; then
  298. return 0
  299. fi
  300. echo "$pid"
  301. for c in $(pgrep -P "$pid" 2>/dev/null || true); do
  302. # 子进程可能是纯 python worker,命令行仍含 uvicorn/app.main;否则跳过
  303. if [ -n "$c" ] && [ "$c" -gt 1 ] && is_our_uvicorn_pid "$c"; then
  304. expand_our_pid_tree "$c"
  305. elif [ -n "$c" ] && [ "$c" -gt 1 ] && ps -p "$c" >/dev/null 2>&1; then
  306. # worker 偶发 cmdline 不含完整模块名时:仅当父进程已是本应用才收其子进程
  307. echo "$c"
  308. fi
  309. done
  310. }
  311. kill_pids_graceful_then_force() {
  312. local pid
  313. local any=0
  314. local all_pids=""
  315. local p
  316. for pid in "$@"; do
  317. if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
  318. continue
  319. fi
  320. for p in $(expand_our_pid_tree "$pid"); do
  321. if [ -n "$p" ] && [ "$p" -gt 1 ]; then
  322. all_pids="${all_pids}${p} "
  323. fi
  324. done
  325. done
  326. # shellcheck disable=SC2086
  327. set -- $all_pids
  328. for pid in "$@"; do
  329. if [ -z "$pid" ] || [ "$pid" -le 1 ]; then
  330. continue
  331. fi
  332. if ! ps -p "$pid" >/dev/null 2>&1; then
  333. continue
  334. fi
  335. any=1
  336. log_info "正在停止测试服务 (PID: $pid)"
  337. kill -TERM "$pid" >/dev/null 2>&1 || true
  338. done
  339. if [ "$any" -eq 0 ]; then
  340. return 0
  341. fi
  342. local i left
  343. for i in $(seq 1 10); do
  344. left=0
  345. for pid in "$@"; do
  346. if [ -n "$pid" ] && [ "$pid" -gt 1 ] && ps -p "$pid" >/dev/null 2>&1; then
  347. left=1
  348. break
  349. fi
  350. done
  351. if [ "$left" -eq 0 ]; then
  352. break
  353. fi
  354. sleep 1
  355. done
  356. for pid in "$@"; do
  357. if [ -n "$pid" ] && [ "$pid" -gt 1 ] && ps -p "$pid" >/dev/null 2>&1; then
  358. log_warn "优雅停止超时,执行强制停止 (PID: $pid)"
  359. kill -9 "$pid" >/dev/null 2>&1 || true
  360. fi
  361. done
  362. }
  363. # 停止本应用在测试端口上的全部实例(含 PID 文件失效的僵尸进程)
  364. stop_server() {
  365. local pids
  366. pids="$(collect_managed_pids | sort -u | tr '\n' ' ')"
  367. # shellcheck disable=SC2086
  368. set -- $pids
  369. if [ "$#" -eq 0 ]; then
  370. # 端口仍被非本应用占用时给出明确提示
  371. if port_is_in_use; then
  372. log_warn "测试服务未由本脚本托管,但端口 ${PORT} 仍被占用: $(list_listen_pids_on_port | tr '\n' ' ')"
  373. log_warn "请人工确认后再启动,避免绑端口失败"
  374. else
  375. log_warn "测试服务未运行"
  376. fi
  377. rm -f "$PID_FILE"
  378. return 0
  379. fi
  380. kill_pids_graceful_then_force "$@"
  381. # 若仍有本应用占端口,再扫一遍强杀
  382. pids="$(collect_managed_pids | sort -u | tr '\n' ' ')"
  383. # shellcheck disable=SC2086
  384. set -- $pids
  385. if [ "$#" -gt 0 ]; then
  386. log_warn "仍检测到本应用进程,执行二次强制停止"
  387. for pid in "$@"; do
  388. kill -9 "$pid" >/dev/null 2>&1 || true
  389. done
  390. fi
  391. rm -f "$PID_FILE"
  392. if ! wait_port_free; then
  393. log_error "端口 ${PORT} 仍被占用,无法确认已停止: $(list_listen_pids_on_port | tr '\n' ' ')"
  394. return 1
  395. fi
  396. log_info "测试服务已停止"
  397. return 0
  398. }
  399. start_server() {
  400. check_python
  401. ensure_venv_and_runtime
  402. ensure_dirs
  403. # 已有本应用在跑(PID 文件或端口探测)则直接返回
  404. if is_running; then
  405. local running_pid
  406. running_pid="$(read_pid_file || true)"
  407. if [ -z "${running_pid:-}" ]; then
  408. running_pid="$(collect_managed_pids | head -n 1)"
  409. fi
  410. log_warn "测试服务已在运行 (PID: ${running_pid:-unknown})"
  411. # 回写 PID 文件,避免后续 stop 找不到
  412. if [ -n "${running_pid:-}" ]; then
  413. echo "$running_pid" > "$PID_FILE"
  414. fi
  415. exit 0
  416. fi
  417. # 端口被占用但不是本应用:拒绝启动,避免静默失败后健康检查打到别人的服务
  418. if port_is_in_use; then
  419. log_error "端口 ${PORT} 已被占用且无法识别为本测试服务: $(list_listen_pids_on_port | tr '\n' ' ')"
  420. log_error "请先释放端口后再 start / restart"
  421. exit 1
  422. fi
  423. rm -f "$PID_FILE"
  424. log_info "启动测试环境服务..."
  425. log_info "监听地址: $HOST:$PORT"
  426. log_info "工作进程: $WORKERS"
  427. log_info "日志文件: $LOG_FILE"
  428. log_info "环境文件: $ENV_FILE"
  429. if [ ! -f "$ENV_FILE" ]; then
  430. log_error "环境文件不存在: $ENV_FILE"
  431. log_warn "可先执行: cp .env.test.example .env.test 并按测试库修改"
  432. exit 1
  433. fi
  434. export ENV_FILE
  435. nohup "$PYTHON_BIN" -m uvicorn "$APP_MODULE" \
  436. --host "$HOST" \
  437. --port "$PORT" \
  438. --workers "$WORKERS" \
  439. --log-level "$LOG_LEVEL" \
  440. >> "$LOG_FILE" 2>&1 &
  441. echo $! > "$PID_FILE"
  442. if ! wait_health_ok; then
  443. log_error "测试服务启动失败(健康检查超时),请检查日志: $LOG_FILE"
  444. # 启动失败时尽量清掉半拉子进程,避免留下占端口僵尸
  445. stop_server || true
  446. exit 1
  447. fi
  448. # 优先把 PID 文件写成实际监听端口的进程,避免 nohup 壳/worker 不一致
  449. local listen_pid
  450. listen_pid="$(list_listen_pids_on_port | head -n 1 || true)"
  451. if [ -n "${listen_pid:-}" ]; then
  452. echo "$listen_pid" > "$PID_FILE"
  453. fi
  454. log_info "测试服务启动成功 (PID: $(cat "$PID_FILE"))"
  455. log_info "健康检查: http://127.0.0.1:$PORT/health"
  456. log_info "API 文档: http://127.0.0.1:$PORT/docs"
  457. }
  458. status_server() {
  459. local pid
  460. if is_running; then
  461. pid="$(read_pid_file || true)"
  462. if [ -z "${pid:-}" ]; then
  463. pid="$(collect_managed_pids | head -n 1)"
  464. fi
  465. echo -e "${GREEN}RUNNING (test)${NC} PID=${pid:-unknown} HOST=$HOST PORT=$PORT"
  466. else
  467. if port_is_in_use; then
  468. echo -e "${YELLOW}PORT BUSY (test)${NC} PORT=$PORT pids=$(list_listen_pids_on_port | tr '\n' ' ')"
  469. else
  470. echo -e "${RED}STOPPED (test)${NC}"
  471. fi
  472. fi
  473. }
  474. show_logs() {
  475. ensure_dirs
  476. if [ ! -f "$LOG_FILE" ]; then
  477. log_warn "暂无日志文件: $LOG_FILE"
  478. exit 0
  479. fi
  480. tail -n 100 "$LOG_FILE"
  481. }
  482. activate_venv() {
  483. if [ ! -f "$ACTIVATE_SCRIPT" ]; then
  484. log_error "虚拟环境激活脚本不存在: $ACTIVATE_SCRIPT"
  485. log_warn "可先执行: python3.9 -m venv .venv && ./.venv/bin/pip install -r requirements.txt"
  486. exit 1
  487. fi
  488. log_info "激活虚拟环境: source $ACTIVATE_SCRIPT"
  489. exec "$SHELL" -i -c "cd '$BASE_DIR' && source '$ACTIVATE_SCRIPT' && exec -i '$SHELL'"
  490. }
  491. ACTION="${1:-start}"
  492. case "$ACTION" in
  493. start)
  494. start_server
  495. ;;
  496. stop)
  497. stop_server
  498. ;;
  499. restart)
  500. stop_server || true
  501. start_server
  502. ;;
  503. status)
  504. status_server
  505. ;;
  506. logs)
  507. show_logs
  508. ;;
  509. activate)
  510. activate_venv
  511. ;;
  512. -h|--help|help)
  513. show_help
  514. ;;
  515. *)
  516. log_error "未知命令: $ACTION"
  517. show_help
  518. exit 1
  519. ;;
  520. esac