Add opt-in scale-to-zero for idle services - #228
Conversation
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “scale-to-zero” capability to kamal-proxy services by introducing an idle state machine that can stop write containers after inactivity and transparently wake them (including waiting for health readiness) on the next non-health-check request. This integrates Docker lifecycle management behind a small interface, persists sleep state, and exposes configuration via deploy/run flags plus documentation.
Changes:
- Introduces
IdleControllerand a Unix-socketDockerClientto stop/start containers and coordinate wake coalescing + readiness waiting. - Wires idle behavior into
Service/Router(state persistence, listing state, health-check behavior while sleeping, wake health-check restart). - Adds CLI/config/docs support for
--idle-timeout,--idle-wake-timeout, and--docker-socket.
Tip
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.
Reviewed changes
Copilot reviewed 18 out of 18 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents the opt-in scale-to-zero behavior, Docker socket implications, and version negotiation. |
| internal/server/testing.go | Updates test server construction for the new NewRouter signature. |
| internal/server/target.go | Adds container-name access and health-check restart hooks used by wake behavior. |
| internal/server/service.go | Integrates IdleController into request flow, state persistence, and wake readiness waiting. |
| internal/server/service_test.go | Updates service construction and adds coverage for idle lifecycle initialization. |
| internal/server/router.go | Adds Docker client wiring, restores idle state correctly, and serializes state snapshots. |
| internal/server/router_test.go | Updates router construction for the new NewRouter signature. |
| internal/server/load_balancer.go | Adds PrepareForWake to restart health checks and re-arm healthy-wait state. |
| internal/server/load_balancer_test.go | Adds coverage for wake health-check restart behavior. |
| internal/server/idle_controller.go | New idle state machine with sleep/wake transitions, coalescing, and persistence hooks. |
| internal/server/idle_controller_test.go | New unit tests for idle sleep/wake/coalescing/restore behavior. |
| internal/server/health_check.go | Adds explicit Start() and refactors construction to support restart semantics. |
| internal/server/docker_client.go | New minimal Docker HTTP client over Unix socket with API version negotiation. |
| internal/server/docker_client_test.go | New tests for negotiation, fallback, concurrency, and error-body bounding. |
| internal/server/config.go | Adds DefaultDockerSocketPath and a config field for socket path. |
| internal/cmd/util.go | Adds getEnvString for string-valued flag defaults from env. |
| internal/cmd/run.go | Adds --docker-socket and passes it into NewRouter. |
| internal/cmd/deploy.go | Adds --idle-timeout and --idle-wake-timeout service options. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
internal/server/service.go:252
- Dispose assumes s.active is always non-nil and will panic if a Service is constructed before an active load balancer is installed (e.g., tests already cover this scenario). Guarding the call avoids a nil dereference during teardown/error paths.
s.active.Dispose()
if s.rollout != nil {
s.rollout.Dispose()
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
internal/server/router.go:394
- When
os.Createfails, the error is returned without logging. Many call sites defersaveStateSnapshot()and ignore its return value, so state persistence failures can become silent. Logging the create error here avoids losing that signal.
f, err := os.Create(r.statePath)
if err != nil {
return err
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
internal/server/idle_controller.go:139
- In BeginRequest, the timer is stopped but its channel is not drained. If the timer fires concurrently with the wake completion / ctx cancellation, leaving the value in timer.C can cause unnecessary retention and subtle races. Use the standard Stop+drain pattern in both the
<-doneand<-ctx.Done()branches.
timer := time.NewTimer(timeout)
select {
case <-done:
timer.Stop()
if state == IdleStateStopping {
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
internal/server/idle_controller.go:139
time.NewTimeris stopped without draining its channel. If the timer fires concurrently with selecting<-done, this can leave a value intimer.C, which is a common source of subtle leaks and flaky behavior in loops. Draintimer.CwhenStop()reports it already fired.
timer := time.NewTimer(timeout)
select {
case <-done:
timer.Stop()
if state == IdleStateStopping {
internal/server/idle_controller.go:150
- In the
ctx.Done()branch, the timer is stopped but not drained. As with the<-donebranch, a concurrent timer fire can leave a value buffered ontimer.C. Drain whenStop()returns false to avoid accumulating unread timer events.
return ErrIdleWakeTimeout
case <-ctx.Done():
timer.Stop()
return ctx.Err()
Summary
Add opt-in scale-to-zero for low-traffic services. When configured, kamal-proxy stops write containers after an idle timeout, starts them on the next application request, waits for health readiness, and then forwards the held request.
This continues the work started by @martijnenco in #197. Thank you for providing the implementation that made this possible.
Behavior
--idle-timeoutContainer operations are isolated behind the small
ContainerLifecycleinterface. The initial implementation connects directly to the Docker socket because it is the smallest opt-in approach. This grants powerful host-level Docker access and is documented explicitly. A restricted host-side start/stop service can be added later without changing the idle controller.The design and Docker socket tradeoff were discussed in #222. The corresponding Kamal PR, basecamp/kamal#1916, configures idle settings and mounts the Docker socket only when the feature is enabled.
Validation
go test ./...go test -race ./internal/server ./internal/cmdgo vet ./...git diff --check origin/main...HEADThe implementation was also tested with real Docker and a Rails application on a 2 GB Ubuntu VPS:
Detailed results and reproduction instructions: https://docs.komagata.org/6456