Skip to main content

mz_ore/
cgroup.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License in the LICENSE file at the
6// root of this repository, or online at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Linux cgroup detection utilities.
17//!
18//! NOTE: this module must stay free of non-`std` dependencies. It is compiled unconditionally,
19//! including into feature-reduced builds such as the wasm32 one, where `mz_ore`'s optional
20//! dependencies are absent. Reaching for `tracing` here breaks that build. Callers that want the
21//! resolved cgroup reported should log [`CgroupV2::path`] themselves.
22use std::fs::File;
23use std::io::{BufRead, BufReader};
24use std::path::{Path, PathBuf};
25
26/// An entry in /proc/self/cgroup
27#[derive(Debug, PartialEq)]
28pub struct CgroupEntry {
29    subsystems: Vec<String>,
30    root: PathBuf,
31}
32
33impl CgroupEntry {
34    fn from_line(line: String) -> Option<CgroupEntry> {
35        let mut fields = line.split(':');
36        let subsystems = fields
37            .nth(1)?
38            .split(',')
39            .filter(|s| !s.is_empty())
40            .map(|s| s.to_owned())
41            .collect();
42        let root = PathBuf::from(fields.next()?);
43        Some(CgroupEntry { subsystems, root })
44    }
45}
46
47/// Parses /proc/self/cgroup into a `Vec<CgroupEntry>`, if the file exists.
48pub fn parse_proc_self_cgroup() -> Option<Vec<CgroupEntry>> {
49    let file = File::open("/proc/self/cgroup").ok()?;
50    let file = BufReader::new(file);
51    Some(
52        file.lines()
53            .map_while(Result::ok)
54            .filter_map(CgroupEntry::from_line)
55            .collect(),
56    )
57}
58
59/// An entry in /proc/self/mountinfo
60#[derive(Debug, PartialEq)]
61pub struct Mountinfo {
62    root: PathBuf,
63    mount_point: PathBuf,
64    fs_type: String,
65    super_opts: Vec<String>,
66}
67
68impl Mountinfo {
69    fn from_line(line: String) -> Option<Mountinfo> {
70        // https://www.kernel.org/doc/Documentation/filesystems/proc.txt
71        let mut split = line.split(" - ");
72
73        let mut mount_fields = split.next()?.split(' ');
74        let root = PathBuf::from(mount_fields.nth(3)?);
75        let mount_point = PathBuf::from(mount_fields.next()?);
76
77        let mut fs_fields = split.next()?.split(' ');
78
79        let fs_type = fs_fields.next()?.split('.').next()?.to_owned();
80        let super_opts: Vec<String> = fs_fields
81            .nth(1)?
82            .split(',')
83            .filter(|s| !s.is_empty())
84            .map(|s| s.to_owned())
85            .collect();
86
87        Some(Mountinfo {
88            root,
89            mount_point,
90            fs_type,
91            super_opts,
92        })
93    }
94}
95
96/// Parses /proc/self/mountinfo into vectors of Mountinfo objects,
97/// returning (v2_mounts, v1_mounts).
98pub fn parse_proc_self_mountinfo() -> Option<(Vec<Mountinfo>, Vec<Mountinfo>)> {
99    let file = File::open("/proc/self/mountinfo").ok()?;
100    let file = BufReader::new(file);
101    Some(
102        file.lines()
103            .map_while(Result::ok)
104            .filter_map(Mountinfo::from_line)
105            .filter(|mi| mi.fs_type == "cgroup" || mi.fs_type == "cgroup2")
106            .partition(|mi| mi.fs_type == "cgroup2"),
107    )
108}
109
110/// Represents a cgroup memory limit, with both ram and swap maximums if they exist.
111/// Fields should be None if a limit does not exist or when running on a platform without cgroup
112/// support (ie: non-Linux platforms).
113#[derive(Debug)]
114pub struct MemoryLimit {
115    /// Maximum RAM limit, in bytes, if a limit exists.
116    pub max: Option<usize>,
117    /// Maximum swap limit, in bytes, if a limit exists.
118    pub swap_max: Option<usize>,
119}
120
121fn read_file_to_usize<P: AsRef<Path>>(path: P) -> Option<usize> {
122    match std::fs::read_to_string(path.as_ref()) {
123        Ok(s) => s.trim().parse::<usize>().ok(),
124        Err(_) => None,
125    }
126}
127
128/// Finds the mountpoint corresponding to the provided cgroup v2,
129/// and reads the memory limits within.
130fn read_v2_memory_limit(cgroups: &[CgroupEntry], mounts: &[Mountinfo]) -> Option<MemoryLimit> {
131    // cgroups v2 only supports a single cgroup per process
132    let mount = mounts.first()?;
133    if mount.root != cgroups.first()?.root {
134        // We don't support mixed v2/v1
135        return None;
136    }
137    let mount_point = &mount.mount_point;
138    let controllers = std::fs::read_to_string(mount_point.join("cgroup.controllers")).ok()?;
139    let mut controllers = controllers.trim().split(' ');
140    if controllers.any(|c| c == "memory") {
141        let max = read_file_to_usize(mount_point.join("memory.max"));
142        // Unlike v1, this is only the swap, not swap + memory.
143        let swap_max = read_file_to_usize(mount_point.join("memory.swap.max"));
144        return Some(MemoryLimit { max, swap_max });
145    }
146    None
147}
148
149/// Finds the cgroup v1 and mountpoint combination containing the memory controller,
150/// and reads the memory limits within.
151fn read_v1_memory_limit(cgroups: &[CgroupEntry], mounts: &[Mountinfo]) -> Option<MemoryLimit> {
152    // https://www.kernel.org/doc/Documentation/cgroup-v1/memory.txt
153    let memory_cgroup = cgroups
154        .into_iter()
155        .find(|cgroup| cgroup.subsystems.iter().any(|s| s == "memory"))?;
156    let memory_mount = mounts
157        .iter()
158        .find(|mi| mi.root == memory_cgroup.root && mi.super_opts.iter().any(|o| o == "memory"))?;
159    let mount_point = &memory_mount.mount_point;
160    let max = read_file_to_usize(mount_point.join("memory.limit_in_bytes"));
161    // This is memory + swap, not just swap.
162    let memsw_max = read_file_to_usize(mount_point.join("memory.memsw.limit_in_bytes"));
163    let swap_max = match (max, memsw_max) {
164        (Some(max), Some(memsw_max)) => Some(memsw_max - max),
165        _ => None,
166    };
167    Some(MemoryLimit { max, swap_max })
168}
169/// Returns the cgroup (v1 or v2) memory limit if it exists.
170pub fn detect_memory_limit() -> Option<MemoryLimit> {
171    let (v2_mounts, v1_mounts) = parse_proc_self_mountinfo()?;
172    let cgroups = parse_proc_self_cgroup()?;
173
174    if !v2_mounts.is_empty() {
175        return read_v2_memory_limit(&cgroups, &v2_mounts);
176    }
177    read_v1_memory_limit(&cgroups, &v1_mounts)
178}
179
180/// The directory holding this process's cgroup v2 interface files.
181///
182/// Resolving the directory walks `/proc/self/mountinfo` and `/proc/self/cgroup`, so callers that
183/// read repeatedly should [`CgroupV2::detect`] once and keep the handle.
184#[derive(Clone, Debug)]
185pub struct CgroupV2 {
186    dir: PathBuf,
187}
188
189impl CgroupV2 {
190    /// Resolve this process's cgroup v2 directory, if it has one with the memory controller
191    /// enabled.
192    ///
193    /// Returns `None` on a v1-only hierarchy, a mixed hierarchy, and on non-Linux platforms.
194    pub fn detect() -> Option<Self> {
195        let (v2_mounts, _v1_mounts) = parse_proc_self_mountinfo()?;
196        let cgroups = parse_proc_self_cgroup()?;
197
198        // cgroups v2 supports only a single cgroup per process.
199        let mount = v2_mounts.first()?;
200        if mount.root != cgroups.first()?.root {
201            // Mixed v1/v2 hierarchies are not supported.
202            return None;
203        }
204
205        let dir = &mount.mount_point;
206        let controllers = std::fs::read_to_string(dir.join("cgroup.controllers")).ok()?;
207        if !controllers.trim().split(' ').any(|c| c == "memory") {
208            return None;
209        }
210
211        Some(Self { dir: dir.clone() })
212    }
213
214    /// The resolved directory.
215    ///
216    /// Readings taken from the wrong cgroup look plausible rather than absent, so callers are
217    /// expected to report this once so that a misresolution is diagnosable without container
218    /// access. This crate does not log it itself, since `tracing` is an optional dependency here.
219    pub fn path(&self) -> &Path {
220        &self.dir
221    }
222
223    /// Read an interface file holding a single integer, in bytes or as a count.
224    ///
225    /// Returns `None` when the file is absent, which is how a kernel too old to provide it
226    /// presents, and when it holds a non-integer. `memory.max` and friends read as `None` when
227    /// unlimited, since they then hold the literal `max`.
228    pub fn read_u64(&self, file: &str) -> Option<u64> {
229        let contents = std::fs::read_to_string(self.dir.join(file)).ok()?;
230        contents.trim().parse().ok()
231    }
232
233    /// Read a `key value` interface file, returning the value for `key`.
234    pub fn read_keyed_u64(&self, file: &str, key: &str) -> Option<u64> {
235        let contents = std::fs::read_to_string(self.dir.join(file)).ok()?;
236        contents.lines().find_map(|line| {
237            let (name, value) = line.split_once(' ')?;
238            (name == key).then(|| value.trim().parse().ok())?
239        })
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use std::path::PathBuf;
246
247    use super::{CgroupEntry, Mountinfo};
248
249    #[crate::test]
250    fn test_cgroup_from_line() {
251        // cgroups v2
252        assert_eq!(
253            CgroupEntry::from_line("0::/".to_owned()),
254            Some(CgroupEntry {
255                subsystems: vec![],
256                root: PathBuf::from("/"),
257            })
258        );
259
260        // cgroups v1
261        assert_eq!(
262            CgroupEntry::from_line("6:cpu,cpuacct:/kubepods/pod5b977639-f878-469b-94ee-47a4aa7e597a/dd55abbabd99bcb4d2ce17ffa77d6f811c90e09202f537c273962a8259cac8a0".to_owned()),
263            Some(CgroupEntry {
264                subsystems: vec!["cpu".to_owned(), "cpuacct".to_owned()],
265                root: PathBuf::from("/kubepods/pod5b977639-f878-469b-94ee-47a4aa7e597a/dd55abbabd99bcb4d2ce17ffa77d6f811c90e09202f537c273962a8259cac8a0"),
266            })
267        );
268        assert_eq!(
269            CgroupEntry::from_line("5:memory:/kubepods/pod5b977639-f878-469b-94ee-47a4aa7e597a/dd55abbabd99bcb4d2ce17ffa77d6f811c90e09202f537c273962a8259cac8a0".to_owned()),
270            Some(CgroupEntry {
271                subsystems: vec!["memory".to_owned()],
272                root: PathBuf::from("/kubepods/pod5b977639-f878-469b-94ee-47a4aa7e597a/dd55abbabd99bcb4d2ce17ffa77d6f811c90e09202f537c273962a8259cac8a0"),
273            })
274        );
275    }
276
277    #[crate::test]
278    fn test_mountinfo_from_line() {
279        // Mount with optional field (master:305)
280        assert_eq!(Mountinfo::from_line("863 758 0:63 / / rw,relatime master:305 - overlay overlay rw,seclabel,lowerdir=/var/lib/docker/overlay2/l/SUKWDHL7W7YZCJ6YI66I7Z5PR2:/var/lib/docker/overlay2/l/ORL2I23UNUGM7FYF4BSL5JUCAB:/var/lib/docker/overlay2/l/LLKK3J2EHGPF5IGGDSAQGRFHLV:/var/lib/docker/overlay2/l/JEQIUQIQTVNRBAGCU7SLV4KK4K:/var/lib/docker/overlay2/l/5DS7KSJCA7BHWAYWII7BI5DBC5:/var/lib/docker/overlay2/l/ZAGXZ62GNFPZFLNUDZ3JOZIMYR:/var/lib/docker/overlay2/l/6WVXMD372IA24ZXRWGGTIPEQPA,upperdir=/var/lib/docker/overlay2/5c7734eb769484f3469b234181365466eb30bcd7f31c912f4250c8d701637ee4/diff,workdir=/var/lib/docker/overlay2/5c7734eb769484f3469b234181365466eb30bcd7f31c912f4250c8d701637ee4/work".to_owned()),
281        Some(Mountinfo{
282            root: PathBuf::from("/"),
283            mount_point: PathBuf::from("/"),
284            fs_type: "overlay".to_owned(),
285            super_opts: vec![
286                "rw".to_owned(),
287                "seclabel".to_owned(),
288                "lowerdir=/var/lib/docker/overlay2/l/SUKWDHL7W7YZCJ6YI66I7Z5PR2:/var/lib/docker/overlay2/l/ORL2I23UNUGM7FYF4BSL5JUCAB:/var/lib/docker/overlay2/l/LLKK3J2EHGPF5IGGDSAQGRFHLV:/var/lib/docker/overlay2/l/JEQIUQIQTVNRBAGCU7SLV4KK4K:/var/lib/docker/overlay2/l/5DS7KSJCA7BHWAYWII7BI5DBC5:/var/lib/docker/overlay2/l/ZAGXZ62GNFPZFLNUDZ3JOZIMYR:/var/lib/docker/overlay2/l/6WVXMD372IA24ZXRWGGTIPEQPA".to_owned(),
289                "upperdir=/var/lib/docker/overlay2/5c7734eb769484f3469b234181365466eb30bcd7f31c912f4250c8d701637ee4/diff".to_owned(),
290                "workdir=/var/lib/docker/overlay2/5c7734eb769484f3469b234181365466eb30bcd7f31c912f4250c8d701637ee4/work".to_owned(),
291            ],
292        })
293        );
294
295        // cgroups v2
296        assert_eq!(Mountinfo::from_line("868 867 0:27 / /sys/fs/cgroup ro,nosuid,nodev,noexec,relatime - cgroup2 cgroup rw,seclabel,nsdelegate,memory_recursiveprot".to_owned()), Some(Mountinfo{
297
298            root: PathBuf::from("/"),
299            mount_point: PathBuf::from("/sys/fs/cgroup"),
300            fs_type: "cgroup2".to_owned(),
301            super_opts: vec![
302                "rw".to_owned(),
303                "seclabel".to_owned(),
304                "nsdelegate".to_owned(),
305                "memory_recursiveprot".to_owned(),
306            ],
307        }));
308
309        // cgroups v1
310        assert_eq!(Mountinfo::from_line("702 697 0:30 /kubepods/pod5b977639-f878-469b-94ee-47a4aa7e597a/dd55abbabd99bcb4d2ce17ffa77d6f811c90e09202f537c273962a8259cac8a0 /sys/fs/cgroup/memory ro,nosuid,nodev,noexec,relatime master:13 - cgroup cgroup rw,memory".to_owned()), Some(Mountinfo{
311
312            root: PathBuf::from("/kubepods/pod5b977639-f878-469b-94ee-47a4aa7e597a/dd55abbabd99bcb4d2ce17ffa77d6f811c90e09202f537c273962a8259cac8a0"),
313            mount_point: PathBuf::from("/sys/fs/cgroup/memory"),
314            fs_type: "cgroup".to_owned(),
315            super_opts: vec![
316                "rw".to_owned(),
317                "memory".to_owned(),
318            ],
319        }));
320    }
321}