sskj/src/sskj/deploy/runtime.py
shishi 6ba04325d3 feat(910c): 部署解耦 - vLLM-Ascend profile 与 deploy 层接管服务启停
- deploy/profiles/910c/ 新增 dsv4/glm52 两个 vLLM-Ascend profile
  (JSON 参数经 BOOTSTRAP base64 注入避开镜像 entrypoint 转义;per-TP 参数
  由 start 脚本导出后经 profile 模板展开)
- dsv4/glm52 start_vllm_docker.sh 改为 deploy 薄包装(保留 sg docker 重入与
  per-TP 覆盖),新增 stop_vllm_docker.sh
- run_bench.sh / run_adaptive_concurrency*.sh 的 stop/build_server_args 改走
  deploy_stop/deploy_render_args
- runtime.py 修复单节点 dry-run 未跳过健康检查的 bug
- ops/README.md 补 910c 章节;.gitignore 补 910c ops_ 输出规则
- 附带入库 910c adaptive 汇总结果
2026-08-03 15:36:11 +08:00

391 lines
15 KiB
Python

"""Docker/native server lifecycle for deployment profiles.
Supports single-node and multi-node (NODE_HOSTS) docker deployments. For
multi-node profiles, start/stop/status distribute docker commands to each
node over ssh; health checks only target the head (rank 0) node.
"""
from __future__ import annotations
import base64
import re
import shlex
import subprocess
import time
from pathlib import Path
from sskj.bench.runner import split_env_assignments, wait_health
_NODE_RANK_RE = re.compile(r"\$\{NODE_RANK(?::-([^}]*))?\}")
_TEMPLATED_KEYS = (
"CONTAINER_NAME",
"LAUNCH_ARGS",
"DEVICE_VARS",
"ENGINE_ENV",
"DOCKER_FLAGS",
"VOLUMES",
"PATCH_MOUNTS",
"BOOTSTRAP",
)
def _log_dir(root: Path, log_dir: str | None) -> Path:
path = Path(log_dir) if log_dir else root / "deploy" / "logs"
path.mkdir(parents=True, exist_ok=True)
return path
def _health_url(profile: dict[str, str]) -> str:
host = profile.get("HEALTH_HOST", "127.0.0.1")
return f"http://{host}:{profile['PORT']}"
def _node_hosts(profile: dict[str, str]) -> list[str]:
return [h for h in shlex.split(profile.get("NODE_HOSTS", "")) if h]
def _ssh_host(profile: dict[str, str], host: str) -> str:
user = profile.get("NODE_SSH_USER", "root")
if not user or "@" in host:
return host
return f"{user}@{host}"
def _sub_node_rank(profile: dict[str, str], rank: int) -> dict[str, str]:
"""Return a profile copy with NODE_RANK rendered for the given rank."""
rendered = dict(profile)
container_name = str(rendered.get("CONTAINER_NAME", "server"))
has_template = _NODE_RANK_RE.search(container_name) is not None
def _sub(value: str | None) -> str:
if not value:
return value or ""
return _NODE_RANK_RE.sub(lambda m: m.group(1) or str(rank), str(value))
for key in _TEMPLATED_KEYS:
if key in rendered:
rendered[key] = _sub(rendered[key])
if not has_template:
rendered["CONTAINER_NAME"] = f"{container_name}_node{rank}"
rendered["NODE_RANK"] = str(rank)
return rendered
def _server_cmd(profile: dict[str, str]) -> str:
"""Launch command prefix (without LAUNCH_ARGS)."""
if profile.get("SERVER_CMD"):
return profile["SERVER_CMD"]
engine = (profile.get("ENGINE") or "sglang").lower()
python_bin = profile.get("CONTAINER_PYTHON", "python")
if engine == "vllm":
return f"{python_bin} -m vllm.entrypoints.openai.api_server"
return f"{python_bin} -m sglang.launch_server"
def _docker_run_cmd(profile: dict[str, str]) -> list[str]:
container = profile["CONTAINER_NAME"]
cmd = ["docker", "run", "-d", "--name", container]
if profile.get("DOCKER_FLAGS"):
cmd += shlex.split(profile["DOCKER_FLAGS"])
if profile.get("NETWORK_MODE") == "bridge" and profile.get("PORT_MAP") == "1":
cmd += ["-p", f"{profile['PORT']}:{profile['PORT']}"]
for volume in split_env_assignments(profile.get("VOLUMES", "") + " " + profile.get("PATCH_MOUNTS", "")):
cmd += ["-v", volume]
for assignment in split_env_assignments(
profile.get("DEVICE_VARS", "") + " " + profile.get("ENGINE_ENV", "")
):
cmd += ["-e", assignment]
cmd.append(profile["DOCKER_IMAGE"])
bootstrap = profile.get("BOOTSTRAP", "")
if bootstrap:
encoded = base64.b64encode(bootstrap.encode("utf-8")).decode("ascii")
cmd += ["bash", "-c", f"echo {encoded} | base64 -d | bash"]
else:
cmd += ["bash", "-c", f"{_server_cmd(profile)} {profile['LAUNCH_ARGS']}"]
return cmd
def _is_multinode(profile: dict[str, str], node_rank: str | None) -> bool:
return len(_node_hosts(profile)) > 1 and node_rank is None
def _local_rank(profile: dict[str, str]) -> int:
"""Rank that runs on this host (no ssh); defaults to 0 (head)."""
try:
return int(profile.get("LOCAL_NODE_RANK", "0"))
except ValueError:
return 0
def _node_target(profile: dict[str, str], rank: int) -> str | None:
"""ssh host for the rank, or None when the rank runs locally."""
if rank == _local_rank(profile):
return None
return _ssh_host(profile, _node_hosts(profile)[rank])
def _run_docker_cmd(
profile: dict[str, str],
cmd: list[str],
host: str | None,
dry_run: bool,
log_path: Path | None = None,
) -> int:
joined = shlex.join(cmd)
if log_path is not None:
log_path.write_text(joined + "\n", encoding="utf-8")
if dry_run:
print(joined)
return 0
if host:
remote = f"bash -c {shlex.quote(joined)}"
result = subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", host, remote],
capture_output=True,
text=True,
)
if result.returncode != 0:
print(result.stdout, result.stderr)
return result.returncode
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(result.stdout, result.stderr)
return result.returncode
def _start_docker(
profile: dict[str, str],
root: Path,
dry_run: bool,
logs: Path,
) -> int:
if not profile.get("DOCKER_IMAGE"):
raise SystemExit("docker runtime requires DOCKER_IMAGE")
if not profile.get("CONTAINER_NAME"):
raise SystemExit("docker runtime requires CONTAINER_NAME")
hosts = _node_hosts(profile)
node_rank = profile.get("NODE_RANK")
health_wait = int(profile.get("HEALTH_WAIT_S", "600") or 600)
if _is_multinode(profile, node_rank):
# Workers first (rank > 0), then head (rank 0), mirroring the legacy
# multi-node scripts so NCCL bootstrap finds the head up. The
# LOCAL_NODE_RANK rank runs locally (no ssh).
order = [r for r in range(len(hosts)) if r != 0] + [0]
for rank in order:
host = hosts[rank]
target = _node_target(profile, rank)
node_profile = _sub_node_rank(profile, rank)
cmd = _docker_run_cmd(node_profile)
if not dry_run:
if target:
subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", target, f"docker rm -f {shlex.quote(node_profile['CONTAINER_NAME'])}"],
capture_output=True,
check=False,
)
else:
subprocess.run(
["docker", "rm", "-f", node_profile["CONTAINER_NAME"]],
capture_output=True,
check=False,
)
print(f"starting container {node_profile['CONTAINER_NAME']} on {host} (rank {rank}) ...")
rc = _run_docker_cmd(
profile,
cmd,
target,
dry_run,
logs / f"{node_profile['CONTAINER_NAME']}.cmd.txt",
)
if rc != 0:
return rc
if rank != 0:
print(" sleeping 5s before head ...")
if not dry_run:
time.sleep(5)
head_url = f"http://{profile.get('HEALTH_HOST', hosts[0])}:{profile['PORT']}"
if dry_run:
print(f"[dry-run] health: {head_url}{profile.get('HEALTH_PATH', '/health')}")
return 0
if wait_health(head_url, profile.get("HEALTH_PATH", "/health"), health_wait):
print(f"server on {hosts[0]} is healthy")
return 0
print("ERROR: head failed health check; inspect per-node logs")
return 1
# Single-node or explicit --node-rank: run locally on this host.
if not dry_run:
subprocess.run(["docker", "rm", "-f", profile["CONTAINER_NAME"]], capture_output=True, check=False)
cmd = _docker_run_cmd(profile)
container = profile["CONTAINER_NAME"]
print(f"starting container {container} ...")
rc = _run_docker_cmd(profile, cmd, None, dry_run, logs / f"{container}.cmd.txt")
if rc != 0:
return rc
if node_rank is not None and node_rank != "0":
print(f"node rank {node_rank} started; skipping health check")
return 0
if dry_run:
print(f"[dry-run] health: {_health_url(profile)}{profile.get('HEALTH_PATH', '/health')}")
return 0
if wait_health(_health_url(profile), profile.get("HEALTH_PATH", "/health"), health_wait):
print(f"container {container} is healthy")
return 0
subprocess.run(["docker", "logs", "--tail", "100", container], check=False)
print(f"ERROR: container {container} failed health check")
return 1
def start(profile: dict[str, str], root: Path, dry_run: bool = False, log_dir: str | None = None) -> int:
validate_runtime(profile)
logs = _log_dir(root, log_dir)
if profile["RUNTIME"] == "docker":
return _start_docker(profile, root, dry_run, logs)
# native runtime
python_bin = profile.get("PYTHON_BIN") or profile.get("CONTAINER_PYTHON") or "python3"
if profile.get("SERVER_CMD"):
cmd = shlex.split(profile["SERVER_CMD"]) + shlex.split(profile.get("LAUNCH_ARGS", ""))
else:
engine = (profile.get("ENGINE") or "sglang").lower()
module = "vllm.entrypoints.openai.api_server" if engine == "vllm" else "sglang.launch_server"
cmd = [python_bin, "-m", module, *shlex.split(profile.get("LAUNCH_ARGS", ""))]
log_path = logs / f"{profile.get('MODEL_NAME', 'server')}.log"
pid_file = logs / f"{profile.get('MODEL_NAME', 'server')}.pid"
if dry_run:
print(shlex.join(cmd))
return 0
with open(log_path, "wb") as f:
proc = subprocess.Popen(cmd, stdout=f, stderr=subprocess.STDOUT)
pid_file.write_text(str(proc.pid), encoding="utf-8")
print(f"started native server pid={proc.pid} log={log_path}")
health_wait = int(profile.get("HEALTH_WAIT_S", "600") or 600)
if wait_health(_health_url(profile), profile.get("HEALTH_PATH", "/health"), health_wait):
print("native server is healthy")
return 0
print(f"ERROR: native server failed health check; see {log_path}")
return 1
def _docker_stop(profile: dict[str, str], dry_run: bool = False) -> int:
node_rank = profile.get("NODE_RANK")
if _is_multinode(profile, node_rank):
for rank, host in enumerate(_node_hosts(profile)):
node_profile = _sub_node_rank(profile, rank)
name = node_profile["CONTAINER_NAME"]
target = _node_target(profile, rank)
if dry_run:
if target:
print(f"[dry-run] ssh {target} docker rm -f {name}")
else:
print(f"[dry-run] docker rm -f {name} (local)")
continue
if target:
subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", target, f"docker rm -f {shlex.quote(name)}"],
capture_output=True,
check=False,
)
print(f"container {name} removed on {host}")
else:
subprocess.run(["docker", "rm", "-f", name], capture_output=True, check=False)
print(f"container {name} removed (local)")
return 0
container = profile.get("CONTAINER_NAME")
if not container:
raise SystemExit("docker runtime requires CONTAINER_NAME")
if dry_run:
print(f"[dry-run] docker rm -f {container}")
return 0
subprocess.run(["docker", "rm", "-f", container], capture_output=True, check=False)
print(f"container {container} removed")
return 0
def stop(profile: dict[str, str], root: Path) -> int:
validate_runtime(profile)
if profile["RUNTIME"] == "docker":
return _docker_stop(profile)
logs = _log_dir(root, None)
pid_file = logs / f"{profile.get('MODEL_NAME', 'server')}.pid"
if pid_file.exists():
pid = pid_file.read_text(encoding="utf-8").strip()
if pid:
subprocess.run(["kill", pid], capture_output=True, check=False)
print(f"killed native server pid={pid}")
pid_file.unlink(missing_ok=True)
return 0
def _docker_status(profile: dict[str, str]) -> int:
node_rank = profile.get("NODE_RANK")
if _is_multinode(profile, node_rank):
all_ok = 0
for rank, host in enumerate(_node_hosts(profile)):
node_profile = _sub_node_rank(profile, rank)
name = node_profile["CONTAINER_NAME"]
target = _node_target(profile, rank)
if target:
result = subprocess.run(
["ssh", "-o", "StrictHostKeyChecking=no", target, f"docker inspect -f '{{{{.State.Status}}}}' {shlex.quote(name)}"],
capture_output=True,
text=True,
check=False,
)
state = result.stdout.strip() or "missing"
print(f"{host}: {name} -> {state}")
if result.returncode != 0 or state != "running":
all_ok = 1
else:
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", name],
capture_output=True,
text=True,
check=False,
)
state = result.stdout.strip() or "missing"
print(f"{host}: {name} -> {state}")
if result.returncode != 0 or state != "running":
all_ok = 1
return all_ok
container = profile.get("CONTAINER_NAME", "")
result = subprocess.run(
["docker", "inspect", "-f", "{{.State.Status}}", container],
capture_output=True,
text=True,
check=False,
)
print(result.stdout.strip() or "missing")
return 0 if result.returncode == 0 else 1
def status(profile: dict[str, str], root: Path) -> int:
validate_runtime(profile)
if profile["RUNTIME"] == "docker":
return _docker_status(profile)
logs = _log_dir(root, None)
pid_file = logs / f"{profile.get('MODEL_NAME', 'server')}.pid"
if not pid_file.exists():
print("stopped")
return 1
pid = pid_file.read_text(encoding="utf-8").strip()
result = subprocess.run(["kill", "-0", pid], capture_output=True, check=False)
print("running" if result.returncode == 0 else "stopped")
return 0 if result.returncode == 0 else 1
def validate_runtime(profile: dict[str, str]) -> None:
runtime = profile.get("RUNTIME", "docker" if profile.get("DOCKER_IMAGE") else "native")
if runtime not in ("docker", "native"):
raise SystemExit(f"unsupported runtime: {runtime}")
profile["RUNTIME"] = runtime
if runtime == "docker" and not profile.get("DOCKER_IMAGE"):
raise SystemExit("docker runtime requires DOCKER_IMAGE")
if runtime == "native" and not profile.get("PYTHON_BIN") and not profile.get("CONTAINER_PYTHON"):
raise SystemExit("native runtime requires PYTHON_BIN or CONTAINER_PYTHON")