Skip to main content

mz_clusterd/
usage_metrics.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Support for collecting system usage metrics.
11//!
12//! Currently only disk and swap usage is supported.
13//! We may want to add CPU and memory usage in the future.
14
15use std::path::PathBuf;
16
17use serde::Serialize;
18use tracing::error;
19
20/// A system usage metrics collector.
21pub(crate) struct Collector {
22    pub disk_root: Option<PathBuf>,
23}
24
25impl Collector {
26    /// Collect current system usage metrics.
27    pub fn collect(&self) -> Usage {
28        let disk_bytes = self.collect_disk_usage();
29        let (memory_bytes, swap_bytes) = collect_heap_usage();
30        let heap_limit = collect_heap_limit();
31
32        Usage {
33            disk_bytes,
34            memory_bytes,
35            swap_bytes,
36            heap_limit,
37        }
38    }
39
40    fn collect_disk_usage(&self) -> Option<u64> {
41        let root = self.disk_root.as_deref()?;
42        match mz_metrics::usage::disk_usage(root) {
43            Ok(bytes) => Some(bytes),
44            Err(err) => {
45                error!("statvfs error: {err}");
46                None
47            }
48        }
49    }
50}
51
52/// A system usage measurement.
53#[derive(Serialize)]
54pub(crate) struct Usage {
55    disk_bytes: Option<u64>,
56    memory_bytes: Option<u64>,
57    swap_bytes: Option<u64>,
58    heap_limit: Option<u64>,
59}
60
61#[cfg(target_os = "linux")]
62mod linux {
63    use std::fs;
64    use std::path::Path;
65
66    use anyhow::{anyhow, bail};
67    use mz_compute::memory_limiter;
68    use mz_metrics::usage::ProcStatus;
69    use mz_ore::cast::CastInto;
70    use tracing::{debug, error};
71
72    /// Collect memory and swap usage.
73    pub fn collect_heap_usage() -> (Option<u64>, Option<u64>) {
74        use mz_ore::cast::CastInto;
75
76        match ProcStatus::from_proc() {
77            Ok(status) => {
78                let memory_bytes = status.vm_rss.cast_into();
79                let swap_bytes = status.vm_swap.cast_into();
80
81                debug!("memory usage: {memory_bytes}");
82                debug!("swap usage: {swap_bytes}");
83
84                (Some(memory_bytes), Some(swap_bytes))
85            }
86            Err(err) => {
87                error!("error reading /proc/self/status: {err}");
88                (None, None)
89            }
90        }
91    }
92
93    /// Collect the heap limit, i.e. memory + swap limit.
94    pub fn collect_heap_limit() -> Option<u64> {
95        // If we don't know the physical limits, we can't know the heap limit.
96        let (phys_mem_limit, phys_swap_limit) = get_physical_limits()?;
97
98        // Limits might be reduced by the cgroup.
99        let (cgroup_mem_limit, cgroup_swap_limit) = get_cgroup_limits();
100        let mem_limit = cgroup_mem_limit.unwrap_or(u64::MAX).min(phys_mem_limit);
101        let swap_limit = cgroup_swap_limit.unwrap_or(u64::MAX).min(phys_swap_limit);
102
103        let heap_limit = mem_limit + swap_limit;
104
105        // Heap limit might be reduced by the memory limiter.
106        let limiter_limit = memory_limiter::get_memory_limit().map(CastInto::cast_into);
107        let heap_limit = limiter_limit.unwrap_or(u64::MAX).min(heap_limit);
108
109        debug!("memory limit: {mem_limit} (phys={phys_mem_limit}, cgroup={cgroup_mem_limit:?})");
110        debug!("swap limit: {swap_limit} (phys={phys_swap_limit}, cgroup={cgroup_swap_limit:?})");
111        debug!("heap limit: {heap_limit} (limiter={limiter_limit:?})");
112
113        Some(heap_limit)
114    }
115
116    /// Helper for parsing `/proc/meminfo`.
117    struct ProcMemInfo {
118        mem_total: u64,
119        swap_total: u64,
120    }
121
122    impl ProcMemInfo {
123        fn from_proc() -> anyhow::Result<Self> {
124            let contents = fs::read_to_string("/proc/meminfo")?;
125
126            fn parse_kib_line(line: &str) -> anyhow::Result<u64> {
127                if let Some(kib) = line
128                    .split_whitespace()
129                    .nth(1)
130                    .and_then(|x| x.parse::<u64>().ok())
131                {
132                    Ok(kib * 1024)
133                } else {
134                    bail!("invalid meminfo line: {line}");
135                }
136            }
137
138            let mut memory = None;
139            let mut swap = None;
140            for line in contents.lines() {
141                if line.starts_with("MemTotal:") {
142                    memory = Some(parse_kib_line(line)?);
143                } else if line.starts_with("SwapTotal:") {
144                    swap = Some(parse_kib_line(line)?);
145                }
146            }
147
148            let mem_total = memory.ok_or_else(|| anyhow!("MemTotal not found"))?;
149            let swap_total = swap.ok_or_else(|| anyhow!("SwapTotal not found"))?;
150
151            Ok(Self {
152                mem_total,
153                swap_total,
154            })
155        }
156    }
157
158    /// Collect the physical memory and swap limits.
159    fn get_physical_limits() -> Option<(u64, u64)> {
160        let meminfo = match ProcMemInfo::from_proc() {
161            Ok(meminfo) => meminfo,
162            Err(error) => {
163                error!("reading `/proc/meminfo`: {error}");
164                return None;
165            }
166        };
167
168        Some((meminfo.mem_total, meminfo.swap_total))
169    }
170
171    /// Collect the memory and swap limits enforced by the current cgroup.
172    ///
173    /// We make the following simplifying assumptions that hold for a standard Kubernetes
174    /// environment:
175    //  * The current process is a member of a cgroups v2 hierarchy.
176    //  * The cgroups hierarchy is mounted at `/sys/fs/cgroup`.
177    //  * The limits are applied to the current cgroup directly (and not one of its ancestors).
178    fn get_cgroup_limits() -> (Option<u64>, Option<u64>) {
179        let Ok(proc_cgroup) = fs::read_to_string("/proc/self/cgroup") else {
180            return (None, None);
181        };
182
183        // Find the cgroups v2 hierarchy. Entries in `/proc/self/cgroup` have the form
184        // `hierarchy-id:controller-list:cgroup-path`. For cgroups v2, the first field is 0 and the
185        // second field is empty.
186        let mut lines = proc_cgroup.lines();
187        let Some(cgroup_path) = lines.find_map(|l| l.strip_prefix("0::")) else {
188            error!("invalid `/proc/self/cgroup` format: {proc_cgroup}");
189            return (None, None);
190        };
191
192        // The cgroup path is a relative path but may include a leading `/`. Strip that so
193        // `Path::join` works as expected.
194        let cgroup_path = cgroup_path.strip_prefix("/").unwrap_or(cgroup_path);
195
196        let root = Path::new("/sys/fs/cgroup").join(cgroup_path);
197        if !root.exists() {
198            error!("invalid cgroup root: {}", root.display());
199            return (None, None);
200        }
201
202        let memory_file = root.join("memory.max");
203        let swap_file = root.join("memory.swap.max");
204
205        let memory = fs::read_to_string(memory_file)
206            .ok()
207            .and_then(|s| s.trim().parse().ok());
208        let swap = fs::read_to_string(swap_file)
209            .ok()
210            .and_then(|s| s.trim().parse().ok());
211
212        (memory, swap)
213    }
214}
215
216#[cfg(not(target_os = "linux"))]
217mod macos {
218    use mz_compute::memory_limiter;
219    use mz_ore::cast::CastInto;
220
221    pub fn collect_heap_usage() -> (Option<u64>, Option<u64>) {
222        (None, None)
223    }
224
225    pub fn collect_heap_limit() -> Option<u64> {
226        memory_limiter::get_memory_limit().map(CastInto::cast_into)
227    }
228}
229
230#[cfg(target_os = "linux")]
231use linux::*;
232#[cfg(not(target_os = "linux"))]
233use macos::*;