Skip to content

Commit e717c75

Browse files
committed
feat: add cgroup CPU usage metric
1 parent 24c2f0a commit e717c75

2 files changed

Lines changed: 161 additions & 4 deletions

File tree

‎src/handlers/http/resource_check.rs‎

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@
1818

1919
#[cfg(target_os = "linux")]
2020
use std::path::{Path, PathBuf};
21+
#[cfg(target_os = "linux")]
22+
use std::sync::Mutex;
2123
use std::sync::{Arc, LazyLock, atomic::AtomicBool};
24+
#[cfg(target_os = "linux")]
25+
use std::time::Instant;
2226

2327
use actix_web::{
2428
body::MessageBody,
@@ -35,19 +39,36 @@ use tokio::{
3539
use tracing::{info, trace, warn};
3640

3741
use crate::analytics::{SYS_INFO, refresh_sys_info};
38-
use crate::metrics::{record_disk_metrics, record_process_metrics_sample};
42+
use crate::metrics::{
43+
record_disk_metrics, record_process_cpu_usage_cores, record_process_metrics_sample,
44+
};
3945
use crate::parseable::PARSEABLE;
4046

4147
const PROCESS_METRICS_SAMPLE_INTERVAL: Duration = Duration::from_secs(5);
4248
#[cfg(target_os = "linux")]
4349
const CGROUP_V2_CPU_MAX_FILE: &str = "cpu.max";
4450
#[cfg(target_os = "linux")]
51+
const CGROUP_V2_CPU_STAT_FILE: &str = "cpu.stat";
52+
#[cfg(target_os = "linux")]
4553
const CGROUP_V1_CPU_QUOTA_FILE: &str = "cpu.cfs_quota_us";
4654
#[cfg(target_os = "linux")]
4755
const CGROUP_V1_CPU_PERIOD_FILE: &str = "cpu.cfs_period_us";
56+
#[cfg(target_os = "linux")]
57+
const CGROUP_V1_CPU_USAGE_FILE: &str = "cpuacct.usage";
4858

4959
static SERVER_OK: LazyLock<Arc<AtomicBool>> = LazyLock::new(|| Arc::new(AtomicBool::new(true)));
5060

61+
#[cfg(target_os = "linux")]
62+
#[derive(Clone, Copy)]
63+
struct CgroupCpuUsageSample {
64+
usage_micros: u64,
65+
sampled_at: Instant,
66+
}
67+
68+
#[cfg(target_os = "linux")]
69+
static CGROUP_CPU_USAGE_SAMPLE: LazyLock<Mutex<Option<CgroupCpuUsageSample>>> =
70+
LazyLock::new(|| Mutex::new(None));
71+
5172
#[cfg(target_os = "linux")]
5273
fn cpu_quota_cores(quota: &str, period: &str) -> Result<Option<f64>, ()> {
5374
let quota = quota.trim();
@@ -107,6 +128,82 @@ fn cgroup_cpu_limit_cores() -> Result<Option<f64>, ()> {
107128
cpu_quota_cores(&quota, &period)
108129
}
109130

131+
#[cfg(target_os = "linux")]
132+
fn cpu_usage_micros_from_stat(cpu_stat: &str) -> Result<u64, ()> {
133+
for line in cpu_stat.lines() {
134+
let mut fields = line.split_whitespace();
135+
if fields.next() == Some("usage_usec") {
136+
return fields.next().ok_or(())?.parse::<u64>().map_err(|_| ());
137+
}
138+
}
139+
Err(())
140+
}
141+
142+
#[cfg(target_os = "linux")]
143+
fn cgroup_cpu_usage_micros() -> Result<u64, ()> {
144+
let process = procfs::process::Process::myself().map_err(|_| ())?;
145+
let cgroups = process.cgroups().map_err(|_| ())?.0;
146+
let mounts = process.mountinfo().map_err(|_| ())?.0;
147+
148+
if let (Some(cgroup), Some(mount)) = (
149+
cgroups.iter().find(|group| group.hierarchy == 0),
150+
mounts.iter().find(|mount| mount.fs_type == "cgroup2"),
151+
) {
152+
let directory =
153+
cgroup_directory(&cgroup.pathname, &mount.root, &mount.mount_point).ok_or(())?;
154+
let cpu_stat =
155+
std::fs::read_to_string(directory.join(CGROUP_V2_CPU_STAT_FILE)).map_err(|_| ())?;
156+
return cpu_usage_micros_from_stat(&cpu_stat);
157+
}
158+
159+
let cgroup = cgroups
160+
.iter()
161+
.find(|group| group.controllers.iter().any(|item| item == "cpuacct"))
162+
.ok_or(())?;
163+
let mount = mounts
164+
.iter()
165+
.find(|mount| mount.fs_type == "cgroup" && mount.super_options.contains_key("cpuacct"))
166+
.ok_or(())?;
167+
let directory = cgroup_directory(&cgroup.pathname, &mount.root, &mount.mount_point).ok_or(())?;
168+
let usage_nanos = std::fs::read_to_string(directory.join(CGROUP_V1_CPU_USAGE_FILE))
169+
.map_err(|_| ())?
170+
.trim()
171+
.parse::<u64>()
172+
.map_err(|_| ())?;
173+
Ok(usage_nanos / 1_000)
174+
}
175+
176+
#[cfg(target_os = "linux")]
177+
fn cpu_usage_cores_between(
178+
previous_usage_micros: u64,
179+
current_usage_micros: u64,
180+
elapsed_micros: u128,
181+
) -> Option<f64> {
182+
let usage_delta = current_usage_micros.checked_sub(previous_usage_micros)?;
183+
(elapsed_micros > 0).then_some(usage_delta as f64 / elapsed_micros as f64)
184+
}
185+
186+
#[cfg(target_os = "linux")]
187+
fn cgroup_cpu_usage_cores() -> Result<Option<f64>, ()> {
188+
let current = CgroupCpuUsageSample {
189+
usage_micros: cgroup_cpu_usage_micros()?,
190+
sampled_at: Instant::now(),
191+
};
192+
let mut previous = CGROUP_CPU_USAGE_SAMPLE.lock().map_err(|_| ())?;
193+
let usage_cores = previous.and_then(|previous| {
194+
cpu_usage_cores_between(
195+
previous.usage_micros,
196+
current.usage_micros,
197+
current
198+
.sampled_at
199+
.duration_since(previous.sampled_at)
200+
.as_micros(),
201+
)
202+
});
203+
*previous = Some(current);
204+
Ok(usage_cores)
205+
}
206+
110207
#[cfg(not(target_os = "linux"))]
111208
fn cgroup_cpu_limit_cores() -> Result<Option<f64>, ()> {
112209
Err(())
@@ -120,6 +217,18 @@ pub fn cpu_limit_cores() -> f64 {
120217
}
121218
}
122219

220+
fn cpu_usage_cores() -> f64 {
221+
#[cfg(target_os = "linux")]
222+
{
223+
return cgroup_cpu_usage_cores().ok().flatten().unwrap_or_default();
224+
}
225+
226+
#[cfg(not(target_os = "linux"))]
227+
{
228+
0.0
229+
}
230+
}
231+
123232
async fn sample_process_metrics() {
124233
refresh_sys_info();
125234
let process_metrics = tokio::task::spawn_blocking(|| {
@@ -137,13 +246,22 @@ async fn sample_process_metrics() {
137246
};
138247

139248
process_metrics.map(|(cpu_usage, memory_bytes, total_mem)| {
140-
(cpu_usage, memory_bytes, total_mem, cpu_limit_cores())
249+
(
250+
cpu_usage,
251+
memory_bytes,
252+
total_mem,
253+
cpu_limit_cores(),
254+
cpu_usage_cores(),
255+
)
141256
})
142257
})
143258
.await
144259
.unwrap();
145-
if let Some((cpu_usage, memory_bytes, total_mem, cpu_limit_cores)) = process_metrics {
260+
if let Some((cpu_usage, memory_bytes, total_mem, cpu_limit_cores, cpu_usage_cores)) =
261+
process_metrics
262+
{
146263
record_process_metrics_sample(cpu_usage, memory_bytes, total_mem, cpu_limit_cores);
264+
record_process_cpu_usage_cores(cpu_usage_cores);
147265
}
148266

149267
let staging_path = PARSEABLE.options.staging_dir().clone();
@@ -254,7 +372,7 @@ pub async fn check_resource_utilization_middleware(
254372

255373
#[cfg(all(test, target_os = "linux"))]
256374
mod tests {
257-
use super::cpu_quota_cores;
375+
use super::{cpu_quota_cores, cpu_usage_cores_between, cpu_usage_micros_from_stat};
258376

259377
#[test]
260378
fn parses_limited_and_unlimited_cpu_quotas() {
@@ -268,4 +386,25 @@ mod tests {
268386
assert_eq!(cpu_quota_cores("invalid", "100000"), Err(()));
269387
assert_eq!(cpu_quota_cores("50000", "0"), Err(()));
270388
}
389+
390+
#[test]
391+
fn parses_cgroup_v2_cpu_usage() {
392+
assert_eq!(
393+
cpu_usage_micros_from_stat("usage_usec 10200000\nuser_usec 8000000\n"),
394+
Ok(10_200_000)
395+
);
396+
assert_eq!(cpu_usage_micros_from_stat("user_usec 8000000\n"), Err(()));
397+
}
398+
399+
#[test]
400+
fn calculates_cpu_usage_in_cores() {
401+
assert_eq!(
402+
cpu_usage_cores_between(10_000_000, 10_200_000, 5_000_000),
403+
Some(0.04)
404+
);
405+
assert_eq!(
406+
cpu_usage_cores_between(10_200_000, 10_000_000, 5_000_000),
407+
None
408+
);
409+
}
271410
}

‎src/metrics/mod.rs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,17 @@ pub static PROCESS_CPU_USAGE_PERCENT_AVG: Lazy<Gauge> = Lazy::new(|| {
243243
.expect("metric can be created")
244244
});
245245

246+
pub static PROCESS_CPU_USAGE_CORES: Lazy<Gauge> = Lazy::new(|| {
247+
Gauge::with_opts(
248+
Opts::new(
249+
"process_cpu_usage_cores",
250+
"CPU cores used by the Parseable cgroup, or zero when cgroup usage is unavailable",
251+
)
252+
.namespace(METRICS_NAMESPACE),
253+
)
254+
.expect("metric can be created")
255+
});
256+
246257
pub static PROCESS_CPU_LIMIT_CORES: Lazy<Gauge> = Lazy::new(|| {
247258
Gauge::with_opts(
248259
Opts::new(
@@ -326,6 +337,10 @@ impl ProcessMetricsAccumulator {
326337
pub static PROCESS_METRICS_ACCUMULATOR: Lazy<ProcessMetricsAccumulator> =
327338
Lazy::new(ProcessMetricsAccumulator::default);
328339

340+
pub fn record_process_cpu_usage_cores(cpu_usage_cores: f64) {
341+
PROCESS_CPU_USAGE_CORES.set(cpu_usage_cores);
342+
}
343+
329344
pub fn record_process_metrics_sample(
330345
cpu_usage_percent: f64,
331346
memory_bytes: u64,
@@ -904,6 +919,9 @@ fn custom_metrics(registry: &Registry) {
904919
registry
905920
.register(Box::new(PROCESS_CPU_USAGE_PERCENT_AVG.clone()))
906921
.expect("metric can be registered");
922+
registry
923+
.register(Box::new(PROCESS_CPU_USAGE_CORES.clone()))
924+
.expect("metric can be registered");
907925
registry
908926
.register(Box::new(PROCESS_CPU_LIMIT_CORES.clone()))
909927
.expect("metric can be registered");

0 commit comments

Comments
 (0)