Skip to main content

conmonrs/oom_watcher/
v2.rs

1//! The memory events of a cgroup v2 and of the cgroups above it.
2
3use super::{
4    OOMEvent, OOMWatcher, WATCH_EVENT_CHANNEL_SIZE,
5    inotify::{SharedWatcher, Subscription, WatchEvent},
6};
7use anyhow::{Context, Result, bail};
8use nix::{errno::Errno, unistd::Pid};
9use std::{
10    io::{self, ErrorKind},
11    path::{Path, PathBuf},
12    sync::atomic::{AtomicBool, Ordering},
13};
14use tokio::sync::mpsc::{Receiver, channel};
15use tokio_util::sync::CancellationToken;
16use tracing::{debug, info, trace, warn};
17
18/// Tell the tests how far the watch of a leaf got, see `tests::Progress`.
19/// Nothing outside of them.
20macro_rules! progress {
21    ($leaf:expr, |$p:ident| $update:expr) => {
22        #[cfg(test)]
23        tests::progress($leaf).send_modify(|$p| $update);
24    };
25}
26
27/// The OOM related counters of a memory events file.
28#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
29pub(super) struct OomCounters {
30    /// Times the memory limit of the cgroup was hit.
31    oom: u64,
32
33    /// Processes killed because of it. Kills are accounted to the victim's
34    /// leaf cgroup, so this drifts apart from `oom` by design and the two are
35    /// compared separately.
36    pub(super) oom_kill: u64,
37
38    /// Whether the file has the kill counter, which kernels since 4.13 do.
39    pub(super) has_kill: bool,
40}
41
42impl OomCounters {
43    /// Whether a process got killed since `baseline`. The kernel counts an
44    /// `oom` before the OOM killer runs, which may kill nothing, if reclaim
45    /// freed enough memory meanwhile or a victim is exiting already, so only
46    /// kernels without the kill counter fall back to it.
47    fn increased_since(&self, baseline: &Self) -> bool {
48        if self.has_kill {
49            self.oom_kill > baseline.oom_kill
50        } else {
51            self.oom > baseline.oom
52        }
53    }
54}
55
56/// The memory events of a cgroup v2 leaf and of the cgroups above it.
57///
58/// crun places the process in a leaf below the container cgroup and applies
59/// the memory limit to both. The kill is always counted in the victim's own
60/// cgroup, the leaf, so it tells about an OOM which the limit of the cgroup
61/// wrapping it caused as well.
62struct CgroupV2 {
63    events: MemoryEvents,
64
65    /// The hierarchical memory events of the pod cgroup, which outlives a
66    /// leaf the systemd cgroup manager removes, or without one of the
67    /// nearest ancestor which is no scope, and the OOM counters which the
68    /// leaf does not account for. They advance while the leaf exists, so
69    /// that only kills after its removal stay ambiguous.
70    outer: Option<(PathBuf, OomCounters)>,
71}
72
73impl CgroupV2 {
74    /// How many levels above the leaf the pod cgroup may be: its parent with
75    /// runc, its grandparent with crun, which adds a leaf below the
76    /// container cgroup.
77    const POD_LEVELS: usize = 2;
78
79    fn new(leaf: &Path) -> Self {
80        let outer = Self::pod_cgroup(leaf)
81            .or_else(|| Self::outliving_ancestor(leaf))
82            .and_then(|outer| {
83                let path = outer.join("memory.events");
84                match OOMWatcher::read_oom_counters(&path) {
85                    Ok(counters) => Some((path, counters)),
86                    Err(e) => {
87                        debug!("No OOM baseline of the outer cgroup: {e:#}");
88                        None
89                    }
90                }
91            });
92        Self {
93            events: MemoryEvents::new(leaf.join("memory.events")),
94            outer,
95        }
96    }
97
98    /// Whether a watch event concerns the outer cgroup. Events which the
99    /// kernel dropped may have, the baseline gets read again then.
100    fn is_outer_event(&self, event: &WatchEvent) -> bool {
101        self.outer.as_ref().is_some_and(|(path, _)| {
102            event
103                .path
104                .as_deref()
105                .is_none_or(|event_path| event_path == path)
106        })
107    }
108
109    /// The pod cgroup above the leaf, if there is one within
110    /// [`CgroupV2::POD_LEVELS`]. Further up, like in a QoS cgroup, the
111    /// siblings are other pods.
112    fn pod_cgroup(leaf: &Path) -> Option<&Path> {
113        leaf.ancestors()
114            .skip(1)
115            .take(Self::POD_LEVELS)
116            .find(|ancestor| {
117                ancestor
118                    .file_name()
119                    .and_then(|name| name.to_str())
120                    .is_some_and(Self::is_pod_cgroup_name)
121            })
122    }
123
124    /// The nearest ancestor which systemd does not remove together with the
125    /// leaf: the parent, unless the leaf is the one crun creates below the
126    /// scope of the container (`<parent>/crio-<id>.scope/container`).
127    fn outliving_ancestor(leaf: &Path) -> Option<&Path> {
128        leaf.ancestors()
129            .skip(1)
130            .find(|ancestor| ancestor.extension() != Some("scope".as_ref()))
131    }
132
133    /// Whether the name is the one the kubelet gives to a pod cgroup, like
134    /// `pod<uid>` with the cgroupfs driver and
135    /// `kubepods-burstable-pod<uid>.slice` with the systemd one.
136    fn is_pod_cgroup_name(name: &str) -> bool {
137        let is_pod = |name: &str| {
138            name.strip_prefix("pod").is_some_and(|uid| {
139                !uid.is_empty()
140                    && uid
141                        .chars()
142                        .all(|c| c.is_ascii_hexdigit() || c == '-' || c == '_')
143            })
144        };
145        match name.strip_suffix(".slice") {
146            Some(slice) => slice.rsplit('-').next().is_some_and(is_pod),
147            None => is_pod(name),
148        }
149    }
150
151    /// Whether the counters of the leaf went up. After an event of the outer
152    /// cgroup, its kills since the last check stem from a sibling otherwise,
153    /// as long as the leaf still exists. The kernel counts a kill in the leaf
154    /// before its ancestors, so the outer cgroup gets read first.
155    fn check(&mut self, outer_changed: bool) -> bool {
156        let outer = self
157            .outer
158            .as_ref()
159            .filter(|_| outer_changed)
160            .and_then(|(path, _)| OOMWatcher::read_oom_counters(path).ok());
161        if self.events.check() {
162            return true;
163        }
164        if let (Some(counters), Some((_, baseline))) = (outer, self.outer.as_mut())
165            && !self.events.gone
166        {
167            *baseline = counters;
168        }
169        false
170    }
171
172    /// Check for an OOM a last time once the process exited. If the leaf is
173    /// gone, a kill of the outer cgroup since tells that it may have been
174    /// one, and so does an outer cgroup which is gone as well. A leaf which
175    /// exists without memory events has no memory controller, its kills
176    /// count in the outer cgroup, but without outer counters nothing tells
177    /// of an OOM.
178    ///
179    /// For such a leaf, the outer counters include the kills of siblings
180    /// over the whole life of the leaf, the baseline only advances while the
181    /// leaf has memory events, so an OOM of a sibling may still read as one
182    /// of this container for an exit with 137.
183    fn check_on_exit(&mut self) -> OOMEvent {
184        debug!("Last resort check for OOM");
185        if self.check(false) {
186            return OOMEvent::new(true);
187        }
188        if !self.events.gone {
189            return OOMEvent::default();
190        }
191        let leaf_gone = self
192            .events
193            .path
194            .parent()
195            .is_none_or(OOMWatcher::file_not_found);
196        let Some((path, baseline)) = &self.outer else {
197            debug!(
198                leaf_gone,
199                "Leaf cgroup without memory events or outer counters"
200            );
201            return OOMEvent::possible(leaf_gone);
202        };
203        match OOMWatcher::read_oom_counters(path) {
204            Ok(counters) => {
205                debug!(
206                    leaf_gone,
207                    path = %path.display(),
208                    "No leaf memory events, OOM counters: {counters:?} since {baseline:?}",
209                );
210                OOMEvent::possible(counters.increased_since(baseline))
211            }
212            Err(e) => {
213                debug!(leaf_gone, "Unable to read outer OOM counters: {e:#}");
214                OOMEvent::possible(leaf_gone)
215            }
216        }
217    }
218
219    /// Watch the memory events of the container and, if present, the ones of
220    /// the outer cgroup.
221    fn subscribe(&self) -> Result<Subscriptions> {
222        let watcher = SharedWatcher::get()?;
223        let (events_tx, rx) = channel(WATCH_EVENT_CHANNEL_SIZE);
224        let subscription = watcher
225            .subscribe(&self.events.path, events_tx.clone())
226            .context("watch memory events file")?;
227
228        // The kills of siblings advance the baseline of the outer cgroup.
229        let mut pod_subscription = None;
230        let mut pod_failed = false;
231        if let Some((pod, _)) = &self.outer {
232            debug!("Also watching outer memory events: {}", pod.display());
233            match watcher.subscribe(pod, events_tx) {
234                Ok(subscription) => pod_subscription = Some(subscription),
235                Err(e) => {
236                    pod_failed = !OOMWatcher::is_not_found(&e);
237                    OOMWatcher::watch_failed("Could not watch pod memory events", &e);
238                }
239            }
240        }
241        if !pod_failed {
242            WATCH_FAILING.store(false, Ordering::Relaxed);
243        }
244
245        Ok(Subscriptions {
246            rx,
247            subscription,
248            _pod_subscription: pod_subscription,
249        })
250    }
251}
252
253/// A memory events file and the OOM counters seen in it so far.
254struct MemoryEvents {
255    path: PathBuf,
256    last: OomCounters,
257
258    /// Whether the last check found the file missing.
259    gone: bool,
260}
261
262impl MemoryEvents {
263    /// Track `path`, starting from its current counters.
264    fn new(path: PathBuf) -> Self {
265        let last = OOMWatcher::initial_oom_counters(&path);
266        Self {
267            path,
268            last,
269            gone: false,
270        }
271    }
272
273    /// Whether the counters increased since the last OOM found.
274    ///
275    /// The cgroupfs read does not block, so it runs inline instead of adding a
276    /// blocking task to every exit of a container or exec.
277    fn check(&mut self) -> bool {
278        let result = OOMWatcher::check_for_oom(&self.path, self.last);
279        self.gone = result.as_ref().is_err_and(OOMWatcher::is_not_found);
280        match result {
281            Ok((counters, true)) => {
282                info!("Found OOM in {}, count {counters:?}", self.path.display());
283                self.last = counters;
284                true
285            }
286            Ok(_) => false,
287            Err(e) => {
288                debug!("Checking {} for OOM failed: {e:#}", self.path.display());
289                false
290            }
291        }
292    }
293}
294
295/// The watches of a single OOM watcher and the receiver of their events. The
296/// watches get removed on drop.
297struct Subscriptions {
298    rx: Receiver<WatchEvent>,
299    subscription: Subscription,
300    _pod_subscription: Option<Subscription>,
301}
302
303impl Subscriptions {
304    /// Whether the reader of the shared watcher stopped, which ended the
305    /// watches.
306    fn watcher_failed(&self) -> bool {
307        self.subscription.watcher_failed()
308    }
309}
310
311/// Whether the last attempt to watch memory events failed for another reason
312/// than a missing file, which got a warning already.
313static WATCH_FAILING: AtomicBool = AtomicBool::new(false);
314
315impl OOMWatcher {
316    /// Watch the cgroup of a process with cgroup v2, see
317    /// [`OOMWatcher::watch`].
318    pub(super) async fn oom_handling_cgroup_v2(
319        token: &CancellationToken,
320        pid: Pid,
321    ) -> Result<Option<OOMEvent>> {
322        // The setup reads procfs and cgroupfs inline, which does not block,
323        // instead of adding a blocking task to every container and exec.
324        let Some(subsystem_path) = Self::process_cgroup_subsystem_path_cgroup_v2(pid)
325            .context("process cgroup subsystem path")?
326        else {
327            debug!("Stopping OOM handler because no cgroup subsystem path exists");
328            return Ok(None);
329        };
330
331        debug!(
332            "Using subsystem cgroup v2 path: {}",
333            subsystem_path.display()
334        );
335
336        Ok(Some(Self::watch_cgroup_v2(token, &subsystem_path).await))
337    }
338
339    /// Wait for an OOM of the cgroup v2 `leaf` until `token` gets cancelled
340    /// and return what was found.
341    ///
342    /// An OOM counts once the counters of the leaf went up. The leaf may be
343    /// gone at the exit, the systemd cgroup manager removes the scope of a
344    /// container once its processes exited. Then the hierarchical `oom_kill`
345    /// counter of the nearest ancestor which outlives it tells whether a kill
346    /// happened meanwhile, see [`OOMEvent::possible_oom`]. A removal is no OOM
347    /// on its own. cgroupfs reports it with `IN_IGNORED` only, which ends the
348    /// watch without an event.
349    async fn watch_cgroup_v2(token: &CancellationToken, leaf: &Path) -> OOMEvent {
350        let mut cgroup = CgroupV2::new(leaf);
351
352        debug!(path = %cgroup.events.path.display(), "Setup cgroup v2 handling");
353
354        let mut subscriptions = match cgroup.subscribe() {
355            Ok(subscriptions) => subscriptions,
356            // The cgroup may be gone already. Still check for an OOM once the
357            // process exited.
358            Err(e) => {
359                Self::watch_failed("Unable to watch memory events, checking on exit only", &e);
360                token.cancelled().await;
361                return cgroup.check_on_exit();
362            }
363        };
364        progress!(leaf, |p| p.subscribed += 1);
365
366        let mut restarts = 0;
367        loop {
368            tokio::select! {
369                // Memory events may change all the time, for example while
370                // processes left in the cgroup hit memory.high, which must
371                // not hold back the exit. The check on exit reads the
372                // counters of an OOM which ended the process anyway.
373                biased;
374                () = token.cancelled() => {
375                    debug!("Loop cancelled");
376                    return cgroup.check_on_exit();
377                }
378                event = subscriptions.rx.recv() => {
379                    let Some(event) = event else {
380                        // All watches ended, their files got removed, or the
381                        // watcher stopped reading events.
382                        let path = cgroup.events.path.display();
383                        if !subscriptions.watcher_failed() {
384                            debug!(%path, "Watches ended, checking once the process exited");
385                            break;
386                        }
387                        if restarts >= Self::MAX_WATCHER_RESTARTS {
388                            let message = "Watcher of the memory events stopped again, checking on exit only";
389                            if subscriptions.subscription.give_up() {
390                                warn!(%path, restarts, "{message}");
391                            } else {
392                                debug!(%path, restarts, "{message}");
393                            }
394                            break;
395                        }
396                        restarts += 1;
397                        // The reader logged why already, once for all watchers.
398                        debug!(%path, "Watcher of the memory events stopped, watching again");
399                        match cgroup.subscribe() {
400                            Ok(new) => subscriptions = new,
401                            Err(e) => {
402                                Self::watch_failed("Unable to watch memory events again, checking on exit only", &e);
403                                break;
404                            }
405                        }
406                        progress!(leaf, |p| p.subscribed += 1);
407                        // The changes while nothing got watched, like the
408                        // kills of siblings, refresh the baselines.
409                        if cgroup.check(true) {
410                            return OOMEvent::new(true);
411                        }
412                        continue;
413                    };
414                    debug!("Got OOM file event: {event:?}");
415
416                    // The shared watcher dispatches the events which came
417                    // meanwhile at once, at most every
418                    // `MIN_DISPATCH_INTERVAL`, so that the counters get read
419                    // once for all of them.
420                    let mut outer = cgroup.is_outer_event(&event);
421                    while let Ok(event) = subscriptions.rx.try_recv() {
422                        outer |= cgroup.is_outer_event(&event);
423                    }
424
425                    let oom = cgroup.check(outer);
426                    progress!(leaf, |p| {
427                        p.handled += 1;
428                        p.outer = cgroup.outer.as_ref().map(|(_, counters)| *counters);
429                    });
430                    if oom {
431                        return OOMEvent::new(true);
432                    }
433                    if cgroup.events.gone {
434                        debug!("Memory events file gone, checking once the process exited");
435                        break;
436                    }
437                    debug!("No OOM found in event");
438                }
439            }
440        }
441
442        drop(subscriptions);
443        progress!(leaf, |p| p.ended = true);
444        token.cancelled().await;
445        cgroup.check_on_exit()
446    }
447
448    /// How often an OOM watcher watches its files again on a new shared
449    /// watcher, after the reader of the events failed.
450    const MAX_WATCHER_RESTARTS: u32 = 3;
451
452    /// Log a failure to watch memory events. A file which is gone, like the
453    /// one of a cgroup which got removed already, is expected. Others, like
454    /// running out of inotify instances or watches, get a warning, only the
455    /// first one until a watch works again, see [`WATCH_FAILING`].
456    fn watch_failed(message: &str, e: &anyhow::Error) {
457        if !Self::is_not_found(e) && !WATCH_FAILING.swap(true, Ordering::Relaxed) {
458            warn!("{message}: {e:#}");
459        } else {
460            debug!("{message}: {e:#}");
461        }
462    }
463
464    /// Whether watching or reading failed because the file does not exist.
465    /// cgroupfs fails with `ENODEV` instead for a file whose cgroup got
466    /// removed while it got opened or read.
467    fn is_not_found(e: &anyhow::Error) -> bool {
468        let cause = e.root_cause();
469        cause.downcast_ref::<Errno>() == Some(&Errno::ENOENT)
470            || cause.downcast_ref::<io::Error>().is_some_and(|e| {
471                e.kind() == ErrorKind::NotFound || e.raw_os_error() == Some(libc::ENODEV)
472            })
473    }
474
475    /// Read the current OOM counters of a memory events file to establish a
476    /// baseline, so that only OOMs during this container's lifetime count.
477    fn initial_oom_counters(path: &Path) -> OomCounters {
478        match Self::read_oom_counters(path) {
479            Ok(counters) => {
480                debug!("Initial OOM counters of {}: {counters:?}", path.display());
481                counters
482            }
483            Err(e) => {
484                debug!(
485                    "Could not read initial OOM counters of {}: {e:#}, starting from 0",
486                    path.display()
487                );
488                OomCounters::default()
489            }
490        }
491    }
492
493    fn check_for_oom(
494        memory_events_file_path: &Path,
495        last: OomCounters,
496    ) -> Result<(OomCounters, bool)> {
497        debug!(
498            "Checking for possible OOM in {}",
499            memory_events_file_path.display()
500        );
501        let counters = Self::read_oom_counters(memory_events_file_path)?;
502        let found_oom = counters.increased_since(&last);
503        debug!("OOM counters {counters:?}, last {last:?}, found OOM: {found_oom}");
504        Ok((counters, found_oom))
505    }
506
507    /// Read the `oom` and `oom_kill` counters of a memory events file.
508    fn read_oom_counters(path: &Path) -> Result<OomCounters> {
509        let content = std::fs::read_to_string(path)
510            .with_context(|| format!("read memory events file: {}", path.display()))?;
511        trace!(content, "Memory events");
512        Self::parse_oom_counters(&content)
513            .with_context(|| format!("parse memory events file: {}", path.display()))
514    }
515
516    /// Parse the `oom` and `oom_kill` counters of the content of a memory
517    /// events file.
518    pub(super) fn parse_oom_counters(content: &str) -> Result<OomCounters> {
519        let mut counters = OomCounters::default();
520        let mut has_oom = false;
521
522        for (key, value) in Self::counters(content) {
523            let target = match key {
524                "oom" => {
525                    has_oom = true;
526                    &mut counters.oom
527                }
528                "oom_kill" => {
529                    counters.has_kill = true;
530                    &mut counters.oom_kill
531                }
532                _ => continue,
533            };
534            *target = value.context("parse u64 counter")?;
535        }
536
537        // Every memory events file has it, also of kernels without the kill
538        // counter. Without, it is no memory events file, or one which is
539        // being written, like a fake one.
540        if !has_oom {
541            bail!("no oom counter")
542        }
543        Ok(counters)
544    }
545
546    /// Checks if a file does not exist on disk.
547    fn file_not_found(f: impl AsRef<Path>) -> bool {
548        std::fs::metadata(f).is_err_and(|e| e.kind() == ErrorKind::NotFound)
549    }
550}
551
552#[cfg(test)]
553mod tests;