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
1732use std:: time:: { Duration , Instant } ;
1833
@@ -23,13 +38,21 @@ use tokio::time::{MissedTickBehavior, interval};
2338/// `true`, `yes`, and `on` (case-insensitive).
2439const 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
3457const PROC_MEMINFO : & str = "/proc/meminfo" ;
3558const PROC_STAT : & str = "/proc/stat" ;
@@ -38,20 +61,41 @@ const PROC_STAT: &str = "/proc/stat";
3861/// and gVisor.
3962const 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
70130fn 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.
89150pub 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`.
237276fn 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-
312324fn bytes_to_mib ( bytes : u64 ) -> f64 {
313325 bytes as f64 / ( 1024.0 * 1024.0 )
314326}
0 commit comments