Skip to main content

start_command/
docker_resource_limits.rs

1//! Docker resource limits that must survive a resume (issue #176).
2//!
3//! `docker commit` captures a container's filesystem and image config, but not
4//! its HostConfig. A snapshot-based resume (`$ --resume <id> -- <cmd>`) used to
5//! start the replacement container without the memory, CPU and PIDs limits the
6//! original one had — limits a supervisor typically applied with
7//! `docker update`, so they were never part of the stored launch options.
8//!
9//! This module reads the limits back out of `docker inspect` and translates the
10//! non-default ones into `docker run` flags. The flags are kept in the
11//! `--flag=value` form, so the same list is stored in the execution record,
12//! shown in `[Isolation]` status lines, and spliced into `docker run` as is.
13
14use serde_json::Value;
15
16use crate::docker_cleanup::docker_command;
17use crate::execution_control::CommandRunner;
18
19/// Docker's default `/dev/shm` size; only a different size is re-applied.
20pub const DEFAULT_SHM_SIZE: i64 = 64 * 1024 * 1024;
21
22const KIB: i64 = 1024;
23const MIB: i64 = KIB * 1024;
24const GIB: i64 = MIB * 1024;
25
26/// Format a byte count with the largest binary unit `docker run` accepts that
27/// represents it exactly (`268435456` → `256m`).
28pub fn format_docker_bytes(bytes: i64) -> String {
29    for (unit, size) in [("g", GIB), ("m", MIB), ("k", KIB)] {
30        if bytes >= size && bytes % size == 0 {
31            return format!("{}{}", bytes / size, unit);
32        }
33    }
34    bytes.to_string()
35}
36
37/// Format `NanoCpus` as the decimal `--cpus` value (`500000000` → `0.5`).
38pub fn format_docker_cpus(nano_cpus: i64) -> String {
39    let text = format!("{:.9}", nano_cpus as f64 / 1e9);
40    let text = text.trim_end_matches('0').trim_end_matches('.');
41    text.to_string()
42}
43
44fn number(value: Option<&Value>) -> Option<i64> {
45    match value? {
46        Value::Number(n) => n.as_i64().or_else(|| n.as_f64().map(|f| f as i64)),
47        Value::String(s) => s.trim().parse().ok(),
48        _ => None,
49    }
50}
51
52fn positive(value: Option<&Value>) -> Option<i64> {
53    number(value).filter(|n| *n > 0)
54}
55
56fn non_empty(value: Option<&Value>) -> Option<String> {
57    value
58        .and_then(Value::as_str)
59        .map(str::trim)
60        .filter(|s| !s.is_empty())
61        .map(str::to_string)
62}
63
64fn scalar_text(value: Option<&Value>) -> String {
65    match value {
66        Some(Value::String(s)) => s.clone(),
67        Some(Value::Number(n)) => n.to_string(),
68        Some(Value::Bool(b)) => b.to_string(),
69        _ => String::new(),
70    }
71}
72
73/// Translate an inspected `HostConfig` into `docker run` flags.
74///
75/// Only limits that differ from Docker's defaults are emitted, and only in
76/// combinations `docker run` accepts: `--memory-swap` needs `--memory`, and
77/// `--cpus` cannot be combined with `--cpu-quota`/`--cpu-period`.
78pub fn parse_docker_resource_limits(host_config: &Value) -> Vec<String> {
79    let Some(h) = host_config.as_object() else {
80        return Vec::new();
81    };
82    let mut limits = Vec::new();
83
84    if let Some(memory) = positive(h.get("Memory")) {
85        limits.push(format!("--memory={}", format_docker_bytes(memory)));
86        match number(h.get("MemorySwap")) {
87            Some(-1) => limits.push("--memory-swap=-1".to_string()),
88            Some(swap) if swap > 0 => {
89                limits.push(format!("--memory-swap={}", format_docker_bytes(swap)))
90            }
91            _ => {}
92        }
93    }
94    if let Some(reservation) = positive(h.get("MemoryReservation")) {
95        limits.push(format!(
96            "--memory-reservation={}",
97            format_docker_bytes(reservation)
98        ));
99    }
100
101    if let Some(nano_cpus) = positive(h.get("NanoCpus")) {
102        limits.push(format!("--cpus={}", format_docker_cpus(nano_cpus)));
103    } else {
104        if let Some(quota) = positive(h.get("CpuQuota")) {
105            limits.push(format!("--cpu-quota={}", quota));
106        }
107        if let Some(period) = positive(h.get("CpuPeriod")) {
108            limits.push(format!("--cpu-period={}", period));
109        }
110    }
111    if let Some(shares) = positive(h.get("CpuShares")) {
112        limits.push(format!("--cpu-shares={}", shares));
113    }
114    if let Some(cpus) = non_empty(h.get("CpusetCpus")) {
115        limits.push(format!("--cpuset-cpus={}", cpus));
116    }
117    if let Some(mems) = non_empty(h.get("CpusetMems")) {
118        limits.push(format!("--cpuset-mems={}", mems));
119    }
120
121    if let Some(pids) = positive(h.get("PidsLimit")) {
122        limits.push(format!("--pids-limit={}", pids));
123    }
124
125    if let Some(shm) = positive(h.get("ShmSize")) {
126        if shm != DEFAULT_SHM_SIZE {
127            limits.push(format!("--shm-size={}", format_docker_bytes(shm)));
128        }
129    }
130
131    if let Some(storage) = h.get("StorageOpt").and_then(Value::as_object) {
132        let mut keys: Vec<&String> = storage.keys().collect();
133        keys.sort();
134        for key in keys {
135            let value = scalar_text(storage.get(key));
136            if !value.trim().is_empty() {
137                limits.push(format!("--storage-opt={}={}", key, value.trim()));
138            }
139        }
140    }
141
142    if let Some(ulimits) = h.get("Ulimits").and_then(Value::as_array) {
143        for ulimit in ulimits {
144            if let Some(name) = non_empty(ulimit.get("Name")) {
145                limits.push(format!(
146                    "--ulimit={}={}:{}",
147                    name,
148                    scalar_text(ulimit.get("Soft")),
149                    scalar_text(ulimit.get("Hard"))
150                ));
151            }
152        }
153    }
154
155    limits
156}
157
158/// Read a container's resource limits with `docker inspect`.
159///
160/// Returns `None` when the container could not be read.
161pub fn read_docker_resource_limits<R: CommandRunner + ?Sized>(
162    container_name: &str,
163    runner: &R,
164) -> Option<Vec<String>> {
165    let docker = docker_command().to_string_lossy().to_string();
166    let args: Vec<String> = [
167        "inspect",
168        "--format",
169        "{{json .HostConfig}}",
170        container_name,
171    ]
172    .iter()
173    .map(|s| s.to_string())
174    .collect();
175    let result = runner.run(&docker, &args);
176    if !result.success {
177        return None;
178    }
179    let host_config: Value = serde_json::from_str(result.stdout.trim()).ok()?;
180    Some(parse_docker_resource_limits(&host_config))
181}
182
183/// Normalize a stored `resourceLimits` value (array or whitespace-separated
184/// string) to a list of flags.
185pub fn normalize_resource_limits(value: Option<&Value>) -> Vec<String> {
186    match value {
187        Some(Value::Array(items)) => items
188            .iter()
189            .map(|item| match item {
190                Value::String(s) => s.clone(),
191                other => other.to_string(),
192            })
193            .filter(|flag| flag.starts_with("--"))
194            .collect(),
195        Some(Value::String(text)) => text
196            .split_whitespace()
197            .filter(|flag| flag.starts_with("--"))
198            .map(str::to_string)
199            .collect(),
200        _ => Vec::new(),
201    }
202}
203
204/// `[Isolation]` status line for resource limits, or `None` when there are none.
205pub fn build_resource_limits_status_line(limits: &[String]) -> Option<String> {
206    if limits.is_empty() {
207        None
208    } else {
209        Some(format!("[Isolation] Resource limits: {}", limits.join(" ")))
210    }
211}