Skip to content

Commit 808f116

Browse files
committed
Fix get available memory to work with cgroup v1
1 parent ae17f70 commit 808f116

File tree

2 files changed

+248
-9
lines changed

2 files changed

+248
-9
lines changed

.github/workflows/container.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ jobs:
7777
copy-to-ecr:
7878
name: Copy ${{ matrix.arch }} to ECR
7979
needs: build
80-
runs-on: ubuntu-latest
80+
runs-on: ubuntu-24.04
8181
strategy:
8282
matrix:
8383
arch: [amd64, arm64]
@@ -137,7 +137,7 @@ jobs:
137137
manifest:
138138
name: Create ${{ matrix.registry }} manifest
139139
needs: [build, copy-to-ecr]
140-
runs-on: ubuntu-latest
140+
runs-on: ubuntu-24.04
141141
strategy:
142142
matrix:
143143
registry: [ghcr, ecr]

lading/src/lib.rs

Lines changed: 246 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ pub(crate) mod proto;
2222
pub mod target;
2323
pub mod target_metrics;
2424

25+
use std::fs::read_to_string;
26+
use std::io::Error;
27+
use std::path::PathBuf;
28+
2529
use byte_unit::Byte;
2630
use sysinfo::System;
2731

@@ -34,21 +38,256 @@ pub(crate) fn full<T: Into<bytes::Bytes>>(
3438
.boxed()
3539
}
3640

