xiaowei-system/scripts/bge-failover-proxy.py

258 lines
10 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
"""
bge 嵌入服务故障切换代理 (2026-09-05)
======================================
zhiyid 只认 localhost:8000 — 本代理常驻 8000
* 远端优先: 192.168.5.104:8000 (ZSB GPU / DML bge-m3)
* 远端故障 → 自动切本地 127.0.0.1:8001 (fallback bge)
* 远端恢复 → 自动切回(每次请求前探测 /health
启动: python3 bge-failover-proxy.py
端口: 8000 (env PROXY_PORT)
远端: 192.168.5.104:8000 (env REMOTE_EMBED_URL)
本地: 127.0.0.1:8001 (env LOCAL_EMBED_URL)
"""
import json
import logging
import os
import socket
import subprocess
import threading
import time
from http.server import HTTPServer, BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib import request as urlrequest
from urllib.error import URLError
PROXY_PORT = int(os.environ.get("PROXY_PORT", "8000"))
REMOTE_EMBED_URL = os.environ.get("REMOTE_EMBED_URL", "http://192.168.5.104:8000")
LOCAL_EMBED_URL = os.environ.get("LOCAL_EMBED_URL", "http://127.0.0.1:8001")
HEALTH_PATH = "/health"
EMBED_PATH = "/v1/embeddings"
HEALTH_TIMEOUT = float(os.environ.get("HEALTH_TIMEOUT", "1.5"))
REQUEST_TIMEOUT = float(os.environ.get("REQUEST_TIMEOUT", "60"))
logging.basicConfig(
level=logging.INFO,
format="[bge-proxy] %(asctime)s %(message)s",
datefmt="%H:%M:%S",
)
log = logging.getLogger("bge-proxy")
_state_lock = threading.Lock()
# 标记文件proxy 拉起的 local 需在 proxy 重启后仍记得(避免重启后 local 永不自动停)
LOCAL_STAMP = os.path.expanduser("~/.hermes/run/bge_proxy_local.stamp")
_state = {"remote_ok": None, "last_check": 0.0, "using": None,
"local_started_by_proxy": os.path.exists(LOCAL_STAMP), # 重启后从标记恢复
"remote_ok_since": None,
"remote_retry_after": 0.0} # embedding 实发失败后的冷却截止300s
# 按需拉起本地 fallback 的参数2026-09-05: bge-embed 不再常驻104 故障时才拉起)
LOCAL_START_TIMEOUT = float(os.environ.get("LOCAL_START_TIMEOUT", "30")) # 拉起后等待就绪上限
LOCAL_STOP_GRACE = float(os.environ.get("LOCAL_STOP_GRACE", "60")) # 远端稳定多久后停本地
BGE_EMBED_SERVICE = os.environ.get("BGE_EMBED_SERVICE", "bge-embed.service")
def _probe_remote() -> bool:
"""探测远端 /health1.5s 超时。"""
try:
req = urlrequest.Request(REMOTE_EMBED_URL + HEALTH_PATH)
with urlrequest.urlopen(req, timeout=HEALTH_TIMEOUT) as r:
if r.status != 200:
return False
body = r.read(200).decode("utf-8", "ignore")
return "ok" in body
except Exception:
return False
def _local_healthy() -> bool:
"""探测本地 fallback (8001) /health。"""
try:
req = urlrequest.Request(LOCAL_EMBED_URL + HEALTH_PATH)
with urlrequest.urlopen(req, timeout=1.5) as r:
if r.status != 200:
return False
return "ok" in r.read(200).decode("utf-8", "ignore")
except Exception:
return False
def _ensure_local() -> bool:
"""远端不可用时确保本地可用:未启动则 systemctl --user 拉起 bge-embed。
返回 True = 本地可用False = 本地拉起失败/超时(转发将 502
"""
if _local_healthy():
return True
with _state_lock:
if not _state["local_started_by_proxy"]:
log.info("远端不可用 → 拉起 %s (按需)", BGE_EMBED_SERVICE)
try:
subprocess.Popen(
["systemctl", "--user", "start", BGE_EMBED_SERVICE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
_state["local_started_by_proxy"] = True
try:
os.makedirs(os.path.dirname(LOCAL_STAMP), exist_ok=True)
open(LOCAL_STAMP, "w").close()
except Exception:
pass
except Exception as e:
log.error("拉起 %s 失败: %s", BGE_EMBED_SERVICE, e)
# 轮询等待模型加载int8 onnx ~10-20s
deadline = time.time() + LOCAL_START_TIMEOUT
while time.time() < deadline:
if _local_healthy():
return True
time.sleep(1)
log.error("本地 fallback %s 拉起超时 (%ss)", BGE_EMBED_SERVICE, LOCAL_START_TIMEOUT)
return False
def _maybe_stop_local(remote_ok: bool):
"""远端已恢复且稳定 → 停掉 proxy 拉起的本地 fallback回到省电模式。"""
now = time.time()
with _state_lock:
if remote_ok and _state["local_started_by_proxy"]:
if _state["remote_ok_since"] is None:
_state["remote_ok_since"] = now
elif now - _state["remote_ok_since"] >= LOCAL_STOP_GRACE:
log.info("远端已稳定 %ss → 停 %s (释放内存)", LOCAL_STOP_GRACE, BGE_EMBED_SERVICE)
try:
subprocess.Popen(
["systemctl", "--user", "stop", BGE_EMBED_SERVICE],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
_state["local_started_by_proxy"] = False
_state["remote_ok_since"] = None
try:
os.remove(LOCAL_STAMP)
except OSError:
pass
except Exception as e:
log.error("停止 %s 失败: %s", BGE_EMBED_SERVICE, e)
elif not remote_ok:
_state["remote_ok_since"] = None
def choose_target() -> tuple[str, bool]:
"""返回 (目标URL, 是否远端)。带 3s 冷却 + 状态缓存 + 按需拉起本地 fallback。"""
global _state
now = time.time()
with _state_lock:
cache_fresh = _state["remote_ok"] is not None and now - _state["last_check"] < 3.0
in_cooldown = _state["remote_ok"] is False and now < _state["remote_retry_after"]
if cache_fresh or in_cooldown:
remote_ok = _state["remote_ok"]
else:
remote_ok = _probe_remote()
_state["remote_ok"] = remote_ok
_state["last_check"] = now
_maybe_stop_local(remote_ok)
if remote_ok:
using = "remote(104-GPU)"
if using != _state["using"]:
log.info("切换 → %s", using)
with _state_lock:
_state["using"] = using
return REMOTE_EMBED_URL, True
# 远端不可用 → 确保本地可用
local_ok = _ensure_local()
using = "local(106-CPU)" if local_ok else "local(106-CPU, 未就绪)"
if using != _state["using"]:
log.info("切换 → %s", using)
with _state_lock:
_state["using"] = using
return LOCAL_EMBED_URL, False
def _forward(url: str, method: str, body: bytes, headers: dict) -> tuple[int, dict, bytes]:
"""把请求转发到目标,返回 (状态码, 响应头, 响应体)。"""
req = urlrequest.Request(url, data=body if method == "POST" else None, method=method)
for k, v in headers.items():
if k.lower() not in ("host", "content-length", "connection", "accept-encoding"):
req.add_header(k, v)
try:
with urlrequest.urlopen(req, timeout=REQUEST_TIMEOUT) as r:
resp_body = r.read()
resp_headers = dict(r.headers.items())
return r.status, resp_headers, resp_body
except URLError as e:
return 502, {"Content-Type": "application/json"}, json.dumps(
{"error": f"proxy forward failed: {e}"}
).encode()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, fmt, *args):
log.info("%s %s" % (self.address_string(), fmt % args))
def _handle(self):
length = int(self.headers.get("Content-Length", 0) or 0)
body = self.rfile.read(length) if length else b""
target, _remote_ok = choose_target()
status, resp_headers, resp_body = _forward(
target + self.path, self.command, body, dict(self.headers)
)
# 远端 embedding 实发失败500/502/503/504health 探测不出的故障)
# → 标故障 + 30s 冷却 + 立即降级 local 重试一次
if status >= 500 and target.startswith(REMOTE_EMBED_URL):
with _state_lock:
_state["remote_ok"] = False
# 300s 冷却: 104 可能出现 health OK 但 embedding 500实发故障
# 短冷却会导致反复 remote→500→local 抖动并可能反复启停 bge-embed
_state["remote_retry_after"] = time.time() + 300
_state["using"] = None # 强制下次 log 切换
log.info("远端 embedding 实发失败(%s) → 降级 local 重试", status)
if _ensure_local():
target = LOCAL_EMBED_URL
status, resp_headers, resp_body = _forward(
target + self.path, self.command, body, dict(self.headers)
)
self.send_response(status)
# 只转发安全响应头Content-Length 必须用实际长度
for k, v in resp_headers.items():
if k.lower() in ("content-type",):
self.send_header(k, v)
self.send_header("Content-Length", str(len(resp_body)))
self.send_header("X-BGE-Proxy", "remote-104" if target.startswith(REMOTE_EMBED_URL) else "local-106")
self.end_headers()
if self.command != "HEAD":
try:
self.wfile.write(resp_body)
except (BrokenPipeError, ConnectionResetError):
# 客户端提前断开(探测超时/取消)——不影响其他请求(多线程)
pass
do_GET = _handle
do_POST = _handle
do_PUT = _handle
do_DELETE = _handle
def _startup_probe():
"""启动时打一条日志说明当前选路。"""
remote_ok = _probe_remote()
with _state_lock:
_state["remote_ok"] = remote_ok
_state["last_check"] = time.time()
_state["using"] = "remote(104-GPU)" if remote_ok else "local(106-CPU)"
log.info("启动选路: %s (remote=%s)", _state["using"], remote_ok)
if __name__ == "__main__":
log.info("bge 故障切换代理启动 0.0.0.0:%s (Threading)", PROXY_PORT)
log.info(" 远端: %s", REMOTE_EMBED_URL)
log.info(" 本地 fallback: %s", LOCAL_EMBED_URL)
_startup_probe()
# ThreadingHTTPServer: 单请求卡住不阻塞 /health 探测和其他请求
server = ThreadingHTTPServer(("0.0.0.0", PROXY_PORT), Handler)
server.daemon_threads = True
try:
server.serve_forever()
except KeyboardInterrupt:
log.info("停止")