Skip to content

feat(swarm): implement autoscaling (scale to zero/one) for Docker Swarm services#564

Open
SCBionicle wants to merge 38 commits into
itzg:mainfrom
SCBionicle:swarm-autoscale
Open

feat(swarm): implement autoscaling (scale to zero/one) for Docker Swarm services#564
SCBionicle wants to merge 38 commits into
itzg:mainfrom
SCBionicle:swarm-autoscale

Conversation

@SCBionicle

@SCBionicle SCBionicle commented Jul 6, 2026

Copy link
Copy Markdown

This pull request implements full auto-scaling support (scale-to-zero and scale-to-one) for Docker Swarm services, supporting both VIP (Virtual IP) and DNSRR (DNS Round-Robin) modes.

The Problem: Premature Connection Severing in VIP Mode

In Swarm's standard VIP mode, Swarm manages traffic through kernel-level load balancing (IPVS). When a service replica scales down to 0, Swarm immediately cuts all active TCP streams routed through the VIP. This ignores the Minecraft server's stop timeout/grace period, preventing it from performing a clean shutdown (e.g. broadcasting warnings, saving player states).

The Solution: Direct-to-Task Routing

To solve this, the Swarm autoscaler bypasses the VIP when scaling is active by discovering and routing directly to the task container's overlay IP:

  1. When a service is scaled down to 0, its route is registered in the routing table with an empty backend (""), which instantly serves the "Asleep" MOTD on status checks without waiting for network timeouts.
  2. When a player logs in, the Waker scales the service up to 1, polls the Swarm Task API (TaskList) to wait for a task to transition to running, extracts the task's concrete overlay IP, dials it to ensure it is healthy, and routes the player connection directly to it.
  3. Because the connection is direct, Swarm does not sever the TCP socket when a scale-down command is issued. The Minecraft server can utilize its full graceful shutdown grace period before being stopped.

Detailed Changes

1. Configuration & Downscaler Wiring

  • server/server.go: Enables the connection DownScaler when InDockerSwarm is active.

2. Swarm Watcher Updates (server/docker_swarm.go)

  • Struct Decoupling: Introduced a Swarm-specific routableSwarmService struct to cleanly encapsulate Swarm metadata without coupling to the Kubernetes data models.
  • Autoscale Label Parsing: Updated parseServiceData and listServices to accept ResolutionModeDNSRR as a valid endpoint resolution mode, read autoscale labels (mc-router.auto-scale-up, mc-router.auto-scale-down, and custom asleep/loading MOTDs), and check if current replicas are 0 (setting backend IP to "").
  • Waker (makeWakerFunc):
    • Automatically scales the Swarm service replicas from 0 to 1 when accessed.
    • Polls the Swarm TaskList API to wait for the task state to enter running.
    • Extracts the container overlay IP from the task's NetworksAttachments.
    • Verifies health via TCP dial before returning the direct task address to the connector.
  • Sleeper (makeSleeperFunc): Scales down the replicated service replicas from 1 to 0 once all active connections have closed and the idle period (DownAfter) has passed.
  • Reconciliation (reconcileServices): Registered Swarm wakers, sleepers, custom MOTDs, and used the Swarm serviceID as the scalingTarget to ensure correct connection counting.

Update (July 8, 2026)

After testing the initial implementation, we refined the state machine evaluation to handle Docker Swarm's asynchronous scheduling behavior and resolved two transient race conditions:

1. State Machine Refactoring (Actual vs. Desired States)

  • Desired State Synchronization: We updated the waker and watcher to inspect both the task's actual state (task.Status.State) and desired state (task.DesiredState).
    • A task is now only considered Running if both actual and desired states are running (preventing routing to stopping tasks).
    • A task is identified as in Restart Delay if both actual and desired states are ready.
    • We detect if Swarm has Given Up on scheduling by checking if the latest task's desired state is shutdown (with no other active tasks present).

