| |
| """Агент 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" |
|
|
| |
| |
| 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) |
| 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 |
| 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 |
| 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 |
|
|
| |
| 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: |
| |
| |
| |
| 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)}" |
|
|
|
|
| |
|
|
| 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 {} |
|
|
|
|
| |
|
|
| 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", |
| |
| "--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}: останавливаю") |
| |
| |
| 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): |
| |
| |
| 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 |
|
|
| |
| 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() |
|
|