Perf: serve stacks list from one Docker call; registry host → 10.10.6.10 (0.21.6)

Stacks overview and detail were doing an N+1 inspect storm: list_stacks
called containers_for_stack AND compute_status (which re-fetched) per
stack, and containers.list(sparse=False) full-inspects every container
plus c.image triggered an image-inspect each. For N stacks that was
~2N*(1 list + M inspects + M image-inspects) sequential socket round
trips (~1s for just 2 stacks, growing linearly).

- compose_service.stack_status_summaries(): one low-level
  api.containers(all=True) summary call grouped by compose project label
  → whole list served in a single Docker round-trip (~10x faster).
- compute_status() takes optional pre-fetched containers; get_stack and
  _stack_summary no longer double-fetch.
- containers_for_stack() reads the image name from the inspect it already
  has instead of c.image (drops the per-container image-inspect).
- Same batching applied to the agent's stack list/detail.

Also: Forgejo (registry + git) moved to 10.10.6.10:3020 — updated image
refs in docker-compose.yml, agent/Dockerfile, agent/docker-compose.yml.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-08 18:53:12 +00:00
co-authored by Claude Opus 4.8
parent cf046648bd
commit 5b59f5e8f9
8 changed files with 120 additions and 52 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# The agent reuses the backend image (same compose/Docker code + deps) and
# just runs a different ASGI app. Build the backend image first.
ARG BACKEND_IMAGE=10.10.5.10:3020/menzelj/stackpilot-backend:latest
ARG BACKEND_IMAGE=10.10.6.10:3020/menzelj/stackpilot-backend:latest
FROM ${BACKEND_IMAGE}
ENV STACKS_DIR=/opt/stacks \
+2 -2
View File
@@ -3,11 +3,11 @@
# token you enter when adding this host in the central StackPilot UI).
services:
agent:
image: 10.10.5.10:3020/menzelj/stackpilot-agent:latest
image: 10.10.6.10:3020/menzelj/stackpilot-agent:latest
build:
context: .
args:
BACKEND_IMAGE: 10.10.5.10:3020/menzelj/stackpilot-backend:latest
BACKEND_IMAGE: 10.10.6.10:3020/menzelj/stackpilot-backend:latest
restart: unless-stopped
environment:
- AGENT_TOKEN=${AGENT_TOKEN:?set AGENT_TOKEN in .env}
+29 -13
View File
@@ -62,7 +62,7 @@ def _map_docker(exc: DockerError):
raise HTTPException(status_code=code, detail=exc.detail or exc.error)
raise exc # falls through to the global 502 DockerError handler
AGENT_VERSION = "0.21.5"
AGENT_VERSION = "0.21.6"
# --------------------------------------------------------------------------- #
@@ -137,20 +137,31 @@ def _file_guard(fn, *args, **kwargs):
# --------------------------------------------------------------------------- #
def _summary(stack_id: str) -> dict:
try:
containers = compose_service.containers_for_stack(stack_id)
status = compose_service.compute_status(stack_id)
except DockerError:
containers = []
status = "unknown"
def _summary(stack_id: str, summaries: dict | None = None) -> dict:
if summaries is None:
try:
containers = compose_service.containers_for_stack(stack_id)
total = len(containers)
running = sum(1 for c in containers if c.state == "running")
status = compose_service.compute_status(stack_id, containers)
except DockerError:
total = running = 0
status = "unknown"
else:
info = summaries.get(stack_id)
total = info["total"] if info else 0
running = info["running"] if info else 0
if compose_service.is_busy(stack_id):
status = "updating"
else:
status = info["status"] if info else "stopped"
return {
"id": stack_id,
"name": stack_id,
"description": None,
"status": status,
"service_count": len(containers),
"running_count": sum(1 for c in containers if c.state == "running"),
"service_count": total,
"running_count": running,
"created_at": None,
"updated_at": None,
}
@@ -245,7 +256,11 @@ def system() -> dict:
@app.get("/agent/stacks", dependencies=[Depends(verify_token)])
def list_stacks() -> list[dict]:
return [_summary(sid) for sid in compose_service.discover_stacks()]
try:
summaries = compose_service.stack_status_summaries()
except DockerError:
summaries = {}
return [_summary(sid, summaries) for sid in compose_service.discover_stacks()]
@app.get("/agent/stacks/stats", dependencies=[Depends(verify_token)])
@@ -259,8 +274,9 @@ def get_stack(stack_id: str) -> dict:
if not os.path.isdir(directory):
raise HTTPException(status_code=404, detail=f"Stack '{stack_id}' not found")
try:
containers = [asdict(c) for c in compose_service.containers_for_stack(stack_id)]
status = compose_service.compute_status(stack_id)
raw = compose_service.containers_for_stack(stack_id)
containers = [asdict(c) for c in raw]
status = compose_service.compute_status(stack_id, raw)
except DockerError:
containers = []
status = "unknown"
+1 -1
View File
@@ -55,7 +55,7 @@ async def lifespan(app: FastAPI):
schedule_task.cancel()
app = FastAPI(title="StackPilot", version="0.21.5", lifespan=lifespan)
app = FastAPI(title="StackPilot", version="0.21.6", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+35 -13
View File
@@ -58,20 +58,37 @@ def _get_stack_or_404(session: Session, stack_id: str) -> Stack:
return stack
def _stack_summary(stack: Stack) -> dict:
try:
containers = compose_service.containers_for_stack(stack.id)
status = compose_service.compute_status(stack.id)
except DockerError:
containers = []
status = "unknown"
def _stack_summary(stack: Stack, summaries: dict | None = None) -> dict:
"""Build a list-row summary.
Pass ``summaries`` (from :func:`compose_service.stack_status_summaries`) to
serve the whole stacks list from a single Docker call. Without it (single
create/update/clone responses), fall back to one direct query for this stack.
"""
if summaries is None:
try:
containers = compose_service.containers_for_stack(stack.id)
total = len(containers)
running = sum(1 for c in containers if c.state == "running")
status = compose_service.compute_status(stack.id, containers)
except DockerError:
total = running = 0
status = "unknown"
else:
info = summaries.get(stack.id)
total = info["total"] if info else 0
running = info["running"] if info else 0
if compose_service.is_busy(stack.id):
status = "updating"
else:
status = info["status"] if info else "stopped"
return {
"id": stack.id,
"name": stack.name,
"description": stack.description,
"status": status,
"service_count": len(containers),
"running_count": sum(1 for c in containers if c.state == "running"),
"service_count": total,
"running_count": running,
"created_at": stack.created_at,
"updated_at": stack.updated_at,
}
@@ -89,7 +106,11 @@ def list_stacks(
) -> list[dict]:
sync_discovered_stacks(session)
stacks = session.exec(select(Stack)).all()
return [_stack_summary(s) for s in stacks]
try:
summaries = compose_service.stack_status_summaries()
except DockerError:
summaries = {}
return [_stack_summary(s, summaries) for s in stacks]
@router.post("", status_code=201)
@@ -130,9 +151,10 @@ def get_stack(
) -> dict:
stack = _get_stack_or_404(session, stack_id)
try:
containers = [asdict(c) for c in compose_service.containers_for_stack(stack_id)]
status = compose_service.compute_status(stack_id)
except DockerError as exc:
raw = compose_service.containers_for_stack(stack_id)
containers = [asdict(c) for c in raw]
status = compose_service.compute_status(stack_id, raw)
except DockerError:
containers = []
status = "unknown"
return {
+49 -19
View File
@@ -184,7 +184,10 @@ def containers_for_stack(stack_id: str) -> list[ContainerInfo]:
id=c.id,
name=c.name,
service=c.labels.get(SERVICE_LABEL, c.name),
image=(c.image.tags[0] if c.image and c.image.tags else attrs.get("Config", {}).get("Image", "")),
# Use the configured image name from the inspect we already have;
# reading c.image.tags would trigger a separate image-inspect call
# per container.
image=attrs.get("Config", {}).get("Image", "") or attrs.get("Image", ""),
state=state.get("Status", c.status),
status=attrs.get("State", {}).get("Status", c.status),
health=health,
@@ -207,21 +210,14 @@ def clear_busy(stack_id: str) -> None:
_BUSY.discard(stack_id)
def compute_status(stack_id: str) -> str:
if stack_id in _BUSY:
return "updating"
try:
containers = containers_for_stack(stack_id)
except DockerError:
return "unknown"
if not containers:
def is_busy(stack_id: str) -> bool:
return stack_id in _BUSY
def _status_from_states(states: list[str]) -> str:
if not states:
return "stopped"
states = [c.state for c in containers]
if any(s in ("dead",) for s in states):
return "error"
if any(
c.state == "exited" and _nonzero_exit(c) for c in containers
):
if any(s == "dead" for s in states):
return "error"
running = [s for s in states if s == "running"]
if len(running) == len(states):
@@ -231,10 +227,44 @@ def compute_status(stack_id: str) -> str:
return "stopped"
def _nonzero_exit(c: ContainerInfo) -> bool:
# We only have the textual state here; treat plain "exited" as stopped, not
# an error unless health says otherwise. Detailed exit codes handled in detail view.
return False
def compute_status(stack_id: str, containers: Optional[list[ContainerInfo]] = None) -> str:
"""Status for one stack. Pass already-fetched ``containers`` to avoid a
redundant Docker round-trip (the detail view already has them)."""
if stack_id in _BUSY:
return "updating"
try:
if containers is None:
containers = containers_for_stack(stack_id)
except DockerError:
return "unknown"
return _status_from_states([c.state for c in containers])
def stack_status_summaries() -> dict[str, dict]:
"""One cheap Docker call → ``{project: {status, total, running}}`` for every
stack at once.
Uses the low-level container *summary* list (``GET /containers/json``, no
per-container inspect) grouped by the compose project label. This is far
cheaper than calling :func:`containers_for_stack` (which full-inspects every
container) once per stack for the overview — turning the stacks list from
O(stacks × containers) Docker round-trips into a single one.
"""
client = get_client()
raw = safe_call(client.api.containers, all=True)
by_project: dict[str, list[str]] = {}
for c in raw:
project = (c.get("Labels") or {}).get(COMPOSE_LABEL)
if project:
by_project.setdefault(project, []).append(c.get("State", ""))
return {
project: {
"status": _status_from_states(states),
"total": len(states),
"running": sum(1 for s in states if s == "running"),
}
for project, states in by_project.items()
}
# --------------------------------------------------------------------------- #
+2 -2
View File
@@ -1,6 +1,6 @@
services:
backend:
image: 10.10.5.10:3020/menzelj/stackpilot-backend:latest
image: 10.10.6.10:3020/menzelj/stackpilot-backend:latest
build: ./backend
restart: unless-stopped
environment:
@@ -38,7 +38,7 @@ services:
# - "5008:5008"
frontend:
image: 10.10.5.10:3020/menzelj/stackpilot-frontend:latest
image: 10.10.6.10:3020/menzelj/stackpilot-frontend:latest
build: ./frontend
restart: unless-stopped
depends_on:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "stackpilot-frontend",
"private": true,
"version": "0.21.5",
"version": "0.21.6",
"type": "module",
"scripts": {
"dev": "vite",