docs: external-resources (license + elim) user manual

Full user manual for the external-license-resources feature set (E2
Phases 1-3 + elim collector): concept/architecture (two collection
legs, EXTERNAL admission formula total >= uncomit + wanted and its
cross-job accounting split), cluster-side declaration (resources.yaml
location: [external], flag semantics), config build/push/dump with the
restart constraints, xceesd deployment (esd.yaml schema, CLI, runtime
behavior incl. bug-1978 rejoin semantics), collector script contract,
elim per-host collector (registry, interval gating, fail-to-zero
semantics), limits.yaml per-project/per-queue quotas (vres names,
LIMIT_GLOBAL vs NO_RESOURCE), job submission (rusage[] bracket rule,
per-type semantics), operations (extres status, definition/distribution
lists, debounced LAST_UPDATED), troubleshooting runbook, and a tested
E2E recipe set (fake_lic.sh live-pid marker pool + licjob.sh checkout
wrapper, global-pool / elim / per-project scenarios).

All CLI flags, YAML schemas, output formats and commands verified
against the live t1/t2/t3 cluster and source (cli.py, esd_config.c,
elim.c, resource.c, xcealloc.c); known limitations listed explicitly
(single daemon, unconsumed esd per-source interval, etc.).

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
lei.chen 2026-10-29 07:12:18 +00:00
parent 0dc2bd2145
commit 0b47076d05

View file

