Skip to content

feat(service): hold requests for a sleeping service and wake it on demand - #65

Merged
mhenrixon merged 2 commits into
dashfrom
feature/scale-to-zero-wiring
Jul 29, 2026
Merged

feat(service): hold requests for a sleeping service and wake it on demand#65
mhenrixon merged 2 commits into
dashfrom
feature/scale-to-zero-wiring

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

The Service half of scale-to-zero (#19): the request gate, the options, the persisted state, and the CLI surface the gem is waiting on.

One chunk remains after this — the Router installing a ContainerLifecycle — so nothing sleeps yet.

The flag is --sleep-after, not --idle-timeout

The gem side needs this correction. kamal#68 assumed kamal-proxy would grow --idle-timeout. It cannot: run --idle-timeout already exists for HTTP keep-alive (config.go, DefaultIdleTimeout = 60s). Two flags sharing one name with two unrelated meanings is an operator trap no compiler catches.

Command Flag Default
deploy --sleep-after 0 (never)
deploy --wake-timeout 30s
deploy --sleep-container (repeatable) infer from target
run --docker-socket (env DOCKER_SOCKET) empty (disabled)

--sleep-after also puts the flag, the persisted state name (sleeping) and the log lines in one vocabulary.

Gate placement is the load-bearing decision

The gate sits in serviceRequestWithTarget, after handlePausedAndStoppedRequests and before target selection.

Everything above it therefore runs first — basic auth, IP allow list, rate limit, redirects, request deadline, the ACME HTTP handler. So a blocked, throttled, unauthenticated or redirected request can never spend a container start. Upstream basecamp#228 puts this a layer up in ServeHTTP, where an anonymous client, a rate-limited flood, or an ACME HTTP-01 challenge each start containers — a denial-of-wallet vector this fork does not inherit.

Being above target selection is what keeps the request byte-for-byte intact: net/http does not read a body until the handler asks, so a chunked POST parked for three seconds is handed on unread. No buffering, no TeeReader.

Health checks: answered, never held, never waking

An uptime monitor polling /up would otherwise pin a service awake forever. Holding the probe instead would make a load balancer in front evict a service that is sleeping exactly as intended. So a sleeping service answers 200 itself.

The other half matters as much: once a wake has actually failed, the health check reports unhealthy. Otherwise a service that can no longer start reports green to its monitoring forever while 503ing every real request.

The 503 leaks nothing

The wake error carries container references and up to 4 KB of daemon output, and that response is reachable by anyone who can open a connection. It is logged, not rendered — with a test asserting neither the container name nor docker.sock appears in the body.

Test plan

  • ValidateSleep — table: negatives, --sleep-container without --sleep-after, and the TLS on-demand combination (a sleeping backend cannot answer an on-demand check, and waking one would let any SNI on the internet start a container)
  • NormalizeDefaultsTheWakeTimeout
  • SleepingServiceWakesOnARequest — one start, state returns to active
  • HealthCheckNeitherWakesNorIsHeld + HealthCheckReportsUnhealthyAfterAFailedWake
  • WakeFailureDoesNotLeakDaemonDetailToTheClient
  • InternalRequestsDoNotWake — the proxy's own TLS probe
  • ContainerRefsPreferTheExplicitOverride, TestTarget_ContainerRef (table; IP literals rejected)
  • IdleStateRoundTrip + StateFileWithoutIdleStateRestoresAwake
  • gofmt, go vet clean; go test -race1301 pass

Deviations & judgment calls

  • Refs are recomputed in UpdateLoadBalancer, not only initialize. initialize runs before a load balancer exists on a first deploy, so refs were empty and a "wake" reported success having started nothing. I caught it because a test asserted the helper had actually derived some refs — worth noting, since the symptom is a silent success, not a failure. A redeploy pointing at new containers lands here too, which is exactly when the controller needs telling.
  • Normalize only defaults a zero wake-timeout. It first defaulted anything <= 0, which swallowed a negative and made its own validation unreachable. A negative is a typo; rejecting it beats silently turning it into 30s.
  • UnmarshalJSON deliberately builds no controller. The lifecycle is nil at that point, so one built there could reach StopContainer on a nil interface. SetContainerLifecycle creates it after the whole state file is decoded. Add opt-in scale-to-zero for idle services basecamp/kamal-proxy#228 built it in unmarshal and had exactly that exposure.
  • Test helpers must pass --sleep-container explicitly. httptest backends listen on 127.0.0.1, which ContainerRef correctly refuses as an address rather than a container. That is the override's real purpose, so the tests exercise the real path rather than a fixture.
  • MarshalJSON still assumes s.active is non-nil. I hit the nil deref writing a test that built a Service with no load balancer, and fixed the test rather than adding a defensive check — the router always installs one before state is saved, and the coding rules say not to nil-check states that cannot happen. Flagging it because I did consider the guard.
  • No label-selector support yet. Derived refs plus --sleep-container only. The selector — which also closes the kamal rollback hole kamal#68 names — is the immediate follow-up, additive: one DockerClient method and one flag.

Refs #19

…mand

The Service half of scale-to-zero (#19): the request gate, the options, the
persisted state, and the CLI surface. The Router still does not install a
container lifecycle, so nothing sleeps yet -- that is the last chunk.

The flag is --sleep-after, NOT --idle-timeout. `run --idle-timeout` already
exists for HTTP keep-alive (config.go, DefaultIdleTimeout = 60s), and two flags
sharing one name with two unrelated meanings is an operator trap no compiler
catches. --sleep-after also puts the flag, the persisted state name, and the log
lines in one vocabulary. The gem needs to know this: kamal#68 assumed
--idle-timeout would be the name.

Gate placement is the load-bearing decision. It sits in
serviceRequestWithTarget, after handlePausedAndStoppedRequests and before target
selection. Every gate above it -- basic auth, IP allow list, rate limit,
redirects, request deadline, the ACME HTTP handler -- therefore runs first, so a
blocked, throttled, unauthenticated or redirected request can never spend a
container start. Upstream basecamp#228 put this a layer up in ServeHTTP, where an
anonymous client, a rate-limited flood, or an ACME HTTP-01 challenge each start
containers. It is also above target selection, so the request handed on is
byte-for-byte the one that arrived, body still unread.

Health checks are answered, never held and never allowed to wake: an uptime
monitor polling /up would pin a service awake forever, and holding the probe
would make a load balancer in front evict a service that is sleeping correctly.
They report unhealthy once a wake has actually failed, so monitoring stops being
told everything is fine while every real request 503s.

The 503 body carries none of the wake error. That error holds container
references and up to four kilobytes of daemon output, and the response is
reachable by anyone who can open a connection, so it is logged instead.

Refs are recomputed in UpdateLoadBalancer, not just initialize. initialize runs
before a load balancer exists on a first deploy, so refs were empty and a "wake"
succeeded having started nothing -- caught by a test asserting the helper
actually derived some. A redeploy pointing at new containers lands here too,
which is exactly when the controller needs telling.

Normalize only defaults a zero wake-timeout. Defaulting anything <= 0 swallowed
a negative, making its validation unreachable; a negative is a typo and is now
rejected rather than silently turned into 30s.

UnmarshalJSON records the restored idle state but never builds a controller: the
lifecycle is nil at that point, so one built there could reach StopContainer on
a nil interface. SetContainerLifecycle creates it after the whole state file is
decoded. A state file written before this feature has no idle_state key, parses
to active, and re-marshals byte-identically since every new key is omitempty.

Refs #19
@mhenrixon mhenrixon self-assigned this Jul 29, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 29, 2026
Three conflicts in internal/server/service.go, all unions of independent
additions from the response cache (#63) and this branch:

- Normalize: so.Cache.Normalize() alongside the wake-timeout default
- Validate: Cache.Validate() before validateSleep, both in the chain
- Service struct: cacheStore/cacheHandler alongside the idle controller fields

Known interaction, deliberately NOT changed here: the idle gate sits above
s.cacheHandler, so a cache hit wakes a sleeping service. That is conservative
rather than wrong -- the request succeeds either way -- but it means a service
that could have stayed asleep serving cached responses does not. Moving the gate
into sendRequestToTarget (below the cache, still above target selection and
still with the body unread) would fix it, and that is a behavioral change that
belongs in its own reviewable commit rather than buried in a merge.
@mhenrixon
mhenrixon merged commit 9e4bdd4 into dash Jul 29, 2026
5 checks passed
mhenrixon added a commit that referenced this pull request Jul 29, 2026
#66)

* feat(router): install the container runtime and complete scale-to-zero

Closes #19. The router now installs a ContainerLifecycle, so everything the
previous chunks built actually runs: a service with --sleep-after stops its
containers when idle and starts them on the next request.

Deploy refuses --sleep-after when no runtime is configured, and proves every
container reference resolves before installing anything. A reference that names
nothing now fails on the operator's terminal instead of at the first idle
timeout an hour later, with an error that says to use --sleep-container. The one
exception is a socket that answers but denies inspect, which is what a hardened
socket proxy does -- that warns and proceeds, so the operators doing the right
thing are not locked out.

Two defects in already-merged code are fixed here rather than left:

statePersister was added in #65 with a call site but nothing ever set it, so
sleep and wake edges were never written and a restart forgot everything. The
router now hands every service -- deployed or restored -- a persister.

Configure treats a changed container set as a redeploy and forces the state back
to active. A restored service builds a brand-new controller whose refs always
look changed, so Configure silently undid RestoreSleeping and a sleeping service
came back awake with a pool pointing at stopped containers. Configure now runs
before the restore.

The idle gate also moved below the response cache, into sendRequestToTarget. A
cache hit never reaches the target, so it must not spend a container start --
serving stored responses while the containers stay stopped is the whole reason
to run both features on one service. The gate is still below every auth,
allow-list, rate-limit and redirect check, and still above target selection, so
a held request is handed on with its body unread.

Refs #19

* chore: drop the deviation log, its contents are in the PR body
@mhenrixon
mhenrixon deleted the feature/scale-to-zero-wiring branch July 29, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant