File size: 29,741 Bytes
8568992 5f92610 8568992 d8ff31c 8568992 0d2889a 8568992 d8ff31c 8568992 d8ff31c 8568992 d8ff31c 8568992 d8ff31c 8568992 d8ff31c 8568992 5f92610 8568992 5f92610 8568992 5f92610 8568992 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 | #!/usr/bin/env python3
"""Агент LLM-ноды: поднимает vLLM по инстансу на карту и держит их
зарегистрированными в vLLM-менеджере.
Работает внутри образа vllm/vllm-openai, только на стандартной библиотеке
плюс huggingface_hub (он уже в образе). Ничего не устанавливает.
Что делает:
1. читает карты через nvidia-smi и раскладывает по ним инстансы —
контекст и размер батча считаются от VRAM, а не берутся с потолка;
2. один раз скачивает веса, чтобы инстансы не тянули их наперегонки;
3. запускает vLLM и следит за процессами;
4. как только инстанс отвечает на /health — регистрирует его в менеджере
по публичному адресу Vast, а когда он умирает — снимает.
Регистрация идёт по публичному адресу вида http://<ip>:<проброшенный порт>:
менеджер ходит к ноде сам, поэтому внутренний порт ему бесполезен. Соответствие
внутренних портов внешним знает только Vast — его и спрашиваем контейнерным
ключом, который лежит в /root/.vast_api_key.
"""
import json
import os
import re
import signal
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
LOG_DIR = "/var/log/vllm-node"
STATE_DIR = "/opt/vllm-node"
VAST_API = "https://console.vast.ai/api/v0"
# Пулы менеджера. Список закрытый: на неизвестный тип /admin/servers/add
# отвечает 422, поэтому лучше отсеять опечатку до отправки.
KNOWN_POOLS = ("regular", "premium", "summary", "critic", "test", "extractor")
def manual_mode() -> bool:
"""Ручной режим: агент держит vLLM, но не трогает менеджер.
Нужен на отладке. Автопилот иначе мешает: снимешь ноду с пула руками —
через минуту сверка вернёт её обратно. Переключается файлом, а не
переменной, чтобы не перезапускать ноду ради смены режима.
"""
return os.path.exists(os.path.join(STATE_DIR, "manual"))
def log(msg: str) -> None:
print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}", flush=True)
def env(name: str, default: str = "") -> str:
return (os.getenv(name) or default).strip()
def env_int(name: str, default: int) -> int:
raw = env(name)
return int(raw) if raw else default
def env_float(name: str, default: float) -> float:
raw = env(name)
return float(raw) if raw else default
# --- Конфигурация -----------------------------------------------------------
MODEL_ID = env("MODEL_ID", "cloud19/G4-MeroMero-26B-FP8-Dynamic-Uncensored")
SERVED_NAME = env("SERVED_MODEL_NAME", "gemma-rp-uncensored")
VLLM_API_KEY = env("VLLM_API_KEY", "dj9hj342fhc4cinj4hj092Hd38D")
HF_TOKEN = env("HF_TOKEN")
MANAGER_URL = env("MANAGER_URL", "http://185.70.186.164:5555").rstrip("/")
MANAGER_API_KEY = env("MANAGER_API_KEY")
NODE_POOLS = env("NODE_POOLS", "test")
GPU_LAYOUT = env("GPU_LAYOUT")
REGISTER = env("REGISTER", "1") != "0"
PORTS = [int(p) for p in env("PORTS", "8080,1111,8082,8083,8084,8085,8086,8087").split(",") if p.strip()]
TP_SIZE = env_int("TENSOR_PARALLEL_SIZE", 1)
VLLM_EXTRA_ARGS = env("VLLM_EXTRA_ARGS")
# Ручные подпорки: если заданы, перебивают всё, что посчитано от железа.
FORCE_CTX = env_int("MAX_MODEL_LEN", 0)
FORCE_SEQS = env_int("MAX_NUM_SEQS", 0)
FORCE_UTIL = env_float("GPU_MEMORY_UTILIZATION", 0.0)
FORCE_KV = env("KV_CACHE_DTYPE")
HEALTH_TIMEOUT = env_int("HEALTH_TIMEOUT", 3600) # сколько ждём первого /health
POLL_INTERVAL = env_int("POLL_INTERVAL", 10)
SYNC_INTERVAL = env_int("SYNC_INTERVAL", 60) # сверка с менеджером
UNHEALTHY_GRACE = env_int("UNHEALTHY_GRACE", 120) # сколько терпим молчание перед снятием
WEIGHTS_GIB = 26.7 # G4-MeroMero-26B FP8; для другой модели см. WEIGHTS_GIB в env
WEIGHTS_GIB = env_float("WEIGHTS_GIB", WEIGHTS_GIB)
# --- Железо -----------------------------------------------------------------
def discover_gpus():
out = subprocess.run(
["nvidia-smi", "--query-gpu=index,name,memory.total,compute_cap",
"--format=csv,noheader,nounits"],
capture_output=True, text=True, timeout=60)
if out.returncode != 0:
raise RuntimeError(f"nvidia-smi не отвечает: {out.stderr.strip()[:200]}")
gpus = []
for line in out.stdout.strip().splitlines():
idx, name, mib, cap = [p.strip() for p in line.split(",")]
gpus.append({"index": int(idx), "name": name, "vram_gib": int(mib) / 1024,
"cap": float(cap)})
return gpus
def gpu_processes(indices):
"""Чужие процессы на наших картах: (pid, занято МБ)."""
found = []
for idx in indices:
out = subprocess.run(
["nvidia-smi", "--query-compute-apps=pid,used_gpu_memory",
"--format=csv,noheader,nounits", "--id", str(idx)],
capture_output=True, text=True, timeout=60)
for line in out.stdout.strip().splitlines():
if not line.strip():
continue
pid, _, mem = line.partition(",")
try:
found.append((int(pid.strip()), int(mem.strip())))
except ValueError:
continue
return found
def shape_for(vram_gib: float, cap: float):
"""Контекст, батч и утилизация под конкретную карту.
Считаем от того, сколько остаётся под KV-кэш после весов. Цифры сверены
с продом: на B200 (183 ГБ) vLLM отдаёт под кэш 132 ГиБ при утилизации 0.90.
"""
util = 0.90
free = vram_gib * util - WEIGHTS_GIB - 4.0 # 4 ГиБ — активации и графы CUDA
if free < 2.0 and vram_gib < 40:
util = 0.94 # на тесных картах выжимаем остаток
free = vram_gib * util - WEIGHTS_GIB - 3.0
if free <= 0.5:
return None # веса не влезают, карту пропускаем
if free >= 100:
ctx, seqs = 16384, 512
elif free >= 40:
ctx, seqs = 16384, 384
elif free >= 12:
ctx, seqs = 8192, 192
elif free >= 4:
ctx, seqs = 8192, 48
else:
ctx, seqs = 4096, 16
# FP8-кэш вдвое дешевле по памяти, но нативно считается с Ada и новее.
kv = "fp8" if cap >= 8.9 else "auto"
return {"ctx": ctx, "seqs": seqs, "util": util, "kv": kv, "kv_free_gib": round(free, 1)}
def parse_layout(spec: str):
"""`0:8192:regular 1:16384:premium,test` — карта, контекст, пулы.
Контекст можно пропустить (`0::regular`) — тогда он считается от VRAM.
"""
plan = {}
for item in re.split(r"[;\s]+", spec.strip()):
if not item:
continue
parts = item.split(":")
if len(parts) != 3:
raise ValueError(f"GPU_LAYOUT: непонятный кусок {item!r}, нужно idx:ctx:пулы")
idx, ctx, pools = parts
plan[int(idx)] = {"ctx": int(ctx) if ctx else 0,
"pools": [p for p in pools.split(",") if p]}
return plan
def build_plan():
gpus = discover_gpus()
log(f"карт найдено: {len(gpus)}")
for g in gpus:
log(f" GPU{g['index']}: {g['name']}, {g['vram_gib']:.0f} ГиБ, compute {g['cap']}")
default_pools = [p.strip() for p in NODE_POOLS.split(",") if p.strip()]
layout = parse_layout(GPU_LAYOUT) if GPU_LAYOUT else {}
groups = []
if TP_SIZE > 1:
for start in range(0, len(gpus) - len(gpus) % TP_SIZE, TP_SIZE):
groups.append(gpus[start:start + TP_SIZE])
else:
groups = [[g] for g in gpus]
plan = []
for slot, group in enumerate(groups):
head = group[0]
shape = shape_for(head["vram_gib"], head["cap"])
if shape is None:
log(f" GPU{head['index']}: {head['vram_gib']:.0f} ГиБ мало под "
f"{WEIGHTS_GIB:.1f} ГиБ весов — карта пропущена")
continue
if slot >= len(PORTS):
log(f" GPU{head['index']}: портов в PORTS меньше, чем карт — карта пропущена")
continue
override = layout.get(head["index"], {})
ctx = FORCE_CTX or override.get("ctx") or shape["ctx"]
pools = override.get("pools") or default_pools
bad = [p for p in pools if p not in KNOWN_POOLS]
if bad:
raise ValueError(f"неизвестные пулы {bad}, допустимы {list(KNOWN_POOLS)}")
plan.append({
"slot": slot,
"gpus": [g["index"] for g in group],
"gpu_name": head["name"],
"port": PORTS[slot],
"ctx": ctx,
"seqs": FORCE_SEQS or shape["seqs"],
"util": FORCE_UTIL or shape["util"],
"kv": FORCE_KV or shape["kv"],
"pools": pools,
"tp": len(group),
})
if not plan:
raise RuntimeError("ни одной пригодной карты — запускать нечего")
return plan
# --- Веса -------------------------------------------------------------------
def fetch_weights():
"""Тянем модель один раз до старта инстансов.
Иначе несколько vLLM лезут в один кэш одновременно: время старта растёт,
а на тесном диске это ещё и лишний риск.
"""
log(f"качаю веса {MODEL_ID}")
started = time.time()
from huggingface_hub import snapshot_download
path = snapshot_download(MODEL_ID, token=HF_TOKEN or None,
max_workers=8, ignore_patterns=["*.pt", "*.bin"])
log(f"веса на месте за {time.time() - started:.0f} с: {path}")
return path
# --- Публичный адрес --------------------------------------------------------
def _read(path: str) -> str:
try:
with open(path) as fh:
return fh.read().strip()
except OSError:
return ""
class PublicAddress:
"""Соответствие внутренних портов внешним.
На Vast его знает только API, поэтому спрашиваем контейнерным ключом.
Вне Vast (голое железо, свой сервер) достаточно PUBLIC_HOST — порты там
и так совпадают.
"""
def __init__(self):
self.host = env("PUBLIC_HOST")
self.ports = {}
self._loaded = False
def load(self) -> None:
if self._loaded:
return
key = env("VAST_CONTAINER_API_KEY") or _read("/root/.vast_api_key")
label = _read("/root/.vast_containerlabel")
instance_id = label.replace("C.", "").strip()
if key and instance_id:
# Здесь нельзя откатываться на «внешний ip + тот же порт»: снаружи
# порт другой, и нода зарегистрируется по адресу, куда менеджер не
# достучится. Лучше долбиться в API, пока не ответит.
for attempt in range(1, 11):
try:
status, data = http_json("GET", f"{VAST_API}/instances/{instance_id}/",
headers={"Authorization": f"Bearer {key}"}, timeout=30)
if status != 200:
raise RuntimeError(f"HTTP {status}: {str(data)[:200]}")
inst = data["instances"]
self.host = self.host or inst.get("public_ipaddr", "").strip()
for spec, binds in (inst.get("ports") or {}).items():
inner = int(spec.split("/")[0])
for b in binds:
if b.get("HostIp") == "0.0.0.0":
self.ports[inner] = int(b["HostPort"])
if not self.host or not self.ports:
raise RuntimeError("Vast ещё не отдал адрес и проброс портов")
log(f"публичный адрес: {self.host}, проброшено портов: {len(self.ports)}")
self._loaded = True
return
except Exception as e:
log(f"Vast API не ответил (попытка {attempt} из 10): {e}")
time.sleep(15)
raise RuntimeError("Vast API так и не отдал проброс портов")
if not self.host:
try:
self.host = http_text("GET", "https://ifconfig.me", timeout=15).strip()
except Exception as e:
raise RuntimeError(f"не удалось определить публичный адрес: {e}")
log(f"публичный адрес: {self.host} (порты как есть)")
self._loaded = True
def url_for(self, port: int) -> str:
self.load()
return f"http://{self.host}:{self.ports.get(port, port)}"
# --- HTTP -------------------------------------------------------------------
def http_raw(method, url, body=None, headers=None, timeout=30):
data = None
headers = dict(headers or {})
if body is not None:
data = json.dumps(body).encode()
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=headers, method=method)
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def http_text(method, url, **kw):
status, text = http_raw(method, url, **kw)
if status >= 400:
raise RuntimeError(f"HTTP {status}: {text[:200]}")
return text
def http_json(method, url, **kw):
status, text = http_raw(method, url, **kw)
try:
return status, json.loads(text)
except json.JSONDecodeError:
return status, {"detail": text[:400]}
# --- Менеджер ---------------------------------------------------------------
class Manager:
def __init__(self, base, key):
self.base = base
self.key = key
self.headers = {"VLLM-MANAGER-API-KEY": key}
def add(self, url, pools):
"""Возвращает (успех, пояснение).
Коды по контракту менеджера: 200 — добавлен хотя бы в один пул,
400 — ни в один (в том числе «уже существует», что нас устраивает),
403 — ключ, 422 — тело, 5xx — беда на той стороне.
"""
status, data = http_json("POST", f"{self.base}/admin/servers/add",
body={"server_url": url, "server_types": list(pools),
"validate_server": True},
headers=self.headers, timeout=60)
detail = data.get("message") or data.get("detail") or ""
if isinstance(detail, list):
detail = json.dumps(detail, ensure_ascii=False)
if status == 200:
return True, str(detail)
if status == 400 and "уже существует" in str(detail):
return True, f"уже был в пулах: {detail}"
return False, f"HTTP {status}: {detail}"
def remove(self, url, pools):
results = []
for pool in pools:
status, data = http_json("POST", f"{self.base}/admin/servers/remove",
body={"server_url": url, "server_type": pool},
headers=self.headers, timeout=30)
results.append(f"{pool}: {status}")
return results
def servers(self):
status, data = http_json("GET", f"{self.base}/admin/servers", headers=self.headers, timeout=30)
return data if status == 200 else {}
# --- Инстанс vLLM -----------------------------------------------------------
class Instance:
def __init__(self, spec, manager, public):
self.spec = spec
self.manager = manager
self.public = public
self.proc = None
self.registered = False
self.healthy = False
self.unhealthy_since = None
self.started_at = 0.0
self.restarts = 0
self.restart_at = None
self.log_path = os.path.join(LOG_DIR, f"gpu{'-'.join(map(str, spec['gpus']))}.log")
@property
def name(self):
return f"GPU{','.join(map(str, self.spec['gpus']))}:{self.spec['port']}"
@property
def url(self):
return self.public.url_for(self.spec["port"])
def command(self):
s = self.spec
cmd = [
sys.executable, "-m", "vllm.entrypoints.openai.api_server",
"--model", MODEL_ID,
"--served-model-name", SERVED_NAME,
"--host", "0.0.0.0",
"--port", str(s["port"]),
"--tensor-parallel-size", str(s["tp"]),
"--dtype", "bfloat16",
"--quantization", "compressed-tensors",
"--load-format", "safetensors",
"--max-model-len", str(s["ctx"]),
"--max-num-batched-tokens", str(s["ctx"]),
"--max-num-seqs", str(s["seqs"]),
"--gpu-memory-utilization", f"{s['util']:.2f}",
"--kv-cache-dtype", s["kv"],
"--no-enable-chunked-prefill",
"--enable-prefix-caching",
# мультимодальность у нас не используется, а VRAM под неё резервируется
"--limit-mm-per-prompt", '{"image": 0, "audio": 0, "video": 0}',
"--trust-remote-code",
"--api-key", VLLM_API_KEY,
"--default-chat-template-kwargs", '{"enable_thinking": false}',
]
if VLLM_EXTRA_ARGS:
cmd += VLLM_EXTRA_ARGS.split()
return cmd
def reap(self):
"""Освободить карту от хвостов прошлого запуска.
vLLM держит веса в отдельном процессе EngineCore. Если умирает только
сервер — а именно так выглядит любое жёсткое падение, — движок остаётся
жив и занимает почти всю VRAM. Следующий запуск тогда падает с
`Free memory on device cuda:0 ... is less than desired GPU memory
utilization`, и нода уходит в вечный цикл перезапусков. Карты этого
инстанса принадлежат только ему, поэтому чистим их целиком.
"""
for _ in range(20):
leftovers = gpu_processes(self.spec["gpus"])
if not leftovers:
return
for pid, mem in leftovers:
log(f"{self.name}: на карте висит процесс {pid} ({mem} МБ) — снимаю")
try:
os.kill(pid, signal.SIGKILL)
except (ProcessLookupError, PermissionError) as e:
log(f"{self.name}: процесс {pid} не снялся: {e}")
time.sleep(3)
log(f"{self.name}: карта так и не освободилась, запускаюсь как есть")
def start(self):
self.reap()
s = self.spec
environ = dict(os.environ)
environ.update({
"CUDA_VISIBLE_DEVICES": ",".join(map(str, s["gpus"])),
"VLLM_USE_MODELSCOPE": "False",
"VLLM_ALLOW_LONG_MAX_MODEL_LEN": "1",
"VLLM_DISABLE_CUSTOM_ALL_REDUCE": "1",
})
environ.setdefault("VLLM_USE_FLASHINFER_SAMPLER", env("VLLM_USE_FLASHINFER_SAMPLER", "1"))
if HF_TOKEN:
environ["HF_TOKEN"] = HF_TOKEN
os.makedirs(LOG_DIR, exist_ok=True)
handle = open(self.log_path, "ab", buffering=0)
self.proc = subprocess.Popen(self.command(), env=environ, stdout=handle,
stderr=subprocess.STDOUT, start_new_session=True)
self.started_at = time.time()
self.healthy = False
self.unhealthy_since = None
self.restart_at = None
log(f"{self.name}: запущен pid={self.proc.pid}, контекст {s['ctx']}, "
f"батч {s['seqs']}, kv={s['kv']}, пулы {','.join(s['pools'])}, лог {self.log_path}")
def alive(self):
return self.proc is not None and self.proc.poll() is None
def probe(self):
try:
status, _ = http_raw("GET", f"http://127.0.0.1:{self.spec['port']}/health", timeout=5)
return status == 200
except Exception:
return False
def stop(self):
if not self.alive():
self.reap()
return
log(f"{self.name}: останавливаю")
# Гасим всю группу процессов: сервер запущен через start_new_session,
# и его потомки (EngineCore с весами на карте) сами не уйдут.
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
self.proc.terminate()
try:
self.proc.wait(timeout=60)
except subprocess.TimeoutExpired:
try:
os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)
except (ProcessLookupError, PermissionError):
self.proc.kill()
self.reap()
def register(self):
if not REGISTER or self.registered or manual_mode():
return
ok, detail = self.manager.add(self.url, self.spec["pools"])
if ok:
self.registered = True
log(f"{self.name}: зарегистрирован как {self.url} — {detail}")
else:
log(f"{self.name}: регистрация не прошла — {detail}")
def unregister(self, why=""):
if not REGISTER or not self.registered:
return
try:
results = self.manager.remove(self.url, self.spec["pools"])
log(f"{self.name}: снят с пулов ({', '.join(results)}){' — ' + why if why else ''}")
except Exception as e:
log(f"{self.name}: снять с пулов не вышло: {e}")
self.registered = False
# --- Главный цикл -----------------------------------------------------------
class Node:
def __init__(self):
self.manager = Manager(MANAGER_URL, MANAGER_API_KEY)
self.public = PublicAddress()
self.instances = []
self.stopping = threading.Event()
def run(self):
if REGISTER and not MANAGER_API_KEY:
log("MANAGER_API_KEY не задан — регистрация выключена, ноду придётся добавить руками")
plan = build_plan()
fetch_weights()
if REGISTER:
self.public.load()
self.instances = [Instance(spec, self.manager, self.public) for spec in plan]
for inst in self.instances:
inst.start()
self.write_state()
last_sync = 0.0
while not self.stopping.is_set():
for inst in self.instances:
self.supervise(inst)
if REGISTER and manual_mode():
for inst in self.instances:
inst.unregister("включён ручной режим")
if REGISTER and not manual_mode() and time.time() - last_sync > SYNC_INTERVAL:
last_sync = time.time()
self.resync()
self.write_state()
self.stopping.wait(POLL_INTERVAL)
def supervise(self, inst):
# Перезапуск отложенный, а не через sleep: соседние карты не должны
# оставаться без присмотра, пока одна ждёт своей паузы.
if not inst.alive():
if inst.restart_at is None:
code = inst.proc.returncode if inst.proc else "?"
inst.unregister(f"процесс умер (код {code})")
inst.restarts += 1
wait = min(30 * inst.restarts, 300)
inst.restart_at = time.time() + wait
log(f"{inst.name}: процесс умер (код {code}), перезапуск через {wait} с "
f"(перезапусков: {inst.restarts}); хвост лога — {inst.log_path}")
elif time.time() >= inst.restart_at:
inst.start()
return
healthy = inst.probe()
if healthy:
if not inst.healthy:
log(f"{inst.name}: поднялся за {time.time() - inst.started_at:.0f} с")
inst.healthy = True
inst.unhealthy_since = None
inst.register()
return
# Пока модель грузится, /health молчит — это нормально и не повод паниковать.
if not inst.healthy:
if time.time() - inst.started_at > HEALTH_TIMEOUT:
log(f"{inst.name}: не поднялся за {HEALTH_TIMEOUT} с, перезапускаю")
inst.stop()
return
inst.unhealthy_since = inst.unhealthy_since or time.time()
if time.time() - inst.unhealthy_since > UNHEALTHY_GRACE:
inst.healthy = False
inst.unregister("перестал отвечать на /health")
def resync(self):
"""Менеджер мог перезапуститься и потерять нас — возвращаемся сами."""
try:
current = self.manager.servers()
except Exception as e:
log(f"сверка с менеджером не удалась: {e}")
return
if not current:
return
for inst in self.instances:
if not inst.healthy:
continue
missing = [p for p in inst.spec["pools"] if inst.url not in (current.get(p) or [])]
if missing:
log(f"{inst.name}: пропал из пулов {missing}, добавляюсь заново")
inst.registered = False
inst.register()
def write_state(self):
os.makedirs(STATE_DIR, exist_ok=True)
state = {
"updated_at": time.time(),
"model": MODEL_ID,
"manager": MANAGER_URL if REGISTER else None,
"mode": "ручной" if manual_mode() else "автоматический",
"instances": [{
"name": i.name, "url": i.url if REGISTER else None,
"port": i.spec["port"], "gpus": i.spec["gpus"], "gpu_name": i.spec["gpu_name"],
"ctx": i.spec["ctx"], "seqs": i.spec["seqs"], "kv": i.spec["kv"],
"pools": i.spec["pools"], "healthy": i.healthy, "registered": i.registered,
"restarts": i.restarts, "log": i.log_path,
} for i in self.instances],
}
tmp = os.path.join(STATE_DIR, "state.json.tmp")
with open(tmp, "w") as fh:
json.dump(state, fh, ensure_ascii=False, indent=2)
os.replace(tmp, os.path.join(STATE_DIR, "state.json"))
def shutdown(self, signum, _frame):
if self.stopping.is_set():
return
log(f"сигнал {signum}: снимаюсь с пулов и глушу инстансы")
self.stopping.set()
for inst in self.instances:
inst.unregister("нода останавливается")
for inst in self.instances:
inst.stop()
def main():
os.makedirs(LOG_DIR, exist_ok=True)
node = Node()
signal.signal(signal.SIGTERM, node.shutdown)
signal.signal(signal.SIGINT, node.shutdown)
try:
node.run()
except Exception as e:
log(f"агент падает: {type(e).__name__}: {e}")
for inst in node.instances:
inst.unregister("агент падает")
inst.stop()
raise
log("агент остановлен")
if __name__ == "__main__":
main()
|