Skip to main content

rigtop_core/linux/
containers.rs

1//! Which container a process runs in, from its cgroup
2//! (`/proc/<pid>/cgroup`, no root needed).
3//!
4//! Docker, Podman and Kubernetes (containerd, CRI-O) name their cgroups
5//! after the container id; names come from `docker ps` / `podman ps`, run
6//! on a background thread only when an unknown id appears (and at most
7//! every [`NAME_LOOKUP_INTERVAL`]), so a slow or stuck daemon never holds up
8//! a sample. Without a name, the short id is shown. LXC and systemd-nspawn
9//! put the container's name in the cgroup itself.
10
11use std::collections::{HashMap, HashSet};
12use std::fs;
13use std::process::{Command, Stdio};
14use std::sync::mpsc::{self, Receiver};
15use std::thread;
16use std::time::{Duration, Instant};
17
18use super::SysRoots;
19use crate::snapshot::ProcessInfo;
20
21/// Least time between two runs of `docker ps` / `podman ps`.
22const NAME_LOOKUP_INTERVAL: Duration = Duration::from_secs(30);
23
24/// Length of a short container id, as `docker ps` shows it.
25const SHORT_ID: usize = 12;
26
27/// A container as its cgroup names it.
28#[derive(Debug, Clone, PartialEq, Eq, Hash)]
29pub(crate) enum ContainerRef {
30    /// A container id (Docker, Podman, Kubernetes), named by a lookup.
31    Id(String),
32    /// A container whose cgroup carries its name (LXC, systemd-nspawn).
33    Named(String),
34}
35
36/// The container of the process whose `/proc/<pid>/cgroup` is `text`, if
37/// any. Looks at every hierarchy (cgroup v1 lists several) and at each path
38/// from its end, since containers nest cgroups of their own inside.
39pub(crate) fn parse_cgroup(text: &str) -> Option<ContainerRef> {
40    text.lines()
41        .filter_map(|line| line.splitn(3, ':').nth(2))
42        .find_map(|path| path.rsplit('/').find_map(parse_component))
43}
44
45/// One component of a cgroup path.
46fn parse_component(part: &str) -> Option<ContainerRef> {
47    // Docker (systemd driver), Podman, containerd and CRI-O under systemd.
48    // "libpod-conmon-<id>.scope" is Podman's monitor, outside the container.
49    for prefix in ["docker-", "libpod-", "cri-containerd-", "crio-"] {
50        if let Some(id) = part
51            .strip_prefix(prefix)
52            .and_then(|rest| rest.strip_suffix(".scope"))
53            .filter(|id| is_container_id(id))
54        {
55            return Some(ContainerRef::Id(id.to_owned()));
56        }
57    }
58    // Docker with the cgroupfs driver: "/docker/<id>".
59    if is_container_id(part) {
60        return Some(ContainerRef::Id(part.to_owned()));
61    }
62    if let Some(name) = part.strip_prefix("lxc.payload.") {
63        return Some(ContainerRef::Named(name.to_owned()));
64    }
65    part.strip_prefix("systemd-nspawn@")
66        .and_then(|rest| rest.strip_suffix(".service"))
67        .map(|name| ContainerRef::Named(name.to_owned()))
68}
69
70/// Container ids are 64 hex digits.
71fn is_container_id(text: &str) -> bool {
72    text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit())
73}
74
75/// Fills in [`ProcessInfo::container`], remembering each process's
76/// container between samples.
77#[derive(Debug, Default)]
78pub struct ContainerTracker {
79    /// Per pid: the process name when its cgroup was read (a different name
80    /// means the pid was reused) and its container.
81    by_pid: HashMap<u32, (String, Option<ContainerRef>)>,
82    /// Container names by full id.
83    names: HashMap<String, String>,
84    /// Ids already looked up without finding a name.
85    unnamed: HashSet<String>,
86    /// When names were last looked up.
87    last_lookup: Option<Instant>,
88    /// A lookup running in the background.
89    pending: Option<Receiver<HashMap<String, String>>>,
90}
91
92impl ContainerTracker {
93    /// Creates a tracker that knows no containers yet.
94    pub fn new() -> Self {
95        Self::default()
96    }
97
98    /// Sets the container of each process.
99    pub fn annotate(&mut self, roots: &SysRoots, processes: &mut [ProcessInfo], now: Instant) {
100        self.receive_names();
101        let alive: HashSet<u32> = processes.iter().map(|p| p.pid).collect();
102        self.by_pid.retain(|pid, _| alive.contains(pid));
103
104        let mut unknown = false;
105        for process in processes.iter_mut() {
106            let cached = self
107                .by_pid
108                .get(&process.pid)
109                .filter(|(name, _)| *name == process.name);
110            let container = match cached {
111                Some((_, container)) => container.clone(),
112                None => {
113                    let path = roots.proc.join(process.pid.to_string()).join("cgroup");
114                    let container = fs::read_to_string(path)
115                        .ok()
116                        .and_then(|text| parse_cgroup(&text));
117                    self.by_pid
118                        .insert(process.pid, (process.name.clone(), container.clone()));
119                    container
120                }
121            };
122            process.container = container.map(|c| match c {
123                ContainerRef::Named(name) => name,
124                ContainerRef::Id(id) => match self.names.get(&id) {
125                    Some(name) => name.clone(),
126                    None => {
127                        unknown |= !self.unnamed.contains(&id);
128                        id[..SHORT_ID].to_owned()
129                    }
130                },
131            });
132        }
133        if unknown {
134            self.start_lookup(now);
135        }
136    }
137
138    /// Takes the names of a finished lookup.
139    fn receive_names(&mut self) {
140        let Some(rx) = &self.pending else {
141            return;
142        };
143        let names = match rx.try_recv() {
144            Ok(names) => names,
145            Err(mpsc::TryRecvError::Empty) => return,
146            Err(mpsc::TryRecvError::Disconnected) => {
147                self.pending = None;
148                return;
149            }
150        };
151        self.pending = None;
152        // Ids seen so far that the lookup did not name stay short ids until
153        // a new container appears.
154        let ids = self.by_pid.values().filter_map(|(_, c)| match c {
155            Some(ContainerRef::Id(id)) => Some(id.clone()),
156            _ => None,
157        });
158        self.unnamed = ids.filter(|id| !names.contains_key(id)).collect();
159        self.names = names;
160    }
161
162    /// Looks names up in the background, unless one is running or ran less
163    /// than [`NAME_LOOKUP_INTERVAL`] ago.
164    fn start_lookup(&mut self, now: Instant) {
165        let recent = self
166            .last_lookup
167            .is_some_and(|at| now.saturating_duration_since(at) < NAME_LOOKUP_INTERVAL);
168        if self.pending.is_some() || recent {
169            return;
170        }
171        self.last_lookup = Some(now);
172        let (tx, rx) = mpsc::channel();
173        self.pending = Some(rx);
174        thread::spawn(move || {
175            let mut names = HashMap::new();
176            for program in ["docker", "podman"] {
177                names.extend(list_containers(program));
178            }
179            let _ = tx.send(names);
180        });
181    }
182}
183
184/// Running containers as `<program> ps` lists them: full id -> name.
185/// Empty when the program is missing or not allowed (e.g. not in the
186/// `docker` group).
187fn list_containers(program: &str) -> HashMap<String, String> {
188    Command::new(program)
189        .args(["ps", "--no-trunc", "--format", "{{.ID}} {{.Names}}"])
190        .stdin(Stdio::null())
191        .stderr(Stdio::null())
192        .output()
193        .ok()
194        .filter(|out| out.status.success())
195        .map(|out| parse_ps(&String::from_utf8_lossy(&out.stdout)))
196        .unwrap_or_default()
197}
198
199/// "<id> <name>" lines into id -> name.
200pub(crate) fn parse_ps(text: &str) -> HashMap<String, String> {
201    text.lines()
202        .filter_map(|line| line.trim().split_once(' '))
203        .filter(|(id, _)| is_container_id(id))
204        .map(|(id, name)| (id.to_owned(), name.trim().to_owned()))
205        .collect()
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use crate::linux::testutil::write;
212
213    const ID: &str = "1712446b193ad552c0d920be599af9ba2bf571a05315a64c1be25fd712e1f17f";
214
215    #[test]
216    fn recognizes_container_cgroups() {
217        let id = |s: &str| Some(ContainerRef::Id(s.to_owned()));
218        let cases = [
219            (format!("0::/system.slice/docker-{ID}.scope"), id(ID)),
220            (format!("0::/docker/{ID}"), id(ID)),
221            (
222                format!(
223                    "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-{ID}.scope/container"
224                ),
225                id(ID),
226            ),
227            (
228                format!(
229                    "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod1.slice/cri-containerd-{ID}.scope"
230                ),
231                id(ID),
232            ),
233            // cgroup v1: several hierarchies.
234            (
235                format!("12:pids:/docker/{ID}\n11:cpu,cpuacct:/docker/{ID}\n0::/"),
236                id(ID),
237            ),
238            (
239                "0::/lxc.payload.web/system.slice/nginx.service".to_owned(),
240                Some(ContainerRef::Named("web".to_owned())),
241            ),
242            (
243                "0::/machine.slice/systemd-nspawn@build.service/payload".to_owned(),
244                Some(ContainerRef::Named("build".to_owned())),
245            ),
246        ];
247        for (text, expected) in cases {
248            assert_eq!(parse_cgroup(&text), expected, "{text}");
249        }
250        for text in [
251            "0::/system.slice/containerd.service".to_owned(),
252            "0::/user.slice/user-1000.slice/session-2.scope".to_owned(),
253            format!(
254                "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-conmon-{ID}.scope"
255            ),
256            "0::/".to_owned(),
257        ] {
258            assert_eq!(parse_cgroup(&text), None, "{text}");
259        }
260    }
261
262    #[test]
263    fn parses_ps_output() {
264        let text = format!("{ID} web-frontend-1\nshort nope\n\n");
265        let names = parse_ps(&text);
266        assert_eq!(names.len(), 1);
267        assert_eq!(names[ID], "web-frontend-1");
268    }
269
270    #[test]
271    fn annotates_processes_and_remembers_them() {
272        let tmp = tempfile::tempdir().unwrap();
273        write(
274            tmp.path(),
275            "proc/10/cgroup",
276            &format!("0::/system.slice/docker-{ID}.scope\n"),
277        );
278        write(
279            tmp.path(),
280            "proc/11/cgroup",
281            "0::/user.slice/session-2.scope\n",
282        );
283        write(tmp.path(), "proc/12/cgroup", "0::/lxc.payload.web\n");
284        let roots = SysRoots {
285            sys: tmp.path().join("sys"),
286            proc: tmp.path().join("proc"),
287        };
288        let process = |pid, name: &str| ProcessInfo {
289            pid,
290            name: name.to_owned(),
291            ..Default::default()
292        };
293        let mut processes = vec![
294            process(10, "nginx"),
295            process(11, "bash"),
296            process(12, "init"),
297        ];
298        let mut tracker = ContainerTracker::new();
299        // A lookup is pending: no name yet, so the short id.
300        tracker.pending = Some(mpsc::channel().1);
301        tracker.last_lookup = Some(Instant::now());
302        tracker.annotate(&roots, &mut processes, Instant::now());
303        let containers: Vec<Option<&str>> =
304            processes.iter().map(|p| p.container.as_deref()).collect();
305        assert_eq!(containers, vec![Some(&ID[..12]), None, Some("web")]);
306
307        // Names arrive; the cgroup is not read again for a known process.
308        let (tx, rx) = mpsc::channel();
309        tx.send(HashMap::from([(ID.to_owned(), "frontend".to_owned())]))
310            .unwrap();
311        tracker.pending = Some(rx);
312        fs::remove_file(tmp.path().join("proc/10/cgroup")).unwrap();
313        tracker.annotate(&roots, &mut processes, Instant::now());
314        assert_eq!(processes[0].container.as_deref(), Some("frontend"));
315
316        // A reused pid (another program) is read again.
317        let mut reused = vec![process(10, "python3")];
318        tracker.annotate(&roots, &mut reused, Instant::now());
319        assert_eq!(reused[0].container, None);
320    }
321}