mod.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. use anyhow::{Context, Result};
  2. use tokio::time::{Duration, sleep};
  3. use tracing::info;
  4. use crate::config::*;
  5. use crate::doctor::try_repaired_image;
  6. use crate::ssh::*;
  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. dry_run: bool,
  14. ) -> Result<()> {
  15. let recipe = load_recipe(recipe_name).await?;
  16. info!(
  17. "🚀 Запуск рецепта: {} ({}) [backend: {}]",
  18. recipe.name, recipe.engine, recipe.distributed_backend
  19. );
  20. let (cluster, nccl) = resolve_cluster(config, &recipe, cluster_name)?;
  21. if let Some(required) = recipe.required_nodes {
  22. let actual = cluster.nodes.len() as u32;
  23. if actual != required {
  24. anyhow::bail!(
  25. "Recipe '{}' requires {} nodes, but cluster '{}' has {} nodes",
  26. recipe.name,
  27. required,
  28. cluster.name,
  29. actual
  30. );
  31. }
  32. }
  33. let image = try_repaired_image(config, &cluster, &recipe).await?;
  34. let mut recipe = recipe;
  35. if image != recipe.docker_image {
  36. recipe.docker_image = image.clone();
  37. }
  38. let cache_dir = config.resolved_hf_cache_dir();
  39. start_cluster(
  40. config,
  41. &cluster,
  42. nccl,
  43. recipe_name,
  44. &recipe,
  45. cache_dir,
  46. keep,
  47. dry_run,
  48. )
  49. .await?;
  50. if !dry_run {
  51. info!("\n📜 Последние логи контейнеров:");
  52. show_logs(config, &cluster, recipe_name, false, dry_run).await?;
  53. }
  54. Ok(())
  55. }
  56. /// Builds the `docker logs` shell command used to fetch the last `tail`
  57. /// lines of the container for `recipe_name` on a specific `rank`.
  58. pub(crate) fn logs_tail_cmd(recipe_name: &str, rank: u32, tail: usize) -> String {
  59. format!(
  60. "docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank={}\" | xargs -r docker logs --timestamps --tail={} 2>&1",
  61. recipe_name, rank, tail
  62. )
  63. }
  64. /// Builds the `docker logs -f` shell command used to follow the head
  65. /// container of `recipe_name` in real time.
  66. pub(crate) fn logs_follow_cmd(recipe_name: &str) -> String {
  67. format!(
  68. "docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank=0\" | xargs -r docker logs -f --timestamps",
  69. recipe_name
  70. )
  71. }
  72. /// Shows Docker logs for a recipe across all nodes (or follows head node).
  73. pub async fn show_logs(
  74. config: &Config,
  75. cluster: &Cluster,
  76. recipe_name: &str,
  77. follow: bool,
  78. dry_run: bool,
  79. ) -> Result<()> {
  80. if follow {
  81. let head_node = cluster.nodes.first().context("No nodes in cluster")?;
  82. info!("📜 Following logs for '{}' (head node)...", head_node.name);
  83. let cmd = logs_follow_cmd(recipe_name);
  84. run_ssh_follow(config, head_node, cmd, dry_run).await?;
  85. } else {
  86. info!(
  87. "📜 Логи кластера '{}' ({} узлов):",
  88. cluster.name,
  89. cluster.nodes.len()
  90. );
  91. let mut handles = Vec::new();
  92. for node in &cluster.nodes {
  93. let cmd = logs_tail_cmd(recipe_name, node.rank, 100);
  94. let config = config.clone();
  95. let node = node.clone();
  96. let dry = dry_run;
  97. let name = node.name.clone();
  98. handles.push(tokio::spawn(async move {
  99. (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await)
  100. }));
  101. }
  102. for result in futures::future::join_all(handles).await {
  103. if let Ok((name, Ok(output))) = result
  104. && !output.trim().is_empty()
  105. {
  106. println!("\n--- {} ---", name);
  107. println!("{}", output);
  108. }
  109. }
  110. }
  111. Ok(())
  112. }
  113. /// Stops and removes containers for a recipe on all nodes.
  114. pub async fn stop_cluster(
  115. config: &Config,
  116. cluster: &Cluster,
  117. recipe_name: &str,
  118. dry_run: bool,
  119. ) -> Result<()> {
  120. info!("🛑 Останавливаем кластер '{}'...", cluster.name);
  121. let mut failures = Vec::new();
  122. for node in &cluster.nodes {
  123. let stop_cmd = format!(
  124. "docker ps -q --filter \"label=sm121.recipe={}\" | xargs -r docker stop",
  125. recipe_name
  126. );
  127. if let Err(e) = run_ssh(config, node, stop_cmd, dry_run).await {
  128. tracing::warn!("Stop failed on {}: {}", node.name, e);
  129. failures.push((node.name.clone(), "stop".to_string(), e));
  130. }
  131. let rm_cmd = format!(
  132. "docker ps -qa --filter \"label=sm121.recipe={}\" | xargs -r docker rm",
  133. recipe_name
  134. );
  135. if let Err(e) = run_ssh(config, node, rm_cmd, dry_run).await {
  136. tracing::warn!("Remove failed on {}: {}", node.name, e);
  137. failures.push((node.name.clone(), "rm".to_string(), e));
  138. }
  139. }
  140. if !failures.is_empty() {
  141. for (name, action, e) in &failures {
  142. tracing::warn!("Failed to {} on {}: {}", action, name, e);
  143. }
  144. anyhow::bail!("Failed to stop containers on {} node(s)", failures.len());
  145. }
  146. info!("✅ Все контейнеры остановлены.");
  147. Ok(())
  148. }
  149. /// Checks Docker health on a cluster — shows all containers and marks ours.
  150. pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) -> Result<()> {
  151. println!("\n=== Кластер: {} ===", cluster.name);
  152. let mut handles = Vec::new();
  153. for node in &cluster.nodes {
  154. let config = config.clone();
  155. let node = node.clone();
  156. let dry = dry_run;
  157. handles.push(tokio::spawn(async move {
  158. // Query all containers + mark sm121-managed ones
  159. let result = run_ssh_with_retry(
  160. &config,
  161. &node,
  162. "docker ps --format '{{.Names}}\t{{.Status}}\t{{.Image}}' 2>&1; echo '---SM121---'; docker ps --filter label=sm121.recipe --format '{{.Names}}' 2>&1"
  163. .to_string(),
  164. dry,
  165. 1,
  166. )
  167. .await;
  168. (node.name, node.ib_ip, result)
  169. }));
  170. }
  171. for result in futures::future::join_all(handles).await {
  172. match result {
  173. Ok((name, ib_ip, Ok(output))) => {
  174. println!("{} ({})", name, ib_ip);
  175. let parts: Vec<&str> = output.splitn(2, "---SM121---").collect();
  176. let all_lines: Vec<&str> = parts
  177. .first()
  178. .map(|s| s.lines().filter(|l| !l.is_empty()).collect())
  179. .unwrap_or_default();
  180. let sm121_names: Vec<&str> = parts
  181. .get(1)
  182. .map(|s| s.lines().filter(|l| !l.is_empty()).collect())
  183. .unwrap_or_default();
  184. if dry_run && all_lines.is_empty() {
  185. continue;
  186. }
  187. if output.starts_with("docker:") || output.contains("permission denied") {
  188. println!(" ✗ Docker недоступен");
  189. if let Some(line) = output.lines().next() {
  190. println!(" └─ {}", line);
  191. }
  192. } else if all_lines.is_empty() {
  193. println!(" (no containers)");
  194. } else {
  195. println!(" Всего контейнеров: {}", all_lines.len());
  196. for line in &all_lines {
  197. let name_col = line.split('\t').next().unwrap_or("");
  198. let tag = if sm121_names.contains(&name_col) {
  199. "sm121"
  200. } else {
  201. "other"
  202. };
  203. println!(" {tag:5} {line}");
  204. }
  205. if sm121_names.is_empty() {
  206. println!(" (нет контейнеров sm121)");
  207. }
  208. }
  209. }
  210. Ok((name, ib_ip, Err(e))) => {
  211. println!("{} ({}): ✗ SSH ошибка: {}", name, ib_ip, e);
  212. }
  213. Err(join_err) => {
  214. println!("✗ Task join error: {}", join_err);
  215. }
  216. }
  217. }
  218. Ok(())
  219. }
  220. /// Starts containers on all nodes in parallel.
  221. #[allow(clippy::too_many_arguments)]
  222. async fn start_cluster(
  223. config: &Config,
  224. cluster: &Cluster,
  225. nccl: &NcclConfig,
  226. recipe_name: &str,
  227. recipe: &Recipe,
  228. cache_dir: String,
  229. keep: bool,
  230. dry_run: bool,
  231. ) -> Result<()> {
  232. let nnodes = cluster.nodes.len() as u32;
  233. let pp_size = if recipe.pipeline_parallel_size.unwrap_or(0) == 0 {
  234. nnodes
  235. } else {
  236. recipe.pipeline_parallel_size.unwrap()
  237. };
  238. let mut handles = Vec::new();
  239. for node in &cluster.nodes {
  240. let node = node.clone();
  241. let cluster = cluster.clone();
  242. let nccl = nccl.clone();
  243. let recipe = recipe.clone();
  244. let config = config.clone();
  245. let rn = recipe_name.to_string();
  246. let dry = dry_run;
  247. let engine = recipe.engine.clone();
  248. let is_head = node.rank == 0;
  249. let cleanup = format!(
  250. "docker rm -f sm121_{}_{} 2>/dev/null; true",
  251. recipe_name, node.rank
  252. );
  253. let cd = cache_dir.clone();
  254. handles.push(tokio::spawn(async move {
  255. if !dry {
  256. let _ = run_ssh(&config, &node, cleanup, dry).await;
  257. }
  258. let cmd = build_docker_command(
  259. &cluster,
  260. &nccl,
  261. &recipe,
  262. &rn,
  263. &node,
  264. is_head,
  265. pp_size,
  266. nnodes,
  267. &cd,
  268. keep,
  269. config.init_port,
  270. )
  271. .join(" ");
  272. let result = run_ssh_with_retry(&config, &node, cmd, dry, 2).await;
  273. (node.name, node.rank, engine, result)
  274. }));
  275. }
  276. for result in futures::future::join_all(handles).await {
  277. match result {
  278. Ok((name, rank, engine, Ok(_))) => info!(
  279. "✅ {} запущен на {} (rank {})",
  280. engine.to_uppercase(),
  281. name,
  282. rank
  283. ),
  284. Ok((name, rank, engine, Err(e))) => anyhow::bail!(
  285. "Failed to start {} on {} (rank {}): {}",
  286. engine.to_uppercase(),
  287. name,
  288. rank,
  289. e
  290. ),
  291. Err(e) => anyhow::bail!("Task join error: {}", e),
  292. }
  293. }
  294. if !dry_run {
  295. sleep(Duration::from_secs(10)).await;
  296. info!(
  297. "🎉 Кластер успешно запущен! API → http://{}:{}",
  298. cluster.master_ib_ip, recipe.port
  299. );
  300. }
  301. Ok(())
  302. }
  303. #[allow(clippy::too_many_arguments)]
  304. fn build_docker_command(
  305. cluster: &Cluster,
  306. nccl: &NcclConfig,
  307. recipe: &Recipe,
  308. recipe_name: &str,
  309. node: &Node,
  310. is_head: bool,
  311. pp_size: u32,
  312. nnodes: u32,
  313. cache_dir: &str,
  314. keep: bool,
  315. init_port: u16,
  316. ) -> Vec<String> {
  317. let is_sglang = recipe.engine == "sglang";
  318. let mut flags = vec![
  319. "--gpus all",
  320. "--ipc=host",
  321. "--privileged",
  322. "--network=host",
  323. "--shm-size 10g",
  324. "--ulimit memlock=-1",
  325. "--ulimit stack=67108864",
  326. ];
  327. if !keep {
  328. flags.push("--rm");
  329. }
  330. let mut cmd = vec![
  331. format!("docker run -d {}", flags.join(" ")),
  332. format!("-e NCCL_IB_DISABLE={}", nccl.ib_disable),
  333. format!("-e NCCL_IB_HCA={}", nccl.ib_hca),
  334. format!("-e NCCL_SOCKET_IFNAME={}", nccl.socket_ifname),
  335. format!("-e NCCL_IB_GID_INDEX={}", nccl.ib_gid_index),
  336. format!("-e HF_HOME=/cache/huggingface"),
  337. format!("-v {}:/cache/huggingface", cache_dir),
  338. ];
  339. // Engine-specific host IP env var
  340. if is_sglang {
  341. cmd.push(format!("-e SGLANG_WORKER_IP={}", node.ib_ip));
  342. } else {
  343. cmd.push(format!("-e VLLM_HOST_IP={}", node.ib_ip));
  344. }
  345. // Default cluster env (recipe env below overrides these if duplicated)
  346. cmd.push("-e NCCL_CUMEM_ENABLE=0".to_string());
  347. cmd.push("-e OMP_NUM_THREADS=4".to_string());
  348. for (k, v) in &recipe.env {
  349. cmd.push(format!("-e {}={}", k, v));
  350. }
  351. if is_head {
  352. cmd.push(format!("-p {}:{}", recipe.port, recipe.port));
  353. }
  354. cmd.push(format!("--name sm121_{}_{}", recipe_name, node.rank));
  355. cmd.push(format!("--label sm121.recipe={}", recipe_name));
  356. cmd.push(format!("--label sm121.rank={}", node.rank));
  357. if recipe.distributed_backend == "ray" {
  358. cmd.push(format!("-e RAY_ADDRESS={}:6379", cluster.master_ib_ip));
  359. cmd.push(format!("-e RAY_PORT={}", recipe.port));
  360. }
  361. cmd.push(recipe.docker_image.clone());
  362. if !recipe.entrypoint.is_empty() {
  363. cmd.extend(recipe.entrypoint.clone());
  364. }
  365. // Model argument - engine-specific
  366. if is_sglang {
  367. cmd.push("--model-path".to_string());
  368. cmd.push(recipe.model.clone());
  369. } else {
  370. cmd.push(recipe.model.clone());
  371. }
  372. cmd.extend(recipe.args.clone());
  373. // Distributed flags - engine-specific
  374. let mut flags: Vec<String> = match recipe.distributed_backend.as_str() {
  375. "ray" | "mp" if is_sglang => vec![
  376. "--tp".into(),
  377. recipe.tensor_parallel_size.to_string(),
  378. "--nnodes".into(),
  379. nnodes.to_string(),
  380. "--node-rank".into(),
  381. node.rank.to_string(),
  382. "--dist-init-addr".into(),
  383. format!("{}:{}", cluster.master_ib_ip, init_port),
  384. ],
  385. "ray" | "mp" => vec![
  386. "--tensor-parallel-size".into(),
  387. "1".into(),
  388. "--pipeline-parallel-size".into(),
  389. pp_size.to_string(),
  390. "--nnodes".into(),
  391. nnodes.to_string(),
  392. "--node-rank".into(),
  393. node.rank.to_string(),
  394. "--master-addr".into(),
  395. cluster.master_ib_ip.clone(),
  396. "--master-port".into(),
  397. init_port.to_string(),
  398. "--distributed-executor-backend".into(),
  399. recipe.distributed_backend.clone(),
  400. ],
  401. _ => vec![],
  402. };
  403. // vLLM workers run headless (skip API server) when not rank 0
  404. if !is_sglang && node.rank > 0 && matches!(recipe.distributed_backend.as_str(), "mp" | "ray") {
  405. flags.push("--headless".into());
  406. }
  407. cmd.extend(flags);
  408. // Port flag — auto-generated for SGLang (all nodes need to bind)
  409. if is_sglang {
  410. cmd.push("--port".to_string());
  411. cmd.push(recipe.port.to_string());
  412. }
  413. cmd
  414. }
  415. #[cfg(test)]
  416. mod tests;