Skip to main content

start_command/
cgroup_memory.rs

1//! cgroup v2 memory counters of a detached docker container (issue #182).
2//!
3//! Docker's `State.OOMKilled` is container-wide and sticky (moby/moby#43564):
4//! it says that *some* process in the container was OOM-killed at some point,
5//! but not how many, not whether the container hit its own `--memory` limit or
6//! the whole host ran out, and not how close the execution came to its limit.
7//! The kernel provides raw counters per cgroup
8//! (<https://docs.kernel.org/admin-guide/cgroup-v2.html#memory-interface-files>):
9//!
10//! ```text
11//! memory.events `oom`       allocation events reaching the memory limit
12//! memory.events `oom_kill`  processes killed here by *any* OOM killer
13//! memory.peak / memory.max  peak usage (Linux 5.19+) and limit (`max` when
14//!                           unlimited)
15//! ```
16//! These counters have different units and are hierarchical. A group OOM can
17//! kill several processes for one allocation event; earlier allocation events
18//! can also coexist with a later host OOM. Their comparison cannot establish
19//! container, parent or host scope (issue #185).
20//!
21//! The cgroup is removed when the container stops, so the values cannot be read
22//! afterwards. The detached completion watcher therefore samples them while the
23//! container runs and hands the last sample to the post-mortem, the finalizer
24//! and the recovery step. Best effort by design: no cgroup v2, a remote docker
25//! daemon or a hidden `/proc` leave the counters unknown, and a kill in the last
26//! sampling interval can be missed when the cgroup is gone before the final
27//! read.
28
29use serde::{Deserialize, Deserializer, Serialize};
30use serde_json::Value;
31
32use crate::isolation::isolation_log::shell_quote;
33
34/// Default seconds between samples; the watcher environment may override it.
35pub const CGROUP_SAMPLE_INTERVAL_SECONDS: u64 = 5;
36
37/// Longest wait between rounds that keep failing (issue #247), unless the
38/// interval itself is longer. Each round without a sample doubles the wait.
39pub const CGROUP_SAMPLE_BACKOFF_MAX_SECONDS: u64 = 60;
40
41/// Exit status of both in-container reads for unreadable counters.
42pub const CGROUP_UNREADABLE_EXIT: i32 = 3;
43
44/// Shell variables used by the sampler snippets.
45pub mod cgroup_shell_vars {
46    /// Last sample: `<memory.max> <memory.peak> <oom> <oom_kill>`.
47    pub const SAMPLE: &str = "__start_command_cgroup";
48    pub const SAMPLER: &str = "__start_command_cgroup_sampler";
49    pub const FILE: &str = "__start_command_cgroup_file";
50}
51
52/// Where the OOM killer that killed processes in the container came from.
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54pub enum OomScope {
55    /// Raw counters do not establish container, parent or host scope.
56    Unknown,
57    /// Retained scope name; raw counters alone cannot establish it.
58    ContainerLimit,
59    /// Retained scope name; raw counters alone cannot establish it.
60    HostOrParent,
61}
62
63impl OomScope {
64    pub fn as_str(&self) -> &'static str {
65        match self {
66            OomScope::Unknown => "unknown",
67            OomScope::ContainerLimit => "container-limit",
68            OomScope::HostOrParent => "host-or-parent",
69        }
70    }
71
72    pub fn note(&self) -> &'static str {
73        match self {
74            OomScope::Unknown => "OOM kill scope unknown",
75            OomScope::ContainerLimit => "the container hit its own memory limit",
76            OomScope::HostOrParent => "a host-wide or parent cgroup OOM killed processes here",
77        }
78    }
79}
80
81/// The counters of one sample. `limit_bytes` is `None` without a limit.
82#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct CgroupMemory {
85    pub limit_bytes: Option<u64>,
86    pub peak_bytes: Option<u64>,
87    pub oom_events: Option<u64>,
88    pub oom_kills: Option<u64>,
89}
90
91/// Shell functions shared by the sampler and its final read:
92///
93/// ```text
94/// __start_command_cgroup_dir NAME  print the container's cgroup v2 directory
95/// __start_command_cgroup_read DIR  print `<max> <peak> <oom> <oom_kill> DIR`
96/// ```
97///
98/// The directory comes from `/proc/<.State.Pid>/cgroup` (`0::<path>`), which
99/// covers the systemd and cgroupfs drivers, `--cgroup-parent` and rootless
100/// docker alike; the two default layouts are fallbacks. A candidate must name
101/// the container ID, so a PID reported by a remote daemon can never select an
102/// unrelated local cgroup. `START_COMMAND_CGROUP_ROOT` and
103/// `START_COMMAND_PROC_ROOT` replace `/sys/fs/cgroup` and `/proc` (tests).
104pub fn build_cgroup_functions_snippet() -> String {
105    r###"__start_command_cgroup_dir() { __scd_id=$(docker inspect -f '{{.Id}}' "$1" 2>/dev/null); [ -n "$__scd_id" ] || return 1; __scd_pid=$(docker inspect -f '{{.State.Pid}}' "$1" 2>/dev/null); __scd_rel=''; case "$__scd_pid" in ''|0|*[!0-9]*) ;; *) __scd_rel=$(sed -n 's/^0:://p' "${START_COMMAND_PROC_ROOT:-/proc}/$__scd_pid/cgroup" 2>/dev/null);; esac; __scd_root=${START_COMMAND_CGROUP_ROOT:-/sys/fs/cgroup}; for __scd_dir in ${__scd_rel:+"$__scd_root$__scd_rel"} "$__scd_root/system.slice/docker-$__scd_id.scope" "$__scd_root/docker/$__scd_id"; do case "$__scd_dir" in *"$__scd_id"*) if [ -r "$__scd_dir/memory.events" ]; then printf '%s' "$__scd_dir"; return 0; fi;; esac; done; return 1; }; __start_command_cgroup_read() { __scr_events=$(cat "$1/memory.events" 2>/dev/null) || return 1; __scr_oom=$(printf '%s\n' "$__scr_events" | sed -n 's/^oom //p'); __scr_kill=$(printf '%s\n' "$__scr_events" | sed -n 's/^oom_kill //p'); case "$__scr_oom:$__scr_kill" in *[!0-9:]*|:*|*:) return 1;; esac; __scr_max=$(cat "$1/memory.max" 2>/dev/null); __scr_peak=$(cat "$1/memory.peak" 2>/dev/null); printf '%s %s %s %s %s\n' "${__scr_max:--}" "${__scr_peak:--}" "${__scr_oom:--}" "${__scr_kill:--}" "$1"; }"###.to_string()
106}
107
108/// In-container loop of the long-lived sampler exec (issue #242): `$1` is the
109/// cgroup directory. The host paces it with one newline per interval on stdin
110/// (`docker exec -i`), so it runs only shell builtins: the task's own CPU
111/// limit pays for no `cat`, `sed` or `sleep` processes. Stdin reaches EOF when
112/// the exec's client is gone, so the loop never outlives the sampler; dockerd
113/// would accept its output even then. Exit 3: unreadable counters (no
114/// delegated `memory` controller), which no later round can change.
115pub fn build_cgroup_stream_script() -> String {
116    r###"while read -r __scx_tick; do __scx_oom=; __scx_kill=; __scx_max=-; __scx_peak=-; [ -r "$1/memory.events" ] || exit 3; while read -r __scx_key __scx_value; do case "$__scx_key" in oom) __scx_oom=$__scx_value;; oom_kill) __scx_kill=$__scx_value;; esac; done < "$1/memory.events"; case "$__scx_oom:$__scx_kill" in *[!0-9:]*|:*|*:) exit 3;; esac; [ ! -r "$1/memory.max" ] || read -r __scx_max < "$1/memory.max"; [ ! -r "$1/memory.peak" ] || read -r __scx_peak < "$1/memory.peak"; printf '%s %s %s %s %s\n' "${__scx_max:--}" "${__scx_peak:--}" "$__scx_oom" "$__scx_kill" "$1" || exit 0; done"###.to_string()
117}
118
119/// `$0` of the long-lived exec, visible in `ps` inside the task container.
120pub const CGROUP_STREAM_NAME: &str = "start-command-cgroup-stream";
121
122/// Interval value that turns the sampler off (issue #242).
123pub const CGROUP_SAMPLING_OFF_REASON: &str =
124    "sampling disabled by START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off";
125
126/// Shell fragment starting the background sampler. It writes each sample to a
127/// temp file with `mv`, so the watcher never reads a half-written line, and
128/// ends by itself once the container is gone.
129///
130/// Where the cgroup is readable locally it reads it directly. Otherwise, with
131/// a private cgroup namespace (a mounted docker.sock), one long-lived
132/// `docker exec -i` answers one host tick per interval (issue #242); a
133/// successful read proves the container runs, so Docker is asked for liveness
134/// only after a failed round or when the stream ends. Without a usable stream it falls back
135/// to one `docker exec` per round; without `sh` in the image (exit 126/127) or
136/// without any readable cgroup (exit 3 from either exec, issue #247) it
137/// records the reason and stops. Other failed rounds are retried, each one
138/// doubling the wait up to [`CGROUP_SAMPLE_BACKOFF_MAX_SECONDS`]; a sample
139/// restores the interval. `START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS=off`
140/// disables sampling.
141pub fn build_cgroup_sampler_start_snippet(container_name: &str) -> String {
142    let functions = build_cgroup_functions_snippet();
143    let remote = format!(
144        "{}; __start_command_cgroup_read /sys/fs/cgroup || exit {}",
145        functions, CGROUP_UNREADABLE_EXIT
146    );
147    format!(
148        "{}; {}",
149        functions,
150        r###"__start_command_cgroup_file="${TMPDIR:-/tmp}/start-command-cgroup.$$"; rm -f "$__start_command_cgroup_file" "$__start_command_cgroup_file.tmp" "$__start_command_cgroup_file.reason"; __start_command_memory_limit=$(docker inspect -f '{{.HostConfig.Memory}}' '__SC_NAME__' 2>/dev/null); __start_command_sample_name='__SC_NAME__'; ( export START_COMMAND_CGROUP_SAMPLING=1; __scs_interval=${START_COMMAND_CGROUP_SAMPLE_INTERVAL_SECONDS:-__SC_INTERVAL__}; case "$__scs_interval" in [Oo][Ff][Ff]) printf '%s\n' __SC_OFF__ > "$__start_command_cgroup_file.reason"; exit 0;; *[!0-9]*|'') __scs_interval=__SC_INTERVAL__;; esac; [ "$__scs_interval" -gt 0 ] 2>/dev/null || __scs_interval=__SC_INTERVAL__; __scs_record() { set -f; set -- $1; set +f; case "$3:$4" in *[!0-9:]*|:*|*:) return 1;; esac; __scs_now=$(date +%s); __scs_previous=''; __scs_changed=0; __scs_previous_at=0; [ ! -r "$__start_command_cgroup_file" ] || read -r __scs_p __scs_p __scs_p __scs_previous __scs_changed __scs_previous_at __scs_p < "$__start_command_cgroup_file"; if [ -n "$__scs_previous" ] && [ "$4" -gt "$__scs_previous" ] 2>/dev/null && [ "$((__scs_now-__scs_previous_at))" -le 3 ]; then __scs_changed=$__scs_now; fi; printf '%s %s %s %s %s %s %s\n' "$1" "$2" "$3" "$4" "$__scs_changed" "$__scs_now" "$__scs_dir" > "$__start_command_cgroup_file.tmp" && mv -f "$__start_command_cgroup_file.tmp" "$__start_command_cgroup_file"; }; __scs_dir=$(__start_command_cgroup_dir '__SC_NAME__'); __scs_ns=$(docker inspect -f '{{.HostConfig.CgroupnsMode}}' '__SC_NAME__' 2>/dev/null); __scs_stream=1; __scs_reader=''; __scs_delay=$__scs_interval; while :; do __scs_ok=''; __scs_sampled=''; if [ -n "$__scs_dir" ]; then __scs_line=$(__start_command_cgroup_read "$__scs_dir") && __scs_record "$__scs_line" && __scs_ok=1; elif [ "$__scs_ns" != private ]; then printf '%s\n' "local cgroup unavailable; container cgroup namespace=${__scs_ns:-unavailable}, private required" > "$__start_command_cgroup_file.reason"; break; elif [ "$__scs_stream" = 1 ]; then trap 'kill "$__scs_reader" 2>/dev/null; wait "$__scs_reader" 2>/dev/null; exit 0' TERM; { { while :; do echo || exit 0; sleep "$__scs_interval" || exit 0; done 2>/dev/null & } | docker exec -i '__SC_NAME__' sh -c __SC_STREAM__ __SC_STREAM_NAME__ /sys/fs/cgroup 2>&1; printf '__scs_exit %s\n' "$?"; } | { __scs_got=''; __scs_exit=''; __scs_detail=''; while IFS= read -r __scs_line; do case "$__scs_line" in '__scs_exit '*) __scs_exit=${__scs_line#__scs_exit };; *) if __scs_record "$__scs_line"; then __scs_got=1; elif [ -z "$__scs_detail" ]; then __scs_detail=$(printf '%s' "$__scs_line" | cut -c 1-240); fi;; esac; done; [ -z "$__scs_got" ] || exit 0; printf '%s\n' "local cgroup unavailable; Docker exec exit=$__scs_exit: ${__scs_detail:-private cgroup v2 counters unreadable}" > "$__start_command_cgroup_file.reason"; case "$__scs_exit" in ''|0|*[!0-9]*) exit 1;; esac; exit "$__scs_exit"; } & __scs_reader=$!; wait "$__scs_reader"; __scs_exit=$?; trap - TERM; __scs_reader=''; case "$__scs_exit" in 0) __scs_sampled=1;; 3|126|127) break;; *) __scs_stream=0;; esac; else __scs_remote=$(docker exec '__SC_NAME__' sh -c __SC_REMOTE__ 2>&1); __scs_exit=$?; if [ "$__scs_exit" = 0 ] && __scs_record "$__scs_remote"; then __scs_ok=1; else __scs_detail=$(printf "%s" "$__scs_remote" | sed -n "1p" | cut -c 1-240); printf '%s\n' "local cgroup unavailable; Docker exec exit=$__scs_exit: ${__scs_detail:-private cgroup v2 counters unreadable}" > "$__start_command_cgroup_file.reason"; case "$__scs_exit" in 3|126|127) break;; esac; fi; fi; if [ -z "$__scs_ok" ] && [ "$(docker inspect -f '{{.State.Running}}' '__SC_NAME__' 2>/dev/null)" = false ]; then break; fi; if [ -n "$__scs_ok$__scs_sampled" ]; then __scs_delay=$__scs_interval; else __scs_delay=$((__scs_delay*2)); [ "$__scs_delay" -le __SC_BACKOFF__ ] || __scs_delay=__SC_BACKOFF__; [ "$__scs_delay" -ge "$__scs_interval" ] || __scs_delay=$__scs_interval; fi; sleep "$__scs_delay"; done ) >/dev/null 2>&1 & __start_command_cgroup_sampler=$!"###
151            .replace("'__SC_NAME__'", &shell_quote(container_name))
152            .replace("__SC_INTERVAL__", &CGROUP_SAMPLE_INTERVAL_SECONDS.to_string())
153            .replace(
154                "__SC_BACKOFF__",
155                &CGROUP_SAMPLE_BACKOFF_MAX_SECONDS.to_string()
156            )
157            .replace("__SC_STREAM_NAME__", CGROUP_STREAM_NAME)
158            .replace("__SC_STREAM__", &shell_quote(&build_cgroup_stream_script()))
159            .replace("__SC_REMOTE__", &shell_quote(&remote))
160            .replace("__SC_OFF__", &shell_quote(CGROUP_SAMPLING_OFF_REASON))
161    )
162}
163
164/// Shell fragment stopping the sampler once the container has exited and
165/// leaving the last sample (`<max> <peak> <oom> <oom_kill>`, or empty) in
166/// `$__start_command_cgroup`. The cgroup can outlive the main process for a
167/// moment, so it is read one last time when it still exists.
168pub fn build_cgroup_sampler_stop_snippet() -> String {
169    r###"kill "$__start_command_cgroup_sampler" 2>/dev/null; wait "$__start_command_cgroup_sampler" 2>/dev/null; __start_command_cgroup=$(cat "$__start_command_cgroup_file" 2>/dev/null); __start_command_memory_unavailable=$(cat "$__start_command_cgroup_file.reason" 2>/dev/null); __scs_dir=$(printf '%s' "$__start_command_cgroup" | cut -s -d ' ' -f 7-); if [ -n "$__scs_dir" ] && __scs_line=$(__start_command_cgroup_read "$__scs_dir"); then __scs_old=$(printf '%s' "$__start_command_cgroup" | cut -d ' ' -f 4); __scs_at=$(printf '%s' "$__start_command_cgroup" | cut -d ' ' -f 6); __scs_changed=$(printf '%s' "$__start_command_cgroup" | cut -d ' ' -f 5); __scs_now=$(date +%s); __scs_new=$(printf '%s' "$__scs_line" | cut -d ' ' -f 4); if [ "$__scs_new" -gt "$__scs_old" ] 2>/dev/null && [ "$((__scs_now-__scs_at))" -le 3 ]; then __scs_changed=$__scs_now; fi; __start_command_cgroup="$(printf '%s' "$__scs_line" | cut -d ' ' -f 1-4) $__scs_changed $__scs_now"; fi; rm -f "$__start_command_cgroup_file" "$__start_command_cgroup_file.tmp" "$__start_command_cgroup_file.reason"; __start_command_cgroup=$(printf '%s' "$__start_command_cgroup" | cut -d ' ' -f 1-6)"###.to_string()
170}
171
172/// Shell fragment appending the `Memory:` line of the post-mortem when a
173/// sample exists. `quoted_log_path` must already be shell-quoted.
174pub fn build_cgroup_memory_log_snippet(quoted_log_path: &str) -> String {
175    r###"__start_command_cgroup_log() { __scm_note=''; if [ "$4" -gt 0 ] 2>/dev/null; then __scm_note=' (OOM kill scope unknown)'; fi; printf 'Memory:     memory.max=%s memory.peak=%s oom=%s oom_kill=%s%s\n' "$1" "$2" "$3" "$4" "$__scm_note"; }; if [ -n "$__start_command_cgroup" ]; then __start_command_cgroup_log $__start_command_cgroup >> __SC_LOG__; else printf 'Memory:     unavailable (%s) memory.limit=%s (HostConfig)\n' "${__start_command_memory_unavailable:-sampler stopped before a reading; local cgroup unavailable}" "${__start_command_memory_limit:-unknown}" >> __SC_LOG__; fi"###.replace("__SC_LOG__", quoted_log_path)
176}
177
178fn parse_counter(text: &str) -> Option<u64> {
179    if !text.is_empty() && text.chars().all(|c| c.is_ascii_digit()) {
180        text.parse().ok()
181    } else {
182        None
183    }
184}
185
186/// Parse a sample written by the watcher
187/// (`<memory.max> <memory.peak> <oom> <oom_kill>`); `None` without a sample.
188pub fn parse_cgroup_memory_sample(text: &str) -> Option<CgroupMemory> {
189    let fields: Vec<&str> = text.split_whitespace().collect();
190    if fields.len() < 4 {
191        return None;
192    }
193    let counters = CgroupMemory {
194        limit_bytes: parse_counter(fields[0]),
195        peak_bytes: parse_counter(fields[1]),
196        oom_events: parse_counter(fields[2]),
197        oom_kills: parse_counter(fields[3]),
198    };
199    if counters.oom_events.is_none() && counters.oom_kills.is_none() {
200        // memory.events was unreadable: the line carries no information.
201        return None;
202    }
203    Some(counters)
204}
205
206/// The `Memory:` line for a sample, exactly as the watcher's shell writes it.
207/// Used where Rust writes the post-mortem itself (the recovery step).
208pub fn format_cgroup_memory_log_line(text: &str) -> Option<String> {
209    let counters = parse_cgroup_memory_sample(text)?;
210    let fields: Vec<&str> = text.split_whitespace().collect();
211    let note = describe_cgroup_oom_scope(&counters)
212        .map(|scope| format!(" ({})", scope.note()))
213        .unwrap_or_default();
214    Some(format!(
215        "Memory:     memory.max={} memory.peak={} oom={} oom_kill={}{}",
216        fields[0], fields[1], fields[2], fields[3], note
217    ))
218}
219
220/// `Unknown` for observed kills, `None` when no kills were observed.
221///
222/// Separately attributed kernel/cgroup evidence would be needed to establish
223/// container, parent or host scope. An unknown `oom_events` remains unknown.
224pub fn describe_cgroup_oom_scope(memory: &CgroupMemory) -> Option<OomScope> {
225    memory.oom_kills.filter(|kills| *kills > 0)?;
226    Some(OomScope::Unknown)
227}
228
229fn format_bytes(bytes: Option<u64>) -> String {
230    let Some(bytes) = bytes else {
231        return "unknown".to_string();
232    };
233    const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
234    let mut value = bytes as f64;
235    let mut unit = 0;
236    while value >= 1024.0 && unit < UNITS.len() - 1 {
237        value /= 1024.0;
238        unit += 1;
239    }
240    if unit == 0 {
241        format!("{} B", bytes)
242    } else {
243        format!("{:.1} {}", value, UNITS[unit])
244    }
245}
246
247/// Human-readable counters for `--status`, e.g.
248/// `peak 255.9 MiB of 256.0 MiB limit, oom 0, oom_kill 3 (...)`.
249pub fn format_cgroup_memory(memory: &CgroupMemory) -> String {
250    let limit = match memory.limit_bytes {
251        Some(_) => format!("{} limit", format_bytes(memory.limit_bytes)),
252        None => "no limit".to_string(),
253    };
254    let count = |value: Option<u64>| value.map_or_else(|| "unknown".to_string(), |v| v.to_string());
255    let note = describe_cgroup_oom_scope(memory)
256        .map(|scope| format!(" ({})", scope.note()))
257        .unwrap_or_default();
258    format!(
259        "peak {} of {}, oom {}, oom_kill {}{}",
260        format_bytes(memory.peak_bytes),
261        limit,
262        count(memory.oom_events),
263        count(memory.oom_kills),
264        note
265    )
266}
267
268fn counter_from_value(value: Option<&Value>) -> Option<u64> {
269    match value? {
270        Value::Number(number) => number.as_u64().or_else(|| {
271            number
272                .as_f64()
273                .filter(|v| v.fract() == 0.0 && *v >= 0.0 && *v <= 9_007_199_254_740_991.0)
274                .map(|v| v as u64)
275        }),
276        Value::String(text) => parse_counter(text.trim()),
277        _ => None,
278    }
279}
280
281/// Normalize counters read back from an execution record. Lenient, like the
282/// JavaScript reader: a malformed value never makes the record unreadable.
283pub fn normalize_cgroup_memory(value: &Value) -> Option<CgroupMemory> {
284    let object = value.as_object()?;
285    Some(CgroupMemory {
286        limit_bytes: counter_from_value(object.get("limitBytes")),
287        peak_bytes: counter_from_value(object.get("peakBytes")),
288        oom_events: counter_from_value(object.get("oomEvents")),
289        oom_kills: counter_from_value(object.get("oomKills")),
290    })
291}
292
293/// `deserialize_with` helper for `ExecutionRecord::cgroup_memory`.
294pub fn deserialize_cgroup_memory<'de, D>(deserializer: D) -> Result<Option<CgroupMemory>, D::Error>
295where
296    D: Deserializer<'de>,
297{
298    let value = Option::<Value>::deserialize(deserializer)?;
299    Ok(value.as_ref().and_then(normalize_cgroup_memory))
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn parses_counters_and_treats_max_as_no_limit() {
308        assert_eq!(
309            parse_cgroup_memory_sample("max 1024 0 2"),
310            Some(CgroupMemory {
311                limit_bytes: None,
312                peak_bytes: Some(1024),
313                oom_events: Some(0),
314                oom_kills: Some(2),
315            })
316        );
317        assert_eq!(parse_cgroup_memory_sample("max - - -"), None);
318        assert_eq!(parse_cgroup_memory_sample("1 2 3"), None);
319    }
320
321    #[test]
322    fn normalizes_floats_strings_and_nulls() {
323        let value = serde_json::json!({
324            "limitBytes": 268435456.0,
325            "peakBytes": "1024",
326            "oomEvents": null,
327            "oomKills": -1
328        });
329        assert_eq!(
330            normalize_cgroup_memory(&value),
331            Some(CgroupMemory {
332                limit_bytes: Some(268_435_456),
333                peak_bytes: Some(1024),
334                oom_events: None,
335                oom_kills: None,
336            })
337        );
338        assert_eq!(normalize_cgroup_memory(&Value::Null), None);
339    }
340}