Skip to content

Commit 298ed2a

Browse files
authored
Add support for cgroup limits (#443)
* Add cgroup package * Refactor procGgroup * Add testdata generation * Add v1 testdata generation * Move scripts around * Add integration test in CI * Remove cgroup v1 * Move to cgroup struct * Remove half-core test as it's redundant
1 parent ea916da commit 298ed2a

19 files changed

Lines changed: 493 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,26 @@ jobs:
106106
# make test
107107
# make test-teardown
108108

109+
test-cgroup-integration:
110+
name: Test cgroup integration
111+
runs-on: ubuntu-latest
112+
steps:
113+
- uses: actions/checkout@v4
114+
- uses: actions/setup-go@v2
115+
with:
116+
go-version: '1.25'
117+
- name: Build test binary
118+
run: go test -c -o cgroup.test ./cgroup/
119+
- name: Run cgroup integration test
120+
run: |
121+
docker run --rm \
122+
--cpus=0.5 --memory=128m \
123+
-e CGROUP_EXPECTED_CPU_QUOTA=0.5 \
124+
-e CGROUP_EXPECTED_MEMORY_LIMIT=134217728 \
125+
-v "$PWD/cgroup.test":/cgroup.test:ro \
126+
debian:bookworm-slim \
127+
/cgroup.test -test.run TestIntegrationCgroupLimits -test.v
128+
109129
run-cli-tests:
110130
name: Run command-line interface tests
111131
runs-on: ubuntu-latest

Makefile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,11 @@ lint: $(BIN)/staticcheck $(BIN)/misspell
115115
clean:
116116
rm -f $(BIN)/$(NAME) $(PAM_MODULE) $(TOOLS) coverage.out $(COVERAGE_FILES) $(PAM_CONFIG)
117117

118+
###### Cgroup testdata ######
119+
.PHONY: gen-cgroup-testdata
120+
gen-cgroup-testdata:
121+
bin/gen-cgroup-testdata
122+
118123
###### Go tests ######
119124
.PHONY: test test-setup test-teardown
120125

actions/config.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@ import (
2424
"bytes"
2525
"fmt"
2626
"log"
27+
"math"
2728
"os"
2829
"runtime"
2930
"time"
3031

3132
"golang.org/x/sys/unix"
3233
"google.golang.org/protobuf/proto"
3334

35+
"github.com/google/fscrypt/cgroup"
3436
"github.com/google/fscrypt/crypto"
3537
"github.com/google/fscrypt/filesystem"
3638
"github.com/google/fscrypt/metadata"
@@ -186,8 +188,9 @@ func getConfig() (*metadata.Config, error) {
186188
func getHashingCosts(target time.Duration) (*metadata.HashingCosts, error) {
187189
log.Printf("Finding hashing costs that take %v\n", target)
188190

189-
// Start out with the minimal possible costs that use all the CPUs.
190-
parallelism := int64(runtime.NumCPU())
191+
// Start out with the minimal possible costs that use all the available
192+
// CPUs, respecting cgroup limits when present.
193+
parallelism := int64(effectiveCPUCount())
191194
// golang.org/x/crypto/argon2 only supports parallelism up to 255.
192195
// For compatibility, don't use more than that amount.
193196
if parallelism > metadata.MaxParallelism {
@@ -248,16 +251,37 @@ func getHashingCosts(target time.Duration) (*metadata.HashingCosts, error) {
248251
}
249252
}
250253

254+
// effectiveCPUCount returns the number of CPUs available to this process,
255+
// taking cgroup limits into account. Falls back to runtime.NumCPU() when
256+
// cgroup information is unavailable.
257+
func effectiveCPUCount() int {
258+
cg, err := cgroup.New()
259+
if err != nil {
260+
return runtime.NumCPU()
261+
}
262+
quota, err := cg.CPUQuota()
263+
if err != nil || quota <= 0 {
264+
return runtime.NumCPU()
265+
}
266+
cpus := int(math.Ceil(quota))
267+
return min(cpus, runtime.NumCPU())
268+
}
269+
251270
// memoryBytesLimit returns the maximum amount of memory we will use for
252271
// passphrase hashing. This will never be more than a reasonable maximum (for
253-
// compatibility) or an 8th the available system RAM.
272+
// compatibility) or an 8th the available RAM (considering cgroup limits).
254273
func memoryBytesLimit() int64 {
255274
// The sysinfo syscall only fails if given a bad address
256275
var info unix.Sysinfo_t
257276
err := unix.Sysinfo(&info)
258277
util.NeverError(err)
259278

260279
totalRAMBytes := int64(info.Totalram)
280+
if cg, err := cgroup.New(); err == nil {
281+
if cgroupMem, err := cg.MemoryLimit(); err == nil && cgroupMem > 0 {
282+
totalRAMBytes = util.MinInt64(totalRAMBytes, cgroupMem)
283+
}
284+
}
261285
return util.MinInt64(totalRAMBytes/8, maxMemoryBytes)
262286
}
263287

bin/gen-cgroup-testdata

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
#!/usr/bin/env bash
2+
#
3+
# gen-cgroup-testdata - Generate cgroup testdata by running
4+
# bin/snapshot-cgroup inside Docker containers with known resource limits.
5+
#
6+
# Usage: gen-cgroup-testdata
7+
#
8+
# Prerequisites: Docker on a host running cgroup v2.
9+
#
10+
# Each testdata directory contains:
11+
# expected.json - {"cpu_quota": <float>, "memory_limit": <int>}
12+
# proc/ - snapshot of /proc/self/cgroup
13+
# sys/ - snapshot of cgroup control files
14+
15+
set -euo pipefail
16+
17+
cd "$(dirname "$0")/.."
18+
19+
testdata="cgroup/testdata"
20+
snapshot_script="bin/snapshot-cgroup"
21+
22+
generate() {
23+
local name="$1" cpu_quota="$2" memory_limit="$3"
24+
shift 3
25+
local outdir="$testdata/$name"
26+
27+
echo "Generating $name..."
28+
rm -rf "$outdir"
29+
mkdir -p "$outdir"
30+
31+
docker run --rm \
32+
--user "$(id -u):$(id -g)" \
33+
"$@" \
34+
-v "$PWD/$snapshot_script:/snapshot:ro" \
35+
-v "$PWD/$outdir:/out" \
36+
debian:bookworm-slim \
37+
/snapshot /out
38+
39+
cat > "$outdir/expected.json" <<EOF
40+
{"cpu_quota": $cpu_quota, "memory_limit": $memory_limit}
41+
EOF
42+
}
43+
44+
generate "v2-two-cores-256m" 2.0 268435456 --cpus=2 --memory=256m
45+
generate "v2-quarter-core-64m" 0.25 67108864 --cpus=0.25 --memory=64m
46+
generate "v2-no-limit" null null
47+
48+
echo "v2 testdata generated successfully."

bin/snapshot-cgroup

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
#!/usr/bin/env bash
2+
#
3+
# snapshot-cgroup - Copy cgroup v2 files from the live system into a
4+
# directory tree suitable for use with TestIntegrationCgroupLimits.
5+
#
6+
# Usage: snapshot-cgroup <output-dir>
7+
#
8+
# The script reads /proc/self/cgroup to find the v2 group path and copies
9+
# exactly the files that the cgroup package needs:
10+
#
11+
# proc/self/cgroup
12+
# sys/fs/cgroup/<group>/cpu.max
13+
# sys/fs/cgroup/<group>/memory.max
14+
15+
set -euo pipefail
16+
17+
if [[ $# -ne 1 ]]; then
18+
echo "Usage: $0 <output-dir>" >&2
19+
exit 1
20+
fi
21+
22+
out="$1"
23+
mkdir -p "$out"
24+
25+
copy_file() {
26+
local src="$1" dst="$out/$2"
27+
mkdir -p "$(dirname "$dst")"
28+
cp "$src" "$dst"
29+
}
30+
31+
copy_file /proc/self/cgroup proc/self/cgroup
32+
33+
group=$(awk -F: '/^0::/ { print $3 }' /proc/self/cgroup)
34+
cgdir="/sys/fs/cgroup${group}"
35+
36+
for f in cpu.max memory.max; do
37+
if [[ -f "$cgdir/$f" ]]; then
38+
copy_file "$cgdir/$f" "sys/fs/cgroup${group}/$f"
39+
fi
40+
done
41+
42+
echo "Snapshot written to $out"

cgroup/cgroup.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/*
2+
* cgroup.go - Read CPU and memory limits from Linux cgroups v2.
3+
*
4+
* Copyright 2026 Google LLC
5+
*
6+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
7+
* use this file except in compliance with the License. You may obtain a copy of
8+
* the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing, software
13+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15+
* License for the specific language governing permissions and limitations under
16+
* the License.
17+
*/
18+
19+
// Package cgroup reads CPU and memory resource limits from Linux control
20+
// groups (cgroup v2).
21+
//
22+
// References:
23+
// - cgroups(7): https://man7.org/linux/man-pages/man7/cgroups.7.html
24+
// - cgroup v2 (cpu.max, memory.max): https://docs.kernel.org/admin-guide/cgroup-v2.html
25+
// - /proc/self/cgroup: https://man7.org/linux/man-pages/man7/cgroups.7.html (see "/proc files")
26+
package cgroup
27+
28+
import (
29+
"bufio"
30+
"errors"
31+
"fmt"
32+
"os"
33+
"path/filepath"
34+
"strconv"
35+
"strings"
36+
)
37+
38+
// Errors.
39+
var (
40+
// ErrNoLimit indicates that no cgroup limit is set.
41+
ErrNoLimit = errors.New("no cgroup limit set")
42+
43+
// ErrV1Detected indicates that cgroup v1 controllers were found. Only v2 is
44+
// supported.
45+
ErrV1Detected = errors.New("cgroup v1 detected; only v2 is supported")
46+
)
47+
48+
// Cgroup provides access to cgroup v2 resource limits. Create one with
49+
// New or NewFromRoot.
50+
type Cgroup struct {
51+
// cgroupDir is the resolved filesystem path to the cgroup directory
52+
// (e.g. /sys/fs/cgroup/user.slice/...).
53+
cgroupDir string
54+
}
55+
56+
// New returns a Cgroup by reading /proc/self/cgroup on the live system.
57+
func New() (Cgroup, error) {
58+
return NewFromRoot("/")
59+
}
60+
61+
// NewFromRoot is like New but resolves all filesystem paths relative to
62+
// root instead of "/". This is useful for testing with a mock filesystem.
63+
func NewFromRoot(root string) (Cgroup, error) {
64+
groupPath, err := parseProcCgroup(filepath.Join(root, "proc/self/cgroup"))
65+
if err != nil {
66+
return Cgroup{}, err
67+
}
68+
return Cgroup{
69+
cgroupDir: filepath.Join(root, "sys/fs/cgroup", groupPath),
70+
}, nil
71+
}
72+
73+
// CPUQuota returns the CPU quota as a fractional number of CPUs (e.g. 0.5
74+
// means half a core). Returns ErrNoLimit if no CPU limit is configured.
75+
func (c Cgroup) CPUQuota() (float64, error) {
76+
data, err := c.readFile("cpu.max")
77+
if err != nil {
78+
return 0, err
79+
}
80+
return parseCPUMax(data)
81+
}
82+
83+
// MemoryLimit returns the cgroup memory limit in bytes. Returns ErrNoLimit
84+
// if no memory limit is configured.
85+
func (c Cgroup) MemoryLimit() (int64, error) {
86+
data, err := c.readFile("memory.max")
87+
if err != nil {
88+
return 0, err
89+
}
90+
return parseMemoryMax(data)
91+
}
92+
93+
func (c Cgroup) readFile(path string) (string, error) {
94+
data, err := os.ReadFile(filepath.Join(c.cgroupDir, path))
95+
if err != nil {
96+
if os.IsNotExist(err) {
97+
return "", ErrNoLimit
98+
}
99+
return "", err
100+
}
101+
return strings.TrimSpace(string(data)), nil
102+
}
103+
104+
// parseProcCgroup parses /proc/self/cgroup and returns the cgroup v2 group
105+
// path. The v2 entry is the line with hierarchy-ID "0" and an empty
106+
// controller list: "0::<path>".
107+
//
108+
// Returns an error if v1 controllers are detected or no v2 entry is found.
109+
//
110+
// https://man7.org/linux/man-pages/man7/cgroups.7.html
111+
func parseProcCgroup(path string) (string, error) {
112+
f, err := os.Open(path)
113+
if err != nil {
114+
return "", err
115+
}
116+
defer f.Close()
117+
118+
var v2Path string
119+
120+
scanner := bufio.NewScanner(f)
121+
for scanner.Scan() {
122+
parts := strings.SplitN(scanner.Text(), ":", 3)
123+
if len(parts) != 3 {
124+
continue
125+
}
126+
if parts[0] == "0" && parts[1] == "" {
127+
v2Path = parts[2]
128+
} else if parts[1] != "" {
129+
return "", ErrV1Detected
130+
}
131+
}
132+
if err := scanner.Err(); err != nil {
133+
return "", err
134+
}
135+
if v2Path == "" {
136+
return "", fmt.Errorf("no cgroup v2 entry found in %s", path)
137+
}
138+
return v2Path, nil
139+
}
140+
141+
func parseCPUMax(content string) (float64, error) {
142+
fields := strings.Fields(content)
143+
if len(fields) == 0 || len(fields) > 2 {
144+
return 0, fmt.Errorf("unexpected cpu.max format: %q", content)
145+
}
146+
if fields[0] == "max" {
147+
return 0, ErrNoLimit
148+
}
149+
quota, err := strconv.ParseFloat(fields[0], 64)
150+
if err != nil {
151+
return 0, fmt.Errorf("parsing cpu.max quota: %w", err)
152+
}
153+
period := 100000.0
154+
if len(fields) == 2 {
155+
period, err = strconv.ParseFloat(fields[1], 64)
156+
if err != nil {
157+
return 0, fmt.Errorf("parsing cpu.max period: %w", err)
158+
}
159+
if period == 0 {
160+
return 0, fmt.Errorf("cpu.max period is zero")
161+
}
162+
}
163+
return quota / period, nil
164+
}
165+
166+
func parseMemoryMax(content string) (int64, error) {
167+
if content == "max" {
168+
return 0, ErrNoLimit
169+
}
170+
v, err := strconv.ParseInt(content, 10, 64)
171+
if err != nil {
172+
return 0, fmt.Errorf("parsing memory.max: %w", err)
173+
}
174+
return v, nil
175+
}

0 commit comments

Comments
 (0)