mod.rs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. use crate::config::{Cluster, Config, NcclConfig, Node, Recipe, load_recipe, resolve_cluster};
  2. use crate::doctor::try_repaired_image;
  3. use crate::ssh::{run_ssh, run_ssh_follow, run_ssh_with_retry};
  4. use anyhow::{Context, Result};
  5. use tokio::time::{Duration, sleep};
  6. use tracing::info;
  7. /// Launches containers across the cluster for a recipe.
  8. pub async fn serve(
  9. config: &Config,
  10. recipe_name: &str,
  11. cluster_name: &str,
  12. keep: bool,
  13. skip_before_serve: bool,
  14. dry_run: bool,
  15. ) -> Result<()> {
  16. let recipe = load_recipe(recipe_name).await?;
  17. let rn = recipe.name.clone();
  18. let re = recipe.engine.clone();
  19. let rb = recipe.distributed_backend.clone();
  20. info!(
  21. "{}",
  22. fluent_i18n::t!("recipe-starting", { "name" => rn, "engine" => re, "backend" => rb })
  23. );
  24. let (cluster, nccl) = resolve_cluster(config, &recipe, cluster_name)?;
  25. if let Some(required) = recipe.required_nodes {
  26. let actual = cluster.nodes.len() as u32;
  27. if actual != required {
  28. anyhow::bail!(
  29. "Recipe '{}' requires {} nodes, but cluster '{}' has {} nodes",
  30. recipe.name,
  31. required,
  32. cluster.name,
  33. actual
  34. );
  35. }
  36. }
  37. crate::before_serve::run(config, &cluster, &recipe, skip_before_serve, dry_run).await?;
  38. let image = try_repaired_image(config, &cluster, &recipe).await?;
  39. let mut recipe = recipe;
  40. if image != recipe.docker_image {
  41. recipe.docker_image = image.clone();
  42. }
  43. let cache_dir = config.resolved_hf_cache_dir();
  44. start_cluster(
  45. config,
  46. &cluster,
  47. nccl,
  48. recipe_name,
  49. &recipe,
  50. cache_dir,
  51. keep,
  52. dry_run,
  53. )
  54. .await?;
  55. if !dry_run {
  56. info!("{}", fluent_i18n::t!("container-log-header"));
  57. show_logs(config, &cluster, recipe_name, false, dry_run).await?;
  58. }
  59. Ok(())
  60. }
  61. /// Builds the `docker logs` shell command used to fetch the last `tail`
  62. /// lines of the container for `recipe_name` on a specific `rank`.
  63. pub(crate) fn logs_tail_cmd(recipe_name: &str, rank: u32, tail: usize) -> String {
  64. format!(
  65. "docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank={}\" | xargs -r docker logs --timestamps --tail={} 2>&1",
  66. recipe_name, rank, tail
  67. )
  68. }
  69. /// Builds the `docker logs -f` shell command used to follow the head
  70. /// container of `recipe_name` in real time.
  71. pub(crate) fn logs_follow_cmd(recipe_name: &str) -> String {
  72. format!(
  73. "docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank=0\" | xargs -r docker logs -f --timestamps 2>&1",
  74. recipe_name
  75. )
  76. }
  77. /// Shell-quote `s` (single-quote + escape embedded `'` as `'\''`).
  78. /// Used for entrypoint scripts passed to `sh -c` so bash treats them
  79. /// as a single argv element in the docker run command.
  80. fn shq(s: &str) -> String {
  81. let mut out = String::with_capacity(s.len() + 2);
  82. out.push('\'');
  83. for c in s.chars() {
  84. if c == '\'' {
  85. out.push_str(r"'\''");
  86. } else {
  87. out.push(c);
  88. }
  89. }
  90. out.push('\'');
  91. out
  92. }
  93. /// Shows Docker logs for a recipe across all nodes (or follows head node).
  94. pub async fn show_logs(
  95. config: &Config,
  96. cluster: &Cluster,
  97. recipe_name: &str,
  98. follow: bool,
  99. dry_run: bool,
  100. ) -> Result<()> {
  101. if follow {
  102. let head_node = cluster.nodes.first().context("No nodes in cluster")?;
  103. info!(
  104. "{}",
  105. fluent_i18n::t!("logs-follow-head", { "name" => &head_node.name })
  106. );
  107. let cmd = logs_follow_cmd(recipe_name);
  108. run_ssh_follow(config, head_node, cmd, dry_run).await?;
  109. } else {
  110. let cn = cluster.name.clone();
  111. info!(
  112. "{}",
  113. fluent_i18n::t!("logs-header", { "name" => cn, "count" => cluster.nodes.len() })
  114. );
  115. let mut handles = Vec::new();
  116. for node in &cluster.nodes {
  117. let cmd = logs_tail_cmd(recipe_name, node.rank, 100);
  118. let config = config.clone();
  119. let node = node.clone();
  120. let dry = dry_run;
  121. let name = node.name.clone();
  122. handles.push(tokio::spawn(async move {
  123. (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await)
  124. }));
  125. }
  126. for result in futures::future::join_all(handles).await {
  127. if let Ok((name, Ok(output))) = result
  128. && !output.trim().is_empty()
  129. {
  130. println!("{}", fluent_i18n::t!("logs-section", { "name" => &name }));
  131. println!("{}", output);
  132. }
  133. }
  134. }
  135. Ok(())
  136. }
  137. /// Stops and removes containers for a recipe on all nodes.
  138. pub async fn stop_cluster(
  139. config: &Config,
  140. cluster: &Cluster,
  141. recipe_name: &str,
  142. dry_run: bool,
  143. ) -> Result<()> {
  144. info!(
  145. "{}",
  146. fluent_i18n::t!("cluster-stopping", { "name" => &cluster.name })
  147. );
  148. let mut failures = Vec::new();
  149. for node in &cluster.nodes {
  150. let stop_cmd = format!(
  151. "docker ps -q --filter \"label=sm121.recipe={}\" | xargs -r docker stop",
  152. recipe_name
  153. );
  154. if let Err(e) = run_ssh(config, node, stop_cmd, dry_run).await {
  155. tracing::warn!(
  156. "{}",
  157. fluent_i18n::t!("stop-failed", { "name" => &node.name, "error" => e.to_string() })
  158. );
  159. failures.push((node.name.clone(), "stop".to_string(), e));
  160. }
  161. let rm_cmd = format!(
  162. "docker ps -qa --filter \"label=sm121.recipe={}\" | xargs -r docker rm",
  163. recipe_name
  164. );
  165. if let Err(e) = run_ssh(config, node, rm_cmd, dry_run).await {
  166. tracing::warn!(
  167. "{}",
  168. fluent_i18n::t!("remove-failed", { "name" => &node.name, "error" => e.to_string() })
  169. );
  170. failures.push((node.name.clone(), "rm".to_string(), e));
  171. }
  172. }
  173. if !failures.is_empty() {
  174. for (name, action, e) in &failures {
  175. tracing::warn!(
  176. "{}",
  177. fluent_i18n::t!("failed-to", { "action" => action, "name" => name, "error" => e.to_string() })
  178. );
  179. }
  180. anyhow::bail!("Failed to stop containers on {} node(s)", failures.len());
  181. }
  182. info!("{}", fluent_i18n::t!("cluster-all-stopped"));
  183. Ok(())
  184. }
  185. /// Checks Docker health on a cluster — shows all containers and marks ours.
  186. pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) -> Result<()> {
  187. println!("\n=== Кластер: {} ===", cluster.name);
  188. let mut handles = Vec::new();
  189. for node in &cluster.nodes {
  190. let config = config.clone();
  191. let node = node.clone();
  192. let dry = dry_run;
  193. handles.push(tokio::spawn(async move {
  194. // Query all containers + mark sm121-managed ones
  195. let result = run_ssh_with_retry(
  196. &config,
  197. &node,
  198. "docker ps --format '{{.Names}}\t{{.Status}}\t{{.Image}}' 2>&1; echo '---SM121---'; docker ps --filter label=sm121.recipe --format '{{.Names}}' 2>&1"
  199. .to_string(),
  200. dry,
  201. 1,
  202. )
  203. .await;
  204. (node.name, node.ib_ip, result)
  205. }));
  206. }
  207. for result in futures::future::join_all(handles).await {
  208. match result {
  209. Ok((name, ib_ip, Ok(output))) => {
  210. println!("{} ({})", name, ib_ip);
  211. let parts: Vec<&str> = output.splitn(2, "---SM121---").collect();
  212. let all_lines: Vec<&str> = parts
  213. .first()
  214. .map(|s| s.lines().filter(|l| !l.is_empty()).collect())
  215. .unwrap_or_default();
  216. let sm121_names: Vec<&str> = parts
  217. .get(1)
  218. .map(|s| s.lines().filter(|l| !l.is_empty()).collect())
  219. .unwrap_or_default();
  220. if dry_run && all_lines.is_empty() {
  221. continue;
  222. }
  223. if output.starts_with("docker:") || output.contains("permission denied") {
  224. println!(" ✗ Docker недоступен");
  225. if let Some(line) = output.lines().next() {
  226. println!(" └─ {}", line);
  227. }
  228. } else if all_lines.is_empty() {
  229. println!(" (no containers)");
  230. } else {
  231. println!(" Всего контейнеров: {}", all_lines.len());
  232. for line in &all_lines {
  233. let name_col = line.split('\t').next().unwrap_or("");
  234. let tag = if sm121_names.contains(&name_col) {
  235. "sm121"
  236. } else {
  237. "other"
  238. };
  239. println!(" {tag:5} {line}");
  240. }
  241. if sm121_names.is_empty() {
  242. println!(" (нет контейнеров sm121)");
  243. }
  244. }
  245. }
  246. Ok((name, ib_ip, Err(e))) => {
  247. println!("{} ({}): ✗ SSH ошибка: {}", name, ib_ip, e);
  248. }
  249. Err(join_err) => {
  250. println!("✗ Task join error: {}", join_err);
  251. }
  252. }
  253. }
  254. Ok(())
  255. }
  256. /// Starts containers on all nodes in parallel.
  257. #[allow(clippy::too_many_arguments)]
  258. async fn start_cluster(
  259. config: &Config,
  260. cluster: &Cluster,
  261. nccl: &NcclConfig,
  262. recipe_name: &str,
  263. recipe: &Recipe,
  264. cache_dir: std::path::PathBuf,
  265. keep: bool,
  266. dry_run: bool,
  267. ) -> Result<()> {
  268. let nnodes = cluster.nodes.len() as u32;
  269. let pp_size = if recipe.pipeline_parallel_size.unwrap_or(0) == 0 {
  270. nnodes
  271. } else {
  272. recipe.pipeline_parallel_size.unwrap()
  273. };
  274. let mut handles = Vec::new();
  275. for node in &cluster.nodes {
  276. let node = node.clone();
  277. let cluster = cluster.clone();
  278. let nccl = nccl.clone();
  279. let recipe = recipe.clone();
  280. let config = config.clone();
  281. let rn = recipe_name.to_string();
  282. let dry = dry_run;
  283. let engine = recipe.engine.clone();
  284. let is_head = node.rank == 0;
  285. let cleanup = format!(
  286. "docker rm -f sm121_{}_{} 2>/dev/null; true",
  287. recipe_name, node.rank
  288. );
  289. let cd = cache_dir.clone();
  290. let cd_s = cd.to_string_lossy().into_owned();
  291. handles.push(tokio::spawn(async move {
  292. if !dry {
  293. let _ = run_ssh(&config, &node, cleanup, dry).await;
  294. }
  295. let cmd = build_docker_command(
  296. &cluster,
  297. &nccl,
  298. &recipe,
  299. &rn,
  300. &node,
  301. is_head,
  302. pp_size,
  303. nnodes,
  304. &cd_s,
  305. keep,
  306. config.init_port,
  307. )
  308. .join(" ");
  309. let result = run_ssh_with_retry(&config, &node, cmd, dry, 2).await;
  310. (node.name, node.rank, engine, result)
  311. }));
  312. }
  313. for result in futures::future::join_all(handles).await {
  314. match result {
  315. Ok((name, rank, engine, Ok(_))) => info!(
  316. "{}",
  317. fluent_i18n::t!("engine-started", { "engine" => engine.to_uppercase(), "name" => &name, "rank" => rank })
  318. ),
  319. Ok((name, rank, engine, Err(e))) => anyhow::bail!(
  320. "{}",
  321. fluent_i18n::t!("engine-start-failed", { "engine" => engine.to_uppercase(), "name" => &name, "rank" => rank, "error" => e.to_string() })
  322. ),
  323. Err(e) => anyhow::bail!("Task join error: {}", e),
  324. }
  325. }
  326. if !dry_run {
  327. sleep(Duration::from_secs(10)).await;
  328. info!(
  329. "{}",
  330. fluent_i18n::t!("cluster-started", { "ip" => &cluster.master_ib_ip, "port" => recipe.port })
  331. );
  332. }
  333. Ok(())
  334. }
  335. #[allow(clippy::too_many_arguments)]
  336. fn build_docker_command(
  337. cluster: &Cluster,
  338. nccl: &NcclConfig,
  339. recipe: &Recipe,
  340. recipe_name: &str,
  341. node: &Node,
  342. is_head: bool,
  343. pp_size: u32,
  344. nnodes: u32,
  345. cache_dir: &str,
  346. keep: bool,
  347. init_port: u16,
  348. ) -> Vec<String> {
  349. let is_sglang = recipe.engine == "sglang";
  350. let mut flags: Vec<String> = vec![
  351. "--gpus all".to_string(),
  352. "--ipc=host".to_string(),
  353. "--privileged".to_string(),
  354. "--network=host".to_string(),
  355. "--ulimit memlock=-1".to_string(),
  356. "--ulimit stack=67108864".to_string(),
  357. ];
  358. if !recipe.shm_size.is_empty() {
  359. flags.push(format!("--shm-size {}", recipe.shm_size.as_str()));
  360. }
  361. if !keep {
  362. flags.push("--rm".to_string());
  363. }
  364. let mut cmd = vec![
  365. format!("docker run -d {}", flags.join(" ")),
  366. format!("-e NCCL_IB_DISABLE={}", nccl.ib_disable),
  367. format!("-e NCCL_IB_HCA={}", nccl.ib_hca),
  368. format!("-e NCCL_SOCKET_IFNAME={}", nccl.socket_ifname),
  369. format!("-e GLOO_SOCKET_IFNAME={}", nccl.socket_ifname),
  370. format!("-e TP_SOCKET_IFNAME={}", nccl.socket_ifname),
  371. format!("-e NCCL_IB_GID_INDEX={}", nccl.ib_gid_index),
  372. format!("-e HF_HOME=/root/.cache/huggingface"),
  373. format!("-v {}:/root/.cache/huggingface", cache_dir),
  374. ];
  375. // Engine-specific host IP env var
  376. if is_sglang {
  377. cmd.push(format!("-e SGLANG_WORKER_IP={}", node.ib_ip));
  378. } else {
  379. cmd.push(format!("-e VLLM_HOST_IP={}", node.ib_ip));
  380. }
  381. // Network interface env vars from cluster NCCL config
  382. cmd.push(format!("-e MN_IF_NAME={}", nccl.socket_ifname));
  383. cmd.push(format!("-e UCX_NET_DEVICES={}", nccl.socket_ifname));
  384. cmd.push(format!(
  385. "-e OMPI_MCA_btl_tcp_if_include={}",
  386. nccl.socket_ifname
  387. ));
  388. // Default cluster env (recipe env below overrides these if duplicated)
  389. cmd.push("-e OMP_NUM_THREADS=4".to_string());
  390. cmd.push("-e NCCL_IGNORE_CPU_AFFINITY=1".to_string());
  391. cmd.push("-e NCCL_NET=IB".to_string());
  392. cmd.push("-e NCCL_DEBUG=WARN".to_string());
  393. // Force colored vLLM logs through Docker pipeline
  394. if is_head {
  395. cmd.push("-e VLLM_LOGGING_COLOR=1".to_string());
  396. cmd.push("-e TERM=xterm-256color".to_string());
  397. }
  398. for (k, v) in &recipe.env {
  399. cmd.push(format!("-e {}={}", k, v));
  400. }
  401. if is_head {
  402. cmd.push(format!("-p {}:{}", recipe.port, recipe.port));
  403. }
  404. cmd.push(format!("--name sm121_{}_{}", recipe_name, node.rank));
  405. cmd.push(format!("--label sm121.recipe={}", recipe_name));
  406. cmd.push(format!("--label sm121.rank={}", node.rank));
  407. if recipe.distributed_backend == "ray" {
  408. cmd.push(format!("-e RAY_ADDRESS={}:6379", cluster.master_ib_ip));
  409. cmd.push(format!("-e RAY_PORT={}", recipe.port));
  410. cmd.push(format!("-e RAY_NODE_IP_ADDRESS={}", node.ib_ip));
  411. cmd.push(format!("-e RAY_OVERRIDE_NODE_IP_ADDRESS={}", node.ib_ip));
  412. }
  413. cmd.push(recipe.docker_image.clone());
  414. if !recipe.entrypoint.is_empty() {
  415. let mut ep = recipe.entrypoint.clone();
  416. // If the entrypoint is `sh -c SCRIPT`, shell-quote the script so
  417. // bash on the SSH server treats it as one argv element to docker.
  418. // Without this, bash word-splits the script and sh -c only sees
  419. // the first token, then the container exits immediately.
  420. if ep.len() >= 3 && ep[ep.len() - 2] == "-c" {
  421. let last = ep.len() - 1;
  422. ep[last] = shq(&ep[last]);
  423. }
  424. cmd.extend(ep);
  425. }
  426. // Model argument - engine-specific
  427. if is_sglang {
  428. cmd.push("--model-path".to_string());
  429. cmd.push(recipe.model.clone());
  430. } else {
  431. cmd.push(recipe.model.clone());
  432. }
  433. cmd.extend(recipe.args.clone());
  434. // Distributed flags - engine-specific
  435. let mut flags: Vec<String> = match recipe.distributed_backend.as_str() {
  436. "ray" | "mp" if is_sglang => vec![
  437. "--tp".into(),
  438. recipe.tensor_parallel_size.to_string(),
  439. "--nnodes".into(),
  440. nnodes.to_string(),
  441. "--node-rank".into(),
  442. node.rank.to_string(),
  443. "--dist-init-addr".into(),
  444. format!("{}:{}", cluster.master_ib_ip, init_port),
  445. ],
  446. "ray" | "mp" => {
  447. let mut fp = vec![
  448. "--tensor-parallel-size".into(),
  449. recipe.tensor_parallel_size.to_string(),
  450. "--pipeline-parallel-size".into(),
  451. pp_size.to_string(),
  452. "--nnodes".into(),
  453. nnodes.to_string(),
  454. "--node-rank".into(),
  455. node.rank.to_string(),
  456. "--master-addr".into(),
  457. cluster.master_ib_ip.clone(),
  458. "--master-port".into(),
  459. init_port.to_string(),
  460. ];
  461. // vLLM auto-detects mp; pass explicitly for ray only.
  462. if recipe.distributed_backend == "ray" {
  463. fp.push("--distributed-executor-backend".into());
  464. fp.push(recipe.distributed_backend.clone());
  465. }
  466. fp
  467. }
  468. _ => vec![],
  469. };
  470. // vLLM workers run headless (skip API server) when not rank 0
  471. if !is_sglang && node.rank > 0 && matches!(recipe.distributed_backend.as_str(), "mp" | "ray") {
  472. flags.push("--headless".into());
  473. }
  474. cmd.extend(flags);
  475. // Host + port flags — auto-generated for both engines.
  476. cmd.push("--host".to_string());
  477. cmd.push("0.0.0.0".to_string());
  478. cmd.push("--port".to_string());
  479. cmd.push(recipe.port.to_string());
  480. cmd
  481. }
  482. #[cfg(test)]
  483. mod tests;