Nomad 新手导读
> 面向没用过 Nomad 但要参与 xx runtime 重构的开发者
> 以 runtime-greenfield-plan.md 里用到的概念为线索组织
> 日期:2026-04-14
从架构 → 核心对象 → 运行态 → 高级能力,按先后关系讲清楚。配合 greenfield doc 读,每个概念都能在实际 job spec 里找到对应。
1. 架构全景:Server + Client
Nomad 是典型的 control plane + data plane 分离:
┌─────────────────┐
│ Nomad server×3 │ raft 共识,存 Job 定义、做调度决策
└─────────────────┘
↓ 下发指令
┌─────────────────┐
│ Nomad client×N │ 每台 workload 节点一个,真正启动进程/容器
└─────────────────┘
- Server:轻量协调服务。3 台组成 raft 集群(保证 HA 和数据一致性),你不在这些机器上跑用户工作负载——它们只管调度
- Client:装在每台干活的机器上,接收 server 的调度决策,在本机上
docker run或直接 fork 进程
greenfield doc §8 部署清单对应这个结构:Nomad server × 3 + 每 workload 节点一个 Nomad client。
你的代码(hosting)和 Nomad 的交互:调 server 的 HTTP API(:4646)。你不直接和 client 说话,server 转达。
2. 四层核心抽象:Job → TaskGroup → Task → Allocation
这是 Nomad 最关键的 4 个名词,先理清:
Job "openclaw-user-1001" ← 你声明想要跑什么(持久化,raft 存着)
└─ TaskGroup "instance" ← 调度单元:同一台机器上的进程集合
├─ Task "prepare-user" ← 一个具体的进程
└─ Task "openclaw"
↓
(scheduler 挑节点)
↓
Allocation (alloc) ← Job 的一次"运行实例",绑在某台节点上
├─ 真正的进程
└─ 有自己的 allocID
对照 greenfield doc §4.2 的 HCL:
job "openclaw-user-1001" { # Job
group "instance" { # TaskGroup
task "prepare-user" { ... } # Task
task "openclaw" { ... } # Task
}
}
关键理解
- Job 是声明式的:你说"我要跑这个",Nomad 负责始终让它跑着
- TaskGroup 是调度单位:同一个 group 的所有 task 一定落在同一台节点(它们共享 network、volume)
- Allocation 是运行时产物:job 重启、重调度会产生新的 alloc,每次 allocID 都不同
- Job 不变,alloc 多变:这就是 greenfield doc 反复强调"JobID 是权威键,allocID 是 transient 缓存"的来源(§4.1
resolveCurrentAlloc)
3. Job 类型
greenfield doc 用了两种:
| 类型 | 用途 | 例子 |
|---|---|---|
| `service` | 长期服务,Nomad 持续维持 running | OpenClaw 用户实例 |
| `batch` | 跑完即退出 | `xx-data-op` reset/delete(§4.1 `dispatchDataOp`) |
还有两种没用到:
system:每个 client 节点都必须跑一份(类似 K8s DaemonSet)sysbatch:system 的 batch 版本
4. Driver:Task 怎么被启动
driver = Task 的执行方式。greenfield doc 用了两种:
task "openclaw" {
driver = "docker" # 在本机 Docker daemon 上起容器
config { image = "..." }
}
task "prepare-user" {
driver = "raw_exec" # 直接在节点上跑一个二进制
config { command = "/usr/local/bin/xx-userdata" }
}
常见 driver:
docker:调 Docker API(最常用)raw_exec:直接 fork,不隔离(方便但不安全,用于可信小工具)exec:用 Linux namespace 做轻量隔离的原生进程java、qemu、社区的firecracker等
为啥 openclaw 用 docker 但 prepare-user 用 raw_exec?
- openclaw 是用户代码,必须容器隔离
- prepare-user 只是调
juicefs quota set,要访问节点上的 JuiceFS 挂载点和二进制,放 docker 里反而麻烦
5. Lifecycle:Task 启动顺序
同一个 group 里多个 task 默认并行启动。如果要"先做 A 再做 B",用 lifecycle:
task "prepare-user" {
lifecycle {
hook = "prestart" # 在主 task 启动前跑
sidecar = false # 跑完就退出,不一直开着
}
}
task "openclaw" {
# 没有 lifecycle,默认就是"主 task"
}
顺序:prestart(非 sidecar)→ main tasks 并行 → poststop。
greenfield doc 里 prepare-user 是 prestart + sidecar=false:先创建目录+设 quota,跑完退出,openclaw 再启动。如果 prepare-user 失败(exit ≠ 0),openclaw 根本不会起。
其他 hook:
prestart + sidecar=true:和 main task 同时跑,提供辅助服务(如日志转发)poststart:main task 起来后触发poststop:main task 停了之后做清理
6. Network:动态端口 + Service Registry
network {
port "dashboard" {} # 只给标签,Nomad 自动分配宿主端口
}
service {
name = "openclaw-1001"
port = "dashboard"
provider = "nomad" # 注册到 Nomad 自带的 SD(也可以 "consul")
check {
type = "http"
path = "/healthz"
interval = "30s"
}
}
关键点
1. 动态端口:port "dashboard" {} 让 Nomad 从 20000-32000 里挑空闲端口绑到宿主机上。容器内可以用 $NOMAD_PORT_dashboard 拿到。不用手写 allocatePort() 那种代码
2. Service 注册:Nomad 把 openclaw-1001 这个服务 + 当前 IP:port 注册到服务注册表(Nomad 自带的或 Consul)。其他组件(doc 里 hosting 的 dashboard proxy)通过服务名查地址
3. 健康检查:Nomad 周期调 http://IP:PORT/healthz,返回非 200 就标 unhealthy,触发重启(按 restart policy)
4. Alloc 换了地址会自动更新:这就是 greenfield doc §2 说"dashboard proxy 在同节点 alloc 重启后自动跟随"的原因——service registry 变了,proxy 查出来就是新地址
7. Restart Policy vs Reschedule Policy
两个都是自愈机制,但作用域不同:
task "openclaw" {
# Task 级:同一个 alloc 内,进程挂了怎么办
restart {
attempts = 3
interval = "5m"
delay = "15s"
mode = "delay" # 用光后等 interval 再来一轮
}
}
group "instance" {
# Group 级:整个 alloc 挂了(或多次 restart 失败),要不要换地方起个新 alloc
reschedule {
attempts = 0
unlimited = true
delay = "30s"
delay_function = "exponential"
}
}
- Restart:进程死了,Nomad client 在同一个 alloc 里重新拉起。快,本地。
- Reschedule:alloc 彻底不行了(节点挂了、restart 用光了),Nomad server 调度一个新 alloc。这是"节点宕机 → 用户实例自动跑到别的节点"背后的机制。
greenfield doc §4.2 用 unlimited = true,意思是"无限重试,永不放弃"。
8. 运行态属性:ClientStatus / DesiredStatus / CreateIndex
Allocation 有两套状态:
| 属性 | 含义 | 取值 |
|---|---|---|
| `DesiredStatus` | 我**想要**这个 alloc 怎样 | `run` / `stop` / `evict` |
| `ClientStatus` | 这个 alloc **实际**怎样 | `pending` / `running` / `complete` / `failed` / `lost` |
| `CreateIndex` | raft log 里的创建顺序号 | 单调递增整数 |
greenfield doc §4.1 resolveCurrentAlloc 选 alloc 的算法直接用了这三个属性:
// 跳过 DesiredStatus == "stop"(管理员/Nomad 决定要停的)
// 优先级:running > pending > others(挑"真在工作的")
// 同优先级按 CreateIndex 最大(最新的)
这就是 Nomad 下"稳态选择"的标准套路。
9. 存储:host_volume / CSI / bind mount / tmpfs
greenfield doc 只用到两种:
bind mount(直接挂节点路径)
config {
mount {
type = "bind"
source = "/srv/xx-data/users/${NOMAD_META_user_id}/openclaw"
target = "/root/.openclaw"
}
}
因为节点上的 /srv/xx-data 是 JuiceFS 的挂载点,本质等效于共享 FS。任何节点看到的都是同一份数据——这是 greenfield 方案能自由调度的基础。
tmpfs(内存临时目录)
mount {
type = "tmpfs"
target = "/tmp"
tmpfs_options { size = 1073741824 } # 1G
}
容器退出即销毁。用于 /tmp、/run 这些不需要持久化的目录。
没用到但要知道
- host_volume:预先在 client 配置里声明某路径作为命名卷。迁移方案(非 greenfield)用这个 + node constraint 做 node-pinning
- CSI volume:容器存储接口,对接云厂商块存储或 Ceph。真正的"volume 跟着 alloc 迁移"用它
10. meta 和 NOMAD_* 环境变量
Job 可以挂一些自定义 metadata,运行时通过 env 注入:
meta {
user_id = "1001"
quota_mb = "10240"
}
task "prepare-user" {
config {
args = ["prepare", "${NOMAD_META_user_id}", "${NOMAD_META_quota_mb}"]
}
}
${NOMAD_META_xxx} 是 Nomad 的模板变量,渲染时替换成实际值。
常用内置变量:
${NOMAD_ALLOC_ID}/${NOMAD_JOB_ID}/${NOMAD_TASK_NAME}${NOMAD_IP_/${NOMAD_PORT_/${NOMAD_HOST_PORT_${NOMAD_ALLOC_DIR}/${NOMAD_TASK_DIR}:每个 alloc/task 独立的临时目录
11. Parameterized Job(可复用的 batch 模板)
greenfield doc §4.1 dispatchDataOp 用的:
job "xx-data-op" {
type = "batch"
parameterized {
payload = "optional"
meta_required = ["action", "user_id"]
}
# ... task 定义用 ${NOMAD_META_action}、${NOMAD_META_user_id}
}
Register 一次,之后每次 nomad job dispatch xx-data-op -meta action=reset -meta user_id=1001 就会派生出一个新的 batch job instance,跑完退出。
相当于"Job 模板"——你不用每次 reset 用户都写一份完整 job,调度器自动从模板生成。
12. Constraint / Affinity(没用到但常见)
Greenfield 方案不用,但迁移方案用到:
# 硬约束:必须在这个节点(迁移方案做 node-pinning 时)
constraint {
attribute = "${node.unique.name}"
operator = "="
value = "nodeA"
}
# 软偏好:尽量在某节点,不行也能换
affinity {
attribute = "${node.class}"
value = "ssd"
weight = 50
}
13. 服务发现:Native vs Consul
service {
provider = "nomad" # 用 Nomad 内置 SD(1.3+)
# provider = "consul" # 用 Consul SD
}
- Nomad native SD:简单,不用额外组件,API 查服务列表
- Consul:更强(跨集群、ACL、DNS 接口、Mesh),但多一个组件
greenfield doc 选 Nomad native,避免为了一点 SD 引入 Consul(§0 "不引入 Consul"约定)。
14. 运维操作速查
| 操作 | 命令 | 效果 |
|---|---|---|
| 提交 job | `nomad job run openclaw-1001.hcl` | 创建/更新 job |
| 查 job 状态 | `nomad job status openclaw-user-1001` | 看当前 alloc 在哪 |
| 查 alloc | `nomad alloc status | 看具体 task 的状态 |
| 看 task 日志 | `nomad alloc logs | 流式日志 |
| exec 进容器 | `nomad alloc exec -task openclaw | 类似 `docker exec` |
| 停 job | `nomad job stop openclaw-user-1001` | stop alloc(job 保留) |
| 彻底删 job | `nomad job stop -purge openclaw-user-1001` | 连定义也删 |
| drain 节点 | `nomad node drain -enable | 把 alloc 迁走(用于维护) |
| 看集群成员 | `nomad server members` / `nomad node status` | server raft / client 列表 |
greenfield doc §4.1 的所有 Go 方法(Jobs().Register、Allocations().Restart 等)对应这些 CLI 命令的底层 HTTP API。
15. 术语速查表
| 术语 | 一句话解释 | doc 对应 |
|---|---|---|
| Server | 调度决策者,raft HA | §8 Nomad server × 3 |
| Client | 节点 agent,接指令跑进程 | 每台 workload 节点 |
| Job | 声明要跑啥(持久定义) | `openclaw-user-{uid}` |
| TaskGroup | 同节点 task 的集合,调度单位 | `group "instance"` |
| Task | 单个进程 | `openclaw` / `prepare-user` |
| Allocation | Job 的一次运行实例 | §4.1 `resolveCurrentAlloc` |
| Driver | Task 的执行方式 | `docker` / `raw_exec` |
| Lifecycle hook | Task 启动时机 | `prestart` |
| Service | 注册到 SD 供他人发现 | `openclaw-{uid}` |
| Check | 服务健康探测 | `/healthz` |
| Restart policy | Task 内重试 | §4.2 task 里的 restart |
| Reschedule policy | 跨节点重调度 | §4.2 group 里的 reschedule |
| DesiredStatus | 管理员/系统意图 | run / stop |
| ClientStatus | 运行时实际状态 | running / pending / failed |
| CreateIndex | raft 序号(判新旧) | §4.1 排序用 |
| host_volume | 节点预声明的命名卷 | 迁移方案用 |
| CSI | 容器存储接口 | 迁移方案 P8 |
| Parameterized Job | Job 模板 | §4.1 `xx-data-op` |
| Constraint | 硬调度约束 | 迁移方案 node-pinning |
| drain | 节点维护模式 | §13 运维 |
16. 学习路径建议
如果你要从零上手:
1. 单机 dev 模式:nomad agent -dev 一条命令起一个完整(非 HA)集群,拿官方 example 跑通 nomad job run
2. 读官方 Learn:
3. 做 HA:看 server bootstrap / gossip encryption / ACL 三件事
4. 对接你的代码:github.com/hashicorp/nomad/api 这个 Go 客户端,所有操作都有对应方法
5. 读 greenfield doc §4:把 NomadRuntime Go 实现看一遍,映射到上面每个概念
以后遇到 job 不跑、alloc 卡住的问题,记住 debug 三板斧:
nomad job status <jobID> # job 层:allocs 列表和调度决策
nomad alloc status <allocID> # alloc 层:task 状态、事件历史
nomad alloc logs <allocID> # task 层:stdout/stderr
90% 问题从这三个命令能看出来。
相关文档
runtime-greenfield-plan.md— xx 的 Nomad + JuiceFS + read-only 方案runtime-nomad-migration-plan.md— 带迁移/兼容的复杂版(host_volume + node-pinning)runtime-evolution-nomad-exploration.md— 更早期的方案调研