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
3 changes: 3 additions & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ func newDeployCommand() *deployCommand {
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSDomainsSource, "tls-domains-source", "", "Fetch additional TLS domains from this endpoint (path resolved against the service, or absolute URL)")
deployCommand.cmd.Flags().DurationVar(&deployCommand.args.ServiceOptions.TLSDomainsInterval, "tls-domains-interval", 0, "Interval between domain source polls (default 5m)")
deployCommand.cmd.Flags().IntVar(&deployCommand.args.ServiceOptions.TLSDomainsBatchSize, "tls-domains-batch-size", 0, "Dynamic domains to batch per certificate (default 1, max 25)")
deployCommand.cmd.Flags().DurationVar(&deployCommand.args.ServiceOptions.SleepAfter, "sleep-after", 0, "Stop this service's target containers after this long with no traffic, and start them again on the next request (default 0, never). Requires the proxy to run with --docker-socket. Health checks and the proxy's own TLS probes are not traffic and never wake a sleeping service")
deployCommand.cmd.Flags().DurationVar(&deployCommand.args.ServiceOptions.WakeTimeout, "wake-timeout", server.DefaultWakeTimeout, "Maximum time a request waits for a sleeping service's containers to start and pass a health check before failing with 503")
deployCommand.cmd.Flags().StringArrayVar(&deployCommand.args.ServiceOptions.SleepContainers, "sleep-container", nil, "Container to stop and start for --sleep-after, replacing what the proxy infers from the target address. Needed when a target names a network alias rather than a container (may be specified multiple times)")
deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.CanonicalHost, "canonical-host", "", "Redirect all requests to this host (e.g., force root or www)")

