docker.rs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  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(config: &Config, recipe_name: &str, cluster_name: &str, dry_run: bool) -> Result<()> {
  9. let recipe = load_recipe(recipe_name).await?;
  10. info!("🚀 Запуск рецепта: {} ({}) [backend: {}]", recipe.name, recipe.engine, recipe.distributed_backend);
  11. let (cluster, nccl) = resolve_cluster(config, &recipe, cluster_name)?;
  12. if let Some(required) = recipe.required_nodes {
  13. let actual = cluster.nodes.len() as u32;
  14. if actual != required {
  15. anyhow::bail!("Recipe '{}' requires {} nodes, but cluster '{}' has {} nodes", recipe.name, required, cluster.name, actual);
  16. }
  17. }
  18. let image = try_repaired_image(config, &cluster, &recipe).await?;
  19. let mut recipe = recipe;
  20. if image != recipe.docker_image { recipe.docker_image = image.clone(); }
  21. start_cluster(config, &cluster, nccl, recipe_name, &recipe, dry_run).await?;
  22. if !dry_run {
  23. info!("\n📜 Последние логи контейнеров:");
  24. show_logs(config, &cluster, recipe_name, false, dry_run).await?;
  25. }
  26. Ok(())
  27. }
  28. /// Shows Docker logs for a recipe across all nodes (or follows head node).
  29. pub async fn show_logs(config: &Config, cluster: &Cluster, recipe_name: &str, follow: bool, dry_run: bool) -> Result<()> {
  30. if follow {
  31. let head_node = cluster.nodes.first().context("No nodes in cluster")?;
  32. info!("📜 Following logs for '{}' (head node)...", head_node.name);
  33. let cmd = format!("docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank=0\" | xargs -r docker logs -f", recipe_name);
  34. run_ssh_follow(config, head_node, cmd, dry_run).await?;
  35. } else {
  36. let flag = "--tail=100";
  37. info!("📜 Логи кластера '{}' ({} узлов):", cluster.name, cluster.nodes.len());
  38. let mut handles = Vec::new();
  39. for node in &cluster.nodes {
  40. let cmd = format!("docker ps -q --filter \"label=sm121.recipe={}\" --filter \"label=sm121.rank={}\" | xargs -r docker logs {} 2>&1", recipe_name, node.rank, flag);
  41. let config = config.clone(); let node = node.clone(); let dry = dry_run;
  42. let name = node.name.clone();
  43. handles.push(tokio::spawn(async move { (name, run_ssh_with_retry(&config, &node, cmd, dry, 1).await) }));
  44. }
  45. for result in futures::future::join_all(handles).await {
  46. if let Ok((name, Ok(output))) = result && !output.trim().is_empty() {
  47. println!("\n--- {} ---", name); println!("{}", output);
  48. }
  49. }
  50. }
  51. Ok(())
  52. }
  53. /// Stops and removes containers for a recipe on all nodes.
  54. pub async fn stop_cluster(config: &Config, cluster: &Cluster, recipe_name: &str, dry_run: bool) -> Result<()> {
  55. info!("🛑 Останавливаем кластер '{}'...", cluster.name);
  56. let mut failures = Vec::new();
  57. for node in &cluster.nodes {
  58. let stop_cmd = format!("docker ps -q --filter \"label=sm121.recipe={}\" | xargs -r docker stop", recipe_name);
  59. if let Err(e) = run_ssh(config, node, stop_cmd, dry_run).await {
  60. tracing::warn!("Stop failed on {}: {}", node.name, e);
  61. failures.push((node.name.clone(), "stop".to_string(), e));
  62. }
  63. let rm_cmd = format!("docker ps -qa --filter \"label=sm121.recipe={}\" | xargs -r docker rm", recipe_name);
  64. if let Err(e) = run_ssh(config, node, rm_cmd, dry_run).await {
  65. tracing::warn!("Remove failed on {}: {}", node.name, e);
  66. failures.push((node.name.clone(), "rm".to_string(), e));
  67. }
  68. }
  69. if !failures.is_empty() {
  70. for (name, action, e) in &failures { tracing::warn!("Failed to {} on {}: {}", action, name, e); }
  71. anyhow::bail!("Failed to stop containers on {} node(s)", failures.len());
  72. }
  73. info!("✅ Все контейнеры остановлены.");
  74. Ok(())
  75. }
  76. /// Checks Docker container health on a cluster.
  77. pub async fn health_check(config: &Config, cluster: &Cluster, dry_run: bool) -> Result<()> {
  78. println!("\n=== Кластер: {} ===", cluster.name);
  79. let mut handles = Vec::new();
  80. for node in &cluster.nodes {
  81. let config = config.clone(); let node = node.clone(); let dry = dry_run;
  82. handles.push(tokio::spawn(async move {
  83. let result = run_ssh_with_retry(&config, &node, "docker ps --filter \"label=sm121\" --format '{{.Names}} {{.Status}}' 2>&1".to_string(), dry, 1).await;
  84. (node.name, node.ib_ip, result)
  85. }));
  86. }
  87. let mut all_healthy = true;
  88. for result in futures::future::join_all(handles).await {
  89. match result {
  90. Ok((name, ib_ip, Ok(check))) => {
  91. print!("{} ({}): ", name, ib_ip);
  92. if check.is_empty() && dry_run { continue; }
  93. if check.starts_with("docker:") || check.contains("permission denied") {
  94. println!("✗ Docker недоступен");
  95. if let Some(line) = check.lines().next() { println!(" └─ {}", line); }
  96. all_healthy = false;
  97. } else {
  98. let containers: Vec<&str> = check.lines().filter(|l| !l.is_empty()).collect();
  99. if containers.is_empty() { println!("✗ Нет контейнеров"); all_healthy = false; }
  100. else { println!("✓ {} контейнер(ов)", containers.len()); for c in containers { println!(" └─ {}", c); } }
  101. }
  102. }
  103. Ok((name, ib_ip, Err(e))) => { println!("{} ({}): ✗ SSH ошибка: {}", name, ib_ip, e); all_healthy = false; }
  104. Err(join_err) => { println!("✗ Task join error: {}", join_err); all_healthy = false; }
  105. }
  106. }
  107. if !dry_run && all_healthy { info!("✅ Кластер здоров!"); } else if !dry_run { info!("⚠️ Кластер имеет проблемы"); }
  108. Ok(())
  109. }
  110. /// Starts containers on all nodes in parallel.
  111. async fn start_cluster(config: &Config, cluster: &Cluster, nccl: &NcclConfig, recipe_name: &str, recipe: &Recipe, dry_run: bool) -> Result<()> {
  112. let nnodes = cluster.nodes.len() as u32;
  113. let pp_size = if recipe.pipeline_parallel_size.unwrap_or(0) == 0 { nnodes } else { recipe.pipeline_parallel_size.unwrap() };
  114. let mut handles = Vec::new();
  115. for node in &cluster.nodes {
  116. let node = node.clone(); let cluster = cluster.clone(); let nccl = nccl.clone();
  117. let recipe = recipe.clone(); let config = config.clone();
  118. let rn = recipe_name.to_string(); let dry = dry_run; let engine = recipe.engine.clone();
  119. let is_head = node.rank == 0;
  120. let cleanup = format!("docker rm -f sm121_{}_{} 2>/dev/null; true", recipe_name, node.rank);
  121. handles.push(tokio::spawn(async move {
  122. if !dry { let _ = run_ssh(&config, &node, cleanup, dry).await; }
  123. let cmd = build_docker_command(&cluster, &nccl, &recipe, &rn, &node, is_head, pp_size, nnodes).join(" ");
  124. let result = run_ssh_with_retry(&config, &node, cmd, dry, 2).await;
  125. (node.name, node.rank, engine, result)
  126. }));
  127. }
  128. for result in futures::future::join_all(handles).await {
  129. match result {
  130. Ok((name, rank, engine, Ok(_))) => info!("✅ {} запущен на {} (rank {})", engine.to_uppercase(), name, rank),
  131. Ok((name, rank, engine, Err(e))) => anyhow::bail!("Failed to start {} on {} (rank {}): {}", engine.to_uppercase(), name, rank, e),
  132. Err(e) => anyhow::bail!("Task join error: {}", e),
  133. }
  134. }
  135. if !dry_run { sleep(Duration::from_secs(10)).await; info!("🎉 Кластер успешно запущен! API → http://{}:{}", cluster.master_ib_ip, recipe.port); }
  136. Ok(())
  137. }
  138. #[allow(clippy::too_many_arguments)]
  139. fn build_docker_command(cluster: &Cluster, nccl: &NcclConfig, recipe: &Recipe, recipe_name: &str, node: &Node, is_head: bool, pp_size: u32, nnodes: u32) -> Vec<String> {
  140. let mut cmd = vec![
  141. "docker run -d --gpus all --ipc=host --privileged --network=host".to_string(),
  142. format!("-e NCCL_IB_DISABLE={}", nccl.ib_disable), format!("-e NCCL_IB_HCA={}", nccl.ib_hca),
  143. format!("-e NCCL_SOCKET_IFNAME={}", nccl.socket_ifname), format!("-e NCCL_IB_GID_INDEX={}", nccl.ib_gid_index),
  144. format!("-e VLLM_HOST_IP={}", node.ib_ip),
  145. ];
  146. for (k, v) in &recipe.env { cmd.push(format!("-e {}={}", k, v)); }
  147. if is_head { cmd.push(format!("-p {}:{}", recipe.port, recipe.port)); }
  148. cmd.push(format!("--name sm121_{}_{}", recipe_name, node.rank));
  149. cmd.push(format!("--label sm121.recipe={}", recipe_name));
  150. cmd.push(format!("--label sm121.rank={}", node.rank));
  151. if recipe.distributed_backend == "ray" {
  152. cmd.push(format!("-e RAY_ADDRESS={}:6379", cluster.master_ib_ip));
  153. cmd.push(format!("-e RAY_PORT={}", recipe.port));
  154. }
  155. cmd.push(recipe.docker_image.clone());
  156. if !recipe.entrypoint.is_empty() { cmd.extend(recipe.entrypoint.clone()); }
  157. cmd.push(recipe.model.clone());
  158. cmd.extend(recipe.args.clone());
  159. let flags: Vec<String> = match recipe.distributed_backend.as_str() {
  160. "ray" | "mp" => vec![
  161. "--tensor-parallel-size".into(), "1".into(),
  162. "--pipeline-parallel-size".into(), pp_size.to_string(),
  163. "--nnodes".into(), nnodes.to_string(),
  164. "--node-rank".into(), node.rank.to_string(),
  165. "--master-addr".into(), cluster.master_ib_ip.clone(),
  166. "--distributed-executor-backend".into(), recipe.distributed_backend.clone(),
  167. ],
  168. _ => vec![],
  169. };
  170. cmd.extend(flags);
  171. cmd
  172. }
  173. #[cfg(test)]
  174. mod tests {
  175. use super::*;
  176. use std::collections::HashMap;
  177. fn tcls() -> Cluster { Cluster { name: "t".into(), master_ib_ip: "1".into(), nodes: vec![
  178. Node { name: "n0".into(), ssh_ip: "10.0.0.1".into(), ib_ip: "1".into(), rank: 0, host_key: None },
  179. Node { name: "n1".into(), ssh_ip: "10.0.0.2".into(), ib_ip: "2".into(), rank: 1, host_key: None },
  180. ] } }
  181. fn tnccl() -> NcclConfig { NcclConfig { ib_hca: "r".into(), socket_ifname: "e".into(), ib_gid_index: 3, ib_disable: 0 } }
  182. fn trec() -> Recipe { Recipe {
  183. engine: "vllm".into(), name: "T".into(), docker_image: "img".into(), model: "mdl".into(), port: 8000,
  184. required_nodes: Some(2), pipeline_parallel_size: Some(2),
  185. args: vec!["--x".into(), "4096".into()], env: HashMap::from([("K".into(), "V".into())]),
  186. cluster: None, entrypoint: vec![], distributed_backend: "mp".into(),
  187. } }
  188. fn run(recipe: &Recipe, node: &Node, head: bool) -> String {
  189. build_docker_command(&tcls(), &tnccl(), recipe, "r", node, head, 2, 2).join(" ")
  190. }
  191. #[test] fn mp() { let cmd = run(&trec(), &tcls().nodes[0], true);
  192. assert!(cmd.contains("docker run -d --gpus all")); assert!(cmd.contains("-e NCCL_IB_HCA=r"));
  193. assert!(cmd.contains("-e K=V")); assert!(cmd.contains("-p 8000:8000")); assert!(cmd.contains("--name sm121_r_0"));
  194. assert!(cmd.contains("--label sm121.recipe=r")); assert!(cmd.contains("--label sm121.rank=0"));
  195. assert!(cmd.contains("img")); assert!(cmd.contains("mdl")); assert!(cmd.contains("--tensor-parallel-size 1"));
  196. assert!(cmd.contains("--pipeline-parallel-size 2")); assert!(cmd.contains("--nnodes 2"));
  197. assert!(cmd.contains("--node-rank 0")); assert!(cmd.contains("--master-addr 1"));
  198. assert!(cmd.contains("--distributed-executor-backend mp")); assert!(!cmd.contains("RAY_ADDRESS"));
  199. }
  200. #[test] fn worker_no_port() { let cmd = run(&trec(), &tcls().nodes[1], false);
  201. assert!(!cmd.contains("-p 8000:8000")); assert!(cmd.contains("--node-rank 1"));
  202. assert!(cmd.contains("--label sm121.rank=1")); assert!(cmd.contains("--name sm121_r_1"));
  203. }
  204. #[test] fn ray_backend() { let mut r = trec(); r.distributed_backend = "ray".into();
  205. let cmd = run(&r, &tcls().nodes[0], true);
  206. assert!(cmd.contains("-e RAY_ADDRESS=1:6379")); assert!(cmd.contains("-e RAY_PORT=8000"));
  207. assert!(cmd.contains("--node-rank 0")); assert!(cmd.contains("--distributed-executor-backend ray"));
  208. }
  209. #[test] fn auto_pp_size() { let cmd = run(&trec(), &tcls().nodes[0], true); assert!(cmd.contains("--pipeline-parallel-size 2")); }
  210. #[test] fn entrypoint() { let mut r = trec(); r.entrypoint = vec!["py".into(), "-m".into(), "mod".into()];
  211. let cmd = run(&r, &tcls().nodes[0], true);
  212. let ip = cmd.find("img").unwrap(); let mp = cmd.find("mdl").unwrap(); let ep = cmd.find("py -m mod").unwrap();
  213. assert!(ip < ep && ep < mp, "entrypoint should be between image and model");
  214. }
  215. }