已合并
[bugfix] deployer NodePort 冲突检测:CRD 重命名 Service 误判导致扩缩容失败 #776
[bugfix] deployer NodePort 冲突检测:CRD 重命名 Service 误判导致扩缩容失败 #776
已合并
杨安创建于 11 天前
3 个文件变更+61-2
@@ -180,6 +180,8 @@ Node selector 字段均为 JSON 对象。自定义标签会与 deployer 根据 `
180 180 
181非 TTY(脚本/CI)场景按 `N` 处理。建议 NodePort 范围:`30000-32767`181非 TTY(脚本/CI)场景按 `N` 处理。建议 NodePort 范围:`30000-32767`
182 182 
183+同一 namespace 下由本服务自己占用的端口不算冲突,因此重复部署与 `--update_instance_num` 扩缩容不会被误判。InferServiceSet 模式下 CRD 会把 Service 重命名为 `{service_name}-{InferServiceSet 名}-{索引}-{role}`,该命名同样识别为本服务自有端口。
184+ 
183说明:交互 remap 只修改本次部署使用的 `output_yamls`**不会**自动回写 `user_config.json`。若需要把新端口持久化到配置里,请手动同步修改 `motor_deploy_config` 中对应的 `*_node_port` 字段。185说明:交互 remap 只修改本次部署使用的 `output_yamls`**不会**自动回写 `user_config.json`。若需要把新端口持久化到配置里,请手动同步修改 `motor_deploy_config` 中对应的 `*_node_port` 字段。
184 186 
185### env.json187### env.json
@@ -20,6 +20,7 @@ from __future__ import annotations
20 20 
21import json21import json
22import os22import os
23+import re
23import subprocess24import subprocess
24import sys25import sys
25from dataclasses import dataclass26from dataclasses import dataclass
@@ -48,6 +49,10 @@ class PlannedNodePort:
48 service_namespace: str49 service_namespace: str
49 purpose: str50 purpose: str
50 container_port: int | None51 container_port: int | None
52+ # InferServiceSet context: the CRD renames Services, so the cluster name
53+ # differs from the name declared in yaml (see _is_self_owned).
54+ infer_set_name: str | None = None
55+ role_name: str | None = None
51 56 
52 57 
53@dataclass(frozen=True)58@dataclass(frozen=True)
@@ -95,6 +100,8 @@ def _planned_from_service_spec(
95 svc_name: str,100 svc_name: str,
96 svc_ns: str,101 svc_ns: str,
97 svc_spec: dict,102 svc_spec: dict,
103+ infer_set_name: str | None = None,
104+ role_name: str | None = None,
98) -> list[PlannedNodePort]:105) -> list[PlannedNodePort]:
99 planned: list[PlannedNodePort] = []106 planned: list[PlannedNodePort] = []
100 for port_entry in (svc_spec or {}).get("ports") or []:107 for port_entry in (svc_spec or {}).get("ports") or []:
@@ -126,6 +133,8 @@ def _planned_from_service_spec(
126 service_namespace=svc_ns,133 service_namespace=svc_ns,
127 purpose=_purpose_for(svc_name, container_port_i),134 purpose=_purpose_for(svc_name, container_port_i),
128 container_port=container_port_i,135 container_port=container_port_i,
136+ infer_set_name=infer_set_name,
137+ role_name=role_name,
129 )138 )
130 )139 )
131 return planned140 return planned
@@ -145,6 +154,7 @@ def _collect_from_infer_service_set(doc: dict, yaml_path: str) -> list[PlannedNo
145 """Collect nodePorts nested under InferServiceSet roles[].services[]."""154 """Collect nodePorts nested under InferServiceSet roles[].services[]."""
146 meta = doc.get("metadata") or {}155 meta = doc.get("metadata") or {}
147 svc_ns = meta.get("namespace") or ""156 svc_ns = meta.get("namespace") or ""
157+ infer_set_name = meta.get("name") or ""
148 planned: list[PlannedNodePort] = []158 planned: list[PlannedNodePort] = []
149 template = (doc.get("spec") or {}).get("template") or {}159 template = (doc.get("spec") or {}).get("template") or {}
150 if not isinstance(template, dict):160 if not isinstance(template, dict):
@@ -182,6 +192,8 @@ def _collect_from_infer_service_set(doc: dict, yaml_path: str) -> list[PlannedNo
182 service_namespace=svc_ns,192 service_namespace=svc_ns,
183 purpose="Controller observability",193 purpose="Controller observability",
184 container_port=container_port_i,194 container_port=container_port_i,
195+ infer_set_name=infer_set_name,
196+ role_name=role_name,
185 )197 )
186 )198 )
187 continue199 continue
@@ -191,6 +203,8 @@ def _collect_from_infer_service_set(doc: dict, yaml_path: str) -> list[PlannedNo
191 svc_name=svc_name,203 svc_name=svc_name,
192 svc_ns=svc_ns,204 svc_ns=svc_ns,
193 svc_spec=svc.get("spec") or {},205 svc_spec=svc.get("spec") or {},
206+ infer_set_name=infer_set_name,
207+ role_name=role_name,
194 )208 )
195 )209 )
196 return planned210 return planned
@@ -301,10 +315,26 @@ def collect_cluster_nodeports() -> dict[int, ClusterNodePort]:
301 return used315 return used
302 316 
303 317 
318+def _matches_crd_service_name(planned: PlannedNodePort, owner_name: str) -> bool:
319+ """InferServiceSet creates Services as {svc}-{inferSetName}-{index}-{role}."""
320+ if not planned.infer_set_name or not planned.role_name:
321+ return False
322+ pattern = (
323+ rf"^{re.escape(planned.service_name)}"
324+ rf"-{re.escape(planned.infer_set_name)}"
325+ rf"-\d+-{re.escape(planned.role_name)}$"
326+ )
327+ return re.match(pattern, owner_name, re.IGNORECASE) is not None
328+ 
329+ 
304def _is_self_owned(planned: PlannedNodePort, owner: ClusterNodePort, job_id: str) -> bool:330def _is_self_owned(planned: PlannedNodePort, owner: ClusterNodePort, job_id: str) -> bool:
305- """Treat same job namespace + same service name as our own (re-deploy)."""331+ """Treat same job namespace + same service as our own (re-deploy / scaling)."""
306 planned_ns = planned.service_namespace or job_id332 planned_ns = planned.service_namespace or job_id
307- return owner.namespace == planned_ns and owner.service_name == planned.service_name333+ if owner.namespace != planned_ns:
334+ return False
335+ if owner.service_name == planned.service_name:
336+ return True
337+ return _matches_crd_service_name(planned, owner.service_name)
308 338 
309 339 
310def find_conflicts(340def find_conflicts(
@@ -356,6 +356,33 @@ def test_resolve_dry_run_does_not_clear_existing_warning_files(tmp_path: Path, m
356 assert warn_path.read_text(encoding="utf-8") == "keep-me\n"356 assert warn_path.read_text(encoding="utf-8") == "keep-me\n"
357 357 
358 358 
359+def test_resolve_scaling_keeps_ports_owned_by_own_crd_services(tmp_path: Path):
360+ """Scaling an existing InferServiceSet deploy: CRD-renamed Services are ours, not conflicts."""
361+ yaml_path = tmp_path / "infer.yaml"
362+ _write_infer_service_set_yaml(yaml_path, "mindie-b")
363+ cluster = {
364+ 31015: ClusterNodePort(31015, "mindie-b", "mindie-motor-coordinator-infer-vllm-0-coordinator"),
365+ 31017: ClusterNodePort(31017, "mindie-b", "mindie-motor-coordinator-obs-vllm-0-coordinator"),
366+ # Non-zero index: InferServiceSet replicas > 1 must still be recognised as ours.
367+ 31027: ClusterNodePort(31027, "mindie-b", "mindie-motor-observability-vllm-1-controller"),
368+ }
369+ 
370+ with (
371+ patch("lib.nodeport_allocator.collect_cluster_nodeports", return_value=cluster),
372+ patch("lib.nodeport_allocator.sys.stdin") as stdin,
373+ patch("builtins.input", return_value="N") as prompt,
374+ ):
375+ stdin.isatty.return_value = True
376+ remapping = resolve_and_rewrite_nodeports([str(yaml_path)], "mindie-b")
377+ 
378+ assert remapping == {}
379+ prompt.assert_not_called()
380+ text = yaml_path.read_text(encoding="utf-8")
381+ assert "nodePort: 31015" in text
382+ assert "nodePort: 31017" in text
383+ assert "nodePort: 31027" in text
384+ 
385+ 
359def test_is_observability_service_name_avoids_robust_false_positive():386def test_is_observability_service_name_avoids_robust_false_positive():
360 assert is_observability_service_name("mindie-motor-coordinator-obs")387 assert is_observability_service_name("mindie-motor-coordinator-obs")
361 assert is_observability_service_name("mindie-motor-observability")388 assert is_observability_service_name("mindie-motor-observability")