From 3b9b83a22b138ca77a04d77b8ce7ed3c5ab05fbc Mon Sep 17 00:00:00 2001 From: linrunlong Date: Fri, 23 Oct 2026 00:49:41 +0800 Subject: [PATCH 1/2] cgroup1: support stats collection for selected subsystems Allow callers that only need CPU and memory metrics to skip other controllers, including blkio and its reads of /proc/partitions. Expose a package-level StatFiltered helper backed by an optional method on the concrete cgroup. This preserves the existing Cgroup interface and Stat behavior while allowing third-party implementations to opt in. Retain the existing error handlers and metrics initialization, and treat an empty filter as full collection, consistent with Add's filtering. Add regression tests for skipped I/O and path resolution, CPU controller selection, error handling and cgroup lifecycle behavior, plus a benchmark using regular-file fixtures. Fixes #370 Assisted-by: OpenAI Codex Signed-off-by: linrunlong --- README.md | 16 +++ cgroup1/cgroup.go | 8 +- cgroup1/control.go | 1 + cgroup1/stat.go | 47 +++++++++ cgroup1/stat_test.go | 226 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 cgroup1/stat.go create mode 100644 cgroup1/stat_test.go diff --git a/README.md b/README.md index c7f37c61..8d442470 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,22 @@ By adding `cgroups.IgnoreNotExist` all non-existent files will be ignored, e.g. stats, err := control.Stat(cgroup1.IgnoreNotExist) ``` +To collect only CPU and memory metrics, use `cgroup1.StatFiltered`. Include +`Cpu` for throttling statistics and `Cpuacct` for CPU usage. Other subsystems +are skipped entirely, including blkio's reads of `/proc/partitions`. + +```go +stats, err := cgroup1.StatFiltered(control, + []cgroup1.Name{cgroup1.Cpu, cgroup1.Cpuacct, cgroup1.Memory}, + cgroup1.IgnoreNotExist, +) +``` + +An empty subsystem list collects all metrics, just like `Stat()`. Duplicate and +inactive subsystem names are ignored. The existing `Cgroup` interface is unchanged; +custom implementations must implement the optional `StatFiltered` method to use +this helper, otherwise it returns an error wrapping `errors.ErrUnsupported`. + ### Move process across cgroups This allows you to take processes from one cgroup and move them to another. diff --git a/cgroup1/cgroup.go b/cgroup1/cgroup.go index f7db0b53..f80f7ed3 100644 --- a/cgroup1/cgroup.go +++ b/cgroup1/cgroup.go @@ -300,6 +300,12 @@ func (c *cgroup) Delete() error { // Stat returns the current metrics for the cgroup func (c *cgroup) Stat(handlers ...ErrorHandler) (*v1.Metrics, error) { + return c.StatFiltered(nil, handlers...) +} + +// StatFiltered returns metrics for the selected active subsystems. +// An empty list selects all subsystems, as in Stat. +func (c *cgroup) StatFiltered(subsystems []Name, handlers ...ErrorHandler) (*v1.Metrics, error) { c.mu.Lock() defer c.mu.Unlock() if c.err != nil { @@ -318,7 +324,7 @@ func (c *cgroup) Stat(handlers ...ErrorHandler) (*v1.Metrics, error) { wg = &sync.WaitGroup{} errs = make(chan error, len(c.subsystems)) ) - for _, s := range c.subsystems { + for _, s := range c.subsystemsFilter(subsystems...) { if ss, ok := s.(stater); ok { sp, err := c.path(s.Name()) if err != nil { diff --git a/cgroup1/control.go b/cgroup1/control.go index 8fee13d0..82676b4e 100644 --- a/cgroup1/control.go +++ b/cgroup1/control.go @@ -76,6 +76,7 @@ type Cgroup interface { // subsystems are moved one at a time MoveTo(Cgroup) error // Stat returns the stats for all subsystems in the cgroup + // Use StatFiltered to collect metrics for selected subsystems. Stat(...ErrorHandler) (*v1.Metrics, error) // Update updates all the subsystems with the provided resource changes Update(resources *specs.LinuxResources) error diff --git a/cgroup1/stat.go b/cgroup1/stat.go new file mode 100644 index 00000000..94783404 --- /dev/null +++ b/cgroup1/stat.go @@ -0,0 +1,47 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package cgroup1 + +import ( + "errors" + "fmt" + + v1 "github.com/containerd/cgroups/v3/cgroup1/stats" +) + +// StatFiltered returns metrics for the selected active subsystems without +// reading files or resolving paths for other subsystems. An empty list selects +// all subsystems, as in Cgroup.Stat. Duplicate and inactive names are ignored. +// Select both Cpu and Cpuacct to collect CPU throttling and usage statistics. +// The CPU, CPU.Usage and CPU.Throttling fields are always initialized, as in Stat. +// +// This function keeps the Cgroup interface compatible with existing implementations. +// Cgroups returned by New, Load and Cgroup.New support filtered stats. Other +// implementations can opt in by providing this method: +// +// StatFiltered([]Name, ...ErrorHandler) (*stats.Metrics, error) +// +// If c does not implement that method, StatFiltered returns an error wrapping +// errors.ErrUnsupported. +func StatFiltered(c Cgroup, subsystems []Name, handlers ...ErrorHandler) (*v1.Metrics, error) { + if filtered, ok := c.(interface { + StatFiltered([]Name, ...ErrorHandler) (*v1.Metrics, error) + }); ok { + return filtered.StatFiltered(subsystems, handlers...) + } + return nil, fmt.Errorf("cgroups: filtered stats: %w", errors.ErrUnsupported) +} diff --git a/cgroup1/stat_test.go b/cgroup1/stat_test.go new file mode 100644 index 00000000..bbf44e77 --- /dev/null +++ b/cgroup1/stat_test.go @@ -0,0 +1,226 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package cgroup1 + +import ( + "errors" + "os" + "path/filepath" + "testing" + + v1 "github.com/containerd/cgroups/v3/cgroup1/stats" + "github.com/opencontainers/runtime-spec/specs-go" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" +) + +// Use regular files so filtering and its I/O savings can be tested without +// requiring a mounted cgroup v1 hierarchy. +func newStatFixture(tb testing.TB) (Cgroup, string) { + tb.Helper() + root := tb.TempDir() + files := map[string]string{ + "cpu/test/cpu.stat": "nr_periods 10\nnr_throttled 2\nthrottled_time 30\n", + "cpuacct/test/cpuacct.stat": "user 100\nsystem 50\n", + "cpuacct/test/cpuacct.usage": "1500000000\n", + "cpuacct/test/cpuacct.usage_percpu": "1000000000 500000000\n", + "memory/test/memory.stat": memoryData, + "memory/test/memory.oom_control": memoryOomControlData, + "pids/test/pids.current": "3\n", + "pids/test/pids.max": "10\n", + "proc/partitions": data, + "blkio/test/blkio.throttle.io_serviced": "259:0 Read 12\n259:0 Write 4\nTotal 16\n", + "blkio/test/blkio.throttle.io_service_bytes": "259:0 Read 4096\n259:0 Write 1024\nTotal 5120\n", + } + for _, module := range []string{"", "memsw.", "kmem.", "kmem.tcp."} { + for _, name := range []string{"usage_in_bytes", "max_usage_in_bytes", "failcnt", "limit_in_bytes"} { + files["memory/test/memory."+module+name] = "1024\n" + } + } + for name, content := range files { + path := filepath.Join(root, name) + require.NoError(tb, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(tb, os.WriteFile(path, []byte(content), 0o644)) + } + control, err := New(StaticPath("test"), &specs.LinuxResources{}, WithHierarchy(func() ([]Subsystem, error) { + return []Subsystem{ + NewCpu(root), NewCpuacct(root), NewMemory(root), NewPids(root), + NewBlkio(root, ProcRoot(filepath.Join(root, "proc"))), + NewNamed(root, "custom"), // No Stat method. + }, nil + })) + require.NoError(tb, err) + return control, root +} + +func TestStatFiltered(t *testing.T) { + control, _ := newStatFixture(t) + all, err := control.Stat() + require.NoError(t, err) + emptyCPU := &v1.CPUStat{Usage: &v1.CPUUsage{}, Throttling: &v1.Throttle{}} + tests := []struct { + name string + subsystems []Name + want *v1.Metrics + }{ + {"all", nil, all}, + {"empty", []Name{}, all}, + {"cpu", []Name{Cpu}, &v1.Metrics{CPU: &v1.CPUStat{Usage: &v1.CPUUsage{}, Throttling: all.CPU.Throttling}}}, + {"cpuacct", []Name{Cpuacct}, &v1.Metrics{CPU: &v1.CPUStat{Usage: all.CPU.Usage, Throttling: &v1.Throttle{}}}}, + {"cpu-and-cpuacct", []Name{Cpu, Cpuacct}, &v1.Metrics{CPU: all.CPU}}, + {"memory", []Name{Memory}, &v1.Metrics{CPU: emptyCPU, Memory: all.Memory, MemoryOomControl: all.MemoryOomControl}}, + {"cpu-and-memory", []Name{Cpu, Cpuacct, Memory}, &v1.Metrics{CPU: all.CPU, Memory: all.Memory, MemoryOomControl: all.MemoryOomControl}}, + {"pids", []Name{Pids}, &v1.Metrics{CPU: emptyCPU, Pids: all.Pids}}, + {"blkio", []Name{Blkio}, &v1.Metrics{CPU: emptyCPU, Blkio: all.Blkio}}, + {"inactive", []Name{"inactive"}, &v1.Metrics{CPU: emptyCPU}}, + {"non-stater", []Name{"custom"}, &v1.Metrics{CPU: emptyCPU}}, + {"duplicates", []Name{Cpu, Cpu, Cpuacct, Cpuacct, "inactive"}, &v1.Metrics{CPU: all.CPU}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := StatFiltered(control, tt.subsystems) + require.NoError(t, err) + require.True(t, proto.Equal(tt.want, got), "want %v, got %v", tt.want, got) + }) + } + // Filtering must not change subsequent unfiltered calls. + got, err := control.Stat() + require.NoError(t, err) + require.True(t, proto.Equal(all, got)) +} + +func TestStatFilteredSkipsBlkio(t *testing.T) { + control, root := newStatFixture(t) + require.NoError(t, os.Remove(filepath.Join(root, "proc", "partitions"))) + + got, err := StatFiltered(control, []Name{Cpu, Cpuacct, Memory}) + require.NoError(t, err) + require.Equal(t, uint64(1500000000), got.CPU.Usage.Total) + require.Equal(t, uint64(10), got.CPU.Throttling.Periods) + require.Equal(t, uint64(1024), got.Memory.Usage.Usage) + require.Nil(t, got.Blkio) + + _, err = StatFiltered(control, []Name{Blkio}) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = control.Stat() + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestStatFilteredPaths(t *testing.T) { + control, _ := newStatFixture(t) + c := control.(*cgroup) + pathErr := errors.New("unexpected blkio path lookup") + calls := make(map[Name]int) + c.path = func(name Name) (string, error) { + calls[name]++ + if name == Blkio { + return "", pathErr + } + return "test", nil + } + _, err := StatFiltered(control, []Name{Cpu, Cpu, Cpuacct}) + require.NoError(t, err) + require.Equal(t, map[Name]int{Cpu: 1, Cpuacct: 1}, calls) + _, err = StatFiltered(control, []Name{Blkio}) + require.ErrorIs(t, err, pathErr) +} + +func TestStatFilteredErrors(t *testing.T) { + control, root := newStatFixture(t) + require.NoError(t, os.Remove(filepath.Join(root, "pids", "test", "pids.current"))) + _, err := StatFiltered(control, []Name{Pids}) + require.ErrorIs(t, err, os.ErrNotExist) + _, err = StatFiltered(control, []Name{Pids}, IgnoreNotExist) + require.NoError(t, err) + handlerErr := errors.New("handled stat error") + _, err = StatFiltered(control, []Name{Pids}, func(err error) error { + if errors.Is(err, os.ErrNotExist) { + return handlerErr + } + return err + }) + require.ErrorIs(t, err, handlerErr) +} + +func TestStatFilteredLoadAndChild(t *testing.T) { + control, _ := newStatFixture(t) + loaded, err := Load(StaticPath("test"), WithHierarchy(func() ([]Subsystem, error) { + return control.Subsystems(), nil + })) + require.NoError(t, err) + got, err := StatFiltered(loaded, []Name{Pids}) + require.NoError(t, err) + require.Equal(t, uint64(3), got.Pids.Current) + + child, err := loaded.New("child", &specs.LinuxResources{}) + require.NoError(t, err) + _, err = StatFiltered(child, []Name{Pids}, IgnoreNotExist) + require.NoError(t, err) + require.NoError(t, child.Delete()) + _, err = StatFiltered(child, []Name{Pids}, IgnoreNotExist) + require.ErrorIs(t, err, ErrCgroupDeleted) +} + +// An implementation of the original interface need not support filtered stats. +type unfilteredCgroup struct{ Cgroup } + +type customFilteredCgroup struct { + Cgroup + stat func([]Name, ...ErrorHandler) (*v1.Metrics, error) +} + +func (c customFilteredCgroup) StatFiltered(names []Name, handlers ...ErrorHandler) (*v1.Metrics, error) { + return c.stat(names, handlers...) +} + +func TestStatFilteredCustomCgroup(t *testing.T) { + _, err := StatFiltered(unfilteredCgroup{}, []Name{Memory}) + require.ErrorIs(t, err, errors.ErrUnsupported) + + want := &v1.Metrics{Pids: &v1.PidsStat{Current: 42}} + c := customFilteredCgroup{stat: func(names []Name, handlers ...ErrorHandler) (*v1.Metrics, error) { + require.Equal(t, []Name{Pids}, names) + require.Len(t, handlers, 1) + require.NoError(t, handlers[0](os.ErrNotExist)) + return want, nil + }} + got, err := StatFiltered(c, []Name{Pids}, IgnoreNotExist) + require.NoError(t, err) + require.Same(t, want, got) +} + +func BenchmarkStatFiltered(b *testing.B) { + control, _ := newStatFixture(b) + for _, tt := range []struct { + name string + subsystems []Name + }{ + {"All", nil}, + {"CPU", []Name{Cpu, Cpuacct}}, + {"Memory", []Name{Memory}}, + {"CPUAndMemory", []Name{Cpu, Cpuacct, Memory}}, + } { + b.Run(tt.name, func(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + if _, err := StatFiltered(control, tt.subsystems); err != nil { + b.Fatal(err) + } + } + }) + } +} From 4f78ca7a79310df6af4615bb4df2cc90f57b8e40 Mon Sep 17 00:00:00 2001 From: linrunlong Date: Fri, 23 Oct 2026 00:53:08 +0800 Subject: [PATCH 2/2] ci: pin fog-json for Vagrant's embedded json The cgroup v1 jobs fail while installing vagrant-libvirt because fog-json 1.5.0 requires json >= 2.19, but Vagrant has already activated its bundled json 2.7.2. The VM never starts and no tests run. Pin fog-json to 1.2.0 before installing vagrant-libvirt, replacing any previously registered version restored from the Vagrant cache. This follows the fix used in containerd/containerd commit b84460e50769d336bf62dca976eb23f984f7d10b. Assisted-by: Codex Signed-off-by: linrunlong --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4cb646c..7af91ad9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,6 +134,10 @@ jobs: sudo apt-get build-dep -y ruby-libvirt sudo apt-get install -y --no-install-recommends libxslt-dev libxml2-dev libvirt-dev ruby-bundler ruby-dev zlib1g-dev + # Keep fog-json compatible with Vagrant's embedded json gem. + sudo env VAGRANT_DISABLE_STRICT_DEPENDENCY_ENFORCEMENT=1 vagrant plugin uninstall fog-json || true + sudo env VAGRANT_DISABLE_STRICT_DEPENDENCY_ENFORCEMENT=1 vagrant plugin install fog-json --plugin-version 1.2.0 + if ! vagrant plugin list | grep -q vagrant-libvirt; then sudo env VAGRANT_DISABLE_STRICT_DEPENDENCY_ENFORCEMENT=1 vagrant plugin install vagrant-libvirt fi