command.rs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402
  1. //! Assembly of the `docker run` command per node — flags, env vars, mounts,
  2. //! and the vLLM/SGLang tail (engine flags + distributed rendezvous).
  3. //!
  4. //! [`build_docker_command`] is the single entry point; everything else here is
  5. //! private to this module.
  6. use super::{mods, shq, tail};
  7. use crate::config::{Cluster, Config, NcclConfig, Node, Recipe};
  8. use crate::ssh::run_ssh;
  9. use anyhow::Result;
  10. use std::borrow::Cow;
  11. use tracing::info;
  12. /// Shared context for [`build_docker_command`] — constructed once, cloned per node.
  13. #[derive(Clone)]
  14. pub(crate) struct DockerCommandCtx {
  15. pub cluster: Cluster,
  16. /// NCCL config — `None` for single-node recipes (no NCCL env vars
  17. /// are emitted). For multi-node, always `Some`.
  18. pub nccl: Option<NcclConfig>,
  19. pub recipe: Recipe,
  20. pub recipe_name: String,
  21. pub pp_size: u32,
  22. pub tp_size: u32,
  23. pub nnodes: u32,
  24. pub cache_dir: String,
  25. pub keep: bool,
  26. pub init_port: u16,
  27. pub mods_remote_path: String,
  28. /// Static `extra_mounts` (alias/host) with host paths already resolved
  29. /// once against `Config.mount_aliases` / `ssh_user`. Path references
  30. /// (`repo`/`subfolder`) are NOT here — `start_cluster` resolves those
  31. /// per-node and substitutes `{{<name>}}` into the recipe args/env before
  32. /// calling [`build_docker_command`].
  33. pub extra_mounts: Vec<ResolvedMount>,
  34. }
  35. /// A recipe `ExtraMount` after host-path resolution (alias + tilde), ready to
  36. /// turn into a `-v` flag / `-e` env pair.
  37. #[derive(Clone)]
  38. pub(crate) struct ResolvedMount {
  39. pub host: String,
  40. pub container: String,
  41. pub read_only: bool,
  42. pub env: Option<String>,
  43. }
  44. /// A model path reference (`repo`+`subfolder`) before per-node resolution.
  45. /// At serve time, the launcher resolves the path on each node (reading
  46. /// `refs/main`) and substitutes `{{<name>}}` in the recipe args/env with the
  47. /// resolved full HF cache path. The HF cache is already mounted at
  48. /// `/root/.cache/huggingface`, so the resolved path's symlinks resolve
  49. /// correctly (a separate bind-mount would break them).
  50. #[derive(Clone)]
  51. pub(crate) struct PathRef {
  52. pub repo: String,
  53. pub subfolder: Option<String>,
  54. pub name: String,
  55. }
  56. /// Partition a recipe's `extra_mounts` into static (resolved now) and
  57. /// path references (deferred to per-node resolution + substitution).
  58. /// Validates that a mount is one or the other — `repo` set means
  59. /// `alias`/`host` must be empty, and `subfolder` without `repo` is an error.
  60. pub(crate) fn resolve_extra_mounts(
  61. config: &Config,
  62. recipe: &Recipe,
  63. ) -> Result<ResolvedExtraMounts> {
  64. let mut static_mounts = Vec::new();
  65. let mut path_refs = Vec::new();
  66. for m in &recipe.extra_mounts {
  67. if m.repo.as_deref().is_some_and(|r| !r.trim().is_empty()) {
  68. if m.alias.is_some() || m.host.is_some() {
  69. anyhow::bail!(
  70. "extra_mount '{}': `repo` is mutually exclusive with `alias`/`host`",
  71. m.container
  72. );
  73. }
  74. path_refs.push(PathRef {
  75. repo: m.repo.as_ref().unwrap().trim().to_string(),
  76. subfolder: m
  77. .subfolder
  78. .as_deref()
  79. .filter(|s| !s.trim().is_empty())
  80. .map(|s| s.trim().to_string()),
  81. name: m.container.clone(),
  82. });
  83. } else if m.subfolder.as_deref().is_some_and(|s| !s.trim().is_empty()) {
  84. anyhow::bail!("extra_mount '{}': `subfolder` requires `repo`", m.container);
  85. } else {
  86. let host = crate::config::resolve_mount_host(
  87. m.alias.as_deref(),
  88. m.host.as_deref(),
  89. &config.mount_aliases,
  90. &config.ssh_user,
  91. &m.container,
  92. )?;
  93. static_mounts.push(ResolvedMount {
  94. host,
  95. container: m.container.clone(),
  96. read_only: m.read_only,
  97. env: m.env.clone(),
  98. });
  99. }
  100. }
  101. Ok(ResolvedExtraMounts {
  102. static_mounts,
  103. path_refs,
  104. })
  105. }
  106. /// Result of [`resolve_extra_mounts`]: static mounts ready to clone per node,
  107. /// path references deferred to per-node resolution + substitution.
  108. pub(crate) struct ResolvedExtraMounts {
  109. pub static_mounts: Vec<ResolvedMount>,
  110. pub path_refs: Vec<PathRef>,
  111. }
  112. /// `--clear`: delete the host-side static mount dirs on the launch nodes
  113. /// before containers start. Containers run as root, so caches there are
  114. /// usually root-owned → `sudo rm -rf`. Dry run prints the exact command
  115. /// (via [`run_ssh`]) and runs nothing. A failure on any node aborts the
  116. /// serve — never start against a stale cache. Repo-based path refs are
  117. /// never cleared (they point into the HF cache).
  118. pub(crate) async fn clear_extra_mounts(
  119. config: &Config,
  120. nodes: &[&Node],
  121. resolved: &ResolvedExtraMounts,
  122. clear: bool,
  123. dry_run: bool,
  124. ) -> Result<()> {
  125. if !clear {
  126. return Ok(());
  127. }
  128. let paths = clear_paths(&resolved.static_mounts);
  129. if paths.is_empty() {
  130. info!("--clear: no host-side extra_mounts to clear");
  131. return Ok(());
  132. }
  133. info!(
  134. "--clear: clearing {} host extra_mount dir(s) on {} launch node(s)",
  135. paths.len(),
  136. nodes.len()
  137. );
  138. for node in nodes {
  139. for p in &paths {
  140. let cmd = format!("sudo rm -rf {}", shq(p));
  141. run_ssh(config, node, cmd, dry_run).await?;
  142. }
  143. }
  144. Ok(())
  145. }
  146. /// Dedup + sort the static mount host paths (the set `--clear` deletes).
  147. pub(crate) fn clear_paths(mounts: &[ResolvedMount]) -> Vec<String> {
  148. let mut paths: Vec<String> = mounts.iter().map(|m| m.host.clone()).collect();
  149. paths.sort();
  150. paths.dedup();
  151. paths
  152. }
  153. /// Substitute `{{<name>}}` placeholders in `args` and `env` with resolved paths.
  154. /// `path_refs` maps placeholder name → resolved path (per-node). Done before
  155. /// building the docker command so vLLM args reference the correct snapshot.
  156. pub(crate) fn substitute_path_refs(recipe: &Recipe, path_refs: &[(String, String)]) -> Recipe {
  157. let mut r = recipe.clone();
  158. for (name, path) in path_refs {
  159. let placeholder = format!("{{{{{}}}}}", name);
  160. if path == name {
  161. // Dry-run: the placeholder survives (no resolved path yet).
  162. continue;
  163. }
  164. r.args = r.args.substitute(&placeholder, path);
  165. for v in r.env.values_mut() {
  166. // Ref entries reference recipe fields — nothing to interpolate
  167. // (they resolve at serve time via `resolved_env`).
  168. if let crate::config::EnvValue::Plain(s) = v {
  169. *s = s.replace(&placeholder, path);
  170. }
  171. }
  172. }
  173. r
  174. }
  175. /// Collect NCCL cluster env vars for multi-node deployments.
  176. ///
  177. /// `ctx.nccl` is `None` for single-node recipes — but this function is
  178. /// only called when `ctx.nnodes > 1`, so `None` indicates a configuration
  179. /// error (multi-node without NCCL config). In that case, only the worker
  180. /// IP env var is emitted (NCCL env vars are skipped), which will cause
  181. /// NCCL to fail at runtime with a visible error.
  182. fn collect_nccl_env<'a>(ctx: &'a DockerCommandCtx, node: &'a Node) -> Vec<(&'a str, Cow<'a, str>)> {
  183. let is_sglang = ctx.recipe.engine == "sglang";
  184. let mut env: Vec<(&str, Cow<str>)> = Vec::new();
  185. if let Some(nccl) = ctx.nccl.as_ref() {
  186. let sock = nccl.socket_ifname.as_str();
  187. let gloo_if = nccl.gloo_socket_ifname.as_deref().unwrap_or(sock);
  188. let ucx_dev = nccl.ucx_net_devices.as_deref().unwrap_or(sock);
  189. env.extend(vec![
  190. ("NCCL_IB_DISABLE", format!("{}", nccl.ib_disable).into()),
  191. ("NCCL_IB_HCA", nccl.ib_hca.as_str().into()),
  192. ("NCCL_SOCKET_IFNAME", sock.into()),
  193. ("GLOO_SOCKET_IFNAME", gloo_if.into()),
  194. ("TP_SOCKET_IFNAME", gloo_if.into()),
  195. (
  196. "NCCL_IB_GID_INDEX",
  197. nccl.ib_gid_index
  198. .map_or_else(|| Cow::Borrowed("auto"), |g| g.to_string().into()),
  199. ),
  200. ("MN_IF_NAME", gloo_if.into()),
  201. ("UCX_NET_DEVICES", ucx_dev.into()),
  202. ("OMPI_MCA_btl_tcp_if_include", gloo_if.into()),
  203. ("OMP_NUM_THREADS", "4".into()),
  204. ("NCCL_IGNORE_CPU_AFFINITY", "1".into()),
  205. ("NCCL_NET", "IB".into()),
  206. ("NCCL_DEBUG", "WARN".into()),
  207. ]);
  208. if let Some(v) = nccl.nccl_cross_nic {
  209. env.push(("NCCL_CROSS_NIC", v.to_string().into()));
  210. }
  211. if let Some(ref v) = nccl.nccl_proto {
  212. env.push(("NCCL_PROTO", v.as_str().into()));
  213. }
  214. if let Some(ref v) = nccl.nccl_p2p_level {
  215. env.push(("NCCL_P2P_LEVEL", v.as_str().into()));
  216. }
  217. }
  218. // Worker IP is always set for multi-node (collect_nccl_env is only
  219. // called when nnodes > 1).
  220. let worker_ip = Cow::Borrowed(node.effective_ib_ip());
  221. if is_sglang {
  222. env.push(("SGLANG_WORKER_IP", worker_ip));
  223. } else {
  224. env.push(("VLLM_HOST_IP", worker_ip));
  225. }
  226. // Per-node rendezvous env: entrypoint-driven images (e.g. aiden's spark
  227. // launchers) read NODE_RANK/MASTER_ADDR from the environment instead of
  228. // CLI flags. Inert for stock vllm/sglang (they use the CLI flags).
  229. env.push(("NODE_RANK", node.rank.to_string().into()));
  230. env.push(("MASTER_ADDR", ctx.cluster.effective_master_ip().into()));
  231. env
  232. }
  233. /// Build the list of `-e KEY=VALUE` Docker env var pairs for a node.
  234. fn collect_docker_env(ctx: &DockerCommandCtx, node: &Node, is_head: bool) -> Result<Vec<String>> {
  235. let mut pairs: Vec<(&str, Cow<str>)> =
  236. vec![("HF_HOME", Cow::Borrowed("/root/.cache/huggingface"))];
  237. if ctx.nnodes > 1 {
  238. pairs.extend(collect_nccl_env(ctx, node));
  239. }
  240. if is_head {
  241. pairs.push(("VLLM_LOGGING_COLOR", "1".into()));
  242. pairs.push(("TERM", "xterm-256color".into()));
  243. }
  244. if ctx.recipe.distributed_backend == Some(crate::config::DistributedBackend::Ray) {
  245. pairs.push((
  246. "RAY_ADDRESS",
  247. format!("{}:6379", ctx.cluster.effective_master_ip()).into(),
  248. ));
  249. pairs.push(("RAY_PORT", "6379".into()));
  250. pairs.push(("RAY_NODE_IP_ADDRESS", node.effective_ib_ip().into()));
  251. pairs.push((
  252. "RAY_OVERRIDE_NODE_IP_ADDRESS",
  253. node.effective_ib_ip().into(),
  254. ));
  255. }
  256. // extra_mount `env` defaults (point at the container path) are injected
  257. // before recipe [env] so the recipe can still override them (last wins).
  258. for m in &ctx.extra_mounts {
  259. if let Some(name) = &m.env {
  260. pairs.push((name.as_str(), m.container.as_str().into()));
  261. }
  262. }
  263. let recipe_env = ctx.recipe.resolved_env()?;
  264. for (k, v) in &recipe_env {
  265. pairs.push((k.as_str(), v.as_str().into()));
  266. }
  267. // Quote the whole `K=V` so a recipe `[env]` value or name containing
  268. // shell metacharacters cannot inject into the remote `docker run` shell.
  269. Ok(pairs
  270. .into_iter()
  271. .map(|(k, v)| format!("-e {}", shq(&format!("{k}={v}"))))
  272. .collect())
  273. }
  274. /// Build the list of Docker run flags (--gpus, --ipc, --ulimit, etc.).
  275. fn build_docker_flags(ctx: &DockerCommandCtx) -> Vec<String> {
  276. let mut flags: Vec<String> = vec![
  277. "--gpus all".to_string(),
  278. "--ipc=host".to_string(),
  279. "--privileged".to_string(),
  280. "--network=host".to_string(),
  281. "--init".to_string(),
  282. "--ulimit memlock=-1".to_string(),
  283. "--ulimit stack=67108864".to_string(),
  284. "--ulimit nofile=1048576".to_string(),
  285. ];
  286. if !ctx.recipe.shm_size.is_empty() {
  287. flags.push(format!("--shm-size {}", ctx.recipe.shm_size.as_str()));
  288. }
  289. for device in &ctx.recipe.devices {
  290. flags.push(format!("--device {}:{}", device, device));
  291. }
  292. if let Some(mem) = &ctx.recipe.memory_limit {
  293. flags.push(format!("--memory {}", mem));
  294. // Same value for memory-swap = no swap allowed (some runtimes
  295. // hard-guard on the 9GiB/no-swap serving cgroup).
  296. flags.push(format!("--memory-swap {}", mem));
  297. }
  298. if let Some(cpus) = ctx.recipe.cpus {
  299. flags.push(format!("--cpus {}", cpus));
  300. }
  301. if !ctx.keep {
  302. flags.push("--rm".to_string());
  303. }
  304. flags
  305. }
  306. /// Build the full Docker command line for a node.
  307. pub(crate) fn build_docker_command(
  308. ctx: &DockerCommandCtx,
  309. node: &Node,
  310. is_head: bool,
  311. ) -> Result<Vec<String>> {
  312. let flags = build_docker_flags(ctx);
  313. let mut cmd = vec![
  314. format!("docker run -d {}", flags.join(" ")),
  315. format!("-v {}:/root/.cache/huggingface", shq(&ctx.cache_dir)),
  316. ];
  317. if !ctx.mods_remote_path.is_empty() && !ctx.recipe.mods.commands.is_empty() {
  318. cmd.push(format!("-v {}:/mods:ro", shq(&ctx.mods_remote_path)));
  319. }
  320. for m in &ctx.extra_mounts {
  321. // Adjacent quoted words concatenate into one shell arg, so
  322. // `-v 'host':\'container':ro` is a single `-v host:container:ro`.
  323. let mut spec = format!("-v {}:{}", shq(&m.host), shq(&m.container));
  324. if m.read_only {
  325. spec.push_str(":ro");
  326. }
  327. cmd.push(spec);
  328. }
  329. cmd.extend(collect_docker_env(ctx, node, is_head)?);
  330. cmd.push(format!(
  331. "--name {}",
  332. shq(&format!("sm121_{}_{}", ctx.recipe_name, node.rank))
  333. ));
  334. cmd.push(format!(
  335. "--label {}",
  336. shq(&format!("sm121.recipe={}", ctx.recipe_name))
  337. ));
  338. cmd.push(format!(
  339. "--label {}",
  340. shq(&format!("sm121.rank={}", node.rank))
  341. ));
  342. cmd.push(format!(
  343. "--label {}",
  344. shq(&format!("sm121.cluster={}", ctx.cluster.name))
  345. ));
  346. // Add solo-node label for single-node deployments
  347. if ctx.cluster.nodes.len() == 1 && ctx.cluster.extra_entities.is_empty() {
  348. cmd.push(format!(
  349. "--label {}",
  350. shq(&format!("sm121.solo-node={}", node.name))
  351. ));
  352. }
  353. let master_ip = ctx.cluster.effective_master_ip();
  354. let tctx = tail::TailCtx {
  355. recipe: &ctx.recipe,
  356. tp_size: ctx.tp_size,
  357. pp_size: ctx.pp_size,
  358. nnodes: ctx.nnodes,
  359. init_port: ctx.init_port,
  360. master_ib_ip: &master_ip,
  361. };
  362. let tail = tail::build_tail(&tctx, node);
  363. let img = shq(&ctx.recipe.docker_image);
  364. if ctx.recipe.mods.commands.is_empty() {
  365. // `--entrypoint <first tail token>`: for images without an ENTRYPOINT
  366. // this is exactly the previous behavior (the tail runs as-is); for
  367. // images with their own ENTRYPOINT (e.g. the ds41 runtime's default
  368. // OpenAI api_server) it overrides it with the recipe's command.
  369. // The remaining tail is the entrypoint's argv (no repetition of the
  370. // first token — docker appends the command after the entrypoint).
  371. if let Some((first, rest)) = tail.split_first() {
  372. cmd.push(format!("--entrypoint {}", shq(first)));
  373. cmd.push(img);
  374. cmd.extend(rest.iter().map(|t| shq(t)));
  375. } else {
  376. cmd.push(img);
  377. }
  378. } else {
  379. let script = mods::build_mod_script(&ctx.recipe.mods.commands, &tail);
  380. // `--entrypoint sh` and `-c` stay literal; `img` and `script` are
  381. // shell-quoted so they survive the remote shell as one argv each.
  382. cmd.extend(["--entrypoint sh".into(), img, "-c".into(), script]);
  383. }
  384. Ok(cmd)
  385. }
  386. #[cfg(test)]
  387. #[path = "command_tests.rs"]
  388. mod tests;