예제 소스
SGF 기보로 학습하는 바둑 LLM 인공지능 · 파일 169개
daemon_mariago_external_probe.py
#!/usr/bin/env python3
"""MariaGO 외부 접속 주기 점검 데몬.
외부 사용자와 같은 경로(HTTPS)로 페이지·WS 프록시가 살아 있는지 확인.
.venv/bin/python scripts/daemon_mariago_external_probe.py
결과:
logs/mariago_external_probe/probes.jsonl
logs/mariago_external_probe/HEARTBEAT.md
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import socket
import ssl
import subprocess
import time
import urllib.error
import urllib.request
from collections import deque
from datetime import datetime, timezone
from pathlib import Path
from zoneinfo import ZoneInfo
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "logs" / "mariago_external_probe"
TUNNEL_OUT = ROOT / "logs" / "mariago_ssh_tunnel"
RESTART_FLAG = TUNNEL_OUT / "RESTART_REQUESTED"
HEART = OUT / "HEARTBEAT.md"
LOG = OUT / "probe.log"
JSONL = OUT / "probes.jsonl"
KST = ZoneInfo("Asia/Seoul")
PY = str(ROOT / ".venv" / "bin" / "python")
SSH_HOST = "[REDACTED]"
LOCAL_WS = 9320
GATEWAY_ERRORS = {501, 502, 503}
RESTART_COOLDOWN_S = 90.0
DEFAULT_TARGETS = [
{"name": "mariago-root", "url": "https://mariago.kr/", "kind": "http"},
{"name": "baduk-play", "url": "https://baduk.mariago.kr/play", "kind": "http"},
{"name": "stoku-home", "url": "https://stoku.mariago.kr/", "kind": "http"},
{"name": "omook-home", "url": "https://omook.mariago.kr/", "kind": "http"},
{"name": "omook-play", "url": "https://omook.mariago.kr/play", "kind": "http"},
{"name": "othello-home", "url": "https://othello.mariago.kr/", "kind": "http"},
{"name": "othello-play", "url": "https://othello.mariago.kr/play", "kind": "http"},
{"name": "tsp-home", "url": "https://tsp.mariago.kr/", "kind": "http"},
{"name": "tsp-play", "url": "https://tsp.mariago.kr/play", "kind": "http"},
{"name": "viruswar-home", "url": "https://viruswar.mariago.kr/", "kind": "http"},
{"name": "viruswar-play", "url": "https://viruswar.mariago.kr/play", "kind": "http"},
{"name": "janggy-home", "url": "https://janggy.mariago.kr/", "kind": "http"},
{"name": "janggy-play", "url": "https://janggy.mariago.kr/play", "kind": "http"},
{"name": "chess-home", "url": "https://chess.mariago.kr/", "kind": "http"},
{"name": "chess-play", "url": "https://chess.mariago.kr/play", "kind": "http"},
{"name": "crossword-home", "url": "https://crossword.mariago.kr/", "kind": "http"},
{"name": "crossword-play", "url": "https://crossword.mariago.kr/play", "kind": "http"},
{"name": "baduk-ws", "url": "https://baduk.mariago.kr/vlm-ws", "kind": "ws"},
]
def now_kst() -> datetime:
return datetime.now(KST)
def log(msg: str) -> None:
line = f"[{now_kst():%Y-%m-%d %H:%M:%S}] {msg}"
print(line, flush=True)
OUT.mkdir(parents=True, exist_ok=True)
with LOG.open("a", encoding="utf-8") as f:
f.write(line + "\n")
def port_open(port: int, host: str = "127.0.0.1") -> bool:
try:
with socket.create_connection((host, port), timeout=1.5):
return True
except OSError:
return False
def pgrep(pattern: str) -> list[int]:
try:
out = subprocess.check_output(["pgrep", "-f", pattern], text=True)
except subprocess.CalledProcessError:
return []
me = os.getpid()
pids: list[int] = []
for tok in out.split():
try:
p = int(tok)
except ValueError:
continue
if p != me:
pids.append(p)
return pids
def kill_pids(pids: list[int], label: str) -> None:
if not pids:
return
log(f"kill {label}: {pids}")
for p in pids:
try:
os.kill(p, signal.SIGTERM)
except ProcessLookupError:
pass
time.sleep(1)
for p in pids:
try:
os.kill(p, signal.SIGKILL)
except ProcessLookupError:
pass
def restart_bridge() -> None:
log("bridge DOWN → restart bridge")
kill_pids(pgrep("mariago_ws_bridge.py"), "bridge")
TUNNEL_OUT.mkdir(parents=True, exist_ok=True)
bridge_log = TUNNEL_OUT / "bridge.probe-restart.log"
with bridge_log.open("a", encoding="utf-8") as fp:
fp.write(f"\n===== probe restart {now_kst():%Y-%m-%d %H:%M:%S} =====\n")
fp.flush()
subprocess.Popen(
[PY, "scripts/mariago_ws_bridge.py", "--ws-port", str(LOCAL_WS)],
cwd=str(ROOT),
stdout=fp,
stderr=subprocess.STDOUT,
start_new_session=True,
)
def request_tunnel_restart(reason: str) -> None:
"""터널 watchdog에 재시작 요청 + SSH 세션 끊기."""
TUNNEL_OUT.mkdir(parents=True, exist_ok=True)
RESTART_FLAG.write_text(f"{now_kst().isoformat()} {reason}\n", encoding="utf-8")
log(f"tunnel restart requested: {reason}")
kill_pids(pgrep(f"ssh .*{SSH_HOST}.*127.0.0.1:18080"), "ssh-tunnel")
kill_pids(pgrep(f"ssh -N.*{SSH_HOST}"), "ssh-tunnel-n")
def needs_recovery(results: list[dict], bridge_ok: bool) -> tuple[bool, list[str], list[str]]:
"""반환: (recover 필요, 터널 재시작 사유, 브리지-only 사유).
WS 단독 실패로 SSH 터널 전체를 끊으면 omook/baduk HTTP 도 잠깐 502 가 난다.
HTTP 게이트웨이 오류일 때만 터널을 재시작하고, WS/브리지는 브리지 재기동으로 충분.
"""
tunnel_reasons: list[str] = []
bridge_reasons: list[str] = []
for r in results:
st = r.get("status")
kind = r.get("kind", "http")
name = r["name"]
if st in GATEWAY_ERRORS:
tunnel_reasons.append(f"{name}:HTTP{st}")
elif not r.get("ok"):
if kind == "ws":
bridge_reasons.append(f"{name}:down")
else:
tunnel_reasons.append(f"{name}:down")
if not bridge_ok:
bridge_reasons.append("bridge:9320_down")
need = bool(tunnel_reasons or bridge_reasons)
return need, tunnel_reasons, bridge_reasons
def maybe_recover(
results: list[dict],
*,
enabled: bool,
last_restart_at: float,
) -> float:
if not enabled:
return last_restart_at
bridge_ok = port_open(LOCAL_WS)
need, tunnel_reasons, bridge_reasons = needs_recovery(results, bridge_ok)
if not need:
return last_restart_at
now = time.monotonic()
if now - last_restart_at < RESTART_COOLDOWN_S:
log(
f"recover skipped (cooldown) tunnel={tunnel_reasons} bridge={bridge_reasons}"
)
return last_restart_at
if bridge_reasons or not bridge_ok:
restart_bridge()
if tunnel_reasons:
request_tunnel_restart(", ".join(tunnel_reasons))
return now
log(f"bridge-only recovery (no tunnel kill): {', '.join(bridge_reasons)}")
return now
def probe_http(url: str, timeout_s: float) -> dict:
t0 = time.monotonic()
ctx = ssl.create_default_context()
req = urllib.request.Request(
url,
method="GET",
headers={
"User-Agent": "MariaGO-external-probe/1.0",
"Accept": "text/html,*/*",
"Connection": "close",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout_s, context=ctx) as resp:
body = resp.read(2048)
code = int(resp.status)
ok = 200 <= code < 400
return {
"ok": ok,
"status": code,
"ms": round((time.monotonic() - t0) * 1000, 1),
"bytes": len(body),
"error": None,
}
except urllib.error.HTTPError as e:
code = int(e.code)
ok = 200 <= code < 400
return {
"ok": ok,
"status": code,
"ms": round((time.monotonic() - t0) * 1000, 1),
"bytes": 0,
"error": None if ok else f"HTTPError: {e}",
}
except Exception as e:
return {
"ok": False,
"status": None,
"ms": round((time.monotonic() - t0) * 1000, 1),
"bytes": 0,
"error": f"{type(e).__name__}: {e}",
}
def probe_ws(url: str, timeout_s: float) -> dict:
"""WS 엔드포인트: 101/400/426 등은 살아 있음, 502/503/000 은 장애."""
t0 = time.monotonic()
ctx = ssl.create_default_context()
req = urllib.request.Request(
url,
method="GET",
headers={
"User-Agent": "MariaGO-external-probe/1.0",
"Connection": "Upgrade",
"Upgrade": "websocket",
"Sec-WebSocket-Version": "13",
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
},
)
try:
with urllib.request.urlopen(req, timeout=timeout_s, context=ctx) as resp:
code = int(resp.status)
ok = code in (101, 200, 400, 426)
return {
"ok": ok,
"status": code,
"ms": round((time.monotonic() - t0) * 1000, 1),
"bytes": 0,
"error": None if ok else f"unexpected status {code}",
}
except urllib.error.HTTPError as e:
code = int(e.code)
ok = code in (400, 426, 101, 200)
return {
"ok": ok,
"status": code,
"ms": round((time.monotonic() - t0) * 1000, 1),
"bytes": 0,
"error": None if ok else f"HTTPError: {e}",
}
except Exception as e:
return {
"ok": False,
"status": None,
"ms": round((time.monotonic() - t0) * 1000, 1),
"bytes": 0,
"error": f"{type(e).__name__}: {e}",
}
def probe_target(target: dict, timeout_s: float) -> dict:
kind = target.get("kind", "http")
if kind == "ws":
body = probe_ws(target["url"], timeout_s)
else:
body = probe_http(target["url"], timeout_s)
return {
"ts": datetime.now(timezone.utc).isoformat(),
"name": target["name"],
"url": target["url"],
"kind": kind,
**body,
}
def write_heartbeat(
*,
interval_s: float,
results: list[dict],
history: dict[str, deque],
fail_streak: int,
total_rounds: int,
ok_rounds: int,
last_recovery: str | None = None,
) -> None:
lines = [
f"# MariaGO external probe — {now_kst():%Y-%m-%d %H:%M:%S KST}",
"",
f"- **interval_s**: `{interval_s}`",
f"- **rounds**: `{total_rounds}` (all-ok `{ok_rounds}` = `{100.0 * ok_rounds / max(1, total_rounds):.1f}%`)",
f"- **fail_streak**: `{fail_streak}` (연속 전체 실패 회차)",
"",
"## last check",
"",
]
all_ok = True
for r in results:
tag = "OK" if r["ok"] else "FAIL"
if not r["ok"]:
all_ok = False
err = f" err=`{r['error']}`" if r["error"] else ""
lines.append(
f"- **{r['name']}**: `{tag}` status=`{r['status']}` {r['ms']}ms{err}"
)
lines.append("")
lines.append(f"- **overall**: `{'OK' if all_ok else 'FAIL'}`")
if last_recovery:
lines.append(f"- **last_recovery**: `{last_recovery}`")
lines.append("")
lines.append("## per-target window (recent ok rate)")
lines.append("")
for name, win in history.items():
ok_n = sum(1 for x in win if x)
lines.append(f"- **{name}**: `{ok_n}/{len(win)}`")
lines.append("")
lines.append("로그: `logs/mariago_external_probe/probe.log`")
lines.append("")
HEART.write_text("\n".join(lines), encoding="utf-8")
def main() -> int:
p = argparse.ArgumentParser(description="MariaGO external reachability probe daemon")
p.add_argument("--interval", type=float, default=60.0, help="초 단위")
p.add_argument("--timeout", type=float, default=12.0)
p.add_argument("--window", type=int, default=60, help="타깃별 최근 N회 요약")
p.add_argument(
"--recover",
action=argparse.BooleanOptionalAction,
default=True,
help="502/503/브리지 다운 시 터널·브리지 자동 복구",
)
args = p.parse_args()
OUT.mkdir(parents=True, exist_ok=True)
history: dict[str, deque] = {
t["name"]: deque(maxlen=max(10, args.window)) for t in DEFAULT_TARGETS
}
fail_streak = 0
total_rounds = ok_rounds = 0
last_restart_at = 0.0
last_recovery_note: str | None = None
log(
f"external probe start interval={args.interval}s recover={args.recover} "
f"targets={[t['name'] for t in DEFAULT_TARGETS]}"
)
while True:
results = [probe_target(t, args.timeout) for t in DEFAULT_TARGETS]
all_ok = all(r["ok"] for r in results)
total_rounds += 1
if all_ok:
ok_rounds += 1
fail_streak = 0
round_tag = "OK"
else:
fail_streak += 1
round_tag = "FAIL"
prev = last_restart_at
last_restart_at = maybe_recover(
results,
enabled=args.recover,
last_restart_at=last_restart_at,
)
if last_restart_at != prev:
last_recovery_note = now_kst().strftime("%Y-%m-%d %H:%M:%S KST")
for r in results:
history[r["name"]].append(r["ok"])
with JSONL.open("a", encoding="utf-8") as f:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
write_heartbeat(
interval_s=args.interval,
results=results,
history=history,
fail_streak=fail_streak,
total_rounds=total_rounds,
ok_rounds=ok_rounds,
last_recovery=last_recovery_note,
)
parts = []
for r in results:
tag = "OK" if r["ok"] else "FAIL"
parts.append(f"{r['name']}={tag}({r['status']},{r['ms']}ms)")
log(f"[{round_tag}] " + " · ".join(parts))
time.sleep(max(5.0, args.interval))
if __name__ == "__main__":
try:
raise SystemExit(main())
except KeyboardInterrupt:
log("external probe stopped")
raise SystemExit(0)