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;