Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,61 @@ or `--tls-domains-source` — serves every hostname no other service claims, so
client CA applies to all of them.


### Scale to zero when idle

A service can stop its containers after a period without traffic and start them
again on the next request. On a host running several low-traffic apps this is
the difference between paying for all of them all the time and paying for the
ones someone is actually using — an idle Rails app holds 200–300 MB it is not
using.

Start the proxy with a container runtime socket, then deploy with `--sleep-after`:

kamal-proxy run --docker-socket /var/run/docker.sock
kamal-proxy deploy service1 --target web-1:3000 --host app.example.com --sleep-after 30m

The first request after the containers stop is held while they start and pass a
health check, then forwarded — body intact, including a chunked POST. Concurrent
requests coalesce into a single start. If the containers do not come up within
`--wake-timeout` (30s by default) the request fails with 503.

> **`--docker-socket` gives the proxy root-equivalent access to the host.** Anyone
> who can execute code in the proxy can control every container on that machine.
> It is off by default and should stay off unless a service uses `--sleep-after`.
> The lifecycle is isolated behind a small interface, so a restricted host-side
> start/stop service can replace direct socket access later.

**What does and does not count as traffic.** Health checks never wake a sleeping
service — an uptime monitor polling `/up` would otherwise pin it awake forever —
and neither do the proxy's own TLS probes. A sleeping service answers its own
health checks with `200`, and reports `503` once a wake has actually failed, so
monitoring reflects reality rather than the last good state.

Requests rejected by `--allow-ip`, `--basic-auth` or `--rate-limit`, and paths
answered by `--redirect`, never reach the containers and so never start them.
Neither does a response served from `--cache`: a cached service can keep serving
while its containers stay stopped.

An open WebSocket or event stream keeps a service awake for as long as it is
open, so a long-lived connection is never cut short by an idle timeout.

**Which container gets stopped.** By default the proxy uses the target's hostname,
which is the container id under Kamal and the container name under Compose. When
a target names something else — a Compose service alias, or an IP address — say
so explicitly:

kamal-proxy deploy service1 --target web:3000 --host app.example.com --sleep-after 30m --sleep-container myapp-web-1

The deploy checks the reference against the runtime and fails immediately if it
names nothing, rather than accepting the deploy and failing at the first idle
timeout an hour later. `kamal-proxy list` shows `sleeping` or `waking` in the
state column, and the state survives a proxy restart.

**Known limitation.** `kamal deploy` prunes stopped containers, and a sleeping
container is stopped. Kamal 2 releases before the per-role prune fix can remove
one, after which every wake fails until the service is redeployed.


### Minimum TLS version

