V2 独立版愿景

存储卷

HeteroFlow 是本站的异构算力与大模型调度产品。存储卷。存储卷 存储卷(S1 持久卷)解决一个具体问题:GPU 任务的容器需要读写宿主机上的数据——模型权重、数据集、checkpoint 产物、共享缓存——而容器本身是易失的。HeteroFlow v2 的做法是" 登记 + 引用 + 约束 ":运维事先在节点上准备好目录(NFS 由运…

存储卷

存储卷(S1 持久卷)解决一个具体问题:GPU 任务的容器需要读写宿主机上的数据——模型权重、数据集、checkpoint 产物、共享缓存——而容器本身是易失的。HeteroFlow v2 的做法是"登记 + 引用 + 约束":运维事先在节点上准备好目录(NFS 由运维挂好),用户在平台给目录起名、标明它是"所有节点同路径的共享存储"还是"只在某一台节点上的本地目录",之后任务、训练组、推理服务按 volume_id 引用,平台负责解析宿主路径进容器挂载、并把任务放到数据实际所在的节点上。

一个必须先说清的边界:卷是平台侧的一条登记记录,不是一块被平台管理的存储。平台不创建、不格式化、不删除宿主机上的目录;删除卷只删平台记录,宿主机数据原样保留;capacity_gb 只是登记值,平台不做任何容量限制或配额强制。界面与本文均按此口径如实表述。

功能总览

能力说明
卷登记共享卷(各节点同路径,如 NFS)与节点本地卷两类;同租户内卷名唯一
按名挂载任务 / 任务组(含训练、推理服务)/ 滚动更新统一按 volume_id 引用,服务端解析成宿主路径
放置约束节点本地卷把挂载它的任务钉在绑定节点;共享卷无放置约束
引用保护卷被"仍在使用"的任务引用时,删除与改放置字段一律 409
路径安全宿主路径黑白名单 + 危险字符校验,服务端与节点 agent 双侧执行同一份清单
训练配合checkpoint / 数据集目录可登记为卷;多机训练挂本地卷直接拒绝
滚动更新一致性新一代任务继承上一代卷挂载,本地卷约束随之继承,重建不换节点
多租户隔离卷与任务同租户红线;成员只看 / 只用自己的卷;写操作全量审计

卷模型

两类卷

维度共享卷(shared=true)节点本地卷(shared=false)
语义所有节点同一路径都能访问到同一份数据(NFS / 分布式文件系统,由运维提前挂好)数据只存在于绑定的一台节点的本地磁盘上
绑定节点必须为空,给了即 400必填,不给即 400
调度约束无:任务可调度到任意满足资源条件的节点挂载它的任务 / 任务组只能调度到绑定节点
数据库约束(shared AND node_id='') 由表级 CHECK 兜底(NOT shared AND node_id<>'') 同上,绕过 API 的写入也违反不了
适用场景多机训练 checkpoint、多副本推理共享模型、跨节点数据集单机任务的大模型缓存、单机训练的数据与产物

绑定节点的存续:本地卷的 node_id 在数据库层不加外键(节点删除后卷记录仍在),但创建 / 改绑定时平台会检查节点存在且对卷租户可见(否则 404);挂载时绑定节点已不存在或不可见则挂载 409,直到卷改绑到可用节点。

字段全解

Volume 结构体的全部字段(core/pkg/types/volume.go):

字段类型约束说明
idstring只读,vol- + 16 位十六进制平台生成(crypto/rand)
namestring必填;≤128 字符;不含控制字符与 /;首尾不得有空白;同租户内唯一(重名 409)卷的展示名与引用凭据之一
tenant_idstring只读,归属租户详见解锁见"权限"节
ownerstring只读,创建者用户 IDmember / viewer 只能看到和使用自己创建的卷;tenant-admin 与平台运营者可管理全租户的卷;空值 = 无主(系统级创建),仅 tenant-admin / 运营者可见
host_pathstring必填;绝对路径;filepath.Clean 后存储;须通过卷挂载同一套路径安全校验并落在宿主路径白名单内卷指向的宿主机目录
sharedbool与 node_id 互斥(见上表)两类卷的判别位
node_idstring本地卷必填 / 共享卷必须为空;绑定时节点须存在且租户可见(否则 404)本地卷的放置锚点
capacity_gbint0 ~ 1048576(1 PB);0 = 未登记仅记录,不强制:平台不限制写入量、不做配额
descriptionstring≤1024 字符说明文字
created_at / updated_at时间戳只读—

任务侧引用卷的载体是挂载条目 VolumeMount(core/pkg/types/types.go):

字段说明
host_path宿主机路径。按 volume_id 引用时可省略;若显式给出,必须与卷记录一致(不一致 400)
container_path容器内挂载点,必填
read_only是否只读挂载
volume_id引用的存储卷。空 = 直接写宿主路径的旧式挂载;非空时 host_path 由服务端按卷记录解析填入
bound_node_id服务端派生,客户端不得设置:被引用卷是节点本地卷时填其绑定节点。没有 volume_id 却带此字段的条目一律 400,带 volume_id 的一律按卷记录覆写

卷生命周期

登记(POST /api/v1/volumes)

平台只做登记:不在宿主机上建目录、不挂 NFS、不检查目录是否真的存在。校验全部通过后返回 201 与完整卷记录。同租户重名返回 409;字段非法返回 400 并说明原因;本地卷绑定到别租户的节点或不存在的节点返回 404。

更新(PUT /api/v1/volumes/{id})

请求字段全部是指针语义:缺省(null)= 不改。字段分两档:

字段档包含字段何时可改
描述档name / description / capacity_gb随时可改(改名撞同租户既有名仍 409)
放置档host_path / shared / node_id未被任何"仍在使用"的任务引用时才可改,否则 409,错误信息点名可见的在用任务并提示"名称、描述、容量仍可编辑"

放置档的两个便利行为:把 shared 改为 true 且未同时给 node_id 时,节点绑定自动清空(不要求调用方显式传空串);改放置字段会重跑创建时的全部校验(白名单、绝对路径、节点可见性)。"检查引用 → 改卷"与"按 volume_id 解析挂载 → 建任务落库"之间用同一把按租户的互斥锁(进程内 mutex + 跨实例 advisory lock)串行,杜绝"检查时没有引用、改完之前却有任务按旧路径落库"的竞态。

删除(DELETE /api/v1/volumes/{id})

删除只删平台记录,从不触碰宿主机数据。被在用引用挡住时返回 409;成功响应里明确写回这一事实:

{
  "message": "volume record deleted; only the platform record was removed — the data under the host path was NOT deleted",
  "id": "vol-3f9c2a1b8e4d6071",
  "host_path": "/data/models/qwen",
  "host_data_deleted": false
}

"仍在使用"的判定比"任务没结束"更宽,两条算在用(types.VolumeReferenceActive):

1. 引用卷的任务本身不是终态(pending / scheduled / running / sleeping / …);

2. 任务已终态,但它所属的任务组仍"活着"——组状态非终态,或者它是failed 的推理组(带 endpoint、非训练):推理故障恢复会以历史任务为模板复制其 volumes 重建任务,此时删掉卷记录会让重建出的任务挂着一个平台已不认识的卷及其节点约束。

单任务(无组,或组已删除)只看任务状态。因此"任务跑完了但删不掉卷"时,先检查组是否还在、是否是可能被恢复流程重建的失败推理组。

挂载机制

引用写法

任务 / 任务组模板 / 滚动更新请求的 volumes[] 里,一条挂载有两种写法,可混用:

{ "host_path": "/data/models/qwen", "container_path": "/models", "read_only": true }    // 旧式:直写宿主路径
{ "volume_id": "vol-3f9c2a1b8e4d6071", "container_path": "/models", "read_only": true } // 按卷引用

三个入口共用同一个解析器(core/pkg/api/volume_mount.go):单任务 POST /api/v1/tasks、建组 POST /api/v1/task-groups(训练组、推理服务都走这里)、滚动更新 PUT /api/v1/task-groups/{id}。

解析过程

按 volume_id 引用时,服务端依次执行:

1. 存在性与租户红线:卷不存在、属于别的租户、或本租户里调用方无权使用(member 用别人的卷),一律返回同一个 404 文案——不泄露其它租户 / 其它成员的卷 ID 是否存在。卷必须与任务同租户,平台运营者也不例外(运营者代某租户建任务时只能挂该租户的卷)。

2. 路径一致性:请求显式给了 host_path 且与卷记录不一致 → 400,提示省略 host_path。

3. 白名单复核:卷登记时白名单已校验过,这里再验一次——防止平台侧白名单收紧后,存量卷绕过新策略。

4. 填充与派生:host_path 按卷记录填入;volume_id 随挂载条目落库(卷详情的"当前挂载方"、删除 409、滚动更新 / 扩容 / 故障重建的继承都靠它,不另建关联表);本地卷的 bound_node_id 由服务端写入。

5. 同节点约束:同一任务挂多个节点本地卷且绑定不同节点 → 409(没有任何一台机器同时有这些数据);绑定节点不存在或对任务租户不可见 → 409。

6. 通用校验:解析后的全部挂载(含旧式条目)过同一套 ValidateVolumes:数量上限 32 条、危险字符、敏感路径前缀、控制字符、长度。

节点上的 agent 只读 host_path / container_path / read_only 三个字段起容器挂载,不感知 volume_id / bound_node_id——卷机制对 agent 完全透明。

路径安全校验

校验层规则执行位置
拒绝前缀(黑名单)/etc、/var/run(含 docker.sock)、/var/lib/docker、/var/log、/proc、/sys、/dev、/boot、/lib、/lib64、/usr/lib、/run、/sbin、/bin、/usr/sbin、/usr/bin服务端 + agent 双侧
允许前缀(白名单)/data/、/tmp/heteroflow/、/home/*/heteroflow/、/opt/、/mnt/、/workspace/、/root/models/(* 不跨 / 分段)同上
危险字符..(路径遍历)、;、|、&、反引号、$(、换行、回车、空字节同上
其他路径必须绝对、非空、无控制字符;单任务 / 单卷挂载条数 ≤32服务端

黑名单故意做前缀匹配、不做分段边界判断——宁可多拒(如 /etcd-data 也会被拒),不放过 /etc/...。白名单外与黑名单内的宿主路径:API 侧提交即 400;agent 侧另有同一份清单做纵深防御,白名单外的挂载会被静默丢弃——服务端前置校验的目的正是把"提交成功但挂载被 agent 悄悄丢掉"提前成"提交即报错"。两份清单在服务端(core/pkg/util/validator.go)与 agent(agent/pkg/executor/executor.go)各有副本,由对齐测试锁住一致。节点可通过 agent-config.json 的 allowed_volume_prefixes 显式扩权,但 API 侧只能按默认白名单判断,感知不到各节点的 override——扩权前缀上的卷请以节点实际行为为准。

调度约束

场景行为代码依据
共享卷无任何放置约束,任务照常参与调度—
单任务挂本地卷绑定节点被当作显式 node_id:走同一套租户可见性校验并盖 requested_node_id 钉扎;请求另给了不同的 node_id → 409(自相矛盾的请求,不静默改写任何一方)service_task.go:490-504
任务组挂本地卷不盖 requested_node_id(钉扎任务会绕过组调度,训练 / TP 组的分布式环境变量注入就没了),只靠候选节点过滤收窄到绑定节点scheduler_filter.go:162
BindTask 护栏绑定节点与候选不符时拒绝绑定(ErrCodeVolumeNodeConflict),防止任何路径绕过过滤scheduler_core.go:758-760
候选被清空绑定节点当前不可候选(离线、不可执行、租户不可见)时任务保持排队,错误信息写明"只能跑在那台节点"scheduler_core.go:183-186
组形状守卫本地卷只在一台节点上:多机训练、跨节点流水线并行、node_count>1 的非副本组建组即 400;无状态副本组(split / data_parallel 推理)允许,全部副本落在卷所在节点volume_mount.go:223-236

约束的继承不需要任何专门机制:bound_node_id 就存在任务的 volumes 里,而滚动更新、扩容、推理故障恢复、副本补建都按"整份 volumes 复制"构造新任务,约束天然随行——不会出现"重建后换了台机器、挂上一个空目录"的假成功。抢占重排路径同样先过卷绑定过滤。

与训练配合

训练组的 checkpoint 与数据集本身就是挂载,存储卷与其配合的方式:

场景行为
checkpoint 挂载training.checkpoint_path 必填(空值 400),无条件挂载为每个节点容器内的 /checkpoint(读写);dataset_path 挂载为 /dataset(只读)。两个挂载点容器内固定
白名单前置checkpoint / dataset 路径必须落在宿主路径白名单内,否则建组即 400——否则 agent 会静默丢弃挂载,checkpoint 写进容器临时层、删容器即丢(真机踩过的坑)
多机训练 + 本地卷n_nodes > 1 的训练组挂节点本地卷建组即 400:本地卷的数据只在一台节点上,其它 rank 挂到的是空目录。多机训练请用共享卷
单机训练 + 本地卷整组只会被调度到卷绑定的节点(组候选过滤),分布式环境变量注入照常
checkpoint 共享存储软告警平台无法从路径判断 checkpoint 目录是否真由共享存储承载。多机训练组建成功时响应的 warnings 数组会提醒:checkpoint_path 必须由 NFS / 共享块设备等承载,否则各 rank 各写本地盘,resume 与分片 checkpoint 合领会无声失败。软告警不阻断创建
checkpoint 目录即卷checkpoint 目录本身登记成共享卷后,可直接按 volume_id 挂它(挂载点自选),与自动挂载的 /checkpoint 并行不悖
断点续训围栏agent 自动探测续跑 checkpoint 时只看本任务声明的 /checkpoint 卷,宿主路径及符号链接解析结果都必须在挂载白名单内,目录内不跟随符号链接——路径穿越、链接逃逸到别的任务 / 租户目录一律拒绝探测

与推理服务配合

场景行为
服务去重建组请求带了卷挂载时,不与既有服务合并去重——不同挂载即不同服务实例,避免"复用了旧实例、挂载却没生效"的错配
滚动更新继承滚动更新请求未带 volumes(null / 缺省)时,新一代任务整体继承同 rank 上一代的卷挂载(含 volume_id 与 bound_node_id);显式提交(含空数组)则以请求为准,并按建组同一套规则解析校验
路径一致性本地卷约束随 volumes 继承,滚动更新 / 回滚重建出的新一代仍落在绑定节点——不会换台机器挂空目录。请求没带 HETEROFLOW_MODEL_PATH 时按 model_path 补上、绝对路径模型卷按需补挂
故障恢复推理组失败后,恢复流程以历史任务为模板复制其 volumes 重建完整 rank 集合;这也是卷删除保护把"failed 的推理组"算作在用的原因
残余窗口(如实说明)滚动更新的新一代任务由后台异步创建,卷互斥锁只覆盖到解析为止:解析之后、新任务落库之前若有人删掉该卷,新任务会挂着一个已删卷的路径(宿主路径与数据仍在,只是平台记录已不存在)。建任务 / 建组入口没有这个窗口

权限

卷 API 与任务组完全同一套权限口径(路由要求 tasks:write scope),不另造体系。

操作平台运营者tenant-adminmemberviewer租户级 API Key(不代表具体用户)
创建卷可(可带 tenant_id 代任意租户登记)可(本租户)可(本租户)403(只读角色)可
列表全量,或 ?tenant_id= 定向全租户仅自己创建的仅自己创建的(无)全租户
详情 / 编辑 / 删除任意卷本租户任意卷(代管他人卷留代管审计)仅自己的卷,他人的 404仅自己的卷,且写操作 403本租户任意卷
挂载(任务引用卷)只能挂任务归属租户的卷本租户任意卷仅自己创建的卷,他人的 404建不了任务(入口 403)本租户任意卷

补充口径:

  • 跨租户一律 404(运营者的列表定向除外):详情、编辑、删除、挂载触碰别租户的卷时,返回与"不存在"相同的文案,不泄露存在性;无凭据 401。
  • 卷详情的"当前挂载方"引用列表按同一口径裁剪:member 只看到自己任务的引用,其余只计数(hidden_reference_count)不展示;409 文案也只点名调用方可见的在用任务。
  • 所有写操作落审计日志(resource_type=volumes);tenant-admin 代管他人卷的写操作带 acted_for_user_id 代管痕迹。

API 参考

方法与路径说明成功码主要失败码
GET /api/v1/volumes列表(口径见权限表;运营者可 ?tenant_id= 定向)200401
POST /api/v1/volumes创建卷201400 校验 / 403 viewer 或越权 tenant_id / 404 租户或节点不存在 / 409 重名
GET /api/v1/volumes/{id}卷详情 + 当前挂载方引用200401 / 404
PUT /api/v1/volumes/{id}编辑(null = 不改;放置档被在用引用挡 409)200400 / 403 / 404 / 409
DELETE /api/v1/volumes/{id}删除平台记录(在用 409;不删宿主数据)200404 / 409
其余方法——405;卷存储不可用 503

创建请求示例(逐字段注释):

POST /api/v1/volumes
{
  "name": "qwen-models",              // 必填。租户内唯一(重名 409);≤128 字符;
                                     // 不含控制字符与 "/";首尾不得有空白
  "host_path": "/data/models/qwen",   // 必填。绝对路径;落在宿主路径白名单内
                                     // (/data/ /opt/ /mnt/ /workspace/ 等,见"路径安全校验");
                                     // 黑名单前缀(/etc、/var/run 等)直接 400;
                                     // 存储前做 filepath.Clean(尾斜杠会被归一)
  "shared": true,                    // true = 各节点同路径的共享存储(NFS 等);
                                     // false = 节点本地目录(须给 node_id)
  "node_id": "",                     // 本地卷必填(绑定节点,须存在且本租户可见,否则 404);
                                     // 共享卷必须为空(给了即 400)
  "capacity_gb": 200,                // 可选。登记容量(GB),0 = 未登记;上限 1 PB。
                                     // 仅记录,平台不做配额强制
  "description": "Qwen 系列权重共享目录" // 可选。≤1024 字符
}

非平台运营者的请求带了 tenant_id 字段 → 403(只有运营者能代别的租户登记;运营者代建时租户不存在 → 404)。请求体出现未知字段一律 400 并逐个点名,拼错字段名不会"看起来成功"。

挂载示例(任务 / 任务组模板 / 滚动更新同构):

POST /api/v1/tasks
{
  "name": "eval-qwen",
  "image": "pytorch/pytorch:2.3.0-cuda12.1-cudnn8-runtime",
  "gpu_required": 1,
  "volumes": [
    { "volume_id": "vol-3f9c2a1b8e4d6071", "container_path": "/models", "read_only": true },
    { "host_path": "/data/eval-datasets", "container_path": "/datasets", "read_only": true }
  ]
}

volume_id 条目的 host_path 可省略(给出则必须与卷记录一致);挂载了节点本地卷的任务被钉到绑定节点,响应中的任务 node_id / requested_node_id 即为该节点。

详情响应示例:

GET /api/v1/volumes/vol-3f9c2a1b8e4d6071
{
  "volume": { "id": "vol-3f9c2a1b8e4d6071", "name": "qwen-models", "...": "..." },
  "references": [
    { "task_id": "task-d4e5f6-...", "task_name": "eval-qwen", "task_status": "running",
      "user_id": "u-...", "group_id": "", "active": true, "created_at": "..." }
  ],
  "in_use": true,                  // 是否存在任何"仍在使用"的引用(含调用方看不到的)
  "active_reference_count": 2,     // 在用引用总数(含看不到的)
  "hidden_reference_count": 1      // 因"成员只看自己的资源"未展示的引用条数
}

references[] 每条含任务 ID / 名称 / 状态 / 创建者、所属任务组(单任务为空)与 active(在用判定,见生命周期节)。引用查询上限单次 500 条。

最佳实践

共享卷与 NFS 配置要点

  • 共享卷的前提是每个节点在同一路径挂到同一份存储:NFS / CephFS / 共享块设备皆可,挂载由运维在各节点完成后才登记卷。路径须落在白名单前缀下(如 /data/nfs/...、/mnt/cephfs/...)。
  • 多机训练的 checkpoint_path 必须由共享存储承载——这是平台的软告警项而非硬校验(平台无法从路径判断后端是不是共享存储),各 rank 各写本地盘的后果是 resume 与分片 checkpoint 合并无声失败。最稳妥的做法是把 checkpoint 根目录登记成共享卷,checkpoint_path 直接指向它。
  • 数据集建议只读挂载(dataset_path 自动只读;自挂卷给 read_only: true),避免训练意外写坏共享数据。

节点本地卷的使用边界

  • 本地卷适合单机任务 / 单机训练:大模型权重缓存到单节点本地盘可显著加速反复启动的评估任务。
  • 本地卷把任务钉死在绑定节点:节点离线时挂它的任务无法调度到别处,只能等节点回来。对可用性敏感的任务不要用本地卷。
  • 给多机训练挂本地卷会在建组时被直接拒绝(400),这不是限制而是保护——其它 rank 挂到的只会是空目录。

命名与登记规范

  • 卷名在租户内唯一(不同租户可重名),建议带用途与环境前缀,如 train-ckpt-prod、eval-datasets-shared;改名撞别人的名字同样 409。
  • capacity_gb 按实际容量如实登记(0 = 不登记)——它不强制任何配额,但容量数字会出现在列表与详情里,是运维容量规划的唯一线索。
  • 卷登记不校验目录是否真的存在:登记一个尚不存在的路径会成功,任务挂载后容器内是空目录或启动失败。登记前先在节点上确认目录就绪。
  • 删除卷前先看详情页"当前挂载方":有在用引用时删除会被 409 挡住;确认无引用后删除仍只清平台记录,宿主机数据由运维自行管理。