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 节点一个,真正启动进程/容器
└─────────────────┘

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
  }
}

关键理解

3. Job 类型

greenfield doc 用了两种:

类型用途例子
`service`长期服务,Nomad 持续维持 runningOpenClaw 用户实例
`batch`跑完即退出`xx-data-op` reset/delete(§4.1 `dispatchDataOp`)

还有两种没用到:

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:

为啥 openclaw 用 docker 但 prepare-user 用 raw_exec?

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:

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"
  }
}

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 这些不需要持久化的目录。

没用到但要知道

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 的模板变量,渲染时替换成实际值。

常用内置变量:

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
}

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 openclaw`流式日志
exec 进容器`nomad alloc exec -task openclaw bash`类似 `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().RegisterAllocations().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`
AllocationJob 的一次运行实例§4.1 `resolveCurrentAlloc`
DriverTask 的执行方式`docker` / `raw_exec`
Lifecycle hookTask 启动时机`prestart`
Service注册到 SD 供他人发现`openclaw-{uid}`
Check服务健康探测`/healthz`
Restart policyTask 内重试§4.2 task 里的 restart
Reschedule policy跨节点重调度§4.2 group 里的 reschedule
DesiredStatus管理员/系统意图run / stop
ClientStatus运行时实际状态running / pending / failed
CreateIndexraft 序号(判新旧)§4.1 排序用
host_volume节点预声明的命名卷迁移方案用
CSI容器存储接口迁移方案 P8
Parameterized JobJob 模板§4.1 `xx-data-op`
Constraint硬调度约束迁移方案 node-pinning
drain节点维护模式§13 运维

16. 学习路径建议

如果你要从零上手:

1. 单机 dev 模式nomad agent -dev 一条命令起一个完整(非 HA)集群,拿官方 example 跑通 nomad job run

2. 读官方 Learn 的 Get Started 模块

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% 问题从这三个命令能看出来。

相关文档