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}
212
213/// An existing quota/period cannot be changed to Docker NanoCPUs in place.
214pub fn cpu_count(limits: &[String]) -> Option<f64> {
215    let get = |prefix: &str| {
216        limits.iter().find_map(|s| {
217            s.strip_prefix(prefix)
218                .and_then(|n| n.parse::<f64>().ok())
219                .filter(|n| *n > 0.0)
220        })
221    };
222    get("--cpus=")
223        .or_else(|| get("--cpu-quota=").map(|q| q / get("--cpu-period=").unwrap_or(100000.0)))
224}
225pub fn cpu_update_args(limits: &[String], cpus: f64) -> Vec<String> {
226    if !limits.iter().any(|s| s.starts_with("--cpus="))
227        && limits
228            .iter()
229            .any(|s| s.starts_with("--cpu-quota=") || s.starts_with("--cpu-period="))
230    {
231        vec![
232            "--cpu-period=100000".into(),
233            format!("--cpu-quota={}", (cpus * 100000.0).ceil() as i64),
234        ]
235    } else {
236        vec!["--cpus".into(), cpus.to_string()]
237    }
238}
239pub fn with_cpu_limit(mut limits: Vec<String>, cpus: f64) -> Vec<String> {
240    let args = cpu_update_args(&limits, cpus);
241    limits.retain(|s| {
242        !["--cpus=", "--cpu-quota=", "--cpu-period="]
243            .iter()
244            .any(|p| s.starts_with(p))
245    });
246    if args[0] == "--cpus" {
247        limits.push(format!("--cpus={}", cpus));
248    } else {
249        limits.extend(args);
250    }
251    limits
252}