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; `None` for a counter the file lacks
95/// 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    /// Processes the kernel killed in it for memory (`oom_kill`).
103    pub oom_kill: Option<u64>,
104    /// Times a socket's receive window was held small because its memory, or a descendant's, was under pressure
105    /// (`sock_throttled`): reclaim that went badly, of the host or of a limit, slows its downloads. `None` on a
106    /// kernel without the counter.
107    pub sock_throttled: Option<u64>,
108}
109
110fn read(path: &Path) -> io::Result<String> {
111    std::fs::read_to_string(path)
112}
113
114fn field(text: &str, key: &str) -> Option<u64> {
115    text.lines().find_map(|line| {
116        let (name, value) = line.split_once(' ')?;
117        (name == key).then(|| value.trim().parse().ok()).flatten()
118    })
119}
120
121fn invalid(path: &Path) -> io::Error {
122    io::Error::new(
123        io::ErrorKind::InvalidData,
124        format!("{} has an unexpected format", path.display()),
125    )
126}
127
128/// Reads a cgroup's CPU and memory counters.
129pub fn read_usage(cgroup: &Path) -> io::Result<Usage> {
130    let current_path = cgroup.join("memory.current");
131    let current: u64 = read(&current_path)?
132        .trim()
133        .parse()
134        .map_err(|_| invalid(&current_path))?;
135    let stat_path = cgroup.join("memory.stat");
136    let stat = read(&stat_path)?;
137    let (Some(active_file), Some(inactive_file)) =
138        (field(&stat, "active_file"), field(&stat, "inactive_file"))
139    else {
140        return Err(invalid(&stat_path));
141    };
142    let cpu_path = cgroup.join("cpu.stat");
143    let cpu_usage = field(&read(&cpu_path)?, "usage_usec").ok_or_else(|| invalid(&cpu_path))?;
144    Ok(Usage {
145        cpu_usage,
146        memory: current.saturating_sub(active_file + inactive_file),
147        memory_with_active_file: current.saturating_sub(inactive_file),
148    })
149}
150
151/// What a cgroup holds that `MemAvailable` does not count as available: its memory without page cache less its
152/// reclaimable slab (`slab_reclaimable`), which `MemAvailable` counts already. 0 when it can't be read.
153pub fn read_unavailable(cgroup: &Path) -> u64 {
154    let slab = read(&cgroup.join("memory.stat"))
155        .ok()
156        .and_then(|stat| field(&stat, "slab_reclaimable"))
157        .unwrap_or(0);
158    read_usage(cgroup).map_or(0, |usage| usage.memory.saturating_sub(slab))
159}
160
161/// A cgroup's `memory.events`; every counter `None` when the file can't be read.
162pub fn read_events(cgroup: &Path) -> Events {
163    let Ok(events) = read(&cgroup.join("memory.events")) else {
164        return Events::default();
165    };
166    Events {
167        oom: field(&events, "oom"),
168        oom_kill: field(&events, "oom_kill"),
169        sock_throttled: field(&events, "sock_throttled"),
170    }
171}
172
173/// The host's memory from `/proc/meminfo` and `/proc/pressure/memory`, beside `slice` bytes the runners hold
174/// without page cache, at `at` (seconds). `None` without `MemTotal` and `MemAvailable`; the pressure lines are `None`
175/// on a kernel without pressure stall information.
176pub fn read_host_memory(paths: &Paths, slice: u64, at: u64) -> Option<api::HostMemory> {
177    let meminfo = read(&paths.proc.join("meminfo")).ok()?;
178    let kib = |key: &str| {
179        meminfo.lines().find_map(|line| {
180            let value = line.strip_prefix(key)?.strip_prefix(':')?;
181            value.trim().strip_suffix("kB")?.trim().parse::<u64>().ok()
182        })
183    };
184    let pressure = read(&paths.proc.join("pressure/memory")).unwrap_or_default();
185    let line = |kind: &str| {
186        let fields = pressure
187            .lines()
188            .find_map(|line| line.strip_prefix(kind)?.strip_prefix(' '))?;
189        let avg = |key: &str| {
190            fields.split_whitespace().find_map(|field| {
191                let value = field.strip_prefix(key)?.strip_prefix('=')?;
192                let (whole, fraction) = value.split_once('.').unwrap_or((value, "0"));
193                let hundredths: u32 = format!("{fraction:0<2}").get(..2)?.parse().ok()?;
194                Some(
195                    whole
196                        .parse::<u32>()
197                        .ok()?
198                        .saturating_mul(100)
199                        .saturating_add(hundredths),
200                )
201            })
202        };
203        Some(api::Pressure {
204            avg10: avg("avg10")?,
205            avg60: avg("avg60")?,
206        })
207    };
208    Some(api::HostMemory {
209        at,
210        total: kib("MemTotal")?.saturating_mul(1024),
211        available: kib("MemAvailable")?.saturating_mul(1024),
212        slice,
213        some: line("some"),
214        full: line("full"),
215        holders: Vec::new(),
216    })
217}
218
219/// The `HOLDERS` programs outside `slice` holding the most memory, largest first, none under `HOLDER_FLOOR`: the
220/// processes in `/proc` whose cgroup is not the slice or under it, grouped by name (`Name` in `/proc/<pid>/status`,
221/// the kernel's escaped `comm`), each holding its `RssAnon` and `RssShmem`. A process that ends while it is read,
222/// or a kernel thread, which holds neither, counts for nothing. None without cgroup v2 mounted at `paths.cgroup`
223/// (no `cgroup.controllers`, as on a v1 or hybrid host), and a process without a v2 cgroup counts for nothing:
224/// where a process's slice can't be told, a runner's could pass for a program outside it.
225pub fn read_holders(paths: &Paths, slice: &str) -> Vec<api::Holder> {
226    if !paths.cgroup.join("cgroup.controllers").is_file() {
227        return Vec::new();
228    }
229    let slice = paths.slice(slice);
230    let slice = slice.strip_prefix(&paths.cgroup).unwrap_or(&slice);
231    let Ok(entries) = std::fs::read_dir(&paths.proc) else {
232        return Vec::new();
233    };
234    let mut held: HashMap<String, u64> = HashMap::new();
235    for entry in entries.flatten() {
236        let name = entry.file_name();
237        if !name
238            .to_str()
239            .is_some_and(|name| name.bytes().all(|byte| byte.is_ascii_digit()))
240        {
241            continue;
242        }
243        let dir = entry.path();
244        let Ok(cgroup) = read(&dir.join("cgroup")) else {
245            continue;
246        };
247        let Some(cgroup) = cgroup.lines().find_map(|line| line.strip_prefix("0::")) else {
248            continue;
249        };
250        if Path::new(cgroup.trim_start_matches('/')).starts_with(slice) {
251            continue;
252        }
253        // `comm` is bytes, and the kernel cuts it at 15 without minding UTF-8.
254        let Ok(status) = std::fs::read(dir.join("status")) else {
255            continue;
256        };
257        let status = String::from_utf8_lossy(&status);
258        let line = |key: &str| {
259            status
260                .lines()
261                .find_map(|line| line.strip_prefix(key)?.strip_prefix(':'))
262        };
263        let kib = |key: &str| {
264            line(key)
265                .and_then(|value| value.trim().strip_suffix("kB")?.trim().parse::<u64>().ok())
266                .unwrap_or(0)
267        };
268        let memory = kib("RssAnon")
269            .saturating_add(kib("RssShmem"))
270            .saturating_mul(1024);
271        let Some(name) = line("Name") else {
272            continue;
273        };
274        let name: String = name.trim().chars().filter(|c| !c.is_control()).collect();
275        if memory > 0 {
276            let total = held.entry(name).or_default();
277            *total = total.saturating_add(memory);
278        }
279    }
280    let mut holders: Vec<api::Holder> = held
281        .into_iter()
282        .filter(|(_, memory)| *memory >= HOLDER_FLOOR)
283        .map(|(name, memory)| api::Holder { name, memory })
284        .collect();
285    holders.sort_by(|a, b| b.memory.cmp(&a.memory).then_with(|| a.name.cmp(&b.name)));
286    holders.truncate(HOLDERS);
287    holders
288}
289
290/// Bytes received and sent by a process's network namespace on its `eth*` interfaces: the container's own
291/// traffic, without its loopback or the bridge of the Docker Engine inside it, whose traffic also crosses `eth0`.
292pub fn read_network(paths: &Paths, pid: u32) -> io::Result<(u64, u64)> {
293    let path = paths.proc.join(pid.to_string()).join("net/dev");
294    let text = read(&path)?;
295    let mut totals = (0, 0);
296    for line in text.lines().skip(2) {
297        let Some((name, counters)) = line.split_once(':') else {
298            return Err(invalid(&path));
299        };
300        if !name.trim().starts_with("eth") {
301            continue;
302        }
303        let counters: Vec<u64> = counters
304            .split_whitespace()
305            .map(str::parse)
306            .collect::<Result<_, _>>()
307            .map_err(|_| invalid(&path))?;
308        // Receive: bytes packets errs drop fifo frame compressed multicast; then transmit, bytes first.
309        let (Some(received), Some(sent)) = (counters.first(), counters.get(8)) else {
310            return Err(invalid(&path));
311        };
312        totals.0 += received;
313        totals.1 += sent;
314    }
315    Ok(totals)
316}
317
318/// A runner container to sample.
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub struct Tracked {
321    /// The runner's name, which keys its samples.
322    pub runner: String,
323    /// The full Docker container ID.
324    pub container: String,
325    /// The container's main process on the host, for its network counters.
326    pub pid: u32,
327}
328
329/// Samples containers and their slice, turning CPU time into CPU use between samples.
330#[derive(Debug)]
331pub struct Sampler {
332    paths: Paths,
333    slice: String,
334    previous: HashMap<String, (u64, u64)>,
335    previous_host: Option<(u64, u64)>,
336    /// The last `read_holders` and when (ms) it was read.
337    holders: Option<(u64, Vec<api::Holder>)>,
338}
339
340/// Millicores used between two readings of CPU time (µs) taken at two times (ms).
341fn millicores(previous: Option<(u64, u64)>, at: u64, cpu_usage: u64) -> u64 {
342    previous.map_or(0, |(then, usage)| {
343        cpu_usage
344            .saturating_sub(usage)
345            .checked_div(at.saturating_sub(then))
346            .unwrap_or(0)
347    })
348}
349
350impl Sampler {
351    /// A sampler for containers in `slice`.
352    pub fn new(paths: Paths, slice: &str) -> Sampler {
353        Sampler {
354            paths,
355            slice: slice.to_owned(),
356            previous: HashMap::new(),
357            previous_host: None,
358            holders: None,
359        }
360    }
361
362    /// The host's memory at `at` (ms), beside what the slice holds that `MemAvailable` leaves out
363    /// (`read_unavailable`), with what holds it outside the slice as last read, at most `HOLDERS_INTERVAL` ago.
364    pub fn host_memory(&mut self, at: u64) -> Option<api::HostMemory> {
365        let slice = read_unavailable(&self.paths.slice(&self.slice));
366        let mut memory = read_host_memory(&self.paths, slice, at / 1000)?;
367        let every = u64::try_from(HOLDERS_INTERVAL.as_millis()).unwrap_or(u64::MAX);
368        let holders = match self.holders.take() {
369            Some((then, holders)) if at.saturating_sub(then) < every => (then, holders),
370            _ => (at, read_holders(&self.paths, &self.slice)),
371        };
372        memory.holders.clone_from(&holders.1);
373        self.holders = Some(holders);
374        Some(memory)
375    }
376
377    /// One sample of every tracked container that still exists, and of the slice, at `at` (ms). A container
378    /// whose files are gone gets no sample, and is forgotten.
379    pub fn sample(
380        &mut self,
381        tracked: &[Tracked],
382        at: u64,
383    ) -> (Vec<SampleRecord>, Option<api::HostSample>) {
384        let mut samples = Vec::new();
385        let mut seen = HashMap::new();
386        for container in tracked {
387            let cgroup = self.paths.container(&self.slice, &container.container);
388            let (Ok(usage), Ok((received, sent))) = (
389                read_usage(&cgroup),
390                read_network(&self.paths, container.pid),
391            ) else {
392                continue;
393            };
394            let cpu = millicores(
395                self.previous.get(&container.runner).copied(),
396                at,
397                usage.cpu_usage,
398            );
399            seen.insert(container.runner.clone(), (at, usage.cpu_usage));
400            samples.push(SampleRecord {
401                runner: container.runner.clone(),
402                at,
403                cpu,
404                cpu_usage: usage.cpu_usage,
405                memory: usage.memory,
406                network_received: received,
407                network_sent: sent,
408                events: read_events(&cgroup),
409            });
410        }
411        self.previous = seen;
412        let host = read_usage(&self.paths.slice(&self.slice))
413            .ok()
414            .map(|usage| {
415                let cpu = millicores(self.previous_host, at, usage.cpu_usage);
416                self.previous_host = Some((at, usage.cpu_usage));
417                api::HostSample {
418                    at: at / 1000,
419                    cpu,
420                    memory: usage.memory_with_active_file,
421                }
422            });
423        (samples, host)
424    }
425}
426
427/// Samples the tracked containers every `SAMPLE_INTERVAL` into the store, until the engine drops its sender.
428pub async fn run(state: Sampler, tracked: watch::Receiver<Vec<Tracked>>, store: Store) {
429    let state = Arc::new(Mutex::new(state));
430    let mut interval = tokio::time::interval(SAMPLE_INTERVAL);
431    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
432    loop {
433        interval.tick().await;
434        if tracked.has_changed().is_err() {
435            return;
436        }
437        let containers = tracked.borrow().clone();
438        let state = state.clone();
439        // Reading cgroup files is quick but blocking.
440        let Ok(((samples, host), memory)) = tokio::task::spawn_blocking(move || {
441            // A panic midway through a sample leaves the counters of the last or of this one, either of which the
442            // next sample measures from.
443            let mut state = lock(&state);
444            let at = time::now_ms();
445            (state.sample(&containers, at), state.host_memory(at))
446        })
447        .await
448        else {
449            continue;
450        };
451        if let Some(memory) = memory {
452            store.host_memory(memory);
453        }
454        if !samples.is_empty() {
455            store.samples(samples);
456        }
457        if let Some(host) = host {
458            store.host_sample(host);
459        }
460    }
461}