The HTTPS listener negotiates TLS 1.2 and above by default. To refuse TLS 1.2 as
Expand Down
9 changes: 9 additions & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,15 @@ func (c *runCommand) run(cmd *cobra.Command, args []string) error {

router.SetCacheStore(cacheStore)

// Only when the operator asked for it: reaching this socket is
// root-equivalent on the host, so an unconfigured proxy holds no such handle
// at all. Without it a deploy carrying --sleep-after is refused outright
// rather than accepted and silently never acted on.
if globalConfig.DockerSocketPath != "" {
router.SetContainerLifecycle(server.NewDockerClient(globalConfig.DockerSocketPath))
slog.Info("Scale-to-zero enabled", "docker_socket", globalConfig.DockerSocketPath)
}

var dynamicDomains *server.DynamicDomainManager

if globalConfig.ACMEEmail != "" {
Expand Down
8 changes: 6 additions & 2 deletions internal/server/idle_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ type fakeLifecycle struct {
starts atomic.Int64
stops atomic.Int64

startFunc func(ctx context.Context, ref string) error
stopFunc func(ctx context.Context, ref string) error
startFunc func(ctx context.Context, ref string) error
stopFunc func(ctx context.Context, ref string) error
existsFunc func(ctx context.Context, ref string) error
}

func (f *fakeLifecycle) StartContainer(ctx context.Context, ref string) error {
Expand All @@ -39,6 +40,9 @@ func (f *fakeLifecycle) StopContainer(ctx context.Context, ref string) error {
}

func (f *fakeLifecycle) ContainerExists(ctx context.Context, ref string) error {
if f.existsFunc != nil {
return f.existsFunc(ctx, ref)
}
return nil
}

Expand Down
13 changes: 12 additions & 1 deletion internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ type Router struct {
dynamicDomainManager *DynamicDomainManager
certRegistry *CertificateRegistry
cacheStore CacheStore
lifecycle ContainerLifecycle
}

type ServiceDescription struct {
Expand Down Expand Up @@ -218,6 +219,8 @@ func (r *Router) RestoreLastSavedState() error {
return nil
})

r.restoreSleepingServices(services)

if r.recheckOnRestore {
for _, service := range services {
service.RecheckTargetHealth()
Expand Down Expand Up @@ -320,6 +323,12 @@ func (r *Router) DeployService(name string, targetURLs, readerURLs []string, opt
return err
}

// Before anything is installed: a reference the runtime does not know should
// fail on the operator's terminal, not at the first idle timeout.
if err := r.validateSleepConfiguration(options, targetURLs, targetOptions); err != nil {
return err
}

options.Normalize()
slog.Info("Deploying", "service", name, "targets", targetURLs, "hosts", options.Hosts, "paths", options.PathPrefixes, "tls", options.TLSEnabled)

Expand Down Expand Up @@ -476,7 +485,7 @@ func (r *Router) ListActiveServices() ServiceDescriptionMap {
Path: path,
Target: target,
TLS: service.options.TLSEnabled,
State: service.pauseController.GetState().String(),
State: describeServiceState(service),
Hosts: service.options.Hosts,
PathPrefixes: service.options.PathPrefixes,
Targets: service.active.WriteTargets().Names(),
Expand Down Expand Up @@ -572,6 +581,8 @@ func (r *Router) createOrUpdateService(name string, options ServiceOptions, targ
}

service.SetCacheStore(r.cacheStore)
service.statePersister = r.persistState
service.SetContainerLifecycle(r.lifecycle)
return service, nil
}

Expand Down
128 changes: 128 additions & 0 deletions internal/server/router_idle.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package server

import (
"context"
"errors"
"fmt"
"log/slog"
"time"
)

// containerPreflightTimeout bounds the deploy-time check. It runs on the RPC
// path, where a hung socket would otherwise hold the operator's terminal open.
const containerPreflightTimeout = 10 * time.Second

// SetContainerLifecycle installs the runtime that starts and stops containers for
// scale-to-zero, and hands it to the services already restored — they were
// decoded before it existed, which is exactly why UnmarshalJSON never builds a
// controller itself.
func (r *Router) SetContainerLifecycle(lifecycle ContainerLifecycle) {
r.withWriteLock(func() error {
r.lifecycle = lifecycle

for _, service := range r.services.All() {
service.SetContainerLifecycle(lifecycle)
}
return nil
})
}

// validateSleepConfiguration proves, in one round trip at deploy time, that a
// container runtime is reachable and that every reference names a container it
// knows.
//
// Upstream discovered all of that at the first idle timeout instead, marked the
// service asleep regardless, and then failed every request for containers that
// were running perfectly well.
func (r *Router) validateSleepConfiguration(options ServiceOptions, targetURLs []string, targetOptions TargetOptions) error {
if options.SleepAfter <= 0 {
return nil
}

if r.lifecycle == nil {
return ErrNoContainerLifecycle
}

refs := options.SleepContainers
if len(refs) == 0 {
targets, err := NewTargetList(targetURLs, nil, targetOptions)
if err != nil {
return err
}

for _, target := range targets {
ref, ok := target.ContainerRef()
if !ok {
return fmt.Errorf("%w: target %s is an address, not a container; name the container with --sleep-container",
ErrNotAContainerRef, target.Address())
}
refs = append(refs, ref)
}
}

ctx, cancel := context.WithTimeout(context.Background(), containerPreflightTimeout)
defer cancel()

for _, ref := range refs {
switch err := r.lifecycle.ContainerExists(ctx, ref); {
case err == nil:

case errors.Is(err, ErrContainerNotFound):
return fmt.Errorf("%w: no container named %q; if the target names a network alias rather than a container, set --sleep-container",
ErrNotAContainerRef, ref)

case errors.Is(err, ErrContainerInspectForbidden):
// A hardened socket proxy commonly allows start and stop while denying
// inspect. Refusing the deploy for that would lock out exactly the
// operators doing the right thing, so this is the one preflight failure
// that is a warning.
slog.Warn("Cannot verify container for --sleep-after; the socket denies inspect",
"container", ref, "error", err)

default:
return fmt.Errorf("cannot manage container %q for --sleep-after: %w", ref, err)
}
}

return nil
}

// restoreSleepingServices gives every restored service a way to persist, and puts
// the ones that were sleeping back the way sleeping left them.
func (r *Router) restoreSleepingServices(services []*Service) {
for _, service := range services {
service.statePersister = r.persistState

if r.lifecycle != nil {
service.SetContainerLifecycle(r.lifecycle)
}
}
}

// persistState writes the routing state after a sleep or a wake. Errors are
// logged rather than returned: the controller has already moved, and failing to
// record it costs a wrong state on the next boot, not a broken proxy now.
func (r *Router) persistState() {
if err := r.SaveState(); err != nil {
slog.Error("Failed to persist idle state", "error", err)
}
}

// describeServiceState reports the one state an operator most needs to see. A
// paused or stopped service says so -- that is a human decision, and it outranks
// anything traffic-driven -- and otherwise a sleeping or waking service says that
// rather than "running".
func describeServiceState(service *Service) string {
pauseState := service.pauseController.GetState()
if pauseState != PauseStateRunning {
return pauseState.String()
}

if service.idleController != nil {
if idleState := service.idleController.State(); idleState != IdleStateActive {
return idleState.String()
}
}

return pauseState.String()
}
Loading
Loading