Skip to content

Commit 74687ac

Browse files
committed
fix(container-runner): read cgroup v1 memory usage under gvisor
1 parent 72999be commit 74687ac

1 file changed

Lines changed: 139 additions & 127 deletions

File tree

container-runner/src/monitor.rs

Lines changed: 139 additions & 127 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,33 @@
11
//! Periodic instance resource monitor.
22
//!
33
//! Opt-in via the [`ENABLE_ENV`] environment variable. When enabled it samples
4-
//! memory and CPU usage every [`SAMPLE_INTERVAL`] and logs them, so memory
4+
//! memory and CPU usage every [`sample_interval`] and logs them, so memory
55
//! growth toward the limit (and the OOM that follows) is visible in the logs at
66
//! fine granularity. Disabled by default so nothing is logged unless explicitly
77
//! turned on.
88
//!
9-
//! Two counter sources are supported, picked at startup:
10-
//! - **cgroup v2** under `/sys/fs/cgroup` (real Linux). Exact against the
11-
//! container limits.
12-
//! - **`/proc`** (`/proc/meminfo`, `/proc/stat`), the fallback for a gVisor
13-
//! sandbox, which does not expose cgroup v2. Values reflect the sandbox and
14-
//! are approximate.
15-
//! If neither is readable the monitor logs once and disables itself.
9+
//! Memory and CPU are detected independently, because the gVisor sandbox
10+
//! exposes cgroup v1 memory but no cgroup v2 or cgroup v1 CPU accounting, so the
11+
//! two counters legitimately come from different sources.
12+
//!
13+
//! Only memory *usage* is reported, not a limit or percentage: under gVisor both
14+
//! the cgroup `memory.limit_in_bytes` and `/proc/meminfo` report the sandbox size
15+
//! rather than the container's configured limit, so any percentage would be
16+
//! misleading.
17+
//!
18+
//! Memory sources, in preference order:
19+
//! - **cgroup v2** `memory.current` (real Linux). Exact.
20+
//! - **cgroup v1** `memory/memory.usage_in_bytes` (gVisor sandbox).
21+
//! Container-wide usage (all processes plus page cache).
22+
//! - **`/proc/meminfo`** last resort. Under gVisor this reflects the whole
23+
//! sandbox, not the container, so it is only an approximation.
24+
//!
25+
//! CPU sources, in preference order:
26+
//! - **cgroup v2** `cpu.stat`.
27+
//! - **`/proc/stat`**, the gVisor sandbox fallback (sandbox-wide, approximate).
28+
//!
29+
//! If neither a memory nor a CPU source is readable the monitor logs once and
30+
//! disables itself.
1631
1732
use std::time::{Duration, Instant};
1833

@@ -23,13 +38,21 @@ use tokio::time::{MissedTickBehavior, interval};
2338
/// `true`, `yes`, and `on` (case-insensitive).
2439
const ENABLE_ENV: &str = "RIVET_LOG_RESOURCE_USAGE";
2540

26-
/// How often to sample and log resource usage.
27-
const SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
41+
/// Default cadence for sampling and logging resource usage, when
42+
/// [`INTERVAL_ENV`] is unset.
43+
const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
2844

29-
const MEMORY_CURRENT: &str = "/sys/fs/cgroup/memory.current";
30-
const MEMORY_MAX: &str = "/sys/fs/cgroup/memory.max";
31-
const CPU_STAT: &str = "/sys/fs/cgroup/cpu.stat";
32-
const CPU_MAX: &str = "/sys/fs/cgroup/cpu.max";
45+
/// Environment variable overriding the sample/log interval, in milliseconds.
46+
/// Unset, unparseable, or `0` falls back to [`DEFAULT_SAMPLE_INTERVAL`].
47+
const INTERVAL_ENV: &str = "RIVET_RESOURCE_USAGE_INTERVAL_MS";
48+
49+
/// cgroup v2 memory usage (real Linux).
50+
const MEMORY_CURRENT_V2: &str = "/sys/fs/cgroup/memory.current";
51+
/// cgroup v1 memory usage (gVisor sandbox). Container-wide, includes page
52+
/// cache. The sibling `memory.limit_in_bytes` is deliberately not read: under
53+
/// gVisor it reports the sandbox size, not the configured limit.
54+
const MEMORY_USAGE_V1: &str = "/sys/fs/cgroup/memory/memory.usage_in_bytes";
55+
const CPU_STAT_V2: &str = "/sys/fs/cgroup/cpu.stat";
3356

