Skip to main content

runnerd_core/observe/
metrics.rs

1//! CPU, memory and network of runner containers and of their slice, read from cgroup v2 files and
2//! `/proc/<pid>/net/dev` every `SAMPLE_INTERVAL`. A container's memory is an estimate of what its job holds without
3//! page cache: `memory.current` less the file LRU lists (`active_file`, `inactive_file`), so anonymous memory, tmpfs
4//! and shared memory (`shmem`, on the anon lists) and kernel memory, reclaimable slab included, since slab in use
5//! (an open file's dentry and inode) can't be freed. Page cache on the file lists is usually the kernel's to drop
6//! before `memory.max` OOM-kills ("reaches this limit and can't be reduced", Documentation/admin-guide/
7//! cgroup-v2.rst), though dirty, writeback or pinned pages may not be dropped at once. `docker stats` subtracts only
8//! `inactive_file`, so it counts page cache read twice. The slice's memory for the high-water mark is that
9//! conservative number, `memory.current` less `inactive_file`, as it always was: it stops starts before the slice
10//! is short of memory, not after. Whether a cgroup met its limit comes from `memory.events`, never from how close
11//! a sample came to it. A container whose files vanish ends its series; that is not an error. Beside the slice, it
12//! reads the host's memory: `MemTotal` and `MemAvailable` from `/proc/meminfo` and the stall shares of
13//! `/proc/pressure/memory`; and, every `HOLDERS_INTERVAL`, which programs outside the slice hold the most of it, by
14//! the name and memory lines of each `/proc/<pid>/status` whose `/proc/<pid>/cgroup` is not in the slice. Only a
15//! program's name leaves this module, never its command line, arguments, environment or paths.
16
17use std::collections::HashMap;
18use std::io;
19use std::path::{Path, PathBuf};
20use std::sync::{Arc, Mutex};
21use std::time::Duration;
22
23use tokio::sync::watch;
24
25use crate::api;
26use crate::store::{SampleRecord, Store};
27use crate::sync::lock;
28use crate::time;
29
30/// How often running containers are sampled.
31pub const SAMPLE_INTERVAL: Duration = Duration::from_secs(api::SAMPLE_INTERVAL);
32
33/// How often the host's processes are read for what holds its memory outside the slice: they are many, and what
34/// they hold changes slowly.
35pub const HOLDERS_INTERVAL: Duration = Duration::from_secs(30);
36
37/// How many programs `read_holders` names at most.
38pub const HOLDERS: usize = 3;
39
40/// What a program must hold for `read_holders` to name it, in bytes.
41pub const HOLDER_FLOOR: u64 = 512 << 20;
42
43/// Where the cgroup and proc file systems are mounted: the host's, or a test's fixture tree.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct Paths {
46    /// Usually `/sys/fs/cgroup`.
47    pub cgroup: PathBuf,
48    /// Usually `/proc`.
49    pub proc: PathBuf,
50}
51
52impl Default for Paths {
53    fn default() -> Paths {
54        Paths {
55            cgroup: PathBuf::from("/sys/fs/cgroup"),
56            proc: PathBuf::from("/proc"),
57        }
58    }
59}
60
61impl Paths {
62    /// The cgroup of a systemd slice: `a-b.slice` is `a.slice/a-b.slice`.
63    pub fn slice(&self, slice: &str) -> PathBuf {
64        let name = slice.strip_suffix(".slice").unwrap_or(slice);
65        let mut dir = self.cgroup.clone();
66        let mut prefix = String::new();
67        for part in name.split('-') {
68            if !prefix.is_empty() {
69                prefix.push('-');
70            }
71            prefix.push_str(part);
72            dir.push(format!("{prefix}.slice"));
73        }
74        dir
75    }
76
77    /// The cgroup Docker's systemd driver gives a container in a slice.
78    pub fn container(&self, slice: &str, container: &str) -> PathBuf {
79        self.slice(slice).join(format!("docker-{container}.scope"))
80    }
81}
82
83/// A cgroup's counters at one moment.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85pub struct Usage {
86    /// CPU time so far, in microseconds (`cpu.stat` `usage_usec`).
87    pub cpu_usage: u64,
88    /// `memory.current` less `active_file` and `inactive_file` from `memory.stat`: without page cache.
89    pub memory: u64,
90    /// `memory.current` less `inactive_file`: page cache read twice still counts.
91    pub memory_with_active_file: u64,
92}
93
94/// What a cgroup's `memory.events` counted so far, its descendants' included, and `memory.events.local`'s `oom`, its
95/// own; `None` for a counter the file lacks or that doesn't parse, which says nothing either way.
96#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
97pub struct Events {
98    /// Times its memory, or a descendant's under its own limit, reached the limit, reclaim could not bring it down
99    /// and an allocation was about to fail (`oom`). Not `max`, which counts every reclaim at the limit, page
100    /// cache's included.
101    pub oom: Option<u64>,
102    /// Times its own limit, not a descendant's, failed reclaim (`memory.events.local` `oom`): its cap stopped an
103    /// allocation. `None` on a kernel without the file.
104    pub local_oom: Option<u64>,
105    /// Processes the kernel killed in it for memory (`oom_kill`).
106    pub oom_kill: Option<u64>,
107    /// Times a socket's receive window was held small because its memory, or a descendant's, was under pressure
108    /// (`sock_throttled`): reclaim that went badly, of the host or of a limit, slows its downloads. `None` on a
109    /// kernel without the counter.
110    pub sock_throttled: Option<u64>,
111}
112
113fn read(path: &Path) -> io::Result<String> {
114    std::fs::read_to_string(path)
115}
116
117fn field(text: &str, key: &str) -> Option<u64> {
118    text.lines().find_map(|line| {
119        let (name, value) = line.split_once(' ')?;
120        (name == key).then(|| value.trim().parse().ok()).flatten()
121    })
122}
123
124fn invalid(path: &Path) -> io::Error {
125    io::Error::new(
126        io::ErrorKind::InvalidData,
127        format!("{} has an unexpected format", path.display()),
128    )
129}
130
131/// Reads a cgroup's CPU and memory counters.
132pub fn read_usage(cgroup: &Path) -> io::Result<Usage> {
133    let current_path = cgroup.join("memory.current");
134    let current: u64 = read(&current_path)?
135        .trim()
136        .parse()
137        .map_err(|_| invalid(&current_path))?;
138    let stat_path = cgroup.join("memory.stat");
139    let stat = read(&stat_path)?;
140    let (Some(active_file), Some(inactive_file)) =
141        (field(&stat, "active_file"), field(&stat, "inactive_file"))
142    else {
143        return Err(invalid(&stat_path));
144    };
145    let cpu_path = cgroup.join("cpu.stat");
146    let cpu_usage = field(&read(&cpu_path)?, "usage_usec").ok_or_else(|| invalid(&cpu_path))?;
147    Ok(Usage {
148        cpu_usage,
149        memory: current.saturating_sub(active_file + inactive_file),
150        memory_with_active_file: current.saturating_sub(inactive_file),
151    })
152}
153
154/// What a cgroup holds that `MemAvailable` does not count as available: its memory without page cache less its
155/// reclaimable slab (`slab_reclaimable`), which `MemAvailable` counts already. 0 when it can't be read.
156pub fn read_unavailable(cgroup: &Path) -> u64 {
157    let slab = read(&cgroup.join("memory.stat"))
158        .ok()
159        .and_then(|stat| field(&stat, "slab_reclaimable"))
160        .unwrap_or(0);
161    read_usage(cgroup).map_or(0, |usage| usage.memory.saturating_sub(slab))
162}
163
164/// A cgroup's `memory.events` and `memory.events.local`; a file's counters `None` when it can't be read.
165pub fn read_events(cgroup: &Path) -> Events {
166    let local_oom = read(&cgroup.join("memory.events.local"))
167        .ok()
168        .and_then(|local| field(&local, "oom"));
169    let Ok(events) = read(&cgroup.join("memory.events")) else {
170        return Events {
171            local_oom,
172            ..Events::default()
173        };
174    };
175    Events {
176        oom: field(&events, "oom"),
177        local_oom,
178        oom_kill: field(&events, "oom_kill"),
179        sock_throttled: field(&events, "sock_throttled"),
180    }
181}
182
183/// The host's memory from `/proc/meminfo` and `/proc/pressure/memory`, beside `slice` bytes the runners hold
184/// without page cache, at `at` (seconds). `None` without `MemTotal` and `MemAvailable`; the pressure lines are `None`
185/// on a kernel without pressure stall information.
186pub fn read_host_memory(paths: &Paths, slice: u64, at: u64) -> Option<api::HostMemory> {
187    let meminfo = read(&paths.proc.join("meminfo")).ok()?;
188    let kib = |key: &str| {
189        meminfo.lines().find_map(|line| {
190            let value = line.strip_prefix(key)?.strip_prefix(':')?;
191            value.trim().strip_suffix("kB")?.trim().parse::<u64>().ok()
192        })
193    };
194    let pressure = read(&paths.proc.join("pressure/memory")).unwrap_or_default();
195    let line = |kind: &str| {
196        let fields = pressure
197            .lines()
198            .find_map(|line| line.strip_prefix(kind)?.strip_prefix(' '))?;
199        let avg = |key: &str| {
200            fields.split_whitespace().find_map(|field| {
201                let value = field.strip_prefix(key)?.strip_prefix('=')?;
202                let (whole, fraction) = value.split_once('.').unwrap_or((value, "0"));
203                let hundredths: u32 = format!("{fraction:0<2}").get(..2)?.parse().ok()?;
204                Some(
205                    whole
206                        .parse::<u32>()
207                        .ok()?
208                        .saturating_mul(100)
209                        .saturating_add(hundredths),
210                )
211            })
212        };
213        Some(api::Pressure {
214            avg10: avg("avg10")?,
215            avg60: avg("avg60")?,
216        })
217    };
218    Some(api::HostMemory {
219        at,
220        total: kib("MemTotal")?.saturating_mul(1024),
221        available: kib("MemAvailable")?.saturating_mul(1024),
222        slice,
223        some: line("some"),
224        full: line("full"),
225        holders: Vec::new(),
226    })
227}
228
229/// The `HOLDERS` programs outside `slice` holding the most memory, largest first, none under `HOLDER_FLOOR`: the
230/// processes in `/proc` whose cgroup is not the slice or under it, grouped by name (`Name` in `/proc/<pid>/status`,
231/// the kernel's escaped `comm`), each holding its `RssAnon` and `RssShmem`. A process that ends while it is read,
232/// or a kernel thread, which holds neither, counts for nothing. None without cgroup v2 mounted at `paths.cgroup`
233/// (no `cgroup.controllers`, as on a v1 or hybrid host), and a process without a v2 cgroup counts for nothing:
234/// where a process's slice can't be told, a runner's could pass for a program outside it.
235pub fn read_holders(paths: &Paths, slice: &str) -> Vec<api::Holder> {
236    if !paths.cgroup.join("cgroup.controllers").is_file() {
237        return Vec::new();
238    }
239    let slice = paths.slice(slice);
240    let slice = slice.strip_prefix(&paths.cgroup).unwrap_or(&slice);
241    let Ok(entries) = std::fs::read_dir(&paths.proc) else {
242        return Vec::new();
243    };
244    let mut held: HashMap<String, u64> = HashMap::new();
245    for entry in entries.flatten() {
246        let name = entry.file_name();
247        if !name
248            .to_str()
249            .is_some_and(|name| name.bytes().all(|byte| byte.is_ascii_digit()))
250        {
251            continue;
252        }
253        let dir = entry.path();
254        let Ok(cgroup) = read(&dir.join("cgroup")) else {
255            continue;
256        };
257        let Some(cgroup) = cgroup.lines().find_map(|line| line.strip_prefix("0::")) else {
258            continue;
259        };
260        if Path::new(cgroup.trim_start_matches('/')).starts_with(slice) {
261            continue;
262        }
263        // `comm` is bytes, and the kernel cuts it at 15 without minding UTF-8.
264        let Ok(status) = std::fs::read(dir.join("status")) else {
265            continue;
266        };
267        let status = String::from_utf8_lossy(&status);
268        let line = |key: &str| {
269            status
270                .lines()
271                .find_map(|line| line.strip_prefix(key)?.strip_prefix(':'))
272        };
273        let kib = |key: &str| {
274            line(key)
275                .and_then(|value| value.trim().strip_suffix("kB")?.trim().parse::<u64>().ok())
276                .unwrap_or(0)
277        };
278        let memory = kib("RssAnon")
279            .saturating_add(kib("RssShmem"))
280            .saturating_mul(1024);
281        let Some(name) = line("Name") else {
282            continue;
283        };
284        let name: String = name.trim().chars().filter(|c| !c.is_control()).collect();
285        if memory > 0 {
286            let total = held.entry(name).or_default();
287            *total = total.saturating_add(memory);
288        }
289    }
290    let mut holders: Vec<api::Holder> = held
291        .into_iter()
292        .filter(|(_, memory)| *memory >= HOLDER_FLOOR)
293        .map(|(name, memory)| api::Holder { name, memory })
294        .collect();
295    holders.sort_by(|a, b| b.memory.cmp(&a.memory).then_with(|| a.name.cmp(&b.name)));
296    holders.truncate(HOLDERS);
297    holders
298}
299
300/// Bytes received and sent by a process's network namespace on its `eth*` interfaces: the container's own
301/// traffic, without its loopback or the bridge of the Docker Engine inside it, whose traffic also crosses `eth0`.
302pub fn read_network(paths: &Paths, pid: u32) -> io::Result<(u64, u64)> {
303    let path = paths.proc.join(pid.to_string()).join("net/dev");
304    let text = read(&path)?;
305    let mut totals = (0, 0);
306    for line in text.lines().skip(2) {
307        let Some((name, counters)) = line.split_once(':') else {
308            return Err(invalid(&path));
309        };
310        if !name.trim().starts_with("eth") {
311            continue;
312        }
313        let counters: Vec<u64> = counters
314            .split_whitespace()
315            .map(str::parse)
316            .collect::<Result<_, _>>()
317            .map_err(|_| invalid(&path))?;
318        // Receive: bytes packets errs drop fifo frame compressed multicast; then transmit, bytes first.
319        let (Some(received), Some(sent)) = (counters.first(), counters.get(8)) else {
320            return Err(invalid(&path));
321        };
322        totals.0 += received;
323        totals.1 += sent;
324    }
325    Ok(totals)
326}
327
328/// A runner container to sample.
329#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct Tracked {
331    /// The runner's name, which keys its samples.
332    pub runner: String,
333    /// The full Docker container ID.
334    pub container: String,
335    /// The container's main process on the host, for its network counters.
336    pub pid: u32,
337}
338
339/// Samples containers and their slice, turning CPU time into CPU use between samples.
340#[derive(Debug)]
341pub struct Sampler {
342    paths: Paths,
343    slice: String,
344    previous: HashMap<String, (u64, u64)>,
345    previous_host: Option<(u64, u64)>,
346    /// The last `read_holders` and when (ms) it was read.
347    holders: Option<(u64, Vec<api::Holder>)>,
348}
349
350/// Millicores used between two readings of CPU time (µs) taken at two times (ms).
351fn millicores(previous: Option<(u64, u64)>, at: u64, cpu_usage: u64) -> u64 {
352    previous.map_or(0, |(then, usage)| {
353        cpu_usage
354            .saturating_sub(usage)
355            .checked_div(at.saturating_sub(then))
356            .unwrap_or(0)
357    })
358}
359
360impl Sampler {
361    /// A sampler for containers in `slice`.
362    pub fn new(paths: Paths, slice: &str) -> Sampler {
363        Sampler {
364            paths,
365            slice: slice.to_owned(),
366            previous: HashMap::new(),
367            previous_host: None,
368            holders: None,
369        }
370    }
371
372    /// The host's memory at `at` (ms), beside what the slice holds that `MemAvailable` leaves out
373    /// (`read_unavailable`), with what holds it outside the slice as last read, at most `HOLDERS_INTERVAL` ago.
374    pub fn host_memory(&mut self, at: u64) -> Option<api::HostMemory> {
375        let slice = read_unavailable(&self.paths.slice(&self.slice));
376        let mut memory = read_host_memory(&self.paths, slice, at / 1000)?;
377        let every = u64::try_from(HOLDERS_INTERVAL.as_millis()).unwrap_or(u64::MAX);
378        let holders = match self.holders.take() {
379            Some((then, holders)) if at.saturating_sub(then) < every => (then, holders),
380            _ => (at, read_holders(&self.paths, &self.slice)),
381        };
382        memory.holders.clone_from(&holders.1);
383        self.holders = Some(holders);
384        Some(memory)
385    }
386
387    /// One sample of every tracked container that still exists, and of the slice, at `at` (ms). A container
388    /// whose files are gone gets no sample, and is forgotten.
389    pub fn sample(
390        &mut self,
391        tracked: &[Tracked],
392        at: u64,
393    ) -> (Vec<SampleRecord>, Option<api::HostSample>) {
394        let mut samples = Vec::new();
395        let mut seen = HashMap::new();
396        for container in tracked {
397            let cgroup = self.paths.container(&self.slice, &container.container);
398            let (Ok(usage), Ok((received, sent))) = (
399                read_usage(&cgroup),
400                read_network(&self.paths, container.pid),
401            ) else {
402                continue;
403            };
404            let cpu = millicores(
405                self.previous.get(&container.runner).copied(),
406                at,
407                usage.cpu_usage,
408            );
409            seen.insert(container.runner.clone(), (at, usage.cpu_usage));
410            samples.push(SampleRecord {
411                runner: container.runner.clone(),
412                at,
413                cpu,
414                cpu_usage: usage.cpu_usage,
415                memory: usage.memory,
416                network_received: received,
417                network_sent: sent,
418                events: read_events(&cgroup),
419            });
420        }
421        self.previous = seen;
422        let host = read_usage(&self.paths.slice(&self.slice))
423            .ok()
424            .map(|usage| {
425                let cpu = millicores(self.previous_host, at, usage.cpu_usage);
426                self.previous_host = Some((at, usage.cpu_usage));
427                api::HostSample {
428                    at: at / 1000,
429                    cpu,
430                    memory: usage.memory_with_active_file,
431                }
432            });
433        (samples, host)
434    }
435}
436
437/// Samples the tracked containers every `SAMPLE_INTERVAL` into the store, until the engine drops its sender.
438pub async fn run(state: Sampler, tracked: watch::Receiver<Vec<Tracked>>, store: Store) {
439    let state = Arc::new(Mutex::new(state));
440    let mut interval = tokio::time::interval(SAMPLE_INTERVAL);
441    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
442    loop {
443        interval.tick().await;
444        if tracked.has_changed().is_err() {
445            return;
446        }
447        let containers = tracked.borrow().clone();
448        let state = state.clone();
449        // Reading cgroup files is quick but blocking.
450        let Ok(((samples, host), memory)) = tokio::task::spawn_blocking(move || {
451            // A panic midway through a sample leaves the counters of the last or of this one, either of which the
452            // next sample measures from.
453            let mut state = lock(&state);
454            let at = time::now_ms();
455            (state.sample(&containers, at), state.host_memory(at))
456        })
457        .await
458        else {
459            continue;
460        };
461        if let Some(memory) = memory {
462            store.host_memory(memory);
463        }
464        if !samples.is_empty() {
465            store.samples(samples);
466        }
467        if let Some(host) = host {
468            store.host_sample(host);
469        }
470    }
471}