// StringArray rather than StringSlice: a pattern or a replacement may
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func newRunCommand() *runCommand {
runCommand.cmd.Flags().DurationVar(&globalConfig.ShutdownTimeout, "shutdown-timeout", getEnvDuration("SHUTDOWN_TIMEOUT", server.DefaultShutdownTimeout), "Maximum time to wait for in-flight requests to drain on shutdown")

// ACME/TLS configuration
runCommand.cmd.Flags().StringVar(&globalConfig.DockerSocketPath, "docker-socket", getEnvString("DOCKER_SOCKET", ""), "Path to the container runtime socket, enabling --sleep-after on deploy (default empty, disabled). Reaching this socket is root-equivalent on the host, so it is opt-in")
runCommand.cmd.Flags().StringVar(&globalConfig.MinTLS, "min-tls", getEnvString("MIN_TLS", server.DefaultMinTLSVersion), "Lowest TLS version the HTTPS listener will negotiate: 1.2 or 1.3 (TLS 1.0 and 1.1 cannot be enabled; HTTP/3 is always 1.3)")
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEEmail, "acme-email", getEnvString("ACME_EMAIL", ""), "Email address for ACME account registration (required for automatic TLS)")
runCommand.cmd.Flags().StringVar(&globalConfig.ACMEDirectory, "acme-directory", getEnvString("ACME_DIRECTORY", server.LetsEncryptProduction), "ACME directory URL")
Expand Down
9 changes: 9 additions & 0 deletions internal/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ type Config struct {
MetricsPort int
HTTP3Enabled bool

// DockerSocketPath enables scale-to-zero by giving the proxy a container
// runtime to stop and start with. Empty (the default) leaves the feature
// unavailable, and a deploy asking for --sleep-after is refused rather than
// accepted and silently never acted on.
//
// Reaching this socket is root-equivalent on the host, which is why it is
// opt-in and off by default.
DockerSocketPath string

// MinTLS is the lowest TLS version the HTTPS listener will negotiate,
// written as "1.2" or "1.3". Empty means 1.2, which is also Go's own
// minimum, so this setting can only ever narrow what the listener accepts -
Expand Down
50 changes: 50 additions & 0 deletions internal/server/idle_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,56 @@ func (c *IdleController) EndRequest() {
c.signal()
}

// Configure applies a redeploy's settings in place. The controller is created
// once per Service lifetime and reconfigured thereafter, so the request path can
// read s.idleController without a lock.
//
// Changing the container set means the same thing a redeploy does -- the service
// is awake and pointed somewhere new -- so this takes the same generation bump
// Reset does, invalidating any lifecycle goroutine started against the old set.
func (c *IdleController) Configure(sleepAfter, wakeTimeout time.Duration, refs []string) {
if wakeTimeout <= 0 {
wakeTimeout = DefaultWakeTimeout
}

c.lock.Lock()
changed := !slices.Equal(c.refs, refs)

c.sleepAfter = sleepAfter
c.wakeTimeout = wakeTimeout
c.refs = slices.Clone(refs)

var cancel context.CancelFunc
if changed {
c.lastRequest = time.Now()
c.lastErr, c.failures, c.retryAfter = nil, 0, time.Time{}

cancel = c.cancel
c.cancel = nil

if c.state == IdleStateActive {
c.generation++
} else {
c.setStateLocked(IdleStateActive)
}
}
c.lock.Unlock()

if cancel != nil {
cancel()
}
c.signal()
}

// LastWakeError reports why the most recent wake failed, so a health check can
// stop claiming a service is fine once it can no longer start. Nil once a wake
// has succeeded.
func (c *IdleController) LastWakeError() error {
c.lock.Lock()
defer c.lock.Unlock()
return c.lastErr
}

func (c *IdleController) WakeTimeout() time.Duration {
c.lock.Lock()
defer c.lock.Unlock()
Expand Down
72 changes: 70 additions & 2 deletions internal/server/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ type ServiceOptions struct {
Redirects []PathRule `json:"redirects,omitempty"`
Rewrites []PathRule `json:"rewrites,omitempty"`

// SleepAfter stops this service's containers after this long with no traffic,
// starting them again on the next request. Zero (the default) never sleeps.
SleepAfter time.Duration `json:"sleep_after,omitempty"`
// WakeTimeout bounds how long a request is held while those containers start
// and pass a health check. Zero means DefaultWakeTimeout.
WakeTimeout time.Duration `json:"wake_timeout,omitempty"`
// SleepContainers names the containers to stop and start, replacing what the
// proxy infers from the target addresses. Needed when a target names a network
// alias rather than a container.
SleepContainers []string `json:"sleep_containers,omitempty"`

// Compression encodes responses on their way back to the client. A zero
// value leaves them alone, which is what every state file written before
// this option existed restores to.
Expand All @@ -194,6 +205,14 @@ func (so *ServiceOptions) Normalize() {
so.PathPrefixes = NormalizePathPrefixes(so.PathPrefixes)
so.Compression.Normalize()
so.Cache.Normalize()

// The cobra default makes --help honest; this is what gives restored state
// files and direct RPC callers the same value. Only zero defaults: a negative
// is a typo, and silently turning it into 30s would hide it -- validateSleep
// rejects it instead.
if so.SleepAfter > 0 && so.WakeTimeout == 0 {
so.WakeTimeout = DefaultWakeTimeout
}
}

func (so ServiceOptions) Validate() error {
Expand Down Expand Up @@ -275,6 +294,10 @@ func (so ServiceOptions) Validate() error {
return err
}

if err := so.validateSleep(); err != nil {
return err
}

return so.validateDynamicDomains()
}

Expand Down Expand Up @@ -334,6 +357,11 @@ type Service struct {
// entry into it, sitting between the checks above and the load balancer.
cacheStore CacheStore
cacheHandler http.Handler

lifecycle ContainerLifecycle
idleController *IdleController
restoredIdleState IdleState
statePersister func()
}

func NewService(name string, options ServiceOptions, targetOptions TargetOptions, sanCertManager *SANCertManager) (*Service, error) {
Expand Down Expand Up @@ -369,7 +397,13 @@ func (s *Service) SetSANCertManager(manager *SANCertManager) {
}

func (s *Service) Dispose() {
s.active.Dispose()
if s.idleController != nil {
s.idleController.Close()
}

if s.active != nil {
s.active.Dispose()
}
if s.rollout != nil {
s.rollout.Dispose()
}
Expand Down Expand Up @@ -397,6 +431,12 @@ func (s *Service) UpdateLoadBalancer(lb *LoadBalancer, slot TargetSlot) *LoadBal
s.active = lb
}

// Refs are derived from the targets, so they are only knowable once a load
// balancer is installed -- initialize runs before that on a first deploy. A
// redeploy pointing at new containers reaches here too, which is exactly when
// the controller needs to be told.
s.configureIdleController(s.options)

return replaced
}

Expand Down Expand Up @@ -434,7 +474,12 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}

type marshalledService struct {
Name string `json:"name"`
Name string `json:"name"`
// IdleState is written as a name rather than an enum's number: the state file
// outlives proxy versions and operators read it. Absent -- every state file
// written before scale-to-zero existed -- parses to active, which is also what
// SleepAfter == 0 produces, so the feature restores off.
IdleState string `json:"idle_state,omitempty"`
Options ServiceOptions `json:"options"`
TargetOptions TargetOptions `json:"target_options"`
ActiveTargets []string `json:"active_targets"`
Expand Down Expand Up @@ -469,8 +514,14 @@ func (s *Service) MarshalJSON() ([]byte, error) {
rolloutReaders = s.rollout.ReadTargets().Specs()
}

idleState := IdleStateActive
if s.idleController != nil {
idleState = s.idleController.State()
}

return json.Marshal(marshalledService{
Name: s.name,
IdleState: idleState.String(),
ActiveTargets: s.active.WriteTargets().Specs(),
ActiveReaders: s.active.ReadTargets().Specs(),
RolloutTargets: rolloutTargets,
Expand Down Expand Up @@ -528,6 +579,11 @@ func (s *Service) UnmarshalJSON(data []byte) error {
s.rollout.MarkAllHealthy()
}

// Recorded, not acted on: the lifecycle is still nil here, so a controller
// built now could reach StopContainer on a nil interface. SetContainerLifecycle
// is what creates it, after the router has decoded the whole state file.
s.restoredIdleState = ParseIdleState(ms.IdleState)

return s.initialize(ms.Options, ms.TargetOptions)
}

Expand Down Expand Up @@ -601,6 +657,7 @@ func (s *Service) initialize(options ServiceOptions, targetOptions TargetOptions
s.cacheHandler = s.createCacheHandler(options)
s.options = options
s.targetOptions = targetOptions
s.configureIdleController(options)
s.certManager = certManager
s.clientCAs = clientCAs
s.middleware = middleware
Expand Down Expand Up @@ -807,6 +864,17 @@ func (s *Service) serviceRequestWithTarget(w http.ResponseWriter, r *http.Reques
return
}

// After every gate above, so that no blocked, throttled, unauthenticated or
// redirected request can spend a container start. Before target selection, so
// the request body is still unread when the hold begins.
handled, endIdleRequest := s.handleIdleRequest(w, r)
if endIdleRequest != nil {
defer endIdleRequest()
}
if handled {
return
}

// Last, so that everything above -- the health check exemptions, the
// redirects, the allow list -- still sees the path the client asked for.
r = s.rewriteRequest(r)
Expand Down
Loading
Loading