3457
const PROC_MEMINFO: &str = "/proc/meminfo";
3558
const PROC_STAT: &str = "/proc/stat";
@@ -38,20 +61,41 @@ const PROC_STAT: &str = "/proc/stat";
3861
/// and gVisor.
3962
const USER_HZ: u64 = 100;
4063

41-
/// Where the monitor reads counters from.
64+
/// Where the monitor reads memory counters from.
65+
#[derive(Clone, Copy, PartialEq, Eq)]
66+
enum MemSource {
67+
/// cgroup v2 `memory.current` (real Linux).
68+
CgroupV2,
69+
/// cgroup v1 `memory.usage_in_bytes` (gVisor sandbox).
70+
CgroupV1,
71+
/// `/proc/meminfo` (sandbox-wide approximation).
72+
Proc,
73+
}
74+
75+
impl MemSource {
76+
fn label(self) -> &'static str {
77+
match self {
78+
MemSource::CgroupV2 => "cgroup_v2",
79+
MemSource::CgroupV1 => "cgroup_v1",
80+
MemSource::Proc => "proc",
81+
}
82+
}
83+
}
84+
85+
/// Where the monitor reads CPU counters from.
4286
#[derive(Clone, Copy, PartialEq, Eq)]
43-
enum Source {
44-
/// cgroup v2 under `/sys/fs/cgroup` (real Linux).
87+
enum CpuSource {
88+
/// cgroup v2 `cpu.stat` (real Linux).
4589
CgroupV2,
46-
/// `/proc` (gVisor sandbox, which does not expose cgroup v2).
90+
/// `/proc/stat` (gVisor sandbox, which does not expose cgroup CPU).
4791
Proc,
4892
}
4993

50-
impl Source {
94+
impl CpuSource {
5195
fn label(self) -> &'static str {
5296
match self {
53-
Source::CgroupV2 => "cgroup_v2",
54-
Source::Proc => "proc",
97+
CpuSource::CgroupV2 => "cgroup_v2",
98+
CpuSource::Proc => "proc",
5599
}
56100
}
57101
}
@@ -63,8 +107,24 @@ pub fn spawn_resource_monitor() {
63107
if !monitor_enabled() {
64108
return;
65109
}
66-
tracing::info!(interval_ms = SAMPLE_INTERVAL.as_millis() as u64, "resource monitor enabled");
67-
tokio::spawn(run_monitor());
110+
let sample_interval = sample_interval();
111+
tracing::info!(
112+
interval_ms = sample_interval.as_millis() as u64,
113+
"resource monitor enabled"
114+
);
115+
tokio::spawn(run_monitor(sample_interval));
116+
}
117+
118+
/// The configured sample/log interval: [`INTERVAL_ENV`] in milliseconds when set
119+
/// to a positive integer, otherwise [`DEFAULT_SAMPLE_INTERVAL`].
120+
fn sample_interval() -> Duration {
121+
match std::env::var(INTERVAL_ENV)
122+
.ok()
123+
.and_then(|value| value.trim().parse::<u64>().ok())
124+
{
125+
Some(ms) if ms > 0 => Duration::from_millis(ms),
126+
_ => DEFAULT_SAMPLE_INTERVAL,
127+
}
68128
}
69129

70130
fn monitor_enabled() -> bool {
@@ -84,66 +144,83 @@ pub fn enabled() -> bool {
84144
monitor_enabled()
85145
}
86146

87-
/// The counter source the monitor would use right now: `"cgroup_v2"`, `"proc"`,
88-
/// or `"none"` when neither is readable. Exposed for the actor-scoped status log.
147+
/// The memory counter source the monitor would use right now: `"cgroup_v2"`,
148+
/// `"cgroup_v1"`, `"proc"`, or `"none"`. Exposed for the actor-scoped status log.
149+
/// Memory is the monitor's headline signal, so this reports the memory source.
89150
pub fn sampling_source() -> &'static str {
90-
match detect_source() {
151+
match detect_mem_source() {
91152
Some(source) => source.label(),
92153
None => "none",
93154
}
94155
}
95156

96-
/// Pick the counter source, preferring exact cgroup v2 over the `/proc` fallback.
97-
fn detect_source() -> Option<Source> {
98-
if read_u64(MEMORY_CURRENT).is_some() && read_cgroup_cpu_usage_usec().is_some() {
99-
Some(Source::CgroupV2)
100-
} else if read_proc_memory().is_some() && read_proc_cpu_busy_usec().is_some() {
101-
Some(Source::Proc)
157+
/// Pick the memory source, preferring exact cgroup v2, then cgroup v1 (gVisor
158+
/// sandbox), then the sandbox-wide `/proc/meminfo` approximation.
159+
fn detect_mem_source() -> Option<MemSource> {
160+
if read_u64(MEMORY_CURRENT_V2).is_some() {
161+
Some(MemSource::CgroupV2)
162+
} else if read_u64(MEMORY_USAGE_V1).is_some() {
163+
Some(MemSource::CgroupV1)
164+
} else if read_proc_memory().is_some() {
165+
Some(MemSource::Proc)
102166
} else {
103167
None
104168
}
105169
}
106170

107-
async fn run_monitor() {
108-
let Some(source) = detect_source() else {
171+
/// Pick the CPU source, preferring cgroup v2 over the `/proc/stat` fallback.
172+
fn detect_cpu_source() -> Option<CpuSource> {
173+
if read_cgroup_cpu_usage_usec().is_some() {
174+
Some(CpuSource::CgroupV2)
175+
} else if read_proc_cpu_busy_usec().is_some() {
176+
Some(CpuSource::Proc)
177+
} else {
178+
None
179+
}
180+
}
181+
182+
async fn run_monitor(sample_interval: Duration) {
183+
let mem_source = detect_mem_source();
184+
let cpu_source = detect_cpu_source();
185+
if mem_source.is_none() && cpu_source.is_none() {
109186
tracing::warn!(
110187
cgroup_dir = "/sys/fs/cgroup",
111188
proc_meminfo = PROC_MEMINFO,
112189
proc_stat = PROC_STAT,
113-
"resource monitor disabled: neither cgroup v2 nor /proc counters are readable"
190+
"resource monitor disabled: no readable memory or cpu counters"
114191
);
115192
return;
116-
};
117-
tracing::info!(source = source.label(), "resource monitor sampling");
193+
}
194+
tracing::info!(
195+
mem_source = mem_source.map(MemSource::label).unwrap_or("none"),
196+
cpu_source = cpu_source.map(CpuSource::label).unwrap_or("none"),
197+
"resource monitor sampling"
198+
);
118199

119-
let mut ticker = interval(SAMPLE_INTERVAL);
200+
let mut ticker = interval(sample_interval);
120201
// A slow sample must not make the monitor try to catch up with a burst of
121202
// back-to-back ticks; just resume on the next boundary.
122203
ticker.set_missed_tick_behavior(MissedTickBehavior::Skip);
123204
// The first tick fires immediately; consume it so the first logged line
124205
// already covers a full interval of CPU time.
125206
ticker.tick().await;
126207

127-
// Limits are fixed for the instance lifetime, so read them once.
128-
let mem_limit_mib = memory_limit_bytes(source).map(bytes_to_mib);
129-
let cpu_limit_cores = cpu_limit_cores(source);
130-
131-
let mut prev_cpu_usec = cpu_busy_usec(source);
208+
let mut prev_cpu_usec = cpu_source.and_then(cpu_busy_usec);
132209
let mut prev_at = Instant::now();
133210

134211
loop {
135212
ticker.tick().await;
136213
let now = Instant::now();
137214
let elapsed = now.duration_since(prev_at);
138215

139-
let cpu_now_usec = cpu_busy_usec(source);
216+
let cpu_now_usec = cpu_source.and_then(cpu_busy_usec);
140217
// CPU time used over the interval, divided by wall time, is the number of
141218
// vCPU cores consumed (1.0 == one core fully busy).
142219
let cpu_cores = match (prev_cpu_usec, cpu_now_usec) {
143220
(Some(prev), Some(cur)) if !elapsed.is_zero() => {
144-
cur.saturating_sub(prev) as f64 / elapsed.as_micros() as f64
221+
Some(cur.saturating_sub(prev) as f64 / elapsed.as_micros() as f64)
145222
}
146-
_ => 0.0,
223+
_ => None,
147224
};
148225
prev_cpu_usec = cpu_now_usec;
149226
prev_at = now;
@@ -156,61 +233,35 @@ async fn run_monitor() {
156233
continue;
157234
}
158235

159-
let mem_used_mib = memory_used_bytes(source).map(bytes_to_mib);
160-
let mem_pct = match (mem_used_mib, mem_limit_mib) {
161-
(Some(used), Some(limit)) if limit > 0.0 => Some(used / limit * 100.0),
162-
_ => None,
163-
};
164-
let cpu_pct = match cpu_limit_cores {
165-
Some(limit) if limit > 0.0 => Some(cpu_cores / limit * 100.0),
166-
_ => None,
167-
};
236+
let mem_used_mib = mem_source.and_then(memory_used_bytes).map(bytes_to_mib);
168237

169238
for actor_id in actor_ids {
170239
tracing::info!(
171240
actor_id = %actor_id,
172-
source = source.label(),
241+
mem_source = mem_source.map(MemSource::label).unwrap_or("none"),
242+
cpu_source = cpu_source.map(CpuSource::label).unwrap_or("none"),
173243
mem_used_mib = ?mem_used_mib,
174-
mem_limit_mib = ?mem_limit_mib,
175-
mem_pct = ?mem_pct,
176-
cpu_cores,
177-
cpu_limit_cores = ?cpu_limit_cores,
178-
cpu_pct = ?cpu_pct,
244+
cpu_cores = ?cpu_cores,
179245
"instance resource usage"
180246
);
181247
}
182248
}
183249
}
184250

185251
/// Current memory usage in bytes for the selected source.
186-
fn memory_used_bytes(source: Source) -> Option<u64> {
252+
fn memory_used_bytes(source: MemSource) -> Option<u64> {
187253
match source {
188-
Source::CgroupV2 => read_u64(MEMORY_CURRENT),
189-
Source::Proc => read_proc_memory().map(|(used, _limit)| used),
190-
}
191-
}
192-
193-
/// Memory limit in bytes for the selected source, or `None` when unlimited.
194-
fn memory_limit_bytes(source: Source) -> Option<u64> {
195-
match source {
196-
Source::CgroupV2 => read_cgroup_memory_max(),
197-
Source::Proc => read_proc_memory().map(|(_used, limit)| limit),
254+
MemSource::CgroupV2 => read_u64(MEMORY_CURRENT_V2),
255+
MemSource::CgroupV1 => read_u64(MEMORY_USAGE_V1),
256+
MemSource::Proc => read_proc_memory(),
198257
}
199258
}
200259

201260
/// Cumulative busy CPU time in microseconds for the selected source.
202-
fn cpu_busy_usec(source: Source) -> Option<u64> {
203-
match source {
204-
Source::CgroupV2 => read_cgroup_cpu_usage_usec(),
205-
Source::Proc => read_proc_cpu_busy_usec(),
206-
}
207-
}
208-
209-
/// CPU limit in vCPU cores for the selected source, or `None` when unknown.
210-
fn cpu_limit_cores(source: Source) -> Option<f64> {
261+
fn cpu_busy_usec(source: CpuSource) -> Option<u64> {
211262
match source {
212-
Source::CgroupV2 => read_cgroup_cpu_limit_cores(),
213-
Source::Proc => read_proc_cpu_count(),
263+
CpuSource::CgroupV2 => read_cgroup_cpu_usage_usec(),
264+
CpuSource::Proc => read_proc_cpu_busy_usec(),
214265
}
215266
}
216267

@@ -220,49 +271,21 @@ fn read_u64(path: &str) -> Option<u64> {
220271
std::fs::read_to_string(path).ok()?.trim().parse().ok()
221272
}
222273

223-
/// cgroup v2 memory limit in bytes, or `None` when unlimited (`memory.max` is
224-
/// `"max"`).
225-
fn read_cgroup_memory_max() -> Option<u64> {
226-
let raw = std::fs::read_to_string(MEMORY_MAX).ok()?;
227-
let raw = raw.trim();
228-
if raw == "max" {
229-
None
230-
} else {
231-
raw.parse().ok()
232-
}
233-
}
234-
235274
/// Cumulative CPU time consumed by the cgroup, in microseconds, from the
236275
/// `usage_usec` line of `cpu.stat`.
237276
fn read_cgroup_cpu_usage_usec() -> Option<u64> {
238-
let stat = std::fs::read_to_string(CPU_STAT).ok()?;
277+
let stat = std::fs::read_to_string(CPU_STAT_V2).ok()?;
239278
stat.lines()
240279
.find_map(|line| line.strip_prefix("usage_usec "))
241280
.and_then(|value| value.trim().parse().ok())
242281
}
243282

244-
/// cgroup v2 CPU limit in vCPU cores from `cpu.max` (`"<quota> <period>"`), or
245-
/// `None` when unlimited (`quota` is `"max"`).
246-
fn read_cgroup_cpu_limit_cores() -> Option<f64> {
247-
let raw = std::fs::read_to_string(CPU_MAX).ok()?;
248-
let mut parts = raw.split_whitespace();
249-
let quota = parts.next()?;
250-
let period: f64 = parts.next()?.parse().ok()?;
251-
if quota == "max" || period <= 0.0 {
252-
return None;
253-
}
254-
let quota: f64 = quota.parse().ok()?;
255-
Some(quota / period)
256-
}
257-
258-
/// `(used_bytes, total_bytes)` from `/proc/meminfo`. Used is `MemTotal -
259-
/// MemAvailable`; total doubles as the limit under gVisor, where it reflects the
260-
/// sandbox memory.
261-
fn read_proc_memory() -> Option<(u64, u64)> {
283+
/// Used memory in bytes from `/proc/meminfo` (`MemTotal - MemAvailable`). Under
284+
/// gVisor this reflects the whole sandbox, not the container.
285+
fn read_proc_memory() -> Option<u64> {
262286
let total_kb = read_meminfo_kb("MemTotal")?;
263287
let available_kb = read_meminfo_kb("MemAvailable")?;
264-
let used_kb = total_kb.saturating_sub(available_kb);
265-
Some((used_kb * 1024, total_kb * 1024))
288+
Some(total_kb.saturating_sub(available_kb) * 1024)
266289
}
267290

268291
/// Value in kB of a `/proc/meminfo` key such as `"MemTotal"`.
@@ -298,17 +321,6 @@ fn read_proc_cpu_busy_usec() -> Option<u64> {
298321
Some(busy * (1_000_000 / USER_HZ))
299322
}
300323

301-
/// Number of vCPUs from the per-CPU (`cpu0`, `cpu1`, ...) lines of `/proc/stat`.
302-
fn read_proc_cpu_count() -> Option<f64> {
303-
let content = std::fs::read_to_string(PROC_STAT).ok()?;
304-
// The aggregate line is `"cpu "` (trailing space); per-CPU lines are `"cpu0"`.
305-
let count = content
306-
.lines()
307-
.filter(|line| line.starts_with("cpu") && !line.starts_with("cpu "))
308-
.count();
309-
(count > 0).then_some(count as f64)
310-
}
311-
312324
fn bytes_to_mib(bytes: u64) -> f64 {
313325
bytes as f64 / (1024.0 * 1024.0)
314326
}

0 commit comments

Comments
 (0)