199 lines
7.4 KiB
Python
199 lines
7.4 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""一次拉起后端的三个服务。
|
||
|
||
服务是拆开的,但开发和小规模部署时逐个开三个终端太啰嗦。这个脚本把它们放在一个
|
||
进程组里:任何一个退出就把其余的一起停掉——留着半套服务在跑,比全停更难排查。
|
||
|
||
python run_backend.py # 三个都起
|
||
python run_backend.py --only api gateway # 只起指定的
|
||
python run_backend.py --db /data/backend.db
|
||
|
||
生产环境请用 systemd / NSSM 之类的进程守护,各服务独立托管、独立重启:
|
||
一个模型上游雪崩不应该把管理后台一起带走,这正是当初把它们拆开的原因。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import signal
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
from pathlib import Path
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
|
||
SERVICES = {
|
||
"api": {
|
||
# 老的网页后台(console/8765)已整体退役:界面由 Vue 管理端承担,
|
||
# 桌面端同步、调用留痕上报也都并进了这个服务。
|
||
"label": "管理端(Vue 前端 + JSON API + 桌面端同步)",
|
||
"default_port": 8766,
|
||
"argv": lambda db, host, port: [
|
||
sys.executable, "admin_api.py",
|
||
"--db", str(db), "--host", host, "--port", str(port),
|
||
],
|
||
},
|
||
"gateway": {
|
||
"label": "模型网关(答题 + 裁判)",
|
||
"default_port": 8770,
|
||
"argv": lambda db, host, port: [
|
||
sys.executable, "model_gateway.py",
|
||
"--db", str(db), "--host", host, "--port", str(port),
|
||
],
|
||
},
|
||
}
|
||
|
||
|
||
def _port_busy(host: str, port: int) -> bool:
|
||
"""这个端口是不是已经被占了。
|
||
|
||
提前查一遍,是为了给一句人话。让 uvicorn 自己撞上去的话,Windows 会把
|
||
WinError 10048 的系统文案按本地代码页塞进管道,输出一串乱码——看到的人
|
||
只知道"起不来",不知道是端口冲突,更不知道是谁占着。
|
||
"""
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||
probe.settimeout(0.4)
|
||
try:
|
||
return probe.connect_ex((host if host != "0.0.0.0" else "127.0.0.1", port)) == 0
|
||
except OSError:
|
||
return False
|
||
|
||
|
||
def _who_holds(port: int) -> str:
|
||
"""尽力查出占用端口的进程,查不到就算了——这只是帮忙,不是必需。"""
|
||
try:
|
||
output = subprocess.run(
|
||
["netstat", "-ano", "-p", "tcp"],
|
||
capture_output=True, text=True, timeout=5,
|
||
).stdout
|
||
except Exception:
|
||
return ""
|
||
for line in output.splitlines():
|
||
parts = line.split()
|
||
if len(parts) >= 5 and parts[3] == "LISTENING" and parts[1].endswith(f":{port}"):
|
||
pid = parts[4]
|
||
try:
|
||
name = subprocess.run(
|
||
["tasklist", "/FI", f"PID eq {pid}", "/FO", "CSV", "/NH"],
|
||
capture_output=True, text=True, timeout=5,
|
||
).stdout.split(",")[0].strip().strip(chr(34))
|
||
except Exception:
|
||
name = ""
|
||
return f"{name or '未知进程'}(PID {pid})"
|
||
return ""
|
||
|
||
|
||
def _pump(name: str, stream) -> None:
|
||
"""把子进程的输出打上服务名前缀转发出来,三路日志混在一起才分得清。"""
|
||
for raw in iter(stream.readline, ""):
|
||
line = raw.rstrip()
|
||
if line:
|
||
print(f"[{name}] {line}", flush=True)
|
||
|
||
|
||
def main() -> None:
|
||
parser = argparse.ArgumentParser(description="拉起后端服务")
|
||
parser.add_argument("--db", default=str(SCRIPT_DIR / "backend.db"))
|
||
parser.add_argument("--host", default="127.0.0.1")
|
||
parser.add_argument(
|
||
"--only", nargs="*", choices=sorted(SERVICES), default=sorted(SERVICES)
|
||
)
|
||
for name, spec in SERVICES.items():
|
||
parser.add_argument(f"--{name}-port", type=int, default=spec["default_port"])
|
||
args = parser.parse_args()
|
||
|
||
db = Path(args.db).resolve()
|
||
# 管理端负责建表和播种 admin,必须最先起来:网关和它读同一个库,表不在就只能报错
|
||
order = [name for name in ("api", "gateway") if name in args.only]
|
||
if not order:
|
||
raise SystemExit("没有选中任何服务")
|
||
|
||
# 先把端口全查一遍再启动。逐个起的话,第三个服务撞上占用端口时前两个已经
|
||
# 在跑了——退出流程会把它们一起停掉,你看到的是"全部服务已停止",得往回翻
|
||
# 好几屏才找得到真正的原因。
|
||
busy = [
|
||
(name, getattr(args, f"{name}_port"))
|
||
for name in order
|
||
if _port_busy(args.host, getattr(args, f"{name}_port"))
|
||
]
|
||
if busy:
|
||
print("以下端口已被占用,服务没有启动:")
|
||
print()
|
||
for name, port in busy:
|
||
holder = _who_holds(port)
|
||
tail = f" ← 被 {holder} 占用" if holder else ""
|
||
print(f" {name:8s} {args.host}:{port}{tail}")
|
||
first_name, first_port = busy[0]
|
||
print()
|
||
print("处理办法(任选其一):")
|
||
print(" 1. 关掉占用它的进程——多半是上一次没退干净的同一套服务")
|
||
print(
|
||
f" 2. 换端口启动:python run_backend.py "
|
||
f"--{first_name}-port {first_port + 10}"
|
||
)
|
||
print(" 查占用:netstat -ano | findstr :" + str(first_port))
|
||
raise SystemExit(1)
|
||
|
||
procs: dict[str, subprocess.Popen] = {}
|
||
print(f"数据库:{db}")
|
||
for name in order:
|
||
spec = SERVICES[name]
|
||
port = getattr(args, f"{name}_port")
|
||
proc = subprocess.Popen(
|
||
spec["argv"](db, args.host, port),
|
||
cwd=SCRIPT_DIR,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
bufsize=1,
|
||
)
|
||
procs[name] = proc
|
||
threading.Thread(
|
||
target=_pump, args=(name, proc.stdout), daemon=True
|
||
).start()
|
||
print(f"已启动 {name:8s} http://{args.host}:{port} {spec['label']}")
|
||
if name == "api":
|
||
# 让建表先跑完,网关起来时表就已经在了
|
||
time.sleep(1.5)
|
||
|
||
print("\nCtrl+C 停止全部服务。\n")
|
||
try:
|
||
while True:
|
||
for name, proc in procs.items():
|
||
code = proc.poll()
|
||
if code is not None:
|
||
print(f"\n[{name}] 已退出(code={code}),正在停止其余服务…")
|
||
raise KeyboardInterrupt
|
||
time.sleep(0.5)
|
||
except KeyboardInterrupt:
|
||
pass
|
||
finally:
|
||
for name, proc in procs.items():
|
||
if proc.poll() is None:
|
||
try:
|
||
if os.name == "nt":
|
||
proc.terminate()
|
||
else:
|
||
proc.send_signal(signal.SIGINT)
|
||
except OSError:
|
||
pass
|
||
deadline = time.time() + 8
|
||
for name, proc in procs.items():
|
||
remaining = max(0.5, deadline - time.time())
|
||
try:
|
||
proc.wait(timeout=remaining)
|
||
except subprocess.TimeoutExpired:
|
||
print(f"[{name}] 没有及时退出,强制结束")
|
||
proc.kill()
|
||
print("全部服务已停止。")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|