daemon: report network stats for Windows containerd runtime

Windows network stats were only available via the builtin HCS runtime; the
containerd runtime (runhcs shim) reports no network data, so `docker stats`
returned empty Networks (0B NET I/O) and TestStatsNetworkStats was skipped.

Implement getNetworkStats on Windows to query stats from HNS per endpoint
(GetHNSEndpointStats, keyed by the driver's hnsid). GetContainerStats only
falls back to it when Networks is empty, leaving the builtin runtime
unchanged. Un-skip TestStatsNetworkStats and add a conversion unit test.

Signed-off-by: Dawei Wei <wei.dawei.cn@gmail.com>
This commit is contained in:
Dawei Wei
2026-07-20 15:52:51 -07:00
parent cb7c8dd909
commit 70b82e42b5
5 changed files with 106 additions and 20 deletions

View File

@@ -11,6 +11,7 @@ import (
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/v2/daemon/container"
"github.com/moby/moby/v2/daemon/server/backend"
"github.com/pkg/errors"
)
// ContainerStats writes information about the container to the stream
@@ -124,8 +125,10 @@ func (daemon *Daemon) GetContainerStats(ctr *container.Container) (*containertyp
goto done
}
// We already have the network stats on Windows directly from HCS.
if !ctr.Config.NetworkDisabled && runtime.GOOS != "windows" {
// On Windows, the builtin (HCS) runtime already provides network stats
// embedded in the stats above; the containerd runtime does not, so query
// them separately when they're missing.
if !ctr.Config.NetworkDisabled && (runtime.GOOS != "windows" || len(stats.Networks) == 0) {
stats.Networks, err = daemon.getNetworkStats(ctr)
}
@@ -144,3 +147,18 @@ done:
}
return stats, nil
}
// getNetworkSandboxID resolves the network SandboxID, following the chain in
// case the container reuses another container's network stack.
func (daemon *Daemon) getNetworkSandboxID(c *container.Container) (string, error) {
curr := c
for curr.HostConfig.NetworkMode.IsContainer() {
containerID := curr.HostConfig.NetworkMode.ConnectedContainer()
connected, err := daemon.GetContainer(containerID)
if err != nil {
return "", errors.Wrapf(err, "Could not get container for %s", containerID)
}
curr = connected
}
return curr.NetworkSettings.SandboxID, nil
}

View File

@@ -259,20 +259,6 @@ func (daemon *Daemon) statsV2(s *containertypes.StatsResponse, stats *statsV2.Me
return s, nil
}
// Resolve Network SandboxID in case the container reuse another container's network stack
func (daemon *Daemon) getNetworkSandboxID(c *container.Container) (string, error) {
curr := c
for curr.HostConfig.NetworkMode.IsContainer() {
containerID := curr.HostConfig.NetworkMode.ConnectedContainer()
connected, err := daemon.GetContainer(containerID)
if err != nil {
return "", errors.Wrapf(err, "Could not get container for %s", containerID)
}
curr = connected
}
return curr.NetworkSettings.SandboxID, nil
}
func (daemon *Daemon) getNetworkStats(c *container.Container) (map[string]containertypes.NetworkStats, error) {
sandboxID, err := daemon.getNetworkSandboxID(c)
if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"context"
"runtime"
"github.com/Microsoft/hcsshim"
cerrdefs "github.com/containerd/errdefs"
containertypes "github.com/moby/moby/api/types/container"
"github.com/moby/moby/v2/daemon/container"
@@ -78,9 +79,55 @@ func (daemon *Daemon) stats(c *container.Container) (*containertypes.StatsRespon
return s, nil
}
// Windows network stats are obtained directly through HCS, hence this is a no-op.
// getNetworkStats collects network statistics for a container. The builtin HCS
// runtime already embeds these in the container stats, but the containerd
// runtime does not, so they are queried here directly from HNS by endpoint.
func (daemon *Daemon) getNetworkStats(c *container.Container) (map[string]containertypes.NetworkStats, error) {
return make(map[string]containertypes.NetworkStats), nil
sandboxID, err := daemon.getNetworkSandboxID(c)
if err != nil {
return nil, err
}
sb, err := daemon.netController.SandboxByID(sandboxID)
if err != nil {
return nil, err
}
stats := make(map[string]containertypes.NetworkStats)
for _, ep := range sb.Endpoints() {
info, err := ep.DriverInfo()
if err != nil {
return nil, err
}
hnsid, ok := info["hnsid"].(string)
if !ok || hnsid == "" {
continue
}
epStats, err := hcsshim.GetHNSEndpointStats(hnsid)
if err != nil {
return nil, err
}
stats[epStats.EndpointID] = hnsStatsToNetworkStats(epStats)
}
return stats, nil
}
// hnsStatsToNetworkStats maps HNS endpoint statistics onto the API network stats
// shape. Errors are not reported by HNS, so RxErrors/TxErrors are left zeroed
// (matching the builtin HCS path, which also does not populate them on Windows).
func hnsStatsToNetworkStats(epStats *hcsshim.HNSEndpointStats) containertypes.NetworkStats {
return containertypes.NetworkStats{
RxBytes: epStats.BytesReceived,
RxPackets: epStats.PacketsReceived,
RxDropped: epStats.DroppedPacketsIncoming,
TxBytes: epStats.BytesSent,
TxPackets: epStats.PacketsSent,
TxDropped: epStats.DroppedPacketsOutgoing,
EndpointID: epStats.EndpointID,
InstanceID: epStats.InstanceID,
}
}
// getSystemCPUUsage returns the host system's cpu usage in

View File

@@ -0,0 +1,37 @@
package daemon
import (
"testing"
"github.com/Microsoft/hcsshim"
containertypes "github.com/moby/moby/api/types/container"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestHNSStatsToNetworkStats(t *testing.T) {
in := &hcsshim.HNSEndpointStats{
EndpointID: "endpoint-1",
InstanceID: "instance-1",
BytesReceived: 100,
PacketsReceived: 10,
DroppedPacketsIncoming: 1,
BytesSent: 200,
PacketsSent: 20,
DroppedPacketsOutgoing: 2,
}
got := hnsStatsToNetworkStats(in)
assert.Check(t, is.DeepEqual(got, containertypes.NetworkStats{
RxBytes: 100,
RxPackets: 10,
RxDropped: 1,
TxBytes: 200,
TxPackets: 20,
TxDropped: 2,
EndpointID: "endpoint-1",
InstanceID: "instance-1",
// RxErrors/TxErrors are not reported by HNS and remain zero.
}))
}

View File

@@ -101,8 +101,6 @@ func TestStatsContainerNotFound(t *testing.T) {
}
func TestStatsNetworkStats(t *testing.T) {
// FIXME(thaJeztah): Broken on Windows + containerd combination, see https://github.com/moby/moby/pull/41479
skip.If(t, testEnv.RuntimeIsWindowsContainerd(), "FIXME: Broken on Windows + containerd combination")
skip.If(t, testEnv.IsRootless() && testEnv.DaemonInfo.CgroupVersion == "1", "Rootless Mode does not support cgroups v1 stats")
skip.If(t, testEnv.IsRemoteDaemon(), "Test requires a local daemon")