sync.rs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497
  1. use crate::config::{Cluster, Config, Node};
  2. use crate::ssh::{run_ssh, run_ssh_long_running};
  3. use anyhow::Result;
  4. use std::time::Duration;
  5. use tracing::info;
  6. use super::shq;
  7. /// The containerd snapshotter (`io.containerd.snapshotter.v1` — every DGX
  8. /// Spark node) returns `docker load` BEFORE the snapshot is fully unpacked,
  9. /// so a `docker run` issued seconds after the sync can die with
  10. /// "unable to start container process ... stat ...: no such file or
  11. /// directory" (dgx3 lost exactly that race on the first Mistral Large 4
  12. /// serve, 2026-10-29: rank 2 died silently, `--rm` removed the evidence and
  13. /// the rendezvous hung until its 30-min timeout). Probe the exec path with
  14. /// retries: starting a trivial `/bin/sh` container forces the snapshot
  15. /// materialization — once that passes, the engine's own exec works too.
  16. async fn verify_image_exec_ready(config: &Config, target: &Node, image: &str) -> Result<()> {
  17. const PROBE_TRIES: u32 = 24; // 24 x 5 s = 2 min worst-case cap
  18. const PROBE_SECS: u64 = 5;
  19. let probe = exec_probe_command(image);
  20. for attempt in 1..=PROBE_TRIES {
  21. match run_ssh(config, target, probe.clone(), false).await {
  22. Ok(_) => {
  23. if attempt > 1 {
  24. info!(
  25. "image unpack probe passed on '{}' (attempt {attempt})",
  26. target.name
  27. );
  28. }
  29. return Ok(());
  30. }
  31. Err(e) => {
  32. if attempt == 1 {
  33. info!(
  34. "image unpack probe on '{}' failed (attempt 1): {e:#} — retrying",
  35. target.name
  36. );
  37. }
  38. tokio::time::sleep(Duration::from_secs(PROBE_SECS)).await;
  39. }
  40. }
  41. }
  42. anyhow::bail!(
  43. "image '{image}' on '{}' is still not exec-ready after {PROBE_TRIES} probe attempts — the containerd snapshotter has not finished unpacking it; refusing to start containers against a half-materialized image",
  44. target.name
  45. )
  46. }
  47. /// The exec-readiness probe: starting a trivial `/bin/sh` container forces
  48. /// the containerd snapshotter to materialize the rootfs — after this passes,
  49. /// the engine's own exec works too.
  50. fn exec_probe_command(image: &str) -> String {
  51. format!(
  52. "docker run --rm --entrypoint /bin/sh {} -c 'exit 0'",
  53. shq(image)
  54. )
  55. }
  56. /// Get the Docker image digest (short ID) from a node.
  57. async fn get_image_digest(config: &Config, node: &Node, image: &str) -> Result<Option<String>> {
  58. // `docker image ls` does not accept digest refs (`repo@sha256:...`) —
  59. // use `docker image inspect` for those; it also returns the image ID.
  60. // `|| true`: inspect exits 1 when the ref is absent (a peer that simply
  61. // doesn't have it yet) — that is "no digest", not an ssh failure.
  62. let cmd = if image.contains('@') {
  63. format!(
  64. "docker image inspect --format '{{{{.Id}}}}' {} 2>/dev/null || true",
  65. shq(image)
  66. )
  67. } else {
  68. format!("docker image ls --format '{{{{.ID}}}}' {}", shq(image))
  69. };
  70. let out = run_ssh(config, node, cmd, false).await?;
  71. let digest = out.trim().to_string();
  72. if digest.is_empty() {
  73. Ok(None)
  74. } else {
  75. Ok(Some(digest))
  76. }
  77. }
  78. /// Probe one node for the image: digest + creation timestamp (the latter
  79. /// only when present — two serial round-trips inside, but nodes are
  80. /// probed in parallel by [`probe_all`]).
  81. async fn probe_node(
  82. config: &Config,
  83. node: &Node,
  84. image: &str,
  85. ) -> Result<(Node, Option<String>, Option<String>)> {
  86. let node = node.clone();
  87. let digest = get_image_digest(config, &node, image).await?;
  88. let created = if digest.is_some() {
  89. run_ssh(
  90. config,
  91. &node,
  92. format!(
  93. "docker image inspect --format '{{{{.Created}}}}' {} 2>/dev/null",
  94. shq(image)
  95. ),
  96. false,
  97. )
  98. .await
  99. .ok()
  100. .map(|c| c.trim().to_string())
  101. } else {
  102. None
  103. };
  104. Ok((node, digest, created))
  105. }
  106. /// Probe every cluster node for the image digest/creation time in parallel.
  107. async fn probe_all(
  108. config: &Config,
  109. cluster: &Cluster,
  110. image: &str,
  111. ) -> Result<Vec<(Node, Option<String>, Option<String>)>> {
  112. let mut handles = Vec::new();
  113. for node in &cluster.nodes {
  114. let config = config.clone();
  115. let node = (*node).clone();
  116. let image = image.to_string();
  117. handles.push(crate::locale::spawn_with_locale(async move {
  118. probe_node(&config, &node, &image).await
  119. }));
  120. }
  121. let mut probed = Vec::new();
  122. for h in futures::future::join_all(handles).await {
  123. probed.push(h??); // join error first, then the probe's own ssh error
  124. }
  125. Ok(probed)
  126. }
  127. /// Ensure a prebuilt (externally built) image is present on every node of
  128. /// the launch set BEFORE `docker run`: sync it node-to-node from a peer that
  129. /// already has it, or fail loudly when no node has it.
  130. ///
  131. /// Without this check a missing image makes `docker run` silently pull it
  132. /// from the registry over the internet; the SSH timeout then kills the pull
  133. /// while the serve still reports "started" and waits out the readiness
  134. /// timeout for a container that never loads.
  135. pub async fn ensure_prebuilt_image(config: &Config, cluster: &Cluster, image: &str) -> Result<()> {
  136. sync_image_to_nodes(config, cluster, image, None).await.map_err(|e| {
  137. anyhow::anyhow!(
  138. "image '{}' not available on the launch nodes: {e} \u{2014} pull it on one node first (or build it with --build-node)",
  139. image
  140. )
  141. })
  142. }
  143. /// Sync a Docker image across cluster nodes — one `docker save` on the
  144. /// source, then a PARALLEL fan-out of `ssh <target> docker load < tar` to
  145. /// every target that is missing or holds a different digest.
  146. ///
  147. /// Why not one save per target (the old shape): with N peers the source
  148. /// re-serialized the whole image N times and the transfers ran strictly
  149. /// serially — N × (save + load) wall time for one image. The fabric
  150. /// carries multiple streams at full rate, so one save + N parallel loads
  151. /// costs ≈ save + max(load), roughly N× faster for a 4-node set.
  152. ///
  153. /// Data path: node-to-node, never through the Mac (mac→node is ~32 MB/s vs
  154. /// ~547 MB/s per stream on the 200G fabric). The inner hop prefers the
  155. /// target's fabric IP (ib_ip); if that fails it falls back to the 10G mgmt
  156. /// IP with a warning (the per-target known_hosts covers both hops and lives
  157. /// until the target task ends — the old per-pipe `rm` deleted it before the
  158. /// fallback attempt).
  159. ///
  160. /// Security of the inner hop: the target's full public key is fetched over
  161. /// a russh connection that enforces the pinned fingerprint, written to a
  162. /// per-target `known_hosts` on the source node, and passed to the inner
  163. /// `ssh` with `StrictHostKeyChecking=yes` — an impersonator at the target
  164. /// IP is rejected before auth. The file is removed after the target task.
  165. pub async fn sync_image_to_nodes(
  166. config: &Config,
  167. cluster: &Cluster,
  168. image: &str,
  169. explicit_source: Option<&str>,
  170. ) -> Result<()> {
  171. let probed = probe_all(config, cluster, image).await?;
  172. let source_idx = if let Some(src_name) = explicit_source {
  173. probed
  174. .iter()
  175. .position(|(n, _, _)| n.name == src_name)
  176. .ok_or_else(|| {
  177. anyhow::anyhow!("sync-image-source '{}' not found in cluster", src_name)
  178. })?
  179. } else {
  180. let mut indexed: Vec<usize> = probed
  181. .iter()
  182. .enumerate()
  183. .filter(|(_, (_, d, _))| d.is_some())
  184. .map(|(i, _)| i)
  185. .collect();
  186. indexed.sort_by(|&a, &b| {
  187. probed[b]
  188. .2
  189. .as_deref()
  190. .unwrap_or("")
  191. .cmp(probed[a].2.as_deref().unwrap_or(""))
  192. });
  193. *indexed
  194. .first()
  195. .ok_or_else(|| anyhow::anyhow!("Image '{}' not found on any node", image))?
  196. };
  197. let (src_node, src_digest_opt, _) = &probed[source_idx];
  198. let source_digest = src_digest_opt
  199. .as_ref()
  200. .ok_or_else(|| anyhow::anyhow!("source node '{}' lost the image", src_node.name))?;
  201. // WHY this node won the source selection: an explicit source (a
  202. // just-built image or --sync-image-source) is authoritative, whereas
  203. // the heuristic source is simply the node with the newest creation
  204. // timestamp — a peer could differ and this is the tie-break that was
  205. // overwriting fresh builds.
  206. let reason = if explicit_source.is_some() {
  207. "authoritative image"
  208. } else {
  209. "newest image (creation timestamp tie-break)"
  210. };
  211. info!(
  212. "Syncing image '{}' FROM '{}' ({}) ...",
  213. image, src_node.name, reason
  214. );
  215. // Targets: peers with no image or a different digest.
  216. let mut targets: Vec<Node> = Vec::new();
  217. for (node, digest, _) in &probed {
  218. if node.name == src_node.name {
  219. continue;
  220. }
  221. if digest.as_deref() == Some(source_digest.as_str()) {
  222. info!(
  223. "{}",
  224. fluent_i18n::t!("sync-digest-match", { "name" => &node.name })
  225. );
  226. continue;
  227. }
  228. targets.push(node.clone());
  229. }
  230. if targets.is_empty() {
  231. return Ok(());
  232. }
  233. // ONE save on the source: /var/tmp on the root NVMe (docker save writes
  234. // owned by the invoking user), filename-safe image name.
  235. let tar = format!("/var/tmp/sm121_sync_{}.tar", safe_name(image));
  236. info!(
  237. "Saving '{}' once on '{}' ({} peer(s) in parallel) ...",
  238. image,
  239. src_node.name,
  240. targets.len()
  241. );
  242. let save_cmd = format!(
  243. "rm -f {tar} && docker save -o {tar} {image} || {{ rm -f {tar}; exit 1; }}",
  244. tar = shq(&tar),
  245. image = shq(image),
  246. );
  247. if let Err(e) = run_ssh_long_running(config, src_node, save_cmd, false).await {
  248. run_ssh(config, src_node, format!("rm -f {}", shq(&tar)), false)
  249. .await
  250. .ok();
  251. anyhow::bail!("docker save on '{}' failed: {e}", src_node.name);
  252. }
  253. // Parallel fan-out: one task per target (fabric IP first, mgmt IP
  254. // fallback, digest verification, known_hosts cleanup inside the task).
  255. let mut handles = Vec::new();
  256. for target in &targets {
  257. let config = config.clone();
  258. let src_node = src_node.clone();
  259. let target = target.clone();
  260. let image = image.to_string();
  261. let tar = tar.clone();
  262. let expected = source_digest.clone();
  263. let key = node_key_path(&config);
  264. let user = config.ssh_user.clone();
  265. handles.push(crate::locale::spawn_with_locale(async move {
  266. info!(
  267. "{}",
  268. fluent_i18n::t!("sync-syncing", { "name" => &target.name, "ip" => &target.ssh_ip })
  269. );
  270. let name = target.name.clone();
  271. let pubkey = match crate::ssh::fetch_host_pubkey(&config, &target).await {
  272. Ok(k) => k,
  273. Err(e) => return (name, Some(format!("host key fetch: {e}"))),
  274. };
  275. let known_hosts = format!("/tmp/sm121_known_hosts_{}", target.name);
  276. let mut kh_ips = vec![target.ssh_ip.clone()];
  277. if let Some(ib) = &target.ib_ip {
  278. kh_ips.push(ib.clone());
  279. }
  280. let entry = kh_ips
  281. .iter()
  282. .map(|ip| format!("{} {}", ip, pubkey))
  283. .collect::<Vec<_>>()
  284. .join("\n")
  285. + "\n";
  286. let write_known = format!(
  287. "printf '%s' {} > {} && chmod 600 {}",
  288. shq(&entry),
  289. shq(&known_hosts),
  290. shq(&known_hosts),
  291. );
  292. if let Err(e) = run_ssh(&config, &src_node, write_known, false).await {
  293. return (name, Some(format!("known_hosts write: {e}")));
  294. }
  295. let attempts: Vec<String> = match &target.ib_ip {
  296. Some(ib) => vec![ib.clone(), target.ssh_ip.clone()],
  297. None => vec![target.ssh_ip.clone()],
  298. };
  299. let mut last_err: Option<String> = None;
  300. let mut synced = false;
  301. for (idx, ip) in attempts.iter().enumerate() {
  302. let pipe = format!(
  303. "ssh -o StrictHostKeyChecking=yes -o HashKnownHosts=no -o UserKnownHostsFile={kh} -i {key} {user}@{ip} docker load < {tar}; exit $?",
  304. kh = shq(&known_hosts),
  305. key = key,
  306. user = shq(&user),
  307. ip = shq(ip),
  308. tar = shq(&tar),
  309. );
  310. match run_ssh_long_running(&config, &src_node, pipe, false).await {
  311. Ok(_) => {
  312. // A digest-only ref (`repo@sha256:...`) loses its
  313. // RepoDigest association through save/load — the tar
  314. // carries no tags, so the loaded image is untagged
  315. // and `docker run <ref>` fails with "No such image"
  316. // on the peer. Re-attach it with a pull-by-digest:
  317. // all layers are local, this fetches nothing but the
  318. // manifest (and pins the association).
  319. if image.contains('@') {
  320. let pull = format!("docker pull {}", shq(&image));
  321. if let Err(e) = run_ssh(&config, &target, pull, false).await {
  322. last_err = Some(format!("pull-by-digest re-attach: {e}"));
  323. continue;
  324. }
  325. }
  326. let verify = get_image_digest(&config, &target, &image).await;
  327. match verify {
  328. Ok(v) if v.as_deref() == Some(expected.as_str()) => {
  329. // Digest present ≠ snapshot unpacked (the
  330. // containerd lazy-unpack race): probe exec
  331. // readiness before declaring the sync done.
  332. // A probe failure is not a connectivity
  333. // problem — the other IP won't help either.
  334. if let Err(e) =
  335. verify_image_exec_ready(&config, &target, &image).await
  336. {
  337. last_err = Some(format!("image exec-ready probe: {e}"));
  338. break;
  339. }
  340. synced = true;
  341. break;
  342. }
  343. Ok(v) => {
  344. // a digest mismatch is not a connectivity
  345. // problem — the other IP won't help, bail out
  346. last_err = Some(format!(
  347. "digest mismatch (source={expected}, target={v:?})"
  348. ));
  349. break;
  350. }
  351. Err(e) => last_err = Some(format!("digest verify: {e}")),
  352. }
  353. }
  354. Err(e) => {
  355. if idx == 0 {
  356. tracing::warn!(
  357. "{}",
  358. fluent_i18n::t!("sync-fabric-fallback", {
  359. "name" => &target.name,
  360. "err" => e.to_string(),
  361. })
  362. );
  363. }
  364. last_err = Some(e.to_string());
  365. }
  366. }
  367. }
  368. // known_hosts matters only for this target — keep it until the
  369. // last attempt has run, then drop it on the source node.
  370. run_ssh(
  371. &config,
  372. &src_node,
  373. format!("rm -f {}", shq(&known_hosts)),
  374. false,
  375. )
  376. .await
  377. .ok();
  378. if synced {
  379. info!(
  380. "{}",
  381. fluent_i18n::t!("sync-synced", { "name" => &target.name })
  382. );
  383. (name, None)
  384. } else {
  385. (name, Some(last_err.unwrap_or_default()))
  386. }
  387. }));
  388. }
  389. let mut failed: Vec<String> = Vec::new();
  390. for h in futures::future::join_all(handles).await {
  391. match h {
  392. Ok((_, None)) => {}
  393. Ok((name, Some(e))) => failed.push(format!("{name}: {e}")),
  394. Err(e) => failed.push(format!("task: {e}")),
  395. }
  396. }
  397. run_ssh(config, src_node, format!("rm -f {}", shq(&tar)), false)
  398. .await
  399. .ok();
  400. if failed.is_empty() {
  401. Ok(())
  402. } else {
  403. anyhow::bail!("Image sync failed on: {}", failed.join("; "))
  404. }
  405. }
  406. /// Identity path for an INNER ssh hop that runs ON a node. The configured
  407. /// key path belongs to the Mac (`/Users/kilork/...`), which nodes do not
  408. /// have — nodes hold the shared key at `~/.ssh/<basename>` (dgx5-8 lack the
  409. /// `~/.ssh/config` IdentityFile fallback dgx1-4 have, so there the mac path
  410. /// failed with exit 255 / "Permission denied", 2026-09-29). The basename is
  411. /// what the inner hop must reference; the shell expands `~` itself.
  412. fn node_key_path(config: &Config) -> String {
  413. config
  414. .ssh_key_path
  415. .file_name()
  416. .map(|f| format!("~/.ssh/{}", f.to_string_lossy()))
  417. .filter(|f| f.len() > "~/.ssh/".len())
  418. .unwrap_or_else(|| config.ssh_key_path.display().to_string())
  419. }
  420. /// Filename-safe image name for the one-shot save tarball on the source.
  421. fn safe_name(image: &str) -> String {
  422. image
  423. .chars()
  424. .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
  425. .collect()
  426. }
  427. /// `sm121 sync-image` handler: distribute a recipe's image across the
  428. /// cluster without starting a serve (pre-warm before bench/serve).
  429. pub async fn handle_sync_image(
  430. config: &Config,
  431. recipe_name: &str,
  432. cluster_name: &str,
  433. source: Option<&str>,
  434. dry_run: bool,
  435. ) -> Result<()> {
  436. let recipe = crate::config::load_recipe(recipe_name, &config.recipe_sources).await?;
  437. let cluster = crate::config::select_cluster_by_name(config, cluster_name)?;
  438. let image = recipe.docker_image.clone();
  439. if dry_run {
  440. // No SSH in dry-run: print the plan only.
  441. println!(
  442. "sync-image: {} over {} node(s) of '{}'",
  443. image,
  444. cluster.nodes.len(),
  445. cluster.name
  446. );
  447. println!(
  448. "sync-image: ssh <source>: rm -f /var/tmp/sm121_sync_<image>.tar && docker save -o /var/tmp/sm121_sync_<image>.tar {image}"
  449. );
  450. for n in &cluster.nodes {
  451. let hop = n.ib_ip.as_deref().unwrap_or(&n.ssh_ip);
  452. println!(
  453. "sync-image: {} <- ssh {}@{} docker load < /var/tmp/sm121_sync_<image>.tar (parallel)",
  454. n.name, config.ssh_user, hop
  455. );
  456. }
  457. return Ok(());
  458. }
  459. sync_image_to_nodes(config, &cluster, &image, source).await
  460. }
  461. #[cfg(test)]
  462. mod tests {
  463. use super::{exec_probe_command, safe_name};
  464. #[test]
  465. fn safe_image_name_is_filename_safe() {
  466. let safe = safe_name("eugr/spark-vllm-b12x:latest@sha256:abc123");
  467. assert!(!safe.contains('/') && !safe.contains(':') && !safe.contains('@'));
  468. assert!(!safe.is_empty());
  469. }
  470. #[test]
  471. fn exec_probe_forces_snapshot_materialization() {
  472. let cmd = exec_probe_command("mistral-large4-vllm:local");
  473. assert!(cmd.starts_with("docker run --rm --entrypoint /bin/sh "));
  474. assert!(cmd.ends_with("-c 'exit 0'"));
  475. assert!(cmd.contains("'mistral-large4-vllm:local'"));
  476. }
  477. }