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, systemd-nspawn and
9//! virtual machines registered with systemd-machined (libvirt's) put the
10//! name in the cgroup itself; Proxmox VE's guests are named by their id.
11
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::process::{Command, Stdio};
15use std::sync::mpsc::{self, Receiver};
16use std::thread;
17use std::time::{Duration, Instant};
18
19use super::SysRoots;
20use crate::snapshot::ProcessInfo;
21
22/// Least time between two runs of `docker ps` / `podman ps`.
23const NAME_LOOKUP_INTERVAL: Duration = Duration::from_secs(30);
24
25/// Length of a short container id, as `docker ps` shows it.
26const SHORT_ID: usize = 12;
27
28/// A container as its cgroup names it.
29#[derive(Debug, Clone, PartialEq, Eq, Hash)]
30pub(crate) enum ContainerRef {
31    /// A container id (Docker, Podman, Kubernetes), named by a lookup.
32    Id(String),
33    /// A container or virtual machine whose cgroup carries its name (LXC,
34    /// systemd-nspawn, libvirt, Proxmox VE).
35    Named(String),
36}
37
38/// The container of the process whose `/proc/<pid>/cgroup` is `text`, if
39/// any. Looks at every hierarchy (cgroup v1 lists several) and at each path
40/// from its end, since containers nest cgroups of their own inside.
41pub(crate) fn parse_cgroup(text: &str) -> Option<ContainerRef> {
42    text.lines()
43        .filter_map(|line| line.splitn(3, ':').nth(2))
44        .find_map(|path| {
45            path.rsplit('/')
46                .find_map(parse_component)
47                .or_else(|| proxmox_guest(path))
48        })
49}
50
51/// Proxmox VE's guests, named by their id: virtual machines run in
52/// "/qemu.slice/<vmid>.scope", containers under "/lxc/<vmid>/" (their
53/// monitor, outside the container, in "/lxc.monitor/<vmid>").
54fn proxmox_guest(path: &str) -> Option<ContainerRef> {
55    let mut parts = path.trim_start_matches('/').split('/');
56    let (first, second) = (parts.next()?, parts.next()?);
57    let vmid = |text: &str| !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit());
58    match first {
59        "qemu.slice" => second
60            .strip_suffix(".scope")
61            .filter(|id| vmid(id))
62            .map(|id| ContainerRef::Named(format!("vm {id}"))),
63        "lxc" if vmid(second) => Some(ContainerRef::Named(format!("ct {second}"))),
64        _ => None,
65    }
66}
67
68/// One component of a cgroup path.
69fn parse_component(part: &str) -> Option<ContainerRef> {
70    // Docker (systemd driver), Podman, containerd and CRI-O under systemd.
71    // "libpod-conmon-<id>.scope" is Podman's monitor, outside the container.
72    for prefix in ["docker-", "libpod-", "cri-containerd-", "crio-"] {
73        if let Some(id) = part
74            .strip_prefix(prefix)
75            .and_then(|rest| rest.strip_suffix(".scope"))
76            .filter(|id| is_container_id(id))
77        {
78            return Some(ContainerRef::Id(id.to_owned()));
79        }
80    }
81    // Docker with the cgroupfs driver: "/docker/<id>".
82    if is_container_id(part) {
83        return Some(ContainerRef::Id(part.to_owned()));
84    }
85    if let Some(name) = part.strip_prefix("lxc.payload.") {
86        return Some(ContainerRef::Named(name.to_owned()));
87    }
88    if let Some(name) = part
89        .strip_prefix("systemd-nspawn@")
90        .and_then(|rest| rest.strip_suffix(".service"))
91    {
92        return Some(ContainerRef::Named(name.to_owned()));
93    }
94    // Machines registered with systemd-machined: libvirt's virtual machines
95    // ("machine-qemu\x2d1\x2dwin11.scope") and containers ("machine-lxc\x2d…"),
96    // systemd-nspawn run by hand ("machine-build.scope").
97    part.strip_prefix("machine-")
98        .and_then(|rest| rest.strip_suffix(".scope"))
99        .map(|name| ContainerRef::Named(machine_name(&unescape_unit(name))))
100}
101
102/// The machine's own name in a systemd-machined one: libvirt prefixes its
103/// driver and the domain id ("qemu-1-win11" is "win11").
104fn machine_name(name: &str) -> String {
105    for driver in ["qemu-", "lxc-"] {
106        if let Some((id, rest)) = name.strip_prefix(driver).and_then(|r| r.split_once('-'))
107            && !id.is_empty()
108            && id.bytes().all(|b| b.is_ascii_digit())
109            && !rest.is_empty()
110        {
111            return rest.to_owned();
112        }
113    }
114    name.to_owned()
115}
116
117/// Undoes systemd's escaping in unit names ("\x2d" for "-").
118fn unescape_unit(text: &str) -> String {
119    let bytes = text.as_bytes();
120    let mut out = Vec::with_capacity(bytes.len());
121    let mut i = 0;
122    while i < bytes.len() {
123        if bytes[i] == b'\\'
124            && bytes.get(i + 1) == Some(&b'x')
125            && let Some(byte) = text
126                .get(i + 2..i + 4)
127                .and_then(|hex| u8::from_str_radix(hex, 16).ok())
128        {
129            out.push(byte);
130            i += 4;
131        } else {
132            out.push(bytes[i]);
133            i += 1;
134        }
135    }
136    String::from_utf8_lossy(&out).into_owned()
137}
138
139/// Container ids are 64 hex digits.
140fn is_container_id(text: &str) -> bool {
141    text.len() == 64 && text.bytes().all(|b| b.is_ascii_hexdigit())
142}
143
144/// Fills in [`ProcessInfo::container`], remembering each process's
145/// container between samples.
146#[derive(Debug, Default)]
147pub struct ContainerTracker {
148    /// Per pid: the process name when its cgroup was read (a different name
149    /// means the pid was reused) and its container.
150    by_pid: HashMap<u32, (String, Option<ContainerRef>)>,
151    /// Container names by full id.
152    names: HashMap<String, String>,
153    /// Ids already looked up without finding a name.
154    unnamed: HashSet<String>,
155    /// When names were last looked up.
156    last_lookup: Option<Instant>,
157    /// A lookup running in the background.
158    pending: Option<Receiver<HashMap<String, String>>>,
159}
160
161impl ContainerTracker {
162    /// Creates a tracker that knows no containers yet.
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Sets the container of each process.
168    pub fn annotate(&mut self, roots: &SysRoots, processes: &mut [ProcessInfo], now: Instant) {
169        self.receive_names();
170        let alive: HashSet<u32> = processes.iter().map(|p| p.pid).collect();
171        self.by_pid.retain(|pid, _| alive.contains(pid));
172
173        let mut unknown = false;
174        for process in processes.iter_mut() {
175            let cached = self
176                .by_pid
177                .get(&process.pid)
178                .filter(|(name, _)| *name == process.name);
179            let container = match cached {
180                Some((_, container)) => container.clone(),
181                None => {
182                    let path = roots.proc.join(process.pid.to_string()).join("cgroup");
183                    let container = fs::read_to_string(path)
184                        .ok()
185                        .and_then(|text| parse_cgroup(&text));
186                    self.by_pid
187                        .insert(process.pid, (process.name.clone(), container.clone()));
188                    container
189                }
190            };
191            process.container = container.map(|c| match c {
192                ContainerRef::Named(name) => name,
193                ContainerRef::Id(id) => match self.names.get(&id) {
194                    Some(name) => name.clone(),
195                    None => {
196                        unknown |= !self.unnamed.contains(&id);
197                        id[..SHORT_ID].to_owned()
198                    }
199                },
200            });
201        }
202        if unknown {
203            self.start_lookup(now);
204        }
205    }
206
207    /// Takes the names of a finished lookup.
208    fn receive_names(&mut self) {
209        let Some(rx) = &self.pending else {
210            return;
211        };
212        let names = match rx.try_recv() {
213            Ok(names) => names,
214            Err(mpsc::TryRecvError::Empty) => return,
215            Err(mpsc::TryRecvError::Disconnected) => {
216                self.pending = None;
217                return;
218            }
219        };
220        self.pending = None;
221        // Ids seen so far that the lookup did not name stay short ids until
222        // a new container appears.
223        let ids = self.by_pid.values().filter_map(|(_, c)| match c {
224            Some(ContainerRef::Id(id)) => Some(id.clone()),
225            _ => None,
226        });
227        self.unnamed = ids.filter(|id| !names.contains_key(id)).collect();
228        self.names = names;
229    }
230
231    /// Looks names up in the background, unless one is running or ran less
232    /// than [`NAME_LOOKUP_INTERVAL`] ago.
233    fn start_lookup(&mut self, now: Instant) {
234        let recent = self
235            .last_lookup
236            .is_some_and(|at| now.saturating_duration_since(at) < NAME_LOOKUP_INTERVAL);
237        if self.pending.is_some() || recent {
238            return;
239        }
240        self.last_lookup = Some(now);
241        let (tx, rx) = mpsc::channel();
242        self.pending = Some(rx);
243        thread::spawn(move || {
244            let mut names = HashMap::new();
245            for program in ["docker", "podman"] {
246                names.extend(list_containers(program));
247            }
248            let _ = tx.send(names);
249        });
250    }
251}
252
253/// Running containers as `<program> ps` lists them: full id -> name.
254/// Empty when the program is missing or not allowed (e.g. not in the
255/// `docker` group).
256fn list_containers(program: &str) -> HashMap<String, String> {
257    Command::new(program)
258        .args(["ps", "--no-trunc", "--format", "{{.ID}} {{.Names}}"])
259        .stdin(Stdio::null())
260        .stderr(Stdio::null())
261        .output()
262        .ok()
263        .filter(|out| out.status.success())
264        .map(|out| parse_ps(&String::from_utf8_lossy(&out.stdout)))
265        .unwrap_or_default()
266}
267
268/// "<id> <name>" lines into id -> name.
269pub(crate) fn parse_ps(text: &str) -> HashMap<String, String> {
270    text.lines()
271        .filter_map(|line| line.trim().split_once(' '))
272        .filter(|(id, _)| is_container_id(id))
273        .map(|(id, name)| (id.to_owned(), name.trim().to_owned()))
274        .collect()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::linux::testutil::write;
281
282    const ID: &str = "1712446b193ad552c0d920be599af9ba2bf571a05315a64c1be25fd712e1f17f";
283
284    #[test]
285    fn recognizes_container_cgroups() {
286        let id = |s: &str| Some(ContainerRef::Id(s.to_owned()));
287        let cases = [
288            (format!("0::/system.slice/docker-{ID}.scope"), id(ID)),
289            (format!("0::/docker/{ID}"), id(ID)),
290            (
291                format!(
292                    "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-{ID}.scope/container"
293                ),
294                id(ID),
295            ),
296            (
297                format!(
298                    "0::/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod1.slice/cri-containerd-{ID}.scope"
299                ),
300                id(ID),
301            ),
302            // cgroup v1: several hierarchies.
303            (
304                format!("12:pids:/docker/{ID}\n11:cpu,cpuacct:/docker/{ID}\n0::/"),
305                id(ID),
306            ),
307            (
308                "0::/lxc.payload.web/system.slice/nginx.service".to_owned(),
309                Some(ContainerRef::Named("web".to_owned())),
310            ),
311            (
312                "0::/machine.slice/systemd-nspawn@build.service/payload".to_owned(),
313                Some(ContainerRef::Named("build".to_owned())),
314            ),
315            // libvirt's virtual machines and containers, nspawn by hand.
316            (
317                r"0::/machine.slice/machine-qemu\x2d3\x2dwin11\x2deval.scope/libvirt/emulator"
318                    .to_owned(),
319                Some(ContainerRef::Named("win11-eval".to_owned())),
320            ),
321            (
322                r"0::/machine.slice/machine-qemu\x2d3\x2dwin11\x2deval.scope/libvirt/vcpu0"
323                    .to_owned(),
324                Some(ContainerRef::Named("win11-eval".to_owned())),
325            ),
326            (
327                r"0::/machine.slice/machine-lxc\x2d4242\x2dweb.scope".to_owned(),
328                Some(ContainerRef::Named("web".to_owned())),
329            ),
330            (
331                "0::/machine.slice/machine-build.scope/payload".to_owned(),
332                Some(ContainerRef::Named("build".to_owned())),
333            ),
334            // Proxmox VE: a virtual machine, a container, and Docker in one.
335            (
336                "0::/qemu.slice/100.scope".to_owned(),
337                Some(ContainerRef::Named("vm 100".to_owned())),
338            ),
339            (
340                "0::/lxc/101/ns/system.slice/nginx.service".to_owned(),
341                Some(ContainerRef::Named("ct 101".to_owned())),
342            ),
343            (
344                format!("0::/lxc/102/ns/system.slice/docker-{ID}.scope"),
345                id(ID),
346            ),
347        ];
348        for (text, expected) in cases {
349            assert_eq!(parse_cgroup(&text), expected, "{text}");
350        }
351        for text in [
352            "0::/system.slice/containerd.service".to_owned(),
353            "0::/user.slice/user-1000.slice/session-2.scope".to_owned(),
354            format!(
355                "0::/user.slice/user-1000.slice/user@1000.service/user.slice/libpod-conmon-{ID}.scope"
356            ),
357            "0::/".to_owned(),
358            // Not inside a machine or guest: the slice itself, libvirt's
359            // daemon, Proxmox VE's container monitor.
360            "0::/machine.slice".to_owned(),
361            "0::/system.slice/libvirtd.service".to_owned(),
362            "0::/lxc.monitor/101".to_owned(),
363            "0::/qemu.slice".to_owned(),
364        ] {
365            assert_eq!(parse_cgroup(&text), None, "{text}");
366        }
367    }
368
369    #[test]
370    fn parses_ps_output() {
371        let text = format!("{ID} web-frontend-1\nshort nope\n\n");
372        let names = parse_ps(&text);
373        assert_eq!(names.len(), 1);
374        assert_eq!(names[ID], "web-frontend-1");
375    }
376
377    #[test]
378    fn annotates_processes_and_remembers_them() {
379        let tmp = tempfile::tempdir().unwrap();
380        write(
381            tmp.path(),
382            "proc/10/cgroup",
383            &format!("0::/system.slice/docker-{ID}.scope\n"),
384        );
385        write(
386            tmp.path(),
387            "proc/11/cgroup",
388            "0::/user.slice/session-2.scope\n",
389        );
390        write(tmp.path(), "proc/12/cgroup", "0::/lxc.payload.web\n");
391        let roots = SysRoots {
392            sys: tmp.path().join("sys"),
393            proc: tmp.path().join("proc"),
394        };
395        let process = |pid, name: &str| ProcessInfo {
396            pid,
397            name: name.to_owned(),
398            ..Default::default()
399        };
400        let mut processes = vec![
401            process(10, "nginx"),
402            process(11, "bash"),
403            process(12, "init"),
404        ];
405        let mut tracker = ContainerTracker::new();
406        // A lookup is pending: no name yet, so the short id.
407        tracker.pending = Some(mpsc::channel().1);
408        tracker.last_lookup = Some(Instant::now());
409        tracker.annotate(&roots, &mut processes, Instant::now());
410        let containers: Vec<Option<&str>> =
411            processes.iter().map(|p| p.container.as_deref()).collect();
412        assert_eq!(containers, vec![Some(&ID[..12]), None, Some("web")]);
413
414        // Names arrive; the cgroup is not read again for a known process.
415        let (tx, rx) = mpsc::channel();
416        tx.send(HashMap::from([(ID.to_owned(), "frontend".to_owned())]))
417            .unwrap();
418        tracker.pending = Some(rx);
419        fs::remove_file(tmp.path().join("proc/10/cgroup")).unwrap();
420        tracker.annotate(&roots, &mut processes, Instant::now());
421        assert_eq!(processes[0].container.as_deref(), Some("frontend"));
422
423        // A reused pid (another program) is read again.
424        let mut reused = vec![process(10, "python3")];
425        tracker.annotate(&roots, &mut reused, Instant::now());
426        assert_eq!(reused[0].container, None);
427    }
428}