container: collect Hyper-V isolated containers from CRI stats (#2568)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Jan-Otto Kröpke <mail@jkroepke.de>
This commit is contained in:
Jan-Otto Kröpke
2026-10-09 09:15:51 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 200f248eba
commit fedca642a1
7 changed files with 649 additions and 36 deletions
+10
View File
@@ -121,6 +121,16 @@ func (c *Client) ListPodSandboxes(ctx context.Context) ([]PodSandbox, error) {
return decodeListPodSandboxResponse(resp)
}
// ListContainerStats returns the stats of all containers.
func (c *Client) ListContainerStats(ctx context.Context) ([]ContainerStats, error) {
resp, err := c.call(ctx, "ListContainerStats", nil)
if err != nil {
return nil, err
}
return decodeListContainerStatsResponse(resp)
}
// call performs a unary gRPC call and returns the response message.
func (c *Client) call(ctx context.Context, method string, msg []byte) ([]byte, error) {
// A gRPC message is prefixed by a compression flag and its length.
+69
View File
@@ -95,6 +95,63 @@ func sandboxesResponse() message {
varint(3, uint64(SandboxNotReady)))
}
func uint64Value(v uint64) message {
return message{}.varint(1, v)
}
// A ListContainerStatsResponse as containerd sends it on Windows, including fields the client skips.
func containerStatsResponse() message {
attributes := message{}.
string(1, "abc").
message(2, message{}.string(1, "nanoserver"))
attributes = append(attributes, labels(3, "io.kubernetes.pod.name", "pod")...)
running := message{}.
message(1, attributes).
message(2, message{}.
varint(1, 1700000000000000000).
message(2, uint64Value(1500000000)).
message(3, uint64Value(20000000))).
message(3, message{}.
varint(1, 1700000000000000000).
message(2, uint64Value(4096)).
message(4, uint64Value(8192))).
message(4, message{}.
varint(1, 1700000000000000001).
message(2, message{}.string(1, `C:\ProgramData\containerd\root`)).
message(3, uint64Value(1024)).
message(4, uint64Value(0)))
// A container without task metrics, e.g. a created container, only has attributes and a
// writable layer which containerd has not measured yet. The value 0 is sent as empty message.
created := message{}.
message(1, message{}.string(1, "def")).
message(4, message{}.message(3, message{}))
return message{}.message(1, running).message(1, created)
}
func TestDecodeListContainerStatsResponse(t *testing.T) {
t.Parallel()
ptr := func(v uint64) *uint64 { return &v }
stats, err := decodeListContainerStatsResponse(containerStatsResponse())
require.NoError(t, err)
require.Equal(t, []ContainerStats{
{
ID: "abc",
CPU: &CPUUsage{Timestamp: 1700000000000000000, UsageCoreNanoSeconds: ptr(1500000000)},
Memory: &MemoryUsage{Timestamp: 1700000000000000000, WorkingSetBytes: ptr(4096), UsageBytes: ptr(8192)},
WritableLayer: &FilesystemUsage{Timestamp: 1700000000000000001, UsedBytes: ptr(1024)},
},
{
ID: "def",
WritableLayer: &FilesystemUsage{UsedBytes: ptr(0)},
},
}, stats)
}
func TestDecodeListContainersResponse(t *testing.T) {
t.Parallel()
@@ -133,6 +190,10 @@ func TestDecodeInvalidMessage(t *testing.T) {
// A nested message with an invalid length.
_, err = decodeListPodSandboxResponse(message{}.message(1, message{0x12, 0x7f}))
require.Error(t, err)
// A UInt64Value with an invalid length.
_, err = decodeListContainerStatsResponse(message{}.message(1, message{}.message(2, message{}.message(2, message{0x08}))))
require.Error(t, err)
}
func TestEncodeListRunningContainersRequest(t *testing.T) {
@@ -200,6 +261,7 @@ func TestClient(t *testing.T) {
message{}.string(1, "0.1.0").string(2, "containerd").string(3, "v2.1.4").string(4, "v1")))
mux.Handle(runtimeService+"ListContainers", grpcHandler(t, encodeListRunningContainersRequest(), containersResponse()))
mux.Handle(runtimeService+"ListPodSandbox", grpcHandler(t, nil, sandboxesResponse()))
mux.Handle(runtimeService+"ListContainerStats", grpcHandler(t, nil, containerStatsResponse()))
client, err := NewClient(startServer(t, mux))
require.NoError(t, err)
@@ -221,6 +283,13 @@ func TestClient(t *testing.T) {
require.NoError(t, err)
require.Len(t, sandboxes, 2)
require.Equal(t, "default", sandboxes[0].Namespace)
stats, err := client.ListContainerStats(t.Context())
require.NoError(t, err)
require.Len(t, stats, 2)
require.Equal(t, "abc", stats[0].ID)
require.NotNil(t, stats[0].CPU)
require.Equal(t, uint64(1500000000), *stats[0].CPU.UsageCoreNanoSeconds)
}
}
+145
View File
@@ -69,6 +69,45 @@ type PodSandbox struct {
State PodSandboxState
}
// ContainerStats is the subset of runtime.v1.ContainerStats used by the exporter.
// Nil fields were not reported by the runtime.
//
// On Windows, containerd fills them from the HCS statistics of the container,
// which the shim also reads for Hyper-V isolated containers.
// https://github.com/containerd/containerd/blob/v2.4.1/internal/cri/server/container_stats_list.go
type ContainerStats struct {
ID string
CPU *CPUUsage
Memory *MemoryUsage
WritableLayer *FilesystemUsage
}
// CPUUsage is the subset of runtime.v1.CpuUsage used by the exporter.
type CPUUsage struct {
// Timestamp is the time of the sample in nanoseconds since the Unix epoch.
Timestamp int64
// UsageCoreNanoSeconds is the cumulative CPU time of the container.
UsageCoreNanoSeconds *uint64
}
// MemoryUsage is the subset of runtime.v1.MemoryUsage used by the exporter.
type MemoryUsage struct {
// Timestamp is the time of the sample in nanoseconds since the Unix epoch.
Timestamp int64
// WorkingSetBytes is the private working set on Windows.
WorkingSetBytes *uint64
// UsageBytes is the commit size on Windows.
UsageBytes *uint64
}
// FilesystemUsage is the subset of runtime.v1.FilesystemUsage used by the exporter.
type FilesystemUsage struct {
// Timestamp is the time of the sample in nanoseconds since the Unix epoch.
// containerd sends 0 if it has not measured the usage yet.
Timestamp int64
UsedBytes *uint64
}
// field is a decoded protobuf field. value holds varints, bytes length-delimited values.
type field struct {
num protowire.Number
@@ -237,6 +276,112 @@ func decodePodSandbox(b []byte) (PodSandbox, error) {
return sandbox, err
}
func decodeListContainerStatsResponse(b []byte) ([]ContainerStats, error) {
var stats []ContainerStats
err := eachField(b, func(f field) error {
if !f.is(1, protowire.BytesType) {
return nil
}
s, err := decodeContainerStats(f.bytes)
if err != nil {
return err
}
stats = append(stats, s)
return nil
})
return stats, err
}
func decodeContainerStats(b []byte) (ContainerStats, error) {
var stats ContainerStats
err := eachField(b, func(f field) error {
var err error
switch {
case f.is(1, protowire.BytesType): // ContainerAttributes
err = eachField(f.bytes, func(f field) error {
if f.is(1, protowire.BytesType) {
stats.ID = string(f.bytes)
}
return nil
})
case f.is(2, protowire.BytesType):
stats.CPU = &CPUUsage{}
err = eachField(f.bytes, func(f field) error {
var err error
switch {
case f.is(1, protowire.VarintType):
stats.CPU.Timestamp = int64(f.value)
case f.is(2, protowire.BytesType):
stats.CPU.UsageCoreNanoSeconds, err = decodeUInt64Value(f.bytes)
}
return err
})
case f.is(3, protowire.BytesType):
stats.Memory = &MemoryUsage{}
err = eachField(f.bytes, func(f field) error {
var err error
switch {
case f.is(1, protowire.VarintType):
stats.Memory.Timestamp = int64(f.value)
case f.is(2, protowire.BytesType):
stats.Memory.WorkingSetBytes, err = decodeUInt64Value(f.bytes)
case f.is(4, protowire.BytesType):
stats.Memory.UsageBytes, err = decodeUInt64Value(f.bytes)
}
return err
})
case f.is(4, protowire.BytesType):
stats.WritableLayer = &FilesystemUsage{}
err = eachField(f.bytes, func(f field) error {
var err error
switch {
case f.is(1, protowire.VarintType):
stats.WritableLayer.Timestamp = int64(f.value)
case f.is(3, protowire.BytesType):
stats.WritableLayer.UsedBytes, err = decodeUInt64Value(f.bytes)
}
return err
})
}
return err
})
return stats, err
}
// decodeUInt64Value decodes a runtime.v1.UInt64Value. An empty message is the value 0.
func decodeUInt64Value(b []byte) (*uint64, error) {
var value uint64
err := eachField(b, func(f field) error {
if f.is(1, protowire.VarintType) {
value = f.value
}
return nil
})
if err != nil {
return nil, err
}
return &value, nil
}
// encodeListRunningContainersRequest encodes a ListContainersRequest
// with filter.state.state set to CONTAINER_RUNNING.
func encodeListRunningContainersRequest() []byte {