37-
/// Get available memory for the process, checking cgroup v2 limits first,
41+
/// Get available memory for the process, checking cgroup limits first,
3842
/// then falling back to system memory.
43+
///
44+
/// Resolves the process's own cgroup path from `/proc/self/cgroup`, then
45+
/// checks the memory limit at that path. Falls back to the cgroup root, then
46+
/// cgroup v1, and finally total system memory.
3947
#[must_use]
4048
pub fn get_available_memory() -> Byte {
41-
if let Ok(content) = std::fs::read_to_string("/sys/fs/cgroup/memory.max") {
42-
let content = content.trim();
43-
if content == "max" {
44-
return Byte::from_u64(u64::MAX);
49+
get_available_memory_with(read_to_string)
50+
}
51+
52+
/// The cgroup v1 "no limit" sentinel: page-aligned `i64::MAX`.
53+
const CGROUP_V1_NO_LIMIT: u64 = 0x7FFF_FFFF_FFFF_F000;
54+
55+
fn parse_cgroup_limit(content: &str) -> Option<Byte> {
56+
let content = content.trim();
57+
if content == "max" {
58+
return None;
59+
}
60+
let bytes: u64 = content.parse().ok()?;
61+
if bytes >= CGROUP_V1_NO_LIMIT {
62+
return None;
63+
}
64+
Some(Byte::from_u64(bytes))
65+
}
66+
67+
/// Parse `/proc/self/cgroup` to find the cgroup v2 path for this process.
68+
///
69+
/// In cgroup v2 unified mode, the file contains a single line like `0::/path`.
70+
/// Returns the path portion (e.g., `/path`), or `None` if not found.
71+
fn resolve_cgroup_v2_path(proc_cgroup_content: &str) -> Option<&str> {
72+
for line in proc_cgroup_content.lines() {
73+
// cgroup v2 unified entries start with "0::"
74+
if let Some(path) = line.strip_prefix("0::") {
75+
let path = path.trim();
76+
if !path.is_empty() && path != "/" {
77+
return Some(path);
78+
}
4579
}
46-
let ignore_case = true;
47-
if let Ok(limit) = Byte::parse_str(content.trim(), ignore_case) {
80+
}
81+
None
82+
}
83+
84+
fn get_available_memory_with<F>(read_file: F) -> Byte
85+
where
86+
F: Fn(PathBuf) -> Result<String, Error>,
87+
{
88+
// Try process-specific cgroup v2 path (handles host cgroup namespace)
89+
if let Ok(proc_cgroup) = read_file(PathBuf::from("/proc/self/cgroup"))
90+
&& let Some(cgroup_path) = resolve_cgroup_v2_path(&proc_cgroup)
91+
{
92+
let memory_max = PathBuf::from(format!("/sys/fs/cgroup{cgroup_path}/memory.max"));
93+
if let Ok(content) = read_file(memory_max)
94+
&& let Some(limit) = parse_cgroup_limit(&content)
95+
{
4896
return limit;
4997
}
5098
}
5199

100+
// Try cgroup v2 root (works when container has its own cgroup namespace)
101+
if let Ok(content) = read_file(PathBuf::from("/sys/fs/cgroup/memory.max"))
102+
&& let Some(limit) = parse_cgroup_limit(&content)
103+
{
104+
return limit;
105+
}
106+
107+
// Try cgroup v1
108+
if let Ok(content) = read_file(PathBuf::from("/sys/fs/cgroup/memory/memory.limit_in_bytes"))
109+
&& let Some(limit) = parse_cgroup_limit(&content)
110+
{
111+
return limit;
112+
}
113+
52114
let sys = System::new_all();
53115
Byte::from_u64(sys.total_memory())
54116
}
117+
118+
#[cfg(test)]
119+
mod tests {
120+
use std::io::ErrorKind;
121+
122+
use super::*;
123+
124+
fn mock_reader(
125+
files: &'static [(&'static str, &'static str)],
126+
) -> impl Fn(PathBuf) -> Result<String, Error> {
127+
move |path| {
128+
for (p, content) in files {
129+
if PathBuf::from(p) == path {
130+
return Ok((*content).to_string());
131+
}
132+
}
133+
Err(Error::new(ErrorKind::NotFound, "not found"))
134+
}
135+
}
136+
137+
#[test]
138+
fn cgroup_v2_max_falls_back_to_system_memory() {
139+
let read = mock_reader(&[("/sys/fs/cgroup/memory.max", "max\n")]);
140+
let mem = get_available_memory_with(read);
141+
let sys = System::new_all();
142+
assert_eq!(mem, Byte::from_u64(sys.total_memory()));
143+
}
144+
145+
#[test]
146+
fn cgroup_v2_numeric_limit() {
147+
let read = mock_reader(&[("/sys/fs/cgroup/memory.max", "12582912\n")]);
148+
let mem = get_available_memory_with(read);
149+
assert_eq!(mem, Byte::from_u64(12_582_912));
150+
}
151+
152+
#[test]
153+
fn cgroup_v2_garbage_falls_back_to_system_memory() {
154+
let read = mock_reader(&[("/sys/fs/cgroup/memory.max", "not-a-number\n")]);
155+
let mem = get_available_memory_with(read);
156+
let sys = System::new_all();
157+
assert_eq!(mem, Byte::from_u64(sys.total_memory()));
158+
}
159+
160+
#[test]
161+
fn no_cgroup_file_falls_back_to_system_memory() {
162+
let read = mock_reader(&[]);
163+
let mem = get_available_memory_with(read);
164+
let sys = System::new_all();
165+
assert_eq!(mem, Byte::from_u64(sys.total_memory()));
166+
}
167+
168+
#[test]
169+
fn cgroup_v1_numeric_limit() {
170+
let read = mock_reader(&[("/sys/fs/cgroup/memory/memory.limit_in_bytes", "12582912\n")]);
171+
let mem = get_available_memory_with(read);
172+
assert_eq!(mem, Byte::from_u64(12_582_912));
173+
}
174+
175+
#[test]
176+
fn cgroup_v1_no_limit_sentinel_falls_back_to_system_memory() {
177+
// cgroup v1 uses page-aligned i64::MAX as "no limit"
178+
let read = mock_reader(&[(
179+
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
180+
"9223372036854771712\n",
181+
)]);
182+
let mem = get_available_memory_with(read);
183+
let sys = System::new_all();
184+
assert_eq!(mem, Byte::from_u64(sys.total_memory()));
185+
}
186+
187+
#[test]
188+
fn cgroup_v2_takes_precedence_over_v1() {
189+
let read = mock_reader(&[
190+
("/sys/fs/cgroup/memory.max", "8388608\n"),
191+
("/sys/fs/cgroup/memory/memory.limit_in_bytes", "16777216\n"),
192+
]);
193+
let mem = get_available_memory_with(read);
194+
assert_eq!(mem, Byte::from_u64(8_388_608));
195+
}
196+
197+
#[test]
198+
fn cgroup_v2_max_falls_through_to_v1() {
199+
let read = mock_reader(&[
200+
("/sys/fs/cgroup/memory.max", "max\n"),
201+
("/sys/fs/cgroup/memory/memory.limit_in_bytes", "16777216\n"),
202+
]);
203+
let mem = get_available_memory_with(read);
204+
assert_eq!(mem, Byte::from_u64(16_777_216));
205+
}
206+
207+
#[test]
208+
fn process_specific_cgroup_v2_path() {
209+
// Simulates host cgroup namespace: /proc/self/cgroup points to a
210+
// nested cgroup, and the memory limit is at that nested path.
211+
let read = mock_reader(&[
212+
(
213+
"/proc/self/cgroup",
214+
"0::/system.slice/docker-abc123.scope\n",
215+
),
216+
(
217+
"/sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max",
218+
"12582912\n",
219+
),
220+
// Root cgroup has no limit
221+
("/sys/fs/cgroup/memory.max", "max\n"),
222+
]);
223+
let mem = get_available_memory_with(read);
224+
assert_eq!(mem, Byte::from_u64(12_582_912));
225+
}
226+
227+
#[test]
228+
fn process_specific_path_takes_precedence_over_root() {
229+
let read = mock_reader(&[
230+
(
231+
"/proc/self/cgroup",
232+
"0::/system.slice/docker-abc123.scope\n",
233+
),
234+
(
235+
"/sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max",
236+
"8388608\n",
237+
),
238+
("/sys/fs/cgroup/memory.max", "16777216\n"),
239+
]);
240+
let mem = get_available_memory_with(read);
241+
assert_eq!(mem, Byte::from_u64(8_388_608));
242+
}
243+
244+
#[test]
245+
fn process_specific_path_max_falls_through_to_root() {
246+
let read = mock_reader(&[
247+
(
248+
"/proc/self/cgroup",
249+
"0::/system.slice/docker-abc123.scope\n",
250+
),
251+
(
252+
"/sys/fs/cgroup/system.slice/docker-abc123.scope/memory.max",
253+
"max\n",
254+
),
255+
("/sys/fs/cgroup/memory.max", "16777216\n"),
256+
]);
257+
let mem = get_available_memory_with(read);
258+
assert_eq!(mem, Byte::from_u64(16_777_216));
259+
}
260+
261+
#[test]
262+
fn proc_cgroup_root_path_skipped() {
263+
// When /proc/self/cgroup shows root path "/", skip it and fall
264+
// through to the root cgroup check.
265+
let read = mock_reader(&[
266+
("/proc/self/cgroup", "0::/\n"),
267+
("/sys/fs/cgroup/memory.max", "12582912\n"),
268+
]);
269+
let mem = get_available_memory_with(read);
270+
assert_eq!(mem, Byte::from_u64(12_582_912));
271+
}
272+
273+
#[test]
274+
fn resolve_cgroup_v2_path_parses_correctly() {
275+
assert_eq!(
276+
resolve_cgroup_v2_path("0::/system.slice/docker-abc.scope\n"),
277+
Some("/system.slice/docker-abc.scope")
278+
);
279+
assert_eq!(resolve_cgroup_v2_path("0::/\n"), None);
280+
assert_eq!(resolve_cgroup_v2_path(""), None);
281+
// cgroup v1 lines (non-zero hierarchy ID) are ignored
282+
assert_eq!(
283+
resolve_cgroup_v2_path("1:memory:/docker/abc\n0::/system.slice\n"),
284+
Some("/system.slice")
285+
);
286+
}
287+
288+
#[test]
289+
fn get_available_memory_returns_nonzero() {
290+
let mem = get_available_memory();
291+
assert!(mem.as_u64() > 0);
292+
}
293+
}

0 commit comments

Comments
 (0)