docs /Autoscaling

Autoscaling

Docker cannot scale on its own; people reach for an orchestrator for that. ply already has a controller per app — the run parent, which owns the instance count, the health gate, the published pool and the kernel's routing table — so autoscaling is a policy it evaluates, not a new process.

[scale]
min = 2
max = 8
signal = "cpu"          # cpu | memory | net | metric:<name>
target = "70%"          # per instance, averaged over the last 30 s
cooldown = "60s"        # at most one step per cooldown (default)

[resources]
mem = { min = "256M", max = "2G" }   # resized live between these
cpu = { min = "0.5", max = "4" }

#Horizontal: the instance count

Every five seconds the parent samples each instance and, when the window of the last 30 seconds says so, changes the count:

  • up, when the average is more than 10 % over target, straight to ceil(current × average / target), capped at max;
  • down, one instance at a time, only when the average is under 70 % of the target — a noisy signal cannot flap the pool;
  • at most one step per cooldown; instances younger than 30 s are warming up and do not vote.

A new instance takes traffic only once its published port accepts a connection; a retiring one leaves the pool first and gets a second to finish (see Deploys). --scale N and a stack member's scale = N set the starting count, clamped into [min, max].

Connections stick to their instance. Published ports balance new connections; a client holding a keep-alive connection stays where it landed. So a new instance takes load only as connections turn over — many clients, idle timeouts, an edge in front all do that; one benchmark tool holding sixteen connections forever does not. The policy knows: it never scales down while any instance is above target on its own, even when the average is low, so an idle newcomer is not removed while its neighbour is saturated.

#Signals

signal measured as target reads like
cpu % of the instance's CPU limit (of one core when unlimited), from its cgroup "70%"
memory % of resources.mem, which must be set "80%"
net bytes per second in and out, from the instance's veth "40MB/s"
metric:NAME the gauge or counter NAME from the app's Prometheus-text endpoint, GET /metrics on the first published port (metrics_path changes the path); labels are summed "100"

CPU, memory and net cost nothing and need nothing from the app. A custom metric is one HTTP request per instance per tick; any OpenTelemetry SDK can expose one through its Prometheus exporter.

Pick a metric that means something over seconds. The parent reads it once per tick and averages six ticks. A queue depth, a rate the app keeps itself, a p99 it tracks — those scale well. An instantaneous gauge of a millisecond-scale quantity (requests inside a 3 ms handler right now) is mostly noise at a 5 s sample and will sit on the threshold; measured live, such a gauge read 7 on one instance and 2 on each of two, against a target of 4.

#The operator's hand

ply scale APP N on an autoscaled app pins it at N and pauses the policy:

ply: scale pinned at 5 by operator (autoscale paused; `ply scale web auto` resumes)

ply scale APP auto hands the count back. A restart of the parent starts from --scale with the policy active.

#Vertical: the limits

A [resources] range starts at min and is resized live, per instance, with cgroup writes — no restart:

  • memory grows by half when an instance uses more than 85 % of its current limit, or the moment it is OOM-killed (its slot restarts with the larger limit); it shrinks by a quarter after a cooldown under 40 %.
  • cpu grows by half when the cgroup reports throttling near the quota; shrinks the same way when idle.

Ranges work with or without a [scale] section. A host where cgroups are not writable (rootless without delegation) says so once and keeps the horizontal side.

#Scale to zero

An app that nobody is talking to costs its resident memory for nothing. min = 0 adds the bottom rung of the ladder: after idle with no connections, the parent stops the last instance and keeps only the published port; the next connection starts an instance and is held until it accepts. No daemon watches the port — the parent already owned it.

[scale]
min = 0
max = 1
idle = "10m"            # no connections for this long → the last instance stops

[scale]                 # or on top of autoscaling: 0 ↔ 1 by idle, 1 ↔ 4 by cpu
min = 0
max = 4
idle = "10m"
signal = "cpu"
target = "70%"

min = 0 needs idle and a published port (a wake has to arrive somewhere). With max = 1 no signal is needed; with more, the signal decides between 1 and max as before, and the app sleeps only once the count is back to 1. An app always starts awake — the first start is where a broken image shows — and sleeps once it has been idle.

Idle means no new connection on any published port and none still open on any instance, so a long-lived websocket keeps an app awake. The parent counts connections itself where it relays them (rootless, macOS) and reads the DNAT rule's counter and the instances' socket tables where the kernel carries them (rootful).

Waking holds the connection: the parent starts one instance, waits for its port to accept, then relays. Later connections go straight to the instance again. The first request pays the app's start time; a client that has waited 60 s is dropped, as it would be today with no instance behind the port. A woken app gets a full idle before it can sleep again.

While asleep, ply ps shows the parent with no instances:

sleepapi   4242   —   —   12m   —   asleep, wakes on :18082 (idle 20s)

ply scale APP 0 puts an app that may sleep to sleep now (and clears a pin); one that may not is told so. ply deploy on a sleeping app swaps the image for the next wake and reports complete at once; ply restart has nothing to do. A dependent's --after APP treats a sleeping app as ready: its first connection is the wake-up call. The parent's own process is the whole cost at zero.

#Evidence

Every step is a line on the parent's stderr and an entry in the events journal (<apps>/events.log), with its reason:

ply: scale-up web 2 -> 4: cpu 84% > 70% over 30s
ply: scale-down web 4 -> 3: cpu 31% < 49% over 30s
ply: resize web.1 memory 256M -> 384M: 91% used
ply: sleep web: idle 10m: no connections on :8080 (wakes on the next connection)
ply: wake web: connection from 203.0.113.7:51844, ready in 850 ms

#Limits (v1)

Open connections as a signal for the 1..max rungs, several rules per app, an OTLP push endpoint, a configurable wake timeout, and anything across hosts are not here. Scale to zero needs a published port to wake on. Rootful Linux only for sampling; the macOS backend runs the policy loop without samples.