Repository: opencontainers/runc
Pull request #3972 (2023-08-07 14:53:50)

libct/cg/stats: support misc for cgroup v2
Triggered by https://github.com/opencontainers/runc/discussions/3910.

Initial version based on v2 `HugetlbStats` and the new v2 memory unit tests.

Opens:
- [ ] misc available for v1 too but can only be set via `unified` so this implementation adds stats for v2 only. Add v1 too?
- [x] stats structure. Currently, this adds per resource key `MiscStats` entries so that stats so that they become easily available.

eero-t: Looks OK to me.  Code comment, or commit message, could point to misc docs: https://docs.kernel.org/admin-guide/cgroup-v2.html#misc

> Triggered by https://github.com/opencontainers/runc/discussions/3910.

Also related to https://github.com/opencontainers/runc/issues/3849.


Patch:
diff --git a/libcontainer/cgroups/fs2/fs2.go b/libcontainer/cgroups/fs2/fs2.go
index 47b67afc..0760be74 100644
--- a/libcontainer/cgroups/fs2/fs2.go
+++ b/libcontainer/cgroups/fs2/fs2.go
@@ -133,6 +133,10 @@ func (m *Manager) GetStats() (*cgroups.Stats, error) {
 	if err := fscommon.RdmaGetStats(m.dirPath, st); err != nil && !os.IsNotExist(err) {
 		errs = append(errs, err)
 	}
+	// misc (since kernel 5.13)
+	if err := statMisc(m.dirPath, st); err != nil && !os.IsNotExist(err) {
+		errs = append(errs, err)
+	}
 	if len(errs) > 0 && !m.config.Rootless {
 		return st, fmt.Errorf("error while statting cgroup v2: %+v", errs)
 	}
diff --git a/libcontainer/cgroups/fs2/misc.go b/libcontainer/cgroups/fs2/misc.go
new file mode 100644
index 00000000..f0b292aa
--- /dev/null
+++ b/libcontainer/cgroups/fs2/misc.go
@@ -0,0 +1,52 @@
+package fs2
+
+import (
+	"bufio"
+	"os"
+	"strings"
+
+	"github.com/opencontainers/runc/libcontainer/cgroups"
+	"github.com/opencontainers/runc/libcontainer/cgroups/fscommon"
+)
+
+func statMisc(dirPath string, stats *cgroups.Stats) error {
+	for _, file := range []string{"current", "events"} {
+		fd, err := cgroups.OpenFile(dirPath, "misc."+file, os.O_RDONLY)
+		if err != nil {
+			return err
+		}
+
+		s := bufio.NewScanner(fd)
+		for s.Scan() {
+			key, value, err := fscommon.ParseKeyValue(s.Text())
+			if err != nil {
+				fd.Close()
+				return err
+			}
+
+			key = strings.TrimSuffix(key, ".max")
+
+			if _, ok := stats.MiscStats[key]; !ok {
+				stats.MiscStats[key] = cgroups.MiscStats{}
+			}
+
+			tmp := stats.MiscStats[key]
+
+			switch file {
+			case "current":
+				tmp.Usage = value
+			case "events":
+				tmp.Events = value
+			}
+
+			stats.MiscStats[key] = tmp
+		}
+		fd.Close()
+
+		if err := s.Err(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/libcontainer/cgroups/stats.go b/libcontainer/cgroups/stats.go
index 8ff1fbb5..17a79ef2 100644
--- a/libcontainer/cgroups/stats.go
+++ b/libcontainer/cgroups/stats.go
@@ -170,6 +170,13 @@ type RdmaStats struct {
 	RdmaCurrent []RdmaEntry `json:"rdma_current,omitempty"`
 }
 
+type MiscStats struct {
+	// current resource usage for a key in misc
+	Usage uint64 `json:"usage,omitempty"`
+	// number of times the resource usage was about to go over the max boundary
+	Events uint64 `json:"events,omitempty"`
+}
+
 type Stats struct {
 	CpuStats    CpuStats    `json:"cpu_stats,omitempty"`
 	CPUSetStats CPUSetStats `json:"cpuset_stats,omitempty"`
@@ -179,10 +186,13 @@ type Stats struct {
 	// the map is in the format "size of hugepage: stats of the hugepage"
 	HugetlbStats map[string]HugetlbStats `json:"hugetlb_stats,omitempty"`
 	RdmaStats    RdmaStats               `json:"rdma_stats,omitempty"`
+	// the map is in the format "misc resource name: stats of the key"
+	MiscStats map[string]MiscStats `json:"misc_stats,omitempty"`
 }
 
 func NewStats() *Stats {
 	memoryStats := MemoryStats{Stats: make(map[string]uint64)}
 	hugetlbStats := make(map[string]HugetlbStats)
-	return &Stats{MemoryStats: memoryStats, HugetlbStats: hugetlbStats}
+	miscStats := make(map[string]MiscStats)
+	return &Stats{MemoryStats: memoryStats, HugetlbStats: hugetlbStats, MiscStats: miscStats}
 }


Test patch:
diff --git a/libcontainer/cgroups/fs2/misc_test.go b/libcontainer/cgroups/fs2/misc_test.go
new file mode 100644
index 00000000..a3f82395
--- /dev/null
+++ b/libcontainer/cgroups/fs2/misc_test.go
@@ -0,0 +1,103 @@
+package fs2
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/opencontainers/runc/libcontainer/cgroups"
+)
+
+const exampleMiscCurrentData = `res_a 123
+res_b 456
+res_c 42`
+
+const exampleMiscEventsData = `res_a.max 1
+res_b.max 2
+res_c.max 3`
+
+func TestStatMiscPodCgroupEmpty(t *testing.T) {
+	// We're using a fake cgroupfs.
+	cgroups.TestMode = true
+	fakeCgroupDir := t.TempDir()
+
+	// create empty misc.current and misc.events files to test the common case
+	// where no misc resource keys are available
+	for _, file := range []string{"misc.current", "misc.events"} {
+		if _, err := os.Create(filepath.Join(fakeCgroupDir, file)); err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	gotStats := cgroups.NewStats()
+
+	err := statMisc(fakeCgroupDir, gotStats)
+	if err != nil {
+		t.Errorf("expected no error when statting empty misc.current/misc.events for cgroupv2, but got %#v", err)
+	}
+
+	if len(gotStats.MiscStats) != 0 {
+		t.Errorf("parsed cgroupv2 misc.* returns unexpected resources: got %#v but expected nothing", gotStats.MiscStats)
+	}
+}
+
+func TestStatMiscPodCgroupNotFound(t *testing.T) {
+	// We're using a fake cgroupfs.
+	cgroups.TestMode = true
+	fakeCgroupDir := t.TempDir()
+
+	// only write misc.current to ensure pod cgroup usage
+	// still reads misc.events.
+	statPath := filepath.Join(fakeCgroupDir, "misc.current")
+	if err := os.WriteFile(statPath, []byte(exampleMiscCurrentData), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	gotStats := cgroups.NewStats()
+
+	// use a fake root path to mismatch the file we wrote.
+	// this triggers the non-root path which should fail to find misc.events.
+	err := statMisc(fakeCgroupDir, gotStats)
+	if err == nil {
+		t.Errorf("expected error when statting misc.current for cgroupv2 root, but was nil")
+	}
+
+	if !strings.Contains(err.Error(), "misc.events: no such file or directory") {
+		t.Errorf("expected error to contain 'misc.events: no such file or directory', but was %s", err.Error())
+	}
+}
+
+func TestStatMiscPodCgroup(t *testing.T) {
+	// We're using a fake cgroupfs.
+	cgroups.TestMode = true
+	fakeCgroupDir := t.TempDir()
+
+	currentPath := filepath.Join(fakeCgroupDir, "misc.current")
+	if err := os.WriteFile(currentPath, []byte(exampleMiscCurrentData), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	eventsPath := filepath.Join(fakeCgroupDir, "misc.events")
+	if err := os.WriteFile(eventsPath, []byte(exampleMiscEventsData), 0o644); err != nil {
+		t.Fatal(err)
+	}
+
+	gotStats := cgroups.NewStats()
+
+	// use a fake root path to trigger the pod cgroup lookup.
+	err := statMisc(fakeCgroupDir, gotStats)
+	if err != nil {
+		t.Errorf("expected no error when statting misc for cgroupv2 root, but got %#+v", err)
+	}
+
+	// make sure all res_* from exampleMisc*Data are returned
+	if len(gotStats.MiscStats) != 3 {
+		t.Errorf("parsed cgroupv2 misc doesn't return all expected resources: \ngot %#v\nexpected %#v\n", len(gotStats.MiscStats), 3)
+	}
+
+	var expectedUsageBytes uint64 = 42
+	if gotStats.MiscStats["res_c"].Usage != expectedUsageBytes {
+		t.Errorf("parsed cgroupv2 misc.current for res_c doesn't match expected result: \ngot %#v\nexpected %#v\n", gotStats.MiscStats["res_c"].Usage, expectedUsageBytes)
+	}
+}