Skip to content

R5: Reduce scale-to-zero cold-wake latency (tiered pause/stop) #59

Description

@mhenrixon

Follow-up to #19. Waking a slept service costs a full container start plus an application boot — basecamp#228 measured ~3.15s for a real Rails app on a 2 GB VPS. (Our implementation has not been measured; that figure is theirs.) The first visitor after an idle period pays all of it.

Why docker pause is not the answer on its own

The obvious idea is docker pause/unpause instead of stop/start: unpause is effectively instant, since it only thaws the cgroup freezer.

But pause does not reclaim any memory. The processes stay resident — docker stats still shows the full RSS. Reclaiming that memory is the entire point of #19: the motivating measurement is a Rails app holding 200–315 MiB while idle. A paused service frees nothing, so on its own this trades away the feature's only benefit for a faster wake.

What could actually work: tiered idling

Two thresholds rather than one:

After Action Wake cost Memory reclaimed
short idle (say 5 min) docker pause ~instant none
long idle (say 1 h) docker stop full boot all of it

That gives fast wakes across a lunch break and full reclaim overnight, which matches how review apps and internal tools are actually used. It fits the existing design: IdleController already has the state machine and ContainerLifecycle is the seam, so this is a third state plus a second timer, not a redesign.

Open questions:

  • Does a paused container still pass health checks? (It will not answer, so the pool handling for paused differs from sleeping — probably suspend on pause too.)
  • Do paused containers survive kamal deploy prune? Prune filters status=exited; paused is paused, so it likely does not hit Prune removes containers kamal-proxy has put to sleep (scale-to-zero) kamal#62. Worth confirming — if true, the pause tier sidesteps that problem entirely.
  • Is two thresholds too many knobs for the value? Possibly ship --sleep-after alone first and add the pause tier only if cold starts prove annoying in practice.

Other angles worth considering

  • Pre-warm on a cheaper signal. Wake on a TLS handshake or a DNS lookup rather than the HTTP request, buying a second or two before the request arrives.
  • Serve a holding page instead of blocking the connection, so the user sees something within 100ms. Changes the UX rather than the latency, and interacts with the --error-pages work already on dash.
  • Measure ours first. Before optimising, get a real number for this implementation on a real app. The plan in docs/plans/2026-07-29-scale-to-zero.md explicitly flags that we have no measurement of our own.

Blocked on #19 landing in full.

Refs #19, #58, zoolutions/kamal#62

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions