Component(s)
prometheus.exporter.cadvisor
What's wrong?
On a Docker host whose storage driver is the containerd snapshotter (docker info → Storage Driver: overlayfs, driver-type: io.containerd.snapshotter.v1, the Docker 29 default), every container start makes prometheus.exporter.cadvisor open a new gRPC ClientConn to /run/containerd/containerd.sock that is never closed. Each leaked connection holds 5 goroutines (3× grpcsync.(*CallbackSerializer).run, transport.(*loopyWriter).run, the HTTP/2 reader) plus its buffers, so go_goroutines and heap grow linearly with the number of container starts for the life of the process.
On hosts that run a docker run --rm job on a one-minute timer this is +4320 goroutines per day per job and roughly +10–18 MB heap per day; one host reached 65k goroutines after 7.5 days. On a 1 GB VM without swap the growth ended in a major-page-fault thrash. Hosts on overlay2 never take this code path and stay flat.
Steps to reproduce
- Docker 29.x with the containerd image store (the default), Alloy v1.19.2 as a systemd service with the config below.
while true; do docker run --rm alpine true; sleep 60; done
- Watch
go_goroutines on Alloy's /metrics: +5 per container start, never released. ss -xp | grep containerd.sock shows Alloy's connection count to containerd growing by one per start.
- Goroutine profile (
/debug/pprof/goroutine?debug=2): the accumulating CallbackSerializer goroutines are all created by github.com/google/cadvisor/manager.(*manager).watchForNewContainers.func1.
Goroutine-profile diff over 15 minutes / 15 container starts on one host (everything else flat):
+45 google.golang.org/grpc/internal/grpcsync.(*CallbackSerializer).run
+15 google.golang.org/grpc/internal/transport.(*loopyWriter).run (created by NewHTTP2Client)
+15 google.golang.org/grpc/internal/transport/readyreader.(*bufReadyReader).Read
Config:
prometheus.exporter.cadvisor "containers" {
docker_host = "unix:///var/run/docker.sock"
}
prometheus.scrape "cadvisor" {
targets = prometheus.exporter.cadvisor.containers.targets
forward_to = []
}
Root cause
Alloy replaces github.com/google/cadvisor with the grafana/cadvisor fork, branch grafana-v0.54.1-noglobals (go.mod at v1.19.2 and on main: replace github.com/google/cadvisor => github.com/grafana/cadvisor v0.0.0-20260204200106-865a22723970, commented "non-singleton cadvisor fork").
In that fork:
container/docker/handler.go newDockerContainerHandler calls opts.ContainerDClient() when storageDriver == ContainerdSnapshotterStorageDriver.
container/docker/client.go (*Options).ContainerDClient() builds a fresh containerd.Options{} on every call and returns cOpts.Client(...).
container/containerd/client.go (*Options).Client guards its grpc.DialContext with opts.once — a sync.Once that lives on that throwaway Options, so there is a new dial per container handler and nothing ever closes the connection.
Upstream cadvisor v0.54.1 keeps var once sync.Once; var ctrdClient ContainerdClient at package level in container/containerd/client.go and the docker handler uses that shared client, so upstream does not leak. The regression is in the fork's de-globalisation: a per-Options once is right for the containerd exporter (one Options per component instance), but the docker exporter builds a new containerd.Options per container instead of holding one per component.
Suggested fix
Give the docker Options a single lazily-initialised containerd client (a containerd.Options field with its own sync.Once), so ContainerDClient() returns the same ClientConn for the lifetime of the exporter instance, and close it on component shutdown.
Environment
- Alloy v1.19.2 (deb package, systemd), Linux amd64, Go 1.26.7 per the binary
- Docker 29.7.2 with the containerd image store, Debian 13 (trixie)
- Workarounds verified: (a) not creating containers on a timer — moving the periodic job into a long-lived container driven by
docker exec made go_goroutines flat over the same window in which an untouched host kept growing at +3/min; (b) bounding the process with GOMEMLIMIT + systemd MemoryMax, so the leak ends in an in-cgroup OOM kill and a Restart=always restart (positions intact) rather than host memory pressure.
Component(s)
prometheus.exporter.cadvisor
What's wrong?
On a Docker host whose storage driver is the containerd snapshotter (
docker info→Storage Driver: overlayfs,driver-type: io.containerd.snapshotter.v1, the Docker 29 default), every container start makesprometheus.exporter.cadvisoropen a new gRPCClientConnto/run/containerd/containerd.sockthat is never closed. Each leaked connection holds 5 goroutines (3×grpcsync.(*CallbackSerializer).run,transport.(*loopyWriter).run, the HTTP/2 reader) plus its buffers, sogo_goroutinesand heap grow linearly with the number of container starts for the life of the process.On hosts that run a
docker run --rmjob on a one-minute timer this is +4320 goroutines per day per job and roughly +10–18 MB heap per day; one host reached 65k goroutines after 7.5 days. On a 1 GB VM without swap the growth ended in a major-page-fault thrash. Hosts onoverlay2never take this code path and stay flat.Steps to reproduce
while true; do docker run --rm alpine true; sleep 60; donego_goroutineson Alloy's/metrics: +5 per container start, never released.ss -xp | grep containerd.sockshows Alloy's connection count to containerd growing by one per start./debug/pprof/goroutine?debug=2): the accumulatingCallbackSerializergoroutines are all created bygithub.com/google/cadvisor/manager.(*manager).watchForNewContainers.func1.Goroutine-profile diff over 15 minutes / 15 container starts on one host (everything else flat):
Config:
prometheus.exporter.cadvisor "containers" { docker_host = "unix:///var/run/docker.sock" } prometheus.scrape "cadvisor" { targets = prometheus.exporter.cadvisor.containers.targets forward_to = [] }Root cause
Alloy replaces
github.com/google/cadvisorwith thegrafana/cadvisorfork, branchgrafana-v0.54.1-noglobals(go.mod at v1.19.2 and onmain:replace github.com/google/cadvisor => github.com/grafana/cadvisor v0.0.0-20260204200106-865a22723970, commented "non-singleton cadvisor fork").In that fork:
container/docker/handler.gonewDockerContainerHandlercallsopts.ContainerDClient()whenstorageDriver == ContainerdSnapshotterStorageDriver.container/docker/client.go(*Options).ContainerDClient()builds a freshcontainerd.Options{}on every call and returnscOpts.Client(...).container/containerd/client.go(*Options).Clientguards itsgrpc.DialContextwithopts.once— async.Oncethat lives on that throwawayOptions, so there is a new dial per container handler and nothing ever closes the connection.Upstream cadvisor v0.54.1 keeps
var once sync.Once; var ctrdClient ContainerdClientat package level incontainer/containerd/client.goand the docker handler uses that shared client, so upstream does not leak. The regression is in the fork's de-globalisation: a per-Optionsonce is right for the containerd exporter (oneOptionsper component instance), but the docker exporter builds a newcontainerd.Optionsper container instead of holding one per component.Suggested fix
Give the docker
Optionsa single lazily-initialised containerd client (acontainerd.Optionsfield with its ownsync.Once), soContainerDClient()returns the sameClientConnfor the lifetime of the exporter instance, and close it on component shutdown.Environment
docker execmadego_goroutinesflat over the same window in which an untouched host kept growing at +3/min; (b) bounding the process withGOMEMLIMIT+ systemdMemoryMax, so the leak ends in an in-cgroup OOM kill and aRestart=alwaysrestart (positions intact) rather than host memory pressure.