Services¶
The top-level services: block declares long-running processes that srtctl launches and tracks next
to the inference workers, the frontend, and the benchmark client. One list, one shape, for everything
that is not a worker or the frontend: the discovery plane (etcd, NATS), the Mooncake master, the
metrics exporters tachometer scrapes, an experimental router built from an unmerged PR, a standalone
Mooncake Store per worker node, a debugging HTTP server. The built-in ones are implied by the rest of
the recipe and need no entry; declaring one by name takes it over. Adding a new one is a recipe
change, not a code change.
Table of Contents¶
- Quick Start
- Configuration Reference
- Implicit Services
- Placement
- Start Order and Readiness
- Environment
- Building From Source
- Service Types
- Example: a router from a PR
- Example: standalone Mooncake stores
- Validation
- Limitations
Quick Start¶
services:
- name: my-sidecar
command:
- python3
- -m
- my_package.my_sidecar
- --port
- "9000"
readiness:
port: 9000
name and command are the only required fields for the default generic type. The service runs in
the job container on the head node, starts once workers and the frontend are healthy, and the job
waits until port 9000 answers before moving on. Its log is service_my-sidecar.out in the job's log
directory. examples/features/services.yaml is a runnable version of this.
Configuration Reference¶
services:
- name: my-sidecar # required, unique across the list
type: generic # generic (default) | etcd | nats | mooncake-master | dcgm-exporter | node-exporter | mooncake-store | lmcache-server
enabled: true # false drops the service (the way to switch an implied one off)
external: null # typed kinds only: use an instance that already runs at this address
command: # argv, not shell-interpreted; required for generic
- python3
- -m
- pkg
args: # appended to command
- --flag
container: my-image # image or srtslurm.yaml alias; default: job container
env: # environment for the service process
MY_FLAG: "1"
options: # kind-specific knobs (nats: max_payload_mb; exporters: port, collect_interval_ms)
max_payload_mb: 24
placement:
node: head # head | infra | dedicated | prefill | decode | agg | workers | compute | all
pool: train # or: ride on the nodes another service owns (replaces node)
nodes: 2 # own whole nodes: a pool added to the allocation next to the roles' nodes
start: after_frontend # infra | before_workers | after_frontend
readiness: # optional probe, checked on every service node; typed kinds have default ports
port: 9000 # or tcp: {port} / http: {port, path, status} / log: {pattern}
timeout_seconds: 120
interval_seconds: 2
inherit_discovery_env: true # inject ETCD_ENDPOINTS / NATS_SERVER
critical: false # a crash fails the run when true
preamble: | # shell run before command, inside the container
ulimit -n 1048576
cpus_per_task: 8 # srun --cpus-per-task
cpu_bind: none # srun --cpu-bind
srun_options: # extra srun options for this service only
exclusive: ""
source: # clone before build/launch; single-node placements only
git: https://github.com/org/repo
rev: <commit-sha, tag, or refs/pull/N/head>
path: subdir
build_command: # run once from the clone, inside the container
- bash
- -lc
- pip install -e .
| Field | Default | Notes |
|---|---|---|
name |
required | Unique. Names service_<name>.out and the tracked process. |
type |
generic |
Selects a service type that supplies defaults, and for the typed kinds the command. |
enabled |
true |
false drops the service. Declaring an implied name with enabled: false switches it off. |
external |
none | etcd, nats, mooncake-master only: an address of an already-running instance. Nothing launches; the address is injected where the job's own would have been. |
options |
{} |
Kind-specific knobs; unknown keys are rejected. nats: max_payload_mb. dcgm-exporter, node-exporter: port, collect_interval_ms. mooncake-master: store_config (vLLM). |
command |
type default | Argv passed directly to the process. generic has no default, so it is required there. |
args |
[] |
Appended to command. Handy with typed services that supply the command. |
container |
type fallback, then job container | Aliases resolve through srtslurm.yaml like every other container key. |
env |
{} |
Merged over the type's defaults; see Environment. |
placement.node |
type default | generic: head. See Placement. compute is every engine worker node plus every pool; all adds the head, infra and client nodes. |
placement.pool |
none | Run on the nodes another service owns, one instance per node of that pool. Replaces node. See pools.md. |
placement.per |
node |
worker: one instance per engine worker on each placed node instead of one per node, attached to that worker (its CUDA_VISIBLE_DEVICES, the {worker_*} placeholders). See Placement. |
nodes |
none | Whole nodes this service owns: its pool, added to the allocation after the engine roles' nodes, in declaration order. Any number of services may own nodes, next to engine roles or without them. An owner is placed on its own pool (placement.node: workers). Not supported with resources.het_jobs. See pools.md. |
start |
type default | etcd, nats: infra. mooncake-master, mooncake-store, lmcache-server: before_workers. generic and the exporters: after_frontend. |
readiness |
type default | One probe per node: port / tcp, http, or log, plus timeout_seconds and interval_seconds. The typed kinds gate on their well-known ports when no probe is written. See Start Order and Readiness. Timing out terminates what this stage started and fails the job. |
inherit_discovery_env |
true |
Inject the same ETCD_ENDPOINTS / NATS_SERVER the Dynamo frontend gets. |
critical |
type default | generic: false. mooncake-store, lmcache-server: true. |
terminal |
false |
This service is the job's run: the job ends when every instance of every terminal service has exited, with the worst exit code as the job's. The recipe has no benchmark step (benchmark.type stays manual); combining the two is refused. See pools.md. |
metrics |
kind default | Prometheus endpoints the service serves: one {port, path, nodes, name} or a list of them (path defaults to /metrics, nodes to all, name to the service name and is required when there are several). Tachometer scrapes each on every node the service runs on, pools included, as endpoint <name>_<node>, and the rows carry service=<service>; nodes: first scrapes only the service's first node (a cluster head that serves a collector or a router). The exporter kinds declare theirs; write it for anything else that publishes metrics. |
preamble |
none | Shell run after the environment is exported and before command. |
cpus_per_task, cpu_bind, srun_options |
none | Pass-through srun knobs for this service's launches. |
source, build_command |
none | See Building From Source. |
build_timeout_seconds |
1800 |
build_command is killed when this runs out, so a hung build cannot hold the allocation. |
command, args, env values, and preamble may use these placeholders: {node}, {node_ip},
{node_id} (position in the worker list), {index} (instance index within the service), {role}
(the placement.node value), {head_node}, {head_ip}, {infra_node}, {infra_ip},
{master_port}, {metadata_port}, {gpus_per_node}, and the service's own node set: {pool_node} /
{pool_ip} (its first node), {pool_nodes} / {pool_ips} (every node, comma-separated, in order),
{pool_node_count}. Only those names are substituted; other braces (JSON in an env value) are left alone.
{pool_ip} is the rendezvous for a service that forms its own cluster on the nodes it owns; {head_ip} is the job head, an engine node when the service runs on a pool next to engine roles. A torchrun owner reads --nnodes={pool_node_count} --nproc-per-node={gpus_per_node} --node-rank={index} --master-addr={pool_ip}; see pools.md, including why such a service should not carry a readiness probe that needs every rank.
Implicit Services¶
Things the recipe asks for elsewhere are services the job runs without an entry; the Mooncake master is the one built-in kind that is always declared. NATS is not implied by the Dynamo frontend alone: the default request plane is tcp and KV events travel over direct ZMQ, so a plain Dynamo job runs etcd only. Declare a nats service to run one regardless.
| Implied by | Services | Where |
|---|---|---|
frontend.type: dynamo |
etcd |
the infra node, phase infra |
dynamo.request_plane: nats, dynamo.event_plane: nats, or a nats entry with options.max_payload_mb |
nats |
the infra node, phase infra |
a declared mooncake-master entry (see Mooncake KV Store) |
mooncake-master |
the infra node, phase before_workers |
a vLLM role with connector: lmcache-mp (engine-level or roles.<role>.args.connector), unless a lmcache-server entry is declared |
lmcache-server |
that role's nodes, or every worker node when several roles use it, phase before_workers |
engine.failover (see Shadow Engine Recovery) |
gms |
one instance per vLLM worker (placement.per: worker), phase before_workers |
tachometer on (the default; observability.tachometer.enabled) |
dcgm-exporter, node-exporter |
every worker node, phase after_frontend |
srtctl dry-run lists them next to the declared ones, marked implied by:. A declared entry with
the same name replaces the implied one, so the recipe only says what differs:
services:
- name: etcd
type: etcd
placement:
node: dedicated # reserve a node for the discovery plane
- name: nats
type: nats
placement:
node: dedicated # etcd and nats share the infra node: both or neither
options:
max_payload_mb: 24
- name: dcgm-exporter
type: dcgm-exporter
container: mirror/dcgm-exporter:3.3.9-3.6.1-ubuntu22.04 # air-gapped cluster
- name: node-exporter
type: node-exporter
enabled: false # drop it
external uses an instance that already runs: nothing launches, and the workers and frontend get its
address in ETCD_ENDPOINTS / NATS_SERVER (or MOONCAKE_MASTER) instead of the infra node's.
services:
- name: etcd
type: etcd
external: http://etcd.shared.example:2379
The power-telemetry path (telemetry.enabled) launches and owns its own DCGM exporter; the implied
dcgm-exporter steps aside when it is on.
The v1 layout (infra: and backend.mooncake_kv_store) is documented in legacy-v1.md; srtctl migrate rewrites it into the entries above.
Placement¶
placement.node picks the physical nodes. head, infra, and dedicated launch one instance;
dedicated reserves a node for the infra services (the job asks Slurm for one more node) and is
accepted by etcd, nats, and mooncake-master. prefill, decode, and agg launch one instance
per distinct node that role's workers use, so two TP1 decode workers on one node share one service.
workers launches one instance per worker node; all one per allocated node (head, infra, benchmark client, and workers).
When a service launches on more than one node its processes and logs get a node suffix:
service_<name>_<node>. Two services that declare the same readiness.port and land on the same
node are rejected before anything launches; give them disjoint placements or ports.
placement.per: worker changes the unit from the node to the engine worker: one instance per worker
of the placed role(s) on each selected node (node must be prefill, decode, agg, or workers),
a sidecar in the Kubernetes sense. The instance runs in that worker's device view (its
CUDA_VISIBLE_DEVICES, the same pinning the worker's engine steps get) and sees {worker_role},
{worker_index}, {worker_node_rank}, {worker_gpus} and {worker_gpu_count}. Its step and log are
service_<name>_<role>_<index>_<node>. Because the instances of one node share its network namespace,
a per-worker service's readiness must be a log probe. The GPU Memory Service of
shadow engine recovery is the built-in per-worker kind, and SGLang fast engine recovery runs SGLang's weight cache daemon as a plain generic per-worker service:
services:
- name: gpu-watch
type: generic
command: ["nvidia-smi", "dmon", "-i", "{worker_gpus}"]
placement:
node: decode
per: worker
Start Order and Readiness¶
Services launch in three phases; within a phase, implied services first, then declared ones in declaration order:
infra: the discovery plane (etcd, NATS). Nothing else should need this phase.before_workers: after the discovery plane, before any worker. The Mooncake master, standalone stores, anything workers connect to at startup.after_frontend: once workers and the frontend are healthy, before the scraper. The exporters, sidecars that register into a running job.
Within a phase, a service with readiness blocks until its probe passes on each of its nodes. The
typed kinds gate on their well-known ports by default (etcd 2379, NATS 4222, the Mooncake master
8700, 8701, and 8702, the exporters none); a generic service without a probe is considered started
when its srun is launched. Three probes are available, and a readiness block names exactly one:
readiness:
port: 9000 # shorthand for tcp
timeout_seconds: 120 # per node; the job fails when it runs out
interval_seconds: 2 # between attempts
readiness:
tcp:
port: 9000 # a TCP connection is accepted
readiness:
http:
port: 8000
path: /ready # GET http://<node>:8000/ready
status: 200 # returns this status
readiness:
log:
pattern: 'Uvicorn running on .*:\d+' # a regex matched against service_<name>.out
The probe is re-run every interval_seconds until it passes. Between attempts the stage checks that
the process is still alive, so a service that crashes fails the job at once with its exit code rather
than after the full timeout. Ongoing health is the shared
ProcessRegistry monitor, the same as every other process in the job. It tears the run down only for
critical: true services. Anything other components register under or route through should be
critical: a router that dies mid-run otherwise leaves the frontend silently talking to the raw
backend and the benchmark measuring something other than what it claims.
Environment¶
The service process environment is built in layers, later ones winning:
- Discovery env, when
inherit_discovery_envis true:ETCD_ENDPOINTS=http://<infra>:2379,NATS_SERVER=nats://<infra>:4222(or theexternaladdresses). etcd and NATS themselves never get it. - The type's defaults (
mooncake-storesetsMOONCAKE_LOCAL_HOSTNAMEto the node's IP). - The recipe's
env, with placeholders substituted. - Values srtctl owns for the type (
mooncake-store:MOONCAKE_MASTER,MOONCAKE_TE_META_DATA_SERVER). A recipe value for these is ignored.
Building From Source¶
source plus build_command clone and build once before launch. The clone runs on the bare host of
the service node (git and network access are host concerns, and the job container may lack git) into
<log_dir>/services/<name>/src, which every container sees at /logs/services/<name>/src.
build_command and command then run inside the service container from that directory (or
source.path under it).
rev must be immutable: a commit SHA, a tag, or refs/pull/<n>/head while iterating on an open
PR. main, master, and HEAD are rejected at load time. Because the build installs into one
container instance, source is only allowed with single-node placements (head, infra).
source is the same shape dynamo.source uses. At submit time srtctl apply resolves a non-commit
rev with git ls-remote and records the commit as source.sha in the submitted config.yaml
(the recipe on disk is untouched), so the job checks out exactly the commit the lockfile names even
if the PR is pushed to again while the job waits in the queue. --json output lists what was pinned
under pinned_sources. srtctl dry-run never touches the network.
Service Types¶
type selects a registered ServiceKind (src/srtctl/services/). A kind supplies defaults and the
environment its process needs; the launch path is shared by every kind. Register a new one with
@register_service("<name>"), the same pattern as @register_benchmark.
| Type | Default command | Start | Critical | Notes |
|---|---|---|---|---|
generic |
none (required) | after_frontend |
false |
Launches exactly what you wrote. |
etcd |
/configs/etcd from the job container, advertising the node's IP |
infra |
true |
Implied by the Dynamo frontend. Placement head, infra, or dedicated; supports external. Fresh data dir on node-local /tmp each job. |
nats |
/configs/nats-server -js from the job container |
infra |
true |
Implied by the Dynamo frontend. options.max_payload_mb writes a server config. Same placements as etcd; supports external. |
mooncake-master |
mooncake_master with the RPC, HTTP metadata, and metrics ports srtctl owns |
before_workers |
true |
Declared by name; see Mooncake KV Store. args are appended; options.store_config is the vLLM connector JSON. Container falls back to the job container. Supports dedicated and external. |
dcgm-exporter |
dcgm-exporter --collect-interval=<ms> --address :9401 in nvcr.io/nvidia/k8s/dcgm-exporter |
after_frontend |
false |
Implied on worker nodes while tachometer runs. Shell-less (distroless image). options: port, collect_interval_ms. |
node-exporter |
/bin/node_exporter with the cpu, infiniband, and meminfo collectors on 9101 in quay.io/prometheus/node-exporter |
after_frontend |
false |
Implied on worker nodes while tachometer runs. Shell-less. options: port. |
process-exporter |
configs/process-exporter -config.path <log_dir>/process-exporter.yml -web.listen-address=:9256 -threads=true ... on the bare node |
after_frontend |
false |
Implied on every allocated node (placement.node: all) while tachometer runs. Host-native from the static binary make setup installs; skipped with a warning when it is missing. A declared container switches to the image's /bin/process-exporter with the group file under /logs. options: port, binary. |
mooncake-store |
python -m mooncake.mooncake_store_service |
before_workers |
true |
Requires a mooncake-master entry. Container falls back to the master's. Injects the master's address. |
lmcache-server |
lmcache server bound to the RPC and HTTP ports srtctl owns (8750, 8751) |
before_workers |
true |
One per worker node (placement.node: workers); vLLM ranks reach it over localhost with connector: lmcache-mp, which implies it. SGLang workers started with enable-lmcache get LMCACHE_MP_HOST/LMCACHE_MP_PORT pointing at it unless the recipe sets lmcache-config-file or LMCACHE_MP_HOST. Services do not inherit the top-level environment:, so set PYTHONHASHSEED in its env: when the workers set it. args are appended (--l1-size-gb, --chunk-size, --max-workers, ...). Ready when GET /healthcheck answers; LMCache must be installed in the job container. |
ray |
ray start --head ... on the first instance, ray start --address=<head>:6379 ... on the rest, both --block |
before_workers |
true |
One Ray cluster across the service's nodes; see Ray cluster. Placement workers (default), head, or all. options: port (GCS, 6379), dashboard_port (8265), num_gpus (the node's count). args are appended to every ray start. |
gms |
one python3 -m gpu_memory_service --device k per GPU of the worker, supervised by bash, in the job container |
before_workers |
true |
Implied by engine.failover; see Shadow Engine Recovery. placement.per: worker (required): one instance per vLLM worker in that worker's device view, sockets and lock file under <shared_dir>/srtctl-<job>/<role>_<index>. Ready when its log says GMS ready:; readiness.timeout_seconds (default 120) also bounds the servers' startup. |
Ray cluster¶
type: ray turns the service's nodes into one Ray cluster: the first instance runs the head (GCS on
options.port, dashboard and job-submission API on options.dashboard_port, bound to the node's
fabric IP), every other instance joins it. Readiness is per role, then per fleet: the head is ready when
its dashboard answers /api/version, a worker when its log says Ray runtime started, and the service
is ready when the head's node summary lists every member ALIVE. The job waits for all three before
anything that submits work starts.
What Ray spawns later (actors, the driver of a ray job submit) runs inside these steps' containers,
so the service carries the job's container, mounts, and environment. The client that submits work,
typically the benchmark step, only needs to reach the dashboard port. CUDA_VISIBLE_DEVICES is
exported to match --num-gpus, and RAY_memory_monitor_refresh_ms=0 disables Ray's host-memory
killer, which a trainer offloading to host RAM trips on purpose; both are defaults the recipe's env
overrides.
frontend:
type: none
services:
- name: train
type: ray
nodes: 2 # the job's two nodes belong to this cluster
options:
port: 6379
dashboard_port: 8265
Metrics¶
Every service that serves Prometheus metrics says so through the same annotation, metrics: {port, path}, and tachometer builds its scrape targets from those declarations: one target per node the service runs on, named <service>_<node>, with service=<name> in the row metadata. The DCGM, node and process exporters are ordinary users of it: their kinds fill in the port from options.port, choose the scraper filter (dcgm, node_exporter, passthrough) and keep their historical endpoint prefixes (dcgm_<node>, node_exporter_<node>, process_exporter_<node>). A generic service writes the block itself; a service that serves several endpoints lists them with names:
services:
- name: envsrv
type: generic
command: ["python3", "-m", "server"]
placement:
pool: train
metrics:
port: 8003 # tachometer scrapes http://<node>:8003/metrics on every pool node
- name: train
type: ray
nodes: 2
metrics: # a trainer's collector and its router's engine aggregate, both on the Ray head
- { name: miles, port: 9090, nodes: first }
- { name: engines, port: 31000, path: /engine_metrics, nodes: first }
Workers and the frontend are not services and keep their own target logic in core/telemetry.py. When power telemetry brings its own DCGM exporter, the telemetry stage scrapes that one on the worker nodes instead of implying a dcgm-exporter service. srtctl dry-run prints metrics=:<port><path> on every scraped service.
The bespoke launch paths these replace (start_head_infrastructure with its own readiness loop, a
Mooncake-master stage, exporter launches inside the tachometer stage) are gone; every one of these is
a ManagedProcess from the same stage, with the same registry, cleanup, and dry-run output.
Example: a router from a PR¶
frontend:
type: dynamo
services:
- name: thunderagent-router
source:
git: https://github.com/ai-dynamo/dynamo
rev: refs/pull/14000/head # switch to a commit SHA once merged
build_command:
- bash
- -lc
- "cd lib/bindings/python && maturin develop --uv && cd ../../.. && pip install -e ."
command:
- python3
- -m
- dynamo.thunderagent_router
- --endpoint
- dyn://namespace.component.endpoint
- --model-name
- my-model
# The router is the thing under test: other components register under its
# endpoint, so running without it must fail the run, not degrade it.
critical: true
inherit_discovery_env defaults to true, so the router sees the same etcd/NATS as the Dynamo
frontend and workers with no extra configuration.
Example: standalone Mooncake stores¶
Inference workers run embedded Mooncake clients with MOONCAKE_GLOBAL_SEGMENT_SIZE=0 while dedicated
per-node stores own the DRAM segments. Decode nodes contribute host memory without an in-process
HiCache pool. One entry per role gives each role its own segment size:
engine: sglang
roles:
prefill:
env:
MOONCAKE_PROTOCOL: rdma
MOONCAKE_DEVICE: "mlx5_0,mlx5_1"
MOONCAKE_GLOBAL_SEGMENT_SIZE: "0"
args:
disaggregation-transfer-backend: mooncake
decode:
env:
MOONCAKE_PROTOCOL: rdma
MOONCAKE_DEVICE: "mlx5_0,mlx5_1"
MOONCAKE_GLOBAL_SEGMENT_SIZE: "0"
args:
disaggregation-transfer-backend: mooncake
services:
- name: mooncake-master
type: mooncake-master
container: mooncake # the master; also the stores' default container
- name: store-prefill
type: mooncake-store
placement:
node: prefill
args:
- --port
- "8800"
env:
MOONCAKE_PROTOCOL: rdma
MOONCAKE_DEVICE: "mlx5_0,mlx5_1"
MOONCAKE_GLOBAL_SEGMENT_SIZE: 100gb
preamble: |
ulimit -n 1048576
ulimit -l unlimited
cpus_per_task: 8
readiness:
port: 8800
- name: store-decode
type: mooncake-store
placement:
node: decode
args:
- --port
- "8800"
env:
MOONCAKE_PROTOCOL: rdma
MOONCAKE_DEVICE: "mlx5_0,mlx5_1"
MOONCAKE_GLOBAL_SEGMENT_SIZE: 400gb
preamble: |
ulimit -n 1048576
ulimit -l unlimited
cpus_per_task: 8
readiness:
port: 8800
Stores start after the master is healthy and before workers. Each store gets MOONCAKE_MASTER,
MOONCAKE_TE_META_DATA_SERVER, and MOONCAKE_LOCAL_HOSTNAME from the runtime. If prefill and decode
share a node, the two entries above collide on port 8800 and the job fails before launching; use one
entry with placement.node: workers and a single segment size instead. See
Mooncake KV Store for the worker side.
Validation¶
Rejected at load time, so srtctl dry-run catches them:
- Empty or duplicate
name; unknowntype. genericwithoutcommand; acommand/argsentry that is blank;build_command: [].placement.nodeorstartoutside their vocabularies;dedicatedorexternalon a kind that does not support them; anoptionskey the kind does not know.sourcewith a movingrev, or with a multi-node placement.type: mooncake-storewithout amooncake-masterentry; twomooncake-masterentries.etcdandnatsdisagreeing ondedicated(they share the infra node).
Rejected at launch, before any service starts: two services listening on the same port on one node.
srtctl dry-run prints every service's type, placement, start phase, criticality, command,
container, source, readiness, and env.
Cleanup¶
Nothing a service launches outlives the job:
- Every long-running service is a named Slurm step (
service_<name>), so cleanup delivers SIGTERM throughscancel --signal=TERMand the process gets 30 seconds to flush (etcd its WAL, a scraper its parquet) before the step is killed. Signalling thesrunclient directly would have killed the task outright. - Every
srunthe stage starts, including the one-shot clone and build steps, is registered with the job'sProcessRegistrythe moment it exists, not when the stage returns. The registry's cleanup runs on normal completion, on any failed stage, from the SIGTERM handler (scancel), and from the crash monitor when a critical process dies, and it terminates then kills each trackedsrun. Slurm cancels the step, which kills the whole step cgroup inside the container, so forked or daemonized children of the service go with it. - A readiness gate that fails, or a signal that arrives during one, terminates everything the stage already launched before the error propagates.
- A service whose process exits before its readiness port answers fails immediately with its exit code instead of waiting out the readiness timeout.
- The clone step is bounded by the
timeouton each git command (600s each) and the build step bybuild_timeout_seconds; a step that overruns is killed and the job fails with a pointer to its log. - When the batch script exits, Slurm releases the allocation and reaps any remaining step, so even a cleanup path srtctl never reaches cannot leave a service running on a compute node.
Limitations¶
- Declared order is launch order, and
readinessis the only wait. A service that needs another service to be ready polls for it in its owncommand. sourcebuilds are single-node only.- Services run on the sbatch/SLURM path only; there is no local dev mode in 2.0.