chore(v5): weekly merge from develop (2026-06-01)

This commit is contained in:
github-actions[bot]
2026-06-01 07:52:40 +00:00
5 changed files with 227 additions and 12 deletions

View File

@@ -9,6 +9,7 @@
"""Docker Extension unit for Glances' Containers plugin."""
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from glances.globals import nativestr, pretty_date, replace_special_chars
@@ -29,6 +30,13 @@ else:
disable_plugin_docker = False
# Issue #3559: number of containers inspected concurrently per update cycle.
# docker-py's default list() inspects each container sequentially (one synchronous
# API call per container). Kept under docker-py's default connection pool size to
# avoid "connection pool is full" warnings.
MAX_CONCURRENT_INSPECT = 6
class DockerStatsFetcher:
MANDATORY_MEMORY_FIELDS = ['usage', 'limit']
@@ -235,6 +243,10 @@ class DockerExtension:
self.ext_name = "containers (Docker)"
self.stats_fetchers = {}
# Issue #3559: cache the (immutable) image tags per container id to avoid
# one inspect_image API call per container on every refresh.
self.image_cache = {}
self.connect()
def connect(self) -> None:
@@ -269,7 +281,11 @@ class DockerExtension:
try:
# Issue #1152: Docker module doesn't export details about stopped containers
# The Containers/all key of the configuration file should be set to True
containers = self.client.containers.list(all=all_tag)
# Issue #3559: docker-py's list() inspects each container sequentially (one
# synchronous API call per container), which dominates the update time when
# there are many containers. Use sparse=True to get the list in a single API
# call, then inspect the containers concurrently (see _inspect_concurrently).
containers = self.client.containers.list(all=all_tag, sparse=True)
self.display_error = True
except Exception as e:
if self.display_error:
@@ -279,6 +295,9 @@ class DockerExtension:
logger.debug(f"{self.ext_name} plugin - Can't get containers list ({e})")
return version_stats, []
# Inspect the containers concurrently to populate their attributes (issue #3559)
containers = self._inspect_concurrently(containers)
# Start new thread for new container
for container in containers:
if container.id not in self.stats_fetchers:
@@ -288,7 +307,8 @@ class DockerExtension:
self.stats_fetchers[container.id] = DockerStatsFetcher(container)
# Stop threads for non-existing containers
absent_containers = set(self.stats_fetchers.keys()) - {c.id for c in containers}
current_ids = {c.id for c in containers}
absent_containers = set(self.stats_fetchers.keys()) - current_ids
for container_id in absent_containers:
# Stop the StatsFetcher
logger.debug(f"{self.ext_name} plugin - Stop thread for old container {container_id[:12]}")
@@ -296,15 +316,69 @@ class DockerExtension:
# Delete the StatsFetcher from the dict
del self.stats_fetchers[container_id]
# Drop image cache entries for non-existing containers (issue #3559)
for container_id in set(self.image_cache) - current_ids:
del self.image_cache[container_id]
# Get stats for all containers
container_stats = [self.generate_stats(container) for container in containers]
return version_stats, container_stats
def _inspect_concurrently(self, containers: list) -> list:
"""Inspect the given (sparse) containers concurrently and return the live ones.
docker-py's `list()` inspects containers one at a time, i.e. O(N) synchronous
API calls on the caller thread (issue #3559). `list(sparse=True)` returns
summary-only objects in a single call; `reload()` performs the per-container
inspect, which we parallelise here to keep the update time roughly independent
of the number of containers. Containers removed between the list and the inspect
are dropped.
"""
if not containers:
return []
def reload_container(container):
try:
container.reload()
return container
except Exception as e:
# Container removed between list and inspect, or inspect failed
logger.debug(f"{self.ext_name} plugin - Can't inspect container {container.id[:12]} ({e})")
return None
max_workers = min(len(containers), MAX_CONCURRENT_INSPECT)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
reloaded = executor.map(reload_container, containers)
return [container for container in reloaded if container is not None]
@property
def key(self) -> str:
"""Return the key of the list."""
return 'name'
def _get_image(self, container):
"""Return the container image tags, cached per container id (issue #3559).
`container.image` performs an inspect_image API call. The image of a container
is immutable for its lifetime, so the result is cached to avoid one synchronous
API call per container on every refresh.
"""
if container.id in self.image_cache:
return self.image_cache[container.id]
try:
# API fails on Unraid - See issue #2233
# Access container.image only once (it triggers an inspect_image API call)
tags = container.image.tags
image = (','.join(tags if tags else []),)
except (requests.exceptions.HTTPError, docker.errors.NullResource):
# Container plugin crashes with docker.errors.NullResource on Podman pod infra containers issue #3498
image = ''
self.image_cache[container.id] = image
return image
def generate_stats(self, container) -> dict[str, Any]:
# Init the stats for the current container
# Manage healthy status (see issue #3402)
@@ -331,12 +405,7 @@ class DockerExtension:
}
# Container Image
try:
# API fails on Unraid - See issue #2233
stats['image'] = (','.join(container.image.tags if container.image.tags else []),)
except (requests.exceptions.HTTPError, docker.errors.NullResource):
# Container plugin crashes with docker.errors.NullResource on Podman pod infra containers issue #3498
stats['image'] = ''
stats['image'] = self._get_image(container)
if container.attrs['Config'].get('Entrypoint', None):
stats['command'].extend(container.attrs['Config'].get('Entrypoint', []))

