mod.rs 13 KB

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