2. Resolving Scale-Up Race Conditions (Stale History)

  • The Problem: Right after scaling up (replicas 0 -> 1), Swarm takes 1–2 seconds to create the new task record. During this window, querying TaskList only returned stale tasks from the previous run. Because the latest old task was shutdown, the waker instantly failed.
  • The Solution: We introduced timestamp-based filtering. The waker records filterTime = time.Now() right before updating the service (and the watcher uses service.Meta.UpdatedAt). We ignore any stale terminal tasks created before filterTime. The waker now cleanly waits for the new task to be scheduled.

3. VIP Propagation Bypass (Direct Task Routing)

  • Since Minecraft services run as a single instance (replicas == 1), we bypass the 5-10s IPVS load balancer VIP propagation delay by resolving and routing directly to the running container's direct overlay IP. Players and server status pings connect instantly without transient connection refused warnings during the start-up transition.

4. Documentation & MOTD Polishing

  • Swarm States Documentation: Added docs/docker-swarm-states.md mapping Docker Swarm's task states to mc-router routing actions for developer reference.
  • Default Failed MOTD: Defaults to "Server failed to start." if mc-router.auto-scale-failed-motd is not set.

The above is vibe coded and is being tested on my own server set up before being unmarked as a draft.

Fixes #561

@itzg

itzg commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Took a really quick look and am liking that the changes are contained almost entirely within docker swarm file. Keep me posted as you verify further.

@SCBionicle

Copy link
Copy Markdown
Author

Thank you. Fixing a routing bug right now where the script routed to an IP of the first network it saw.

I did notice a possible bug while working on this that docker socket path does not have a default and seems to need to be explicitly defined, at least in swarm mode. I'm not going to fix that in this PR because I'm trying to keep the scope of the PR narrow, but it's something worth looking out for and/or posting in the documentation.

@itzg

itzg commented Jul 7, 2026

Copy link
Copy Markdown
Owner

I did notice a possible bug while working on this that docker socket path does not have a default and seems to need to be explicitly defined, at least in swarm mode.

Thanks for keeping the PR scope narrow. If you get a chance, can you open an issue track this?

@itzg itzg linked an issue Jul 7, 2026 that may be closed by this pull request
@SCBionicle

Copy link
Copy Markdown
Author

If you get a chance, can you open an issue track this?

I have opened an issue at #567.


Regarding progress so far. VIP mode for auto spool down and auto spool up is working perfectly in my live environment. However, I have yet to test DNSRR endpoint mode and I'm running into the issue where the sleeping and waking custom MOTD are not displaying in the client. So, that's what I'm working on now.

@SCBionicle

Copy link
Copy Markdown
Author

Okay, so custom MOTD fixed and tested on my system. So, the last thing I need to test is DNSRR deployments and then this PR would be ready for review.

@SCBionicle

SCBionicle commented Jul 7, 2026

Copy link
Copy Markdown
Author

@itzg While testing the auto-scaling implementation on a live cluster with a heavy modpack (NeoForge), two edge cases were identified that would make the Swarm autoscaler much more production-ready:

1. Configurable Startup Timeout (via Service Label)

  • The issue: Heavy modpacks can take 2–3 minutes to boot up and bind to the TCP port. The current hardcoded 60s timeout is too short for these servers, causing the waker to give up prematurely.
  • The solution: Introduce a mc-router.auto-scale-wait-timeout service label (e.g., "5m"). This allows admins to define how long the router waits for slow-starting servers, matching the existing Kubernetes annotation mc-router.itzg.me/autoScaleWaitTimeout.

2. Failed-Task / Crash-Loop Detection (Fail-Fast)

  • The issue: If the container is permanently misconfigured and crashlooping, we want the router to fail fast if Swarm gives up.
  • The solution: In the polling loop, query all tasks for the service. If a finite restart policy is configured (e.g., max_attempts: 3) and has been exhausted, Swarm will stop spawning new tasks. The router can detect that there are no active tasks left in the scheduler (new, pending, preparing, running, etc.) and fail immediately rather than making the player wait for the full timeout duration.

To further improve player UX:

3. Custom MOTD for Exhausted / Failed State

  • The issue: If a server fails to start, players currently see the generic "Sleeping" or "Loading" MOTD on their server list, leading them to repeatedly try joining a broken server.
  • The solution: Introduce a mc-router.auto-scale-failed-motd label (e.g., "Server failed to start. Contact admin!"). If the watcher detects that the service is scaled to 1 but all tasks have terminated (exhausted restart policy), it maps the backend to "" and displays this custom failed MOTD.

Are you okay for me to tack this into this PR?

@itzg

itzg commented Jul 7, 2026

Copy link
Copy Markdown
Owner

Are you okay for me to tack this into this PR?

Yes, that all sounds quite reasonable. As you can guess, purposely avoid implementing the same for regular docker and kubernetes modes. That can be addressed separately/later.

@SCBionicle

Copy link
Copy Markdown
Author

I got most of the core logic down, but still working through edge cases.

- Refactor Swarm task parsing to inspect both task.Status.State and task.DesiredState.
- Treat a task as fully running only if both actual and desired states are "running".
- Trust the "ready" state (actual or desired) directly to identify tasks in a restart delay.
- Track the most recently created task to detect when Swarm has given up (latestTask.DesiredState == "shutdown").
- Add a default fallback permanently failed MOTD ("Server failed to start.") when no custom MOTD is configured.
@SCBionicle

Copy link
Copy Markdown
Author

@itzg I got a question about your vision regarding the join process when the server is sleeping or waking. Were you preferring the UX where the player waits in the connecting screen while the server wakes up or were you envisioning players being immediately disconnected if the server isn't ready as part of a failfast approach?

Reason I ask is that I cannot fully eliminate race conditions in the Swarm autoscale up to make it smooth and my testing environment guarentees that the server takes more than 30 seconds to start (from scale up to game server ready).

@itzg

itzg commented Jul 11, 2026

Copy link
Copy Markdown
Owner

Personally I don't feel strongly either way. It looks like the Minecraft client will only wait 30 seconds for the server to respond, so it's inherently a risky assumption that a waking server would startup within 30 seconds anyway. So failing fast on the connection is actually a more deterministic UX anyway.

If the behavior needs to different (or aimpler) for Docker Swarm mode then that's ok.

@SCBionicle

SCBionicle commented Jul 12, 2026

Copy link
Copy Markdown
Author

Progress

Core Routing

  1. Routes to server while server is healthy: Pass (automatically bypasses VIP loadbalancer)
  2. Holds connections while server is stopping: Pass
  3. Holds connections while server is starting: Pass
  4. Wakes server while server is sleeping: Pass
  5. Autoscales Down Server when Connections are idle at timeout: Pass
  6. Allows currently connected players to remain connected to server while server is in graceful shutdown state while preventing new connections: Pass

MOTD Messages

  1. Shows server MotD while server is healthy: Pass (will always show the server MotD if it is accepting routes to the server)
  2. Shows waking MotD while server is starting or pending: Pass
  3. Shows restart delay MotD while restart delay in progress: Pass
  4. Shows permanently failed MotD if Swarm is not scheduling or planning new service start: Pass
  5. Shows sleeping MotD while while server is sleeping (replicas=0): Pass

Fast Fail is not implemented. The feature is now done. I just need to update the docs and clean up, and this is ready.

@itzg

itzg commented Jul 12, 2026

Copy link
Copy Markdown
Owner

Thanks for the update @SCBionicle . Move out of draft when you're ready.

@SCBionicle
SCBionicle marked this pull request as ready for review July 12, 2026 20:05
@SCBionicle

Copy link
Copy Markdown
Author

@itzg, just following up. I have marked this PR as ready for review.

@itzg

itzg commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Thanks for letting me know @SCBionicle . Apparently my assumption was wrong that github would notify me when a PR goes to ready.

@itzg

itzg commented Jul 15, 2026

Copy link
Copy Markdown
Owner

As you've probably seen from the govulncheck, the docker client module seems to have a CVE they're never going to fix. As such I am pondering switch to the moby/client module in #568 . The non-Swarm code seems fairly simple to switch over with mainly argument changes for options, etc. Any thoughts on the impact for the Swarm operations?

Comment thread docs/docker-swarm-states.md

@itzg itzg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great! Just the refactoring of that lengthy parseServiceData and I'm good to merge.

Comment thread server/docker_swarm.go Outdated
for _, network := range service.Spec.TaskTemplate.Networks {
networkAliases[network.Target] = network.Aliases
}
func (w *dockerSwarmWatcherImpl) parseServiceData(ctx context.Context, service *swarm.Service, networkMap map[string]*network.Inspect) (data parsedDockerServiceData, ok bool) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function has now gotten pretty long. Can you refactor it out just slightly into a couple or so functions? I realized after a while that I was still reading the content of the same function 😄

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can do that once I get the chance.

I was wondering what was going on, didn't get an email, and decided to check up on this PR 🫤. Looks like Github isn't notifying either of us. 😅

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also don't mind refactoring it in a follow up. The Swarm logic seems get intense pretty quick, so it's not like the length of the function was unexpected.

I was wondering what was going on, didn't get an email, and decided to check up on this PR 🫤. Looks like Github isn't notifying either of us. 😅

Very strange.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I probably need to give it a stricter look over because there's really only few things happening, caching the state of the Swarm service to detect changes, polling the Swarm service state based on the diagram I put in the docs dirs, and updating routing rules and modifying the MotD that is returned from the server ping. It got really messy because there's a lot of things I learned about Swarm that should've been covered in their official documentation.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that would be good if you don't mind. Take you time.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I did a refactor. I renamed parseServiceData to evaluateSwarmService and broke a lot of the code into smaller pieces. I applied an optimization in the first step to use a switch statement instead of a long chain of if statements. And then elsewhere, I applied a bugfix so it only updates the service definition set replicas to 1 only if replicas are set to 0.

I've yet to test this, it's been a long week, and people are playing on my MC server now, so it'll be trickier to do a standardize test than to just deploy this patch to the wild on my cluster and hope it doesn't crash.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome. As always, no hurry on the last bit of testing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discovered a bug with the motd restart delay and permafailure and that's now fixed and tested.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to rereview, it should be ready again.

@SCBionicle

Copy link
Copy Markdown
Author

As you've probably seen from the govulncheck, the docker client module seems to have a CVE they're never going to fix. As such I am pondering switch to the moby/client module in #568 . The non-Swarm code seems fairly simple to switch over with mainly argument changes for options, etc. Any thoughts on the impact for the Swarm operations?

Something I noticed with the godocker package is that they had things in the API that the Swarm cluster doesn't actually support, like filer for events that were never implemented into Swarm. I'm not familiar with coding Go, so I found this really difficult to build. The best thing we could try is to test a migration and see it breaks anything.

Endpoint IP is based off of the service definition and task state polling should be off the Task API. In theory, you shouldn't need a full blown cluster to test this, I only had enough spare compute to test it on my homelab cluster. In theory, you should be able to do the initial testing in docker desktop and we can probably test the edge cases on my homelab which is a multi node environment.

Those are my thoughts, unfortunately, no straight answer.

@itzg

itzg commented Jul 17, 2026

Copy link
Copy Markdown
Owner

I'm not familiar with coding Go, so I found this really difficult to build.

It's not you, there's something weird or perhaps overly "generic" that they've done with their SDK. Even non-Swarm I'm finding the moby client still needs constants from the docker module. So it's like both directions are not fully portable and not fully platform-aware.

So, yep, probably just need to try it out when I'm ready to do that SDK switch.

@SCBionicle
SCBionicle marked this pull request as draft July 20, 2026 23:39
…from being stopped while server was in play (active players)
@SCBionicle
SCBionicle marked this pull request as ready for review July 21, 2026 02:15
@SCBionicle

Copy link
Copy Markdown
Author

Had an issue where the router would scale down the server while people are playing on it due to a logic oversight in the waker function. I fixed it and is now ready again for review.

@SCBionicle
SCBionicle requested a review from itzg July 21, 2026 05:35

@itzg itzg left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code is certainly complex, but that's the nature of the beast. As such, it looks great. I just have a curiosity question. Otherwise, ready for me to merge?

Comment thread server/docker_swarm.go
Comment on lines +512 to +513
(service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeVIP &&
service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeDNSRR) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There seem to be only two possible resolution modes

https://pkg.go.dev/github.com/docker/docker/api/types/swarm@v28.5.2+incompatible#ResolutionMode

Were there cases where a junk value would show up?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docker Swarm Auto Scale

2 participants