feat: CHV 执行器优化(拆模块/去重复/SSH 钉死/池持久化/中途死亡分类)
- 拆模块:CHV 从 cloud/sandbox.py 巨类拆到 cloud/chv/(executor/ pool/config/api_client/ssh_transport/disk/errors),sandbox 只留 兼容 re-export,旧导入路径与新路径是同一对象 - 去重复:goal 解析、host 顶层参数、runner 超时抽成共享 helper, Local 与 CHV 在矩阵输入下结论全等(Docker goal-resume 变体 有意保留内联版,见 PR 描述) - 安全:SSH host key 按 VM 钉死(state_dir/<vm>/known_hosts, ssh-keyscan 首次捕获 TOFU),废除 no + /dev/null;CHV_SSH_USER 空串与 CHV_SSH_PORT 越界直接拒绝,不静默 fallback - execute 语义:只读已运行 VM,未运行报 vm_not_running,不为 status 起 VM - 预热池:registry 落盘、重启载入只当孤儿、reconcile 清理、 损坏 quarantine;条目仍一次性,绝不复用脏 VM - 长任务:VM 中途死亡判 interrupted(断点可续跑);中途心跳 经评估不做(无客内进度信号可采) - #215 未合并:只列复用清单,不复制模块 测试:原 42 项 + 新 24 项全过;相关旧套件与 cloud 全量 1496 项 全过(skipped=4)。验证仍是 mock + 干跑 + 单元测试,未做真机、 压测、安全审计;堆叠于 #216 之上,base 为 feat-cloud-hypervisor。
This commit is contained in:
1 parent
c371bbdf1e
commit
fde7bf4c3e
14 files changed
+2706
-1408
No files matched your search
@@ -0,0 +1,257 @@
|
||||
"""快照预热池 WarmPool(含 state_dir registry 持久化与孤儿清理)。
|
||||
|
||||
隔离红线(与 #216 一致,绝不妥协):预热条目是一次性的
|
||||
干净快照 VM,acquire 消耗掉它,release 一律销毁,绝不把
|
||||
一个任务用过的 VM(带任务文件、带 agent 痕迹)翻出来给
|
||||
下一个任务/租户复用;池子空了就回落冷启动(wake_vm),
|
||||
绝不因为池空就降级 Docker/Local。
|
||||
|
||||
Registry(本轨新增):
|
||||
- 文件:state_dir/warm-pool.json(registry_path 可覆盖),
|
||||
内容为 {<合成名>: {"state", "vm", "task_id", "snapshot_url"}}。
|
||||
每次 prewarm/acquire/release 后立即落盘(临时文件 + 原子
|
||||
rename),worker 崩溃后重启仍能发现上一世的条目。
|
||||
- 载入时绝不复用:新池实例从 registry 读到的条目一律标为
|
||||
orphan(不计入 warm_count,acquire 绝不发给任务),因为
|
||||
旧 VMM 进程已随旧 worker 消失,无法验证它还是当初那个
|
||||
干净快照 VM。它们只能走 reconcile_orphans() 清理。
|
||||
- reconcile_orphans():worker 启动/清理时的显式入口,对
|
||||
每个 orphan/assigned 条目尽力调 executor.shutdown_vm
|
||||
(清 socket 与残留登记),条目清掉并落盘,返回已清理的
|
||||
VM 名列表。registry 里没有的残留 socket 仍由执行器的
|
||||
cleanup_stale_vms() 扫 state_dir 处理,两者互补,不互相
|
||||
替代;reconcile 绝不在构造时自动执行(同一 state_dir 只
|
||||
能有一个活池,构造时自动清理会误杀另一个活池的 VM)。
|
||||
- 损坏 fail-safe:registry 不是合法 JSON / 顶层不是 dict 时,
|
||||
整个文件 quarantine 成 <path>.corrupt(尽力而为),池子
|
||||
从空开始,绝不用损坏数据复用 VM;单条目结构非法时只
|
||||
丢该条(记 registry_corrupt_entries),不连坐其它条目。
|
||||
registry 写失败时只记日志警告、不炸任务流(池子内存态
|
||||
仍可用,重启后孤儿改由 socket 扫描兜底)。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
_LOGGER = logging.getLogger("cloud.cloud_hypervisor")
|
||||
|
||||
_REGISTRY_FILENAME = "warm-pool.json"
|
||||
|
||||
|
||||
class WarmPool:
|
||||
"""快照预热池(快速启动设计,接口与状态机在代码里)。
|
||||
|
||||
目标:冷启动(preflight + 起 VMM + create + boot + SSH
|
||||
就绪)是慢路径;预热池提前把干净 VM 起好并暂停,任务
|
||||
来时 acquire 直接 resume,省掉冷启动的大头。
|
||||
状态机:cold(未预热)→ prewarming(wake 中)→ warm
|
||||
(已暂停待分配)→ assigned(已分配给某任务)→
|
||||
running(已 resume)→ recycled/destroyed(release 后)。
|
||||
prewarm 的实现:对每个条目调 executor.wake_vm(冷启动)
|
||||
再 pause_vm,得到 paused 的干净 VM;mock 单测里 wake/
|
||||
pause 均可注入假实现,真机上这一步需要 KVM 宿主(见
|
||||
就绪清单“需 KVM 宿主实测”)。snapshot_url 未设时
|
||||
prewarm 返回 [](无基准快照可预热,不是静默造快照)。
|
||||
"""
|
||||
|
||||
def __init__(self, executor, size=None, snapshot_url=None,
|
||||
registry_path=None):
|
||||
self.executor = executor
|
||||
self.size = int(size if size is not None
|
||||
else getattr(executor, "warm_pool_size", 0))
|
||||
self.snapshot_url = snapshot_url \
|
||||
if snapshot_url is not None \
|
||||
else getattr(executor, "snapshot_url", None)
|
||||
self._entries = {} # 合成名 -> {"state", "vm", "task", ...}
|
||||
self.registry_path = registry_path
|
||||
if self.registry_path is None:
|
||||
state_dir = getattr(executor, "state_dir", None)
|
||||
if state_dir:
|
||||
self.registry_path = os.path.join(
|
||||
state_dir, _REGISTRY_FILENAME)
|
||||
self.registry_corrupt = False
|
||||
self.registry_corrupt_entries = 0
|
||||
self._load_registry()
|
||||
|
||||
# -- registry ------------------------------------------------------
|
||||
def _load_registry(self):
|
||||
"""载入 registry:读到的条目一律标 orphan(见模块
|
||||
docstring,绝不直接当 warm 发给任务)。无文件时为空
|
||||
池;损坏时 quarantine 并从空开始。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
try:
|
||||
with open(self.registry_path, "r") as fh:
|
||||
data = json.load(fh)
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except (OSError, ValueError):
|
||||
self.registry_corrupt = True
|
||||
self._quarantine_registry()
|
||||
return
|
||||
if not isinstance(data, dict):
|
||||
self.registry_corrupt = True
|
||||
self._quarantine_registry()
|
||||
return
|
||||
for name, entry in data.items():
|
||||
if not isinstance(entry, dict) \
|
||||
or not entry.get("vm") \
|
||||
or entry.get("state") not in ("warm", "assigned"):
|
||||
self.registry_corrupt_entries += 1
|
||||
continue
|
||||
self._entries[name] = {
|
||||
"state": "orphan",
|
||||
"vm": entry["vm"],
|
||||
"task": {"id": entry.get("task_id") or name},
|
||||
"snapshot_url": entry.get("snapshot_url"),
|
||||
}
|
||||
|
||||
def _quarantine_registry(self):
|
||||
"""把损坏的 registry 文件挪到 <path>.corrupt 旁(尽力
|
||||
而为),原路径不再留损坏数据给下一次载入。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
try:
|
||||
os.replace(self.registry_path,
|
||||
self.registry_path + ".corrupt")
|
||||
except OSError:
|
||||
_LOGGER.warning("chv_pool_registry_quarantine_failed "
|
||||
"path=%s", self.registry_path)
|
||||
|
||||
def _save_registry(self):
|
||||
"""落盘当前条目(只写 warm/assigned 态;orphan 是
|
||||
待清理的上一世条目,reconcile 后即删,不写回)。
|
||||
写失败只记日志,不炸任务流。"""
|
||||
if not self.registry_path:
|
||||
return
|
||||
data = {}
|
||||
for name, entry in self._entries.items():
|
||||
if entry["state"] not in ("warm", "assigned"):
|
||||
continue
|
||||
task = entry.get("task") or {}
|
||||
task_id = task.get("id") if isinstance(task, dict) \
|
||||
else None
|
||||
data[name] = {
|
||||
"state": entry["state"],
|
||||
"vm": entry.get("vm"),
|
||||
"task_id": task_id,
|
||||
"snapshot_url": entry.get("snapshot_url"),
|
||||
}
|
||||
try:
|
||||
directory = os.path.dirname(self.registry_path)
|
||||
if directory:
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
tmp_path = self.registry_path + ".tmp"
|
||||
with open(tmp_path, "w") as fh:
|
||||
json.dump(data, fh)
|
||||
os.replace(tmp_path, self.registry_path)
|
||||
except OSError:
|
||||
_LOGGER.warning("chv_pool_registry_save_failed path=%s",
|
||||
self.registry_path)
|
||||
|
||||
def reconcile_orphans(self):
|
||||
"""清理上一世残留条目:对每个 orphan/assigned 条目
|
||||
尽力 shutdown_vm(清 socket 与残留登记),条目清掉
|
||||
并落盘,返回已清理的 VM 名列表。worker 启动/清理时
|
||||
显式调它(见模块 docstring 的单活池假设)。"""
|
||||
cleaned = []
|
||||
for name, entry in list(self._entries.items()):
|
||||
if entry["state"] not in ("orphan", "assigned"):
|
||||
continue
|
||||
vm = entry.get("vm") or name
|
||||
try:
|
||||
self.executor.shutdown_vm(vm)
|
||||
except Exception:
|
||||
pass
|
||||
self._entries.pop(name, None)
|
||||
cleaned.append(vm)
|
||||
if cleaned:
|
||||
self._save_registry()
|
||||
try:
|
||||
self.executor._emit(
|
||||
"warm_pool_reconcile", {"id": None},
|
||||
"cleaned=%s" % ",".join(cleaned))
|
||||
except Exception:
|
||||
pass
|
||||
return cleaned
|
||||
|
||||
# -- 池操作 ----------------------------------------------------------
|
||||
def warm_count(self):
|
||||
return sum(1 for e in self._entries.values()
|
||||
if e["state"] == "warm")
|
||||
|
||||
def orphan_count(self):
|
||||
"""上一世残留条目数(待 reconcile,不可分配)。"""
|
||||
return sum(1 for e in self._entries.values()
|
||||
if e["state"] in ("orphan", "assigned"))
|
||||
|
||||
def prewarm(self, count=None):
|
||||
"""预热到目标数:已有 warm 条目不重复建;每个新条目
|
||||
用一个合成任务名 wake + pause(合成任务 id 为
|
||||
warm-<序号>,绝不与真实任务 id 撞名)。返回新增的
|
||||
warm VM 名列表。"""
|
||||
if self.snapshot_url is None:
|
||||
return []
|
||||
target = int(count) if count is not None else self.size
|
||||
added = []
|
||||
i = 0
|
||||
while self.warm_count() < target:
|
||||
i += 1
|
||||
name = "warm-%d" % i
|
||||
if name in self._entries:
|
||||
continue
|
||||
task = {"id": name}
|
||||
self._entries[name] = {"state": "prewarming",
|
||||
"task": task}
|
||||
try:
|
||||
self.executor.wake_vm(task)
|
||||
self.executor.pause_vm(task)
|
||||
except Exception:
|
||||
self._entries.pop(name, None)
|
||||
raise
|
||||
vm = self.executor.vm_name(task)
|
||||
self._entries[name] = {
|
||||
"state": "warm", "task": task, "vm": vm,
|
||||
"snapshot_url": self.snapshot_url}
|
||||
added.append(name)
|
||||
if added:
|
||||
self._save_registry()
|
||||
return added
|
||||
|
||||
def acquire(self, task):
|
||||
"""分配一个预热 VM 给任务:有 warm 条目时消耗一个
|
||||
(状态 assigned,返回 mode=warm + 该条目的合成任务
|
||||
名,供调用方 resume 后使用);无 warm 条目时返回
|
||||
mode=cold(调用方回落 wake_vm 冷启动)。"""
|
||||
for name, entry in list(self._entries.items()):
|
||||
if entry["state"] == "warm":
|
||||
entry["state"] = "assigned"
|
||||
entry["task"] = task
|
||||
self._save_registry()
|
||||
self.executor._emit(
|
||||
"warm_pool_acquire", task,
|
||||
"vm=%s" % name)
|
||||
return {"mode": "warm", "vm": name,
|
||||
"pool_task": entry["task"]}
|
||||
return {"mode": "cold", "vm": None, "pool_task": None}
|
||||
|
||||
def release(self, task, pool_vm=None):
|
||||
"""释放:无论 warm 还是 cold 路径,VM 一律销毁
|
||||
(executor.shutdown_vm),池条目清掉;绝不把用过
|
||||
的 VM 放回 warm 池。返回 True 表示确实销毁了一个
|
||||
条目或 VM。"""
|
||||
destroyed = False
|
||||
if pool_vm and pool_vm in self._entries:
|
||||
self._entries.pop(pool_vm, None)
|
||||
destroyed = True
|
||||
try:
|
||||
if self.executor.shutdown_vm(task):
|
||||
destroyed = True
|
||||
except Exception:
|
||||
pass
|
||||
self._save_registry()
|
||||
self.executor._emit(
|
||||
"warm_pool_release", task,
|
||||
"vm=%s destroyed=%s" % (pool_vm, destroyed))
|
||||
return destroyed
|
||||
Reference in new issue
Block a user