feat(maintenance): a run spreads swarm services back out after its last host, moving only what the emptier node can start (#6398)
Each weekly run drains Docker02 then Docker03, and the swarm never moves a task back by itself, so every run ended with nearly every service on one node. On 2026-10-25 the operator had that spread out by hand. - Agent 1.26.0 reports each swarm service's pinned digest, and every repo@sha256 its host holds (from /system/df, every 10 min). - Once a run's last host is back in service, Steward picks services on the fullest node to move to the emptiest until they are within one task. It takes services with several replicas on one node first. A service moves only when the emptier node already holds its exact image. One the plan keeps in place, one below its replicas, and one with no pinned digest stay put, and the run says why. - A swarm manager's helper carries it out through a new action, rebalance, with the service names (validated, at most 48). It force-updates each in turn with the spec unchanged and no registry lookup, skips any a constraint places, and gives each 5 min to settle. - The run ends done with what moved, or failed if a move didn't settle or the manager didn't answer in 60 min. A docker-scoped window covers the restarts, and the step goes on the timeline. - Plans gain "Spread swarm services back out" (on by default) and "Keep in place", on the create form and in an "After the run" form on each plan. list_maintenance_plans shows both and the latest run's spread. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -239,6 +239,8 @@ async def _persist_swarm(session, host, swarm: dict) -> None:
|
||||
row.desired = int(s.get("desired") or 0)
|
||||
row.running = int(s.get("running") or 0)
|
||||
row.image = s.get("image")
|
||||
digest = s.get("image_digest")
|
||||
row.image_digest = digest[:80] if isinstance(digest, str) else None
|
||||
row.placement_json = json.dumps(s.get("placement") or [])
|
||||
row.tasks_json = json.dumps(s.get("tasks") or [])
|
||||
row.updated_at = now
|
||||
@@ -298,6 +300,9 @@ async def _persist_disk(session, host, disk: dict) -> None:
|
||||
summary.images_active = int(disk.get("images_active") or 0)
|
||||
summary.containers_count = int(disk.get("containers_count") or 0)
|
||||
summary.volumes_count = int(disk.get("volumes_count") or 0)
|
||||
digests = disk.get("digests")
|
||||
summary.digests_json = json.dumps(
|
||||
[d[:512] for d in digests if isinstance(d, str)][:400] if isinstance(digests, list) else [])
|
||||
summary.updated_at = now
|
||||
# The totals as metrics too (#5304): a history of each host's growth, and
|
||||
# something an alert rule can watch. Resource: the host's name.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Which image a swarm service is pinned to, and which images each host holds
|
||||
|
||||
Adds docker_swarm_services.image_digest (the sha256 digest a stack deploy
|
||||
pinned) and docker_disk_usage.digests_json (every "repo@sha256:…" the host
|
||||
holds), both from agent 1.26.0. A maintenance run spreads services back out
|
||||
only onto a node holding the service's image. Older agents leave them empty.
|
||||
|
||||
Revision ID: docker_014_image_digests
|
||||
Revises: docker_013_mem_used
|
||||
Create Date: 2026-10-25
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "docker_014_image_digests"
|
||||
down_revision: Union[str, None] = "docker_013_mem_used"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("docker_swarm_services", sa.Column("image_digest", sa.String(80), nullable=True))
|
||||
op.add_column("docker_disk_usage", sa.Column("digests_json", sa.Text, nullable=False,
|
||||
server_default="[]"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("docker_disk_usage", "digests_json")
|
||||
op.drop_column("docker_swarm_services", "image_digest")
|
||||
@@ -159,6 +159,9 @@ class DockerSwarmService(Base):
|
||||
desired: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
running: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
image: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# The digest a stack deploy pinned ("sha256:…"), agent ≥ 1.26.0; None when
|
||||
# the spec names a tag only.
|
||||
image_digest: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
# task→node placement of running replicas: [{"node_id", "running"}], JSON.
|
||||
# A manager sees every task, so this captures cross-node placement that the
|
||||
# local docker_containers rows (this host only) can't.
|
||||
@@ -196,6 +199,10 @@ class DockerDiskUsage(Base):
|
||||
images_active: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
containers_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
volumes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
# Every image the host holds by repository digest, ["repo@sha256:…"], JSON
|
||||
# (agent ≥ 1.26.0): what a swarm service's pinned image is checked against
|
||||
# before a maintenance run moves the service here.
|
||||
digests_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
|
||||
Reference in New Issue
Block a user