View File

@@ -245,7 +245,12 @@ class LxdExtension:
# List instances
try:
instances = self.client.instances.all()
# Issue #3559: recursion=1 fetches every instance's config (status, devices,
# created_at, ...) in a single API call. Without it, pylxd creates name-only
# instances and each attribute access below (i.status, expanded_devices, ...)
# lazily triggers one synchronous GET per instance, i.e. O(N) calls per cycle.
# State (CPU/MEM/NET/IO) still comes from the background pollers, not from here.
instances = self.client.instances.all(recursion=1)
# In a cluster, only show instances running on this node
if self.local_node:
instances = [i for i in instances if getattr(i, 'location', None) == self.local_node]

View File

@@ -295,6 +295,10 @@ class PodmanExtension:
self.pods_stats_fetcher = None
self.container_stats_fetchers = {}
# Issue #3559: cache the (immutable) image tags per container id to avoid
# one inspect_image API call per container on every refresh.
self.image_cache = {}
self.connect()
def connect(self):
@@ -354,7 +358,8 @@ class PodmanExtension:
self.container_stats_fetchers[container.id] = PodmanContainerStatsFetcher(container)
# Stop threads for non-existing containers
absent_containers = set(self.container_stats_fetchers.keys()) - {c.id for c in containers}
current_ids = {c.id for c in containers}
absent_containers = set(self.container_stats_fetchers.keys()) - current_ids
for container_id in absent_containers:
# Stop the StatsFetcher
logger.debug(f"{self.ext_name} plugin - Stop thread for old container {container_id[:12]}")
@@ -362,6 +367,10 @@ class PodmanExtension:
# Delete the StatsFetcher from the dict
del self.container_stats_fetchers[container_id]
# Drop image cache entries for non-existing containers (issue #3559)
for container_id in set(self.image_cache) - current_ids:
del self.image_cache[container_id]
# Get stats for all containers
container_stats = [self.generate_stats(container) for container in containers]
@@ -378,13 +387,26 @@ class PodmanExtension:
"""Return the key of the list."""
return "name"
def _get_image(self, container):
"""Return the container image tags, cached per container id (issue #3559).
`container.image` performs an inspect_image API call. The image of a container
is immutable for its lifetime, so the result is cached to avoid one synchronous
API call per container on every refresh.
"""
if container.id not in self.image_cache:
# Access container.image only once (it triggers an inspect_image API call)
tags = container.image.tags
self.image_cache[container.id] = ",".join(tags if tags else [])
return self.image_cache[container.id]
def generate_stats(self, container) -> dict[str, Any]:
# Init the stats for the current container
stats = {
"key": self.key,
"name": nativestr(container.name),
"id": container.id,
"image": ",".join(container.image.tags if container.image.tags else []),
"image": self._get_image(container),
"status": container.attrs["State"],
"created": container.attrs["Created"],
"command": container.attrs.get("Command") or [],

View File

@@ -18,7 +18,7 @@ class ThreadedIterableStreamer:
Use `ThreadedIterableStreamer.stats` to access the latest streamed results
"""
def __init__(self, iterable, initial_stream_value=None, sleep_duration=0.01):
def __init__(self, iterable, initial_stream_value=None, sleep_duration=0.1):
"""
iterable: an Iterable instance that needs to be streamed
"""

119
tests/test_plugin_docker.py Executable file
View File

@@ -0,0 +1,119 @@
#!/usr/bin/env python
#
# Glances - An eye on your system
#
# SPDX-FileCopyrightText: 2026 Nicolas Hennion <nicolas@nicolargo.com>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
"""Tests for the Docker container engine (issue #3559 performance fix)."""
from unittest.mock import MagicMock, patch
import pytest
from glances.plugins.containers.engines import docker as docker_engine
class FakeImage:
def __init__(self, tags):
self.tags = tags
class FakeContainer:
"""Minimal container double counting image lookups and inspects (reload)."""
def __init__(self, cid, status="exited", tags=None):
self.id = cid
self.name = f"name-{cid}"
self.attrs = {
"State": {"Status": status},
"Created": "2026-01-01T00:00:00Z",
"Config": {"Entrypoint": None, "Cmd": ["sh"]},
"HostConfig": {},
}
self._tags = tags if tags is not None else [f"img-{cid}:latest"]
self.ports = {}
self.image_access_count = 0
self.reload_count = 0
@property
def image(self):
# container.image performs an inspect_image API call in docker-py
self.image_access_count += 1
return FakeImage(self._tags)
def reload(self):
# reload() performs the per-container inspect in docker-py
self.reload_count += 1
def make_extension(containers):
"""Build a DockerExtension without connecting to a real daemon."""
ext = docker_engine.DockerExtension.__new__(docker_engine.DockerExtension)
ext.disable = False
ext.display_error = True
ext.ext_name = "containers (Docker)"
ext.stats_fetchers = {}
ext.image_cache = {}
ext.client = MagicMock()
ext.client.containers.list.return_value = containers
return ext
def test_list_is_sparse():
"""The list must be fetched sparse (single API call, no per-container inspect)."""
containers = [FakeContainer("a"), FakeContainer("b")]
ext = make_extension(containers)
with patch.object(docker_engine, "DockerStatsFetcher", MagicMock()):
ext.update(all_tag=True)
ext.client.containers.list.assert_called_once_with(all=True, sparse=True)
def test_containers_are_inspected_every_cycle():
"""Each container is inspected (reload) once per update cycle (fresh status/uptime)."""
containers = [FakeContainer("a"), FakeContainer("b"), FakeContainer("c")]
ext = make_extension(containers)
with patch.object(docker_engine, "DockerStatsFetcher", MagicMock()):
ext.update(all_tag=True)
ext.update(all_tag=True)
assert all(c.reload_count == 2 for c in containers)
def test_image_is_cached_across_cycles():
"""Issue #3559: image (inspect_image) must be looked up once, then cached."""
containers = [FakeContainer("a"), FakeContainer("b")]
ext = make_extension(containers)
with patch.object(docker_engine, "DockerStatsFetcher", MagicMock()):
ext.update(all_tag=True)
ext.update(all_tag=True)
ext.update(all_tag=True)
# 3 cycles, but the immutable image is only fetched once per container
assert all(c.image_access_count == 1 for c in containers)
def test_image_field_format_preserved():
"""The exposed image field keeps its historical shape (tuple of joined tags)."""
containers = [FakeContainer("a", tags=["redis:7"])]
ext = make_extension(containers)
with patch.object(docker_engine, "DockerStatsFetcher", MagicMock()):
_, stats = ext.update(all_tag=True)
assert stats[0]["image"] == ("redis:7",)
def test_image_cache_evicts_removed_containers():
"""Image cache must not grow unbounded: entries for gone containers are dropped."""
first = [FakeContainer("a"), FakeContainer("b")]
ext = make_extension(first)
with patch.object(docker_engine, "DockerStatsFetcher", MagicMock()):
ext.update(all_tag=True)
assert set(ext.image_cache) == {"a", "b"}
# Container "b" disappears
ext.client.containers.list.return_value = [FakeContainer("a")]
ext.update(all_tag=True)
assert set(ext.image_cache) == {"a"}
if __name__ == "__main__":
pytest.main([__file__, "-v"])