@ -0,0 +1,864 @@
# 外部资源(License + elim)用户手册
> 适用版本:working 分支(含 E2 Phase 1-3 与 elim 采集器,2026-10-28)。
> 目标读者:需要接入外部 license 池或自定义主机资源,并对其做调度与测试的
> 集群管理员和测试工程师。
> 配套设计文档:`.kiro/specs/external-license-resources/design.md`。
---
## 目录
1. [概念与架构](#1-概念与架构)
2. [集群侧资源声明](#2-集群侧资源声明)
3. [配置构建与下发](#3-配置构建与下发)
4. [全局腿:xceesd 部署](#4-全局腿xceesd-部署)
5. [采集脚本契约](#5-采集脚本契约)
6. [主机腿:elim 采集器](#6-主机腿elim-采集器)
7. [策略层:limits 配额](#7-策略层limits-配额)
8. [作业提交](#8-作业提交)
9. [运维观测](#9-运维观测)
10. [故障处理](#10-故障处理)
11. [测试指南(E2E 配方)](#11-测试指南e2e-配方)
12. [已知限制](#12-已知限制)
---
## 1. 概念与架构
### 1.1 两条腿
外部资源有两条独立的采集路径,共享同一套资源模型和记账规则:
```
┌────────────────────────────────────────────────┐
│ 调度器(准入判定) │
│ EXTERNAL: total ≥ uncomit + wanted │
│ 其他: total ≥ inuse + uncomit + wanted │
└────────────┬───────────────────────────────────┘
│ RESVALS_DELTA
│ (host=="" → sharedInst 全局池)
┌───────────────┴───────────────┐
│ │
┌──────────▼──────────┐ ┌──────────▼──────────┐
│ 全局腿(license 池) │ │ 主机腿(elim 指标) │
│ xceesd daemon │ │ xcecd -c external_ │
│ controller 角色扩展 │ │ resources │
│ (extres) │ │ worker resource_sync│
└──────────┬──────────┘ └──────────┬──────────┘
│ 脚本采集 │ 每主机脚本采集
▼ ▼
license 服务器/任何来源 本机 GPU/任何本机量
```
- **全局腿**:`xceesd` 是一个独立 daemon,跑在一台机器上,周期执行若干采集
脚本,把**集群级 free 值**(如剩余 license 数)推给 controller。它以
`extres` 角色 join,推送落点是无 host 的共享实例(`sharedInst`),也就是
"一个资源、一个全局池"。
- **主机腿**:`xcecd`(worker 节点上的 daemon)支持
`external_resources` 配置节,在每个主机本地执行脚本,把结果注册为自定义
load index,走 worker 既有的 15 秒 load-index 上报链,落点是**每台主机各自
的资源值**("一个资源、每主机一个池")。
同一个资源名(如 `extlic`)只能选一条腿——声明位置(`resourcemap` 的
`location`)决定它落在哪条腿上。
### 1.2 EXTERNAL 语义与准入公式
带 `location: [external]` 声明的资源打上 `EXTERNAL` flag(0x10)。它与普通
动态资源的关键区别在**记账规则**:
- 普通资源(含 elim 落地的动态资源):调度器按
`total ≥ inuse + uncomit + wanted` 判准入——`inuse` 从节点上报值反推。
- EXTERNAL 资源:**跳过 inuse**,只判 `total ≥ uncomit + wanted`。
原因:xceesd 推上来的已经是 license 服务器扣减后的 **free 值**(还剩多少
可用),不是 total。作业占用的部分在 license 服务器侧已经体现为 free 值
下降,调度器再算一遍 inuse 会双扣。脚本返回 free 值是该模型的核心约定。
实现位置:`src/scheduler/src/resource.c` `unsatisfy_()`(EXTERNAL 分支只做
`total ≥ uncomit + wanted`);标记:`r->flags & XCE_RESF_EXTERNAL`。
**跨作业记账分工**(重要):EXTERNAL 池的"作业之间互斥"由**外部服务器的
自由值**承担——作业 A 占 3 个 license,服务器自由值降到 1,调度器下一轮
看到的就是 1。调度器自己的账本(`sharedInUse`)对 EXTERNAL 资源**记了但
准入时不看**。两个推论:
- 自由值的推送周期是暴露窗口:同一个调度轮内连发多个申请同一 EXTERNAL
资源的作业,可能在自由值还没降下来时都获准入(真实 license 服务器会在
checkout 时兜底拒绝,见 §11.2 licjob 的 exit 99)。生产上要么接受这个
窗口,要么在提交脚本里隔开(≥ poll_interval)。
- §7 的 limit vres 是**非 EXTERNAL** 的 GLOBAL 资源,走完整
`inuse + uncomit + wanted` 公式,配额在调度器本地即时扣减——不依赖
推送,同一轮内也严格生效。
### 1.3 失败语义(fail-open 采集 + fail-conservative 调度)
- **采集侧 fail-open**:脚本一轮失败(超时、无输出、输出不可解析)时,
xceesd/elim 保留上一个好值继续报,并打一次 WARN;不主动把池子清零引发
误停摆。elim 的特例:**从未成功过**的 elim 源失败时返回 "0"(池子从零
开始),**有过旧值**的 elim 源失败时返回 ""(解析为 0,值归零)——详见
§6.4。
- **stale 升级**:持续失败超过 `stale_limit`(xceesd 默认 150 秒)后,每轮
追加 STALE WARN;`xceadmin extres status` 里对应源标记 `"stale": true`。
从未有过值的源不判 stale。
- **调度侧 fail-conservative**:xceesd 未上线(或还没推过值)时,全局池读
0,申请该资源的作业 **PEND**(pending reason `NO_RESOURCE`,detail 带
资源名),而不是放行。宁可排队,不超发。
- **配置不播种**:`resources.yaml` 里 EXTERNAL 资源**不写 value**——写了也
会被忽略(`seed_sharedinst_from_config` 对 EXTERNAL 跳过)。池值只来自
daemon,避免配置里的幻影容量掩盖 daemon 掉线。
### 1.4 与 LSF 的路线差异(背景)
LSF 的 `bld`(License Scheduler daemon)+ `elim` 是"采集器 → 专门 license
分发器 → 分布式记账"路线;XCE 的路线是把 license free 值直接当作一种**全局
共享资源值**灌进统一资源模型,复用既有的 resval 推送链、准入判式和 limit
策略层。因此 fairshare/reserve 等策略对 license 资源天然生效(它们作用在
作业选择层,与资源来源无关);池容量 itself 的分发由 free 值 + EXTERNAL
记账规则完成。当前版本暂未做 license 级 fairshare(按项目对池子的历史用量
再分配),如需要可配 per-project/per-queue 配额近似实现(§7)。
---
## 2. 集群侧资源声明
### 2.1 resources.yaml
全局腿资源在 `resources.yaml` 声明,关键三件事:`type: numeric`、
`resourcemap` 指到 `external`、**不写 value**:
```yaml
resources:
- name: extlic
description: externally reported license pool
type: numeric
interval: 0
consumable: true
order: decrease
resourcemap:
- location: [external] # → SHARED|GLOBAL|EXTERNAL (0x1C)
```
各字段含义:
| 字段 | 必填 | 说明 |
|------|------|------|
| `name` | ✅ | 资源名,调度/提交时引用 |
| `type` | ✅ | numeric(外部资源均为数值型) |
| `consumable` | ❌ | true = 计量消耗型(license 用 true) |
| `resourcemap[].location` | ✅ | `[external]` = 全局腿;`[all]` = SHARED\|GLOBAL 非 EXTERNAL;`[default]` = 普通动态资源 |
| `interval` | ❌ | 解析但当前无消费方(见 §12) |
| `release` | ❌ | 动态资源回收到 worker 池的标记(普通动态资源用) |
JSON schema(`src/configs/schema/resources.schema.json`)强制 `name`/`type`
必填,resourcemap 项允许 `value`/`location` 数组。
**常见错误**:
- 在 EXTERNAL 资源的 resourcemap 里写 `value:` —— 没有意义,seed 阶段会跳
过(§1.3),且容易误以为配置兜了底。
- `location` 写 `[default]` 却想让 xceesd 推值 —— 推送会被 opupd 的
"SHARED 即收"守卫拒绝(extres 角色推的值只收 SHARED 资源),见 §10.4。
### 2.2 Flag 语义速查
flags 在 `src/xceadmin/src/xcecommon/mapping.py` 生成,bit 含义:
| Flag | Bit | 含义 | 何时打上 |
|------|-----|------|----------|
| BUILTIN | 0x01 | 内置资源 | resources.yaml 里 `builtin: true` |
| DYNAMIC | 0x02 | 动态(有 resourcemap/location) | 声明了 resourcemap |
| GLOBAL | 0x04 | 全局共享池 | location 含 all/external |
| SHARED | 0x08 | 值来自共享实例(sharedInst) | location 含 all/external |
| EXTERNAL | 0x10 | 外部 daemon 推 free 值 | location 含 external |
| RELEASE | 0x20 | 作业结束值回收到来源 | `release: true` |
| CONSUMABLE | 0x80 | 计量消耗型 | `consumable: true` |
| LIMIT | 0x100 | 限额派生的虚拟资源 | limits 实例化自动打 |
`location: [all]` 与 `location: [external]` 的区别就是有没有 EXTERNAL 位,
决定准入公式走哪条分支(§1.2)。
### 2.3 主机腿资源声明
elim 落地的资源用普通动态资源声明(location 用 `[all]` 或 `[default]`),
**值来自每台主机的 elim 脚本**:
```yaml
resources:
- name: dynres1
description: per-host dynamic resource from elim
type: numeric
interval: 0
resourcemap:
- location: [all] # 每主机都有各自的值
```
> ⚠️ 主机腿资源**不要**加 `consumable: true` + 依赖调度器算 inuse——elim 推
> 的同样是"free 值"(脚本返回的数直接就是该主机的可用量),调度器把它当作
> 该主机的资源值参与 `sched_check_host_satisfy_` 的 host 腿判定。申请值的
> 扣减由调度器 uncomit 跟踪(同普通 host 资源)。
---
## 3. 配置构建与下发
用 `xceadmin` 把 YAML 构建成 cluster.bin 并推给 controller:
```bash
# 1. 构建(CONFIGS_DIR 下可放 7 类 YAML:xce/resources/queues/users/
# hosts/scaler/limits——hosts.yaml 承载 hosts+hostgroups 两个键;
# 缺哪个文件该节为空不算错,但 push 会把对应配置清掉,实践上给全套)
xceadmin config build /path/to/configs -o cluster.bin
# 2. 预览(探针,不落账)
xceadmin config push cluster.bin --dry-run
# 3. 推送(注意:push/dump 的参数是 build 产物 cluster.bin,不是 YAML 目录)
xceadmin config push cluster.bin
```
- controller 地址:`--controller host:port` 显式指定,或环境变量
`XCE_CONTROLLER_PEERS` > `XCEADMIN_CONTROLLER` > 默认 `localhost:9991`。
- 推送后**必须重启 apiserver** 才能读到新定义(apiserver 的定义 store 只在
join 时的 config push 里建立;这是已知运维约束)。
- 单节点集群 `push` 可能报 `ackedCount == 0` 误报(push 实际成功,以
controller 日志 gen/tag 为准)——不要因此反复重推。
- 验证:
```bash
xceadmin config dump cluster.bin # 看 build 产物
# resources 段的 extlic 行应长这样(真实输出):
# name=extlic type=numeric order=decrease desc='...' consumable=True
# release=False external=True shared=true distributions=[external]=(reported)
# external=True 只在 EXTERNAL 资源上出现;absent 即普通资源
# limits 段能看到 resname=extlic value=3.0 per_project=True 之类
```
- 调度器对资源定义的感知:config push 激活后调度器会被 re-push,但**老版本
调度器需重启才拾取资源新定义**;生产升级路径保持"push 后重启 scheduler
+ apiserver"最稳。
---
## 4. 全局腿:xceesd 部署
### 4.1 esd.yaml
xceesd 的配置文件(默认 `/etc/xce/esd.yaml`):
```yaml
# 必填二选一:单 controller 或 peers 列表
controller: "172.31.1.30:9990"
# peers: "172.31.1.30:9990,172.31.1.31:9990"
# 可选
poll_interval: 10 # 采集+推送轮询间隔(秒),≤0 视为 30
stale_limit: 60 # 超过此秒数未成功升级为 STALE(秒)
status_file: /tmp/e2e/esd-status.json # 状态 JSON 落点
resources:
- name: extlic # 必填,须与 resources.yaml 声明一致
collector: script # 必填,当前唯一类型
command: /tmp/e2e/fake_lic.sh extlic # 必填,采集命令
interval: 10 # 可选:解析但当前 poll 循环未消费(见 §12)
stale_limit: 60 # 可选:源级覆盖 daemon 级
```
字段约束(`src/esd/src/esd_config.c`):
| 字段 | 必填 | 约束 |
|------|------|------|
| `controller` | 与 peers 二选一 | `host:port`,port 1-65535 |
| `peers` | 与 controller 二选一 | 逗号分隔 `host:port` |
| `poll_interval` | ❌ | ≤0 → 30(在 main.c 兜底) |
| `stale_limit` | ❌ | 默认 150 |
| `status_file` | ❌ | 默认 `/var/run/xce/esd-status.json` |
| `resources[].name` | ✅ | |
| `resources[].collector` | ✅ | 当前只有 `script` |
| `resources[].command` | ✅ | |
| `resources[].interval` | ❌ | **解析但未消费**(§12) |
| `resources[].stale_limit` | ❌ | 覆盖 daemon 默认 |
未知键忽略;文件不存在 → `-ENOENT`;结构非法 → `-EINVAL`;均 exit 1。
### 4.2 启动与 CLI
```bash
xceesd -c /etc/xce/esd.yaml -l /tmp/xceesd.log -v
```
| 选项 | 默认 | 说明 |
|------|------|------|
| `-c` / `--config` | `/etc/xce/esd.yaml` | 配置文件 |
| `-l` / `--logfile` | `/tmp/xceesd.log` | 日志(16MB × 4 轮转) |
| `-v` / `--verbose` | off | debug 日志 |
| `--script-timeout` | 10000 (ms) | 单次脚本采集的看门狗上限 |
SIGINT/SIGTERM 优雅退出。
### 4.3 运行时行为
1. **join**:以 `extres` 角色 join controller(默认第一次连上的端点)。
2. **每轮 poll**:对每个 source 执行脚本 → 解析 → 与上次值比较
(**debounce**:值没变就不推,controller 侧 LAST_UPDATED 也不刷新——这
是特性不是 bug,`extres status` 的 age 才是新鲜度真值)。
3. **stale 判定**:`now - last_ok_time > stale_limit` → STALE WARN +
status JSON 置 `stale: true`。
4. **连接断开**:断开标志置位后每轮强制推一次(debounce 旁路),唤醒重连。
5. **重连/rejoin**(bug-1978 修复后语义):重连成功先**重新 join**(新端点
的新连接不 join 直接推会被拒 `-EPERM`),然后**全量重推**所有源(不管
值有没有变)——controller 重启或网络抖动后池子自动恢复,无需人工干预。
6. **status JSON**:每轮原子写(tmp + rename),字段见 §9.1。
### 4.4 部署约束
- **xceesd 必须与它的采集脚本同主机**(脚本路径按本机解析;license 场景
通常意味着 xceesd 部署在能访问 license 服务器的主机上)。
- **当前支持单 xceesd 实例**(无 HA/多实例去重),见 §12。
- 当前版本全局腿**只支持"一个资源名 = 一个全局池"**。若需要"部分主机共享
一个池"(host 子集池),见 §12。
---
## 5. 采集脚本契约
xceesd 和 elim 共用同一套脚本解析逻辑(`src/esd/src/esd_script.c` 与
`src/worker/src/elim.c`),契约如下:
### 5.1 输出格式
stdout 打印**一行**,两种形式都接受:
```
extlic 3 # "<name> <value>"
3 # 裸值
```
解析器是 token 跳跃式:把 stdout 按 token 拆,跳过非数字 token 找到第一个
`strtod` 可解析的段作为值——所以 `value is 3` 也行,`extlic 3` 和
`3 extlic` 都能解析。stderr 丢弃(xceesd)或 /dev/null(elim),不影响
结果。无输出 / 输出无数字 → 采集失败(isnan),走 fail-open 路径。
### 5.2 运行环境
- 由 daemon 用 `posix_spawn("/bin/sh -c <command>")` 执行(**禁 fork**,
daemon 多线程环境);脚本继承 daemon 的环境变量。
- **看门狗**:脚本超过 `--script-timeout`(默认 10 秒)被 SIGKILL,按失败
处理。轮询切片 100ms(poll(2)),daemon 主循环不阻塞。
- **脚本返回的是 free 值**(还剩多少可用),不是 total、不是 inuse——见
§1.2。
### 5.3 脚本编写要点
```bash
#!/bin/sh
# 例:从 license 服务器拿剩余 license 数。
# 失败路径务必输出一个保守值(如 0)或什么都不输出——
# 输出垃圾数字会污染池子。
free=$(query_license_server extlic 2>/dev/null) || exit 1
echo "extlic $free"
```
- 脚本自身要快(看门狗 10s);license 服务器查询建议加超时。
- 失败时 `exit 1` 且**不输出数字**,比输出错误猜测值安全(fail-open 会保
旧值,不会拿错误值覆盖)。
- 别往 stdout 打任何诊断信息(日志去 stderr)。
---
## 6. 主机腿:elim 采集器
### 6.1 配置
worker 节点上的 `/etc/xce/xcecd.yaml`(或 `-c` 指定的文件):
```yaml
external_resources:
- name: dynres1
command: /opt/xce-denix/bin/elim-dynres1.sh
interval: 30 # 秒;0(或省略)= 每个 send tick 都重跑脚本
```
- `name`/`command` 必填;`interval` 可选,默认 0。
- 文件不存在或缺必填字段:xcecd **启动即退出**(fail-loud-at-load,
stderr 报 `-ENOENT`/`-EINVAL`)——坏配置不会静默空跑。
- 上限 `ELIM_MAX_SOURCES = 16` 个源/主机。
### 6.2 与内置 load index 的关系
elim 源注册进 worker `resource_sync` 的 load-index 注册表(与 11 个内置
指标 r15s/r1m/r15m/ut/pg/ls/it/swap/mem/tmp/io 同一张表),随既有的
15-tick(15 秒)上报链推给 controller。注册顺序上先保证注册表容量就绪
(`resource_sync_ensure_builtin_load_indices`),避免 capacity-0 拒绝。
**名字冲突保护**:
- 撞内置名(`mem`/`r15s`/`tmp`/…)→ `-EEXIST`,xcecd 启动报错退出。
- 撞已注册的 elim 名 → 同样 `-EEXIST`。
### 6.3 interval 缓存语义
elim 的 `interval` 与 xceesd 的**不同**:它是**脚本重跑的门控**,不是推送
节奏:
- `interval: 0`:每次收集(每 15s tick)都重跑脚本。
- `interval: N`(>0):距上次成功执行不足 N 秒时,直接复用缓存的脚本
返回值,跳过执行;推送节奏仍固定 15s tick。
即 elim 源的**上报节奏恒为 15s**,`interval` 只控制脚本执行频率(省脚本
开销)。
### 6.4 失败语义(与 xceesd 的差异)
elim 的 fail-open 路径有两档(`elim_getter_run`):
| 场景 | getter 返回 | 生效值 |
|------|-------------|--------|
| 从未成功过 + 本轮失败 | `"0"` | 0(池子从零开始) |
| 有过旧值 + 本轮失败 | `""` | 0(**值归零**,非保旧值) |
| 从未成功过 + 从未失败 | 无值(不注册前不收集) | — |
> ⚠️ 注意:elim 失败时**值归零**而非保旧值——这与 xceesd 的"保旧值 +
> STALE"不同。主机腿的语义是"这台主机的量现在拿不到,就当没有"。调度侧
> 效果是申请该主机资源的作业 PEND(fail-conservative),不会超发。
> interval>0 的源在缓存窗口内的"失败"其实不会发生(根本没跑脚本);一旦
> 窗口过后重跑失败,缓存值即被置零。
---
## 7. 策略层:limits 配额
外部资源可以进 limits.yaml 做按项目/队列的配额(E2 Phase 3):
```yaml
limits:
- name: extlic_share
projects:
members: [projA, projB]
resname: extlic
value: 3.0 # projA+projB 合计最多 3 个
per_project: true # → 每项目各 3 个
- name: extlic_qdist
queues:
members: [normal, night]
resname: extlic
value: 4.0
per_queue: true # → 每队列各 4 个
```
机制:limit 实例化为一个**虚拟资源**(vres),名字格式
`limit=<name>[/u=<user>][/q=<queue>][/p=<project>]`(只有对应 per_* 打开的
段会出现),flags = `LIMIT|CONSUMABLE|RELEASE|SHARED|GLOBAL`。作业提交时
按其 project/queue 挂上对应 vres 的 rusage 需求,调度时同样走 GLOBAL 池
准入判定。
- 超限时 PEND,pending reason:
- 全局 limit 超限 → `LIMIT_GLOBAL`(值 4)
- 底层资源不足 → `NO_RESOURCE`(值 2)
- 项目不在任何 limit 的 `projects.members` 里 → **不受该 limit 约束**,只
受底层池容量约束(某项目免疫配额是特性)。
- `limits.yaml` 的 `projects.members` 不做 catalog 校验(无 projects.yaml
强校验),写错项目名不会在 build 时报错——测试时注意。
schema(`src/configs/schema/limits.schema.json`):`name`/`resname`/`value`
必填;`percent` 0-1;`per_project`/`per_queue` 等布尔。
---
## 8. 作业提交
### 8.1 基本语法
```bash
# 全局 license 池
xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- sleep 3600
# 主机 elim 资源
xce jobs submit -R "rusage[dynres1=15]" -- sleep 3600
# 多资源、per 类型
xce jobs submit -R "rusage[extlic=1, mem=1024:per_host]" -- sleep 3600
```
### 8.2 ⚠️ 必须用方括号
`rusage[...]` 的方括号是强制的。圆括号 `rusage(extlic=1)` 会走 select 语法的
回退解析路径被吞掉,导致整个提交请求硬失败(bug-1887 家族)。手册级规则:
**永远 `rusage[...]`**。
### 8.3 per 类型语义
`rusage[res=N:per_TYPE]` 的 TYPE:
| per 类型 | 语义 | 值计算 |
|----------|------|--------|
| `per_task`(默认) | 每任务槽 | N × nslots |
| `per_job` | 每作业一份 | N |
| `per_host` | 每主机一份 | N × nhosts |
省略 per 类型时继承 caller 默认(`PER_DEFAULT` = per_task)——即
`-R "rusage[extlic=1]"` 单槽作业申请 1,4 槽作业申请 4。
`duration=N[s|m|h]` 和 `decay=0|1` 目前**可解析但只进指纹/展示,不参与
准入算术**(§12)。
### 8.4 pending reason 解读
- `NO_RESOURCE` (2):底层池不够(xceesd 没推值/池被占满),detail 会带资源名。
- `LIMIT_GLOBAL` (4):limit vres 配额超限。
- CLI 当前 pending_reason 列可能显示 `-`(展示链缺口,§12);pending
reason 的完整内容(reason 值 + detail 资源名)在 apiserver JSON
(`GET /xce/v1/jobs` 的 pending_reason 字段)与调度器日志里可见。
---
## 9. 运维观测
### 9.1 xceadmin extres status
```bash
xceadmin extres status
```
读本机 status JSON(默认 `/var/run/xce/esd-status.json`,或 esd.yaml 的
`status_file`)。**该命令只读本机文件**——多节点部署时要 ssh 到 xceesd
所在节点执行(§12)。
输出解读(真实渲染格式,`_format_extres_status`):
```
daemon: xceesd (pid 12345)
started at: 2026-10-28T10:00:00 (up 3600s)
pushes: 350 ok, 2 failed
sources:
extlic: value=3 age=12s
```
- `value=V age=Ns`:V = 最后成功采集的值;age = 距该值的秒数。
- `-- STALE` 后缀:超过 stale_limit,源值得怀疑(xceesd 还在推旧值,调度
侧继续放行——这时要人工介入看脚本/license 服务器)。
- `extlic: no value yet`:daemon 起来后从未成功采到值(池子视为 0,作业
PEND)。
- `sources: (none configured)`:esd.yaml 没配 resources。
- `pushes: N failed` 持续增长:controller 连接问题(对照 §10.2/§10.3)。
- 另有 `--status-file PATH` 选项覆盖默认路径。
status JSON 完整字段:`daemon`/`pid`/`started_at`/`generated_at`/
`pushes_ok`/`pushes_fail`/`sources[{name, last_value, last_ok_time, stale}]`。
### 9.2 资源定义与值观测
```bash
# 定义(-l/--verbose 显示 detail 列)
xce resources definition list -l
# RESOURCE: extlic
# TYPE ORDER INTERVAL LAST_UPDATED BUILTIN DYNAMIC RELEASE CONSUMABLE
# Numeric Decr 0 Oct 28 14:04 false false false true
# LAST_UPDATED:"-" = 从未推过;EXTERNAL 资源随推送刷新(debounce:值没变
# 不推,稳定值下 LAST_UPDATED 也长期不动——新鲜度以 extres status 的
# age 为准);elim 资源定义 LAST_UPDATED 一般也是 "-"(值走 distribution)
# 分布(-t static=配置声明的静态值,-t dynamic=live 推送值;全局资源的
# LOCATION 列显示 "-",主机资源显示主机名)
xce resources distribution list -t dynamic
# dynres1 22 t2
# extlic 4 -
# 主机视角(每个主机一节的 CURRENT LOAD 表只渲染 12 个内置列
# r15s..slots——elim 自定义指标**不**出现在这个表里;主机腿的值
# 用上面的 distribution list -t dynamic 看主机名落点)
xce hosts list -a
```
---
## 10. 故障处理
### 10.1 xceesd 掉线(进程挂/主机挂)
**现象**:作业 PEND,pending reason `NO_RESOURCE`(detail 带资源名);
`extres status` 读不到(文件陈旧)或 pushes 不再增长。
**语义**:xceesd 掉线后没有新值进来,但**旧值仍在**(controller 的
sharedInst 不会因 daemon 掉线自动清零)。调度器会继续按旧值放行作业——
这是有意的 fail-open:license 服务器真实状态未知时,按最后已知值走。
风险窗口靠观测面发现:`extres status` 的 age 持续增长、超过 stale_limit
后 STALE 标记出现(status 文件本身随最后一轮 poll 冻结,不会再更新)。
**处理**:
```bash
# 1. 到 xceesd 主机看进程/日志
ps aux | grep [x]ceesd
tail -50 /tmp/xceesd.log
# 2. 重启(会自动 rejoin + 全量重推,池子自动恢复)
xceesd -c /etc/xce/esd.yaml -l /tmp/xceesd.log &
```
### 10.2 controller 重启
xceesd 检测到连接断开会自动 re-join + 全量重推(bug-1978 修复的语义),
**无需重启 xceesd**。确认方式:`extres status` 的 pushes ok 继续增长。
**但调度器要重启**:调度器 rejoin 对 sharedInst 的重建有已知问题
(stale join 卡死),controller 重启后按标准流程**调度器随 controller 一并
重启**(既有运维约束,不特属于本特性)。
### 10.3 config push 之后
1. **重启 apiserver**(定义 store 只吃 join 时 config push)。
2. 调度器激活 re-push 会刷新 live 定义,但稳妥起见**重启 scheduler**。
3. xceesd **无需**重启(它推值走的是 live 通道,与定义 store 无关);
若新资源是第一次出现,xceesd 下一次推送即落账。
### 10.4 推送被拒(opupd -EPERM)
`/var/log` 下 controller 日志出现 extres 值被拒:通常是 esd.yaml 里的
`name` 与 resources.yaml 声明的 location 不匹配——extres 角色推的值只收
**SHARED** 资源("SHARED 即收"守卫)。检查:resources.yaml 是否
`location: [external]` 或 `[all]`,config push + apiserver/scheduler 重启
是否做完。
### 10.5 采集脚本挂死
看门狗 10 秒 SIGKILL,按失败处理,走 fail-open(保旧值 + WARN)。若脚本
持续挂死,stale_limit 后升级 STALE。排查:脚本是否在等 license 服务器
响应无超时——**给脚本内部的网络调用加超时**,别依赖看门狗兜底。
---
## 11. 测试指南(E2E 配方)
以下配方在 t1/t2/t3 测试集群实测通过(2026-10-27/28)。用假脚本即可做全
链路验证,不依赖真实 license 服务器。
### 11.1 准备
```bash
mkdir -p /tmp/e2e
```
**假 license 脚本** `/tmp/e2e/fake_lic.sh`——用"活 pid marker 文件池"模拟
license 占用:`held.<pid>` marker 的进程活着 = 一份 license 被持有;进程
死了(TERM/KILL/exit 任何方式)`/proc/<pid>` 消失,下次读取自动回收,
计数永不漂移。TOTAL=4:
```bash
#!/bin/bash
# Fake license collector. Pool = marker files held.<pid>; a license is held
# iff its pid is alive. Death (TERM/KILL/exit) releases automatically, so the
# count can never drift. Stale markers self-clean on every read.
DIR=/tmp/e2e/licpool
TOTAL=4
mkdir -p "$DIR"
free=$TOTAL
for f in "$DIR"/held.*; do
[ -e "$f" ] || continue
pid=${f##*.}
if [ -d "/proc/$pid" ]; then
free=$((free-1)) # alive (works cross-user; kill -0 confuses EPERM with ESRCH)
else
rm -f "$f" # gone -> clean up marker
fi
done
echo "extlic $free"
```
**占坑 wrapper** `/tmp/e2e/licjob.sh`——作业本体包一层:占一份 license
(lock 目录互斥 + `/proc` 活性清扫),占不到 exit 99,占到了就执行真正的
作业命令。作业进程死亡 = marker 自动失效,**无需 trap 清理**:
```bash
#!/bin/bash
# Fake license checkout: hold 1 license for the duration of "$@".
# The license IS a pid-keyed marker file; holding ends when this process dies
# (any way, incl. SIGKILL). No trap needed — accounting is liveness-based.
DIR=/tmp/e2e/licpool
TOTAL=4
mkdir -p "$DIR"
while ! mkdir "$DIR.lock" 2>/dev/null; do sleep 0.2; done
for f in "$DIR"/held.*; do
[ -e "$f" ] || continue
pid=${f##*.}
[ -d "/proc/$pid" ] || rm -f "$f" # /proc dir gone = truly dead (works cross-user;
done # kill -0 cannot distinguish EPERM from ESRCH)
count=$(ls "$DIR"/held.* 2>/dev/null | wc -l)
if [ "$count" -ge "$TOTAL" ]; then
rmdir "$DIR.lock"
echo "licjob: no license available (free=0)" >&2
exit 99
fi
if ! echo $$ > "$DIR/held.$$" 2>/dev/null; then
rmdir "$DIR.lock"
echo "licjob: cannot write marker in $DIR (perm?)" >&2
exit 99
fi
rmdir "$DIR.lock"
echo "licjob: checked out 1 license (free now $((TOTAL - count - 1)))"
"$@"
exit $?
```
用法是**包住作业命令**:`-- /tmp/e2e/licjob.sh sleep 3600`(licjob 占 1 份
license,然后跑 `sleep 3600`;licjob 进程活着多久,license 持有多久)。
> ⚠️ 两个实测过的坑:
> 1. 活性检查用 `/proc/<pid>` 目录存在性而非 `kill -0`——后者在跨用户
> (worker 以别的 uid 跑作业)时把 EPERM 当活着,死 marker 永不回收。
> 2. 不要用 SIGSTOP 冻结作业进程来"保持占用"——冻结进程的 marker 照样被
> 认活,但 kill 回收语义就测不到了;回收测试直接 kill。另外 SIGSTOP
> 冻结 status JSON 文件本身会让 `extres status` 读到假 false。
**esd.yaml 测试样例** `/tmp/e2e/esd.yaml`:
```yaml
controller: "172.31.1.30:9990"
poll_interval: 10
stale_limit: 60
status_file: /tmp/e2e/esd-status.json
resources:
- name: extlic
collector: script
command: /tmp/e2e/fake_lic.sh extlic
```
### 11.2 场景 1:全局 license 池 E2E
前置:resources.yaml 声明 `extlic`(§2.1),config push + 重启 apiserver/
scheduler,xceesd 跑在 t2(脚本同主机)。
**记账模型先读一遍**(配方为什么这样设计):调度器侧的账与 fake 池的账是
两本——
- 调度器侧:EXTERNAL 只看推送来的 total ≥ uncomit + wanted。`uncomit` 是
每次分配尝试的暂存簿(作业派发即清),RUN 作业的占用**不**进调度器准入
——靠自由值下降体现(§1.2 跨作业记账分工)。
- fake 池侧:licjob 进程活着 → 1 个 marker → free 降 1。**每个 licjob
进程占 1 份,不管 rusage 申请多少**。
所以用 rusage[extlic=3] 的作业时:调度器第一轮 total=4 ≥ 3 放行,之后
推送值降到 3(1 个 marker),后续作业按 3 算。想要"精确对账"的场景用
rusage[extlic=1] 的作业最干净(调度器扣 1、marker 扣 1,两本账一致)。
```bash
# 1. 池子在线
ssh t2 'xceadmin extres status'
# 期望:value=4 (TOTAL),age 正常,无 STALE
# 2. 大块申请 + 超额(两本账的暴露窗口演示)
xce jobs submit -q normal -P projA -R "rusage[extlic=3]" -- /tmp/e2e/licjob.sh sleep 3600
# 期望:RUN(第一轮 total=4 ≥ 0+3)
xce jobs submit -q normal -P projA -R "rusage[extlic=2]" -- /tmp/e2e/licjob.sh sleep 3600
# 紧跟着提交(自由值还没降):total=4 ≥ 0+2 → RUN 也可能拿到;
# 但 fake 池只剩 4-1-1=2,licjob 拿不到 marker 会 exit 99 → 作业 EXIT。
# 这正是真实 license 服务器 checkout 失败的等价物。
# 隔一个 poll_interval 再提交,则看到 total=3(marker 已入账),
# 3 ≥ 0+2 → RUN(还能容纳);free 再降到 2。
# 3. 占满 → PEND(最干净的对账场景:4 个 rusage[extlic=1])
for i in 1 2 3 4; do
xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600
sleep 12 # ≥ poll_interval,让每次占用都先落到推送值里
done
# 期望:4 个 RUN,free 一路 3→2→1→0
xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600
# 期望:PEND,reason NO_RESOURCE,detail 带 extlic
# 4. 回填(再准入)
# kill 一个 RUN 作业 → marker 回收 → free=1 → 第 5 个作业 RUN
# 5. 恒超额
xce jobs submit -q normal -P projA -R "rusage[extlic=5]" -- /tmp/e2e/licjob.sh sleep 60
# 期望:永远 PEND(TOTAL=4 < 5)
```
### 11.3 场景 2:elim 主机腿 E2E
前置:`/opt/xce-denix/bin/elim-dynres1.sh`(按主机名给值:t1=11/t2=22/
t3=33),xcecd.yaml 配 `external_resources`,三节点都重启 xcecd。
```bash
# 1. 落值
xce hosts list -a # t2 的 dynres1 列应显示 22
# 2. 定向申请
xce jobs submit -R "rusage[dynres1=15]" -- sleep 300
# 期望:RUN 且落 t2(t1=11/t3=33 不够 15)
# 3. 超额 PEND + kill 回填 RUN(同场景 1 步骤 3-4)
```
### 11.4 场景 3:per-project 配额 E2E
前置:limits.yaml 配 `extlic_share`(projA/projB,per_project,value 3),
config push + 重启 apiserver/scheduler。注意 limit vres 是**非 EXTERNAL**
的 GLOBAL 资源,配额在调度器本地即时扣减,无需等推送——同一轮内连发也
严格生效(§1.2)。底层池 TOTAL=4 够用(每个 licjob 占 1 marker,3 个
RUN 作业占 3 < 4,不会被池子先卡住)。
```bash
# projA 连发 4 个单 license 作业
for i in 1 2 3 4; do
xce jobs submit -q normal -P projA -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600
done
# 期望:前 3 个 RUN,第 4 个 PEND(LIMIT_GLOBAL,detail 带
# vres 名 limit=extlic_share/p=projA)
# kill 一个 → 第 4 个 RUN(配额再准入)
# projB 免疫性
xce jobs submit -q normal -P projB -R "rusage[extlic=1]" -- /tmp/e2e/licjob.sh sleep 600
# 期望:RUN(projB 有自己的 3 个配额,与 projA 用量无关)
```
### 11.5 回归清单
提交相关改动前跑:
```bash
# C 侧单元/白盒(meson test 名:"esd core test" / "worker elim test" /
# "worker resource_sync test";调度器 suite 在 src/scheduler)
ninja -C build.dbg
meson test -C build.dbg 'esd core test' 'worker elim test' 'worker resource_sync test'
# Go 侧
cd src/frontend && make test # 含 config dump external 渲染等
```
相关测试文件:`src/esd/testsuite/test_esd_core.c`(配置/解析/stale/重连
flag/status 写)、`src/worker/testsuite/test_elim.c`(配置/解析/注册表/
看门狗/interval 缓存)、调度器侧 EXTERNAL 判式与 limit vres 白盒测试。
---
## 12. 已知限制
| # | 限制 | 说明 |
|---|------|------|
| 1 | 单 xceesd 实例 | 无 HA、无多实例去重;xceesd 挂了池子停在旧值(fail-open),观测靠 STALE |
| 2 | 无手动覆盖入口 | 没有改池值的 REST/CLI(调试时只能改脚本输出) |
| 3 | host 子集池不支持 | 全局池是全集群一份;"t1/t2 共享 5 个 license、t3 另有 2 个"需拆成两个资源名 |
| 4 | esd per-source `interval` 未消费 | esd_config 解析了它,但 poll 循环只用 daemon 级 `poll_interval`——每轮都跑脚本。想省脚本开销就调大 poll_interval |
| 5 | `duration`/`decay` 不参与准入 | rusage 语法可解析,当前只进作业指纹/展示 |
| 6 | FlagValue.ToJSON 无 external 键 | apiserver JSON 里看不到 EXTERNAL bit;用 `xceadmin config dump` 的 `external=True` 判断 |
| 7 | extres status 仅本机 | `xceadmin extres status` 读本机 JSON 文件;多节点时需 ssh 到 xceesd 节点 |
| 8 | pending_reason CLI 显示 `-` | CLI 侧 pending reason 展示链有缺口(已知遗留),用 describe/JSON 看 |
| 9 | 调度器 stale join | controller 重启后调度器 rejoin 有已知问题,标准流程是调度器随 controller 重启 |
| 10 | limits 项目名无校验 | limits.yaml 的 `projects.members` 不做 catalog 校验,拼写错误静默不生效 |
---
## 附:快速上手 checklist
```bash
# 1. resources.yaml 声明 extlic(location: [external],无 value)
# 2. xceadmin config build && config push
# 3. 重启 apiserver + scheduler
# 4. 部署 xceesd(esd.yaml)到 license 采集主机,脚本同主机
# 5. xceadmin extres status 确认 value 非 "no value yet"
# 6. xceadmin config dump 确认 external=True
# 7. xce jobs submit -R "rusage[extlic=1]" -- sleep 60 → RUN
```