feat(swarm): implement autoscaling (scale to zero/one) for Docker Swarm services#564
feat(swarm): implement autoscaling (scale to zero/one) for Docker Swarm services#564SCBionicle wants to merge 38 commits into
Conversation
|
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. |
|
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. |
Thanks for keeping the PR scope narrow. 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. |
…low status interception
|
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. |
|
@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)
2. Failed-Task / Crash-Loop Detection (Fail-Fast)
To further improve player UX: 3. Custom MOTD for Exhausted / Failed State
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. |
… TaskEventType events
…late for dynamic formatting
|
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.
|
@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). |
|
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. |
ProgressCore Routing
MOTD Messages
Fast Fail is not implemented. The feature is now done. I just need to update the docs and clean up, and this is ready. |
|
Thanks for the update @SCBionicle . Move out of draft when you're ready. |
|
@itzg, just following up. I have marked this PR as ready for review. |
|
Thanks for letting me know @SCBionicle . Apparently my assumption was wrong that github would notify me when a PR goes to ready. |
|
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? |
itzg
left a comment
There was a problem hiding this comment.
Looks great! Just the refactoring of that lengthy parseServiceData and I'm good to merge.
| 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) { |
There was a problem hiding this comment.
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 😄
There was a problem hiding this comment.
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. 😅
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yeah, that would be good if you don't mind. Take you time.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Awesome. As always, no hurry on the last bit of testing.
There was a problem hiding this comment.
Discovered a bug with the motd restart delay and permafailure and that's now fixed and tested.
There was a problem hiding this comment.
If you want to rereview, it should be ready again.
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. |
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. |
…from being stopped while server was in play (active players)
|
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. |
itzg
left a comment
There was a problem hiding this comment.
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?
| (service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeVIP && | ||
| service.Spec.EndpointSpec.Mode != swarmtypes.ResolutionModeDNSRR) { |
There was a problem hiding this comment.
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?
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:
""), which instantly serves the "Asleep" MOTD on status checks without waiting for network timeouts.TaskList) to wait for a task to transition torunning, extracts the task's concrete overlay IP, dials it to ensure it is healthy, and routes the player connection directly to it.Detailed Changes
1. Configuration & Downscaler Wiring
server/server.go: Enables the connectionDownScalerwhenInDockerSwarmis active.2. Swarm Watcher Updates (
server/docker_swarm.go)routableSwarmServicestruct to cleanly encapsulate Swarm metadata without coupling to the Kubernetes data models.parseServiceDataandlistServicesto acceptResolutionModeDNSRRas 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 are0(setting backend IP to"").makeWakerFunc):0to1when accessed.TaskListAPI to wait for the task state to enterrunning.NetworksAttachments.makeSleeperFunc): Scales down the replicated service replicas from1to0once all active connections have closed and the idle period (DownAfter) has passed.reconcileServices): Registered Swarm wakers, sleepers, custom MOTDs, and used the SwarmserviceIDas thescalingTargetto 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)
task.Status.State) and desired state (task.DesiredState).running(preventing routing to stopping tasks).ready.shutdown(with no other active tasks present).2. Resolving Scale-Up Race Conditions (Stale History)
replicas 0 -> 1), Swarm takes 1–2 seconds to create the new task record. During this window, queryingTaskListonly returned stale tasks from the previous run. Because the latest old task wasshutdown, the waker instantly failed.filterTime = time.Now()right before updating the service (and the watcher usesservice.Meta.UpdatedAt). We ignore any stale terminal tasks created beforefilterTime. The waker now cleanly waits for the new task to be scheduled.3. VIP Propagation Bypass (Direct Task Routing)
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 transientconnection refusedwarnings during the start-up transition.4. Documentation & MOTD Polishing
"Server failed to start."ifmc-router.auto-scale-failed-motdis 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