wake-sleep
Turn an always-on AWS resource into a pay-only-when-used one: it starts on traffic (or on a schedule), serves, and stops itself after a proven-idle window. Zero new always-on spend by design — it reuses infra the project already has (an ALB, or a cheap cron), and the two Lambdas sit in the free tier at low traffic.
This is a kit of parts, not one hardcoded stack. Read the target project, pick adapters, assemble.
The contract (what's generic vs. pluggable)
The orchestration is fixed and correct-by-construction; only the cloud-specific bits swap:
| Seam | Options (pick one per axis) |
|---|---|
| compute driver | ec2_instance · ec2_asg · ecs_fargate · ecs_ec2 · rds · composite |
| wake trigger | alb (reuse existing ALB) · apigw (no ALB) · schedule (office hours) · manual (CLI only) |
| idle signal | cw_requestcount (ALB reqs) · cw_metric (any CloudWatch metric) · http (a probe URL) · none (schedule-only sleep) |
| busy probe | ssm (shell cmd on the box) · http (200 = busy) · none |
Everything else is universal and ships unchanged every time (see design-decisions.md for the why):
- Ground-truth health check — never trust load-balancer target health (it
lags
unhealthy_threshold × intervalafter a stop); the wake Lambda hits a configurableHEALTH_URLthat is routed directly to the target, bypassing the wake path, to avoid recursion. - Recursion-safe routing — when the trigger is an ALB, listener rules are
ordered so
/→ wake Lambda → health check → EC2 can't loop. - Reload cap — a forgotten tab during a slow boot can't pin the box awake.
- Fail-safe-busy — any error in the busy probe is treated as "busy"; we never stop on uncertain signal.
- Min-uptime guard + idempotent start/stop + DRY_RUN default true.
Workflow
1. Interview the target (infer from repo, confirm with user)
Use AskUserQuestion to lock these. Many have a sensible default you can
propose first:
- Compute target — what gets slept? (single EC2 / ASG / ECS service / RDS / a group). Find how it's currently defined (Terraform? Console? ECS cluster name?).
- Existing public ingress? — is there an ALB the trigger can reuse (cheapest, zero new always-on spend)? If not, offer API Gateway or schedule-only.
- Idle signal — default
cw_requestcountif there's an ALB; elsecw_metric(e.g. CPU) orhttp. - In-flight work — does the target run async jobs? If yes, pick a busy probe and pick the exact command from job-probe-recipes.md (Celery / RQ / BullMQ / Sidekiq / k8s Job / DB-row / none).
- Schedule window? — office-hours wake (don't start at 3am even if poked) and/or schedule-only mode. Optional.
- Notify? — SNS/Slack on wake/sleep. Optional.
2. Generate the lambda bundle (always)
Copy all of assets/lambda/ (wake.py, autosleep.py, compute_drivers.py,
signals.py, state.py) into the project verbatim — they are generic and
env-driven. Only the env vars differ per deployment (set in Terraform).
3. Generate Terraform (compose fragments)
Always: assets/terraform/variables.tf + wake-sleep-core.tf (IAM roles
scoped to the chosen driver, both Lambdas sharing one zip, autosleep
EventBridge rate(5 minutes)).
Then add one wake trigger:
- ALB present →
wake-alb.tf(listener rules with the recursion-safe priority ordering +/_healthbypass). This is the zero-new-spend path. - No ALB, want on-demand wake →
wake-apigw.tf - Schedule only →
wake-schedule.tf
Optional: notify-sns.tf.
Fill the resource references for the project's integration mode:
- Same Terraform state (ALB/instance managed here): replace
var.*IDs with direct resource refs (aws_instance.app.id,aws_lb.public.arn, etc.). - External (managed elsewhere): pass IDs via variables or
datasources. Both are shown as comments inwake-sleep-core.tf.
4. Adapt the busy probe
Drop the project-specific busy-probe command into BUSY_PROBE_CMD (Terraform
env var). See references/job-probe-recipes.md. If none, autosleep skips the
probe.
5. Wire ground-truth health
Ensure HEALTH_URL resolves to a 200 from the target, served through the wake
trigger but routed directly to the target (not back into the wake Lambda).
- ALB trigger: add a high-priority listener rule forwarding
HEALTH_PATH(default/_health) straight to the target group. The fragment does this. - Else: point
HEALTH_URLat the target's own health endpoint.
6. Ship DRY_RUN, validate, flip
Generate autosleep with AUTOSLEEP_DRY_RUN = true. Tell the user:
terraform apply- Watch CloudWatch Logs for
autosleep— confirm it logs "keep alive" / "would stop" with the right reasoning for a few cycles. - Flip
AUTOSLEEP_DRY_RUN = false,applyagain.
7. Hand off
- Wake URL to bookmark (ALB:
http://<alb>/<wake_path>; APIGW: the endpoint; schedule: "starts at HH:MM"). - sleepctl — copy
assets/sleepctl.sh, setDRIVER+ resource IDs + region,chmod +x.status | up | down [--force]. - Cost note: compute bills per-second while running, ~$0 while stopped; only storage + ingress baseline keep accruing.
Minimum-viable fast path
The 80% case — single EC2 behind an existing ALB, with an async worker — is
just the MAAP reference shape: driver ec2_instance, trigger alb, idle
cw_requestcount, busy ssm. Reach for that first; only deviate when the
interview says so.
Reference index
- porting-guide.md — decision tree: which driver/signal/trigger per stack, with copy-paste configs.
- job-probe-recipes.md — busy-probe one-liners per task system.
- design-decisions.md — the why behind every default (read before deviating).
- generated-layout.md — what the generated layout looks like and how to wire it.
Iron rules (don't regress)
- Never weaken fail-safety: probe errors ⇒ busy; never stop on uncertain signal.
- Never ship autosleep without DRY_RUN first.
- Never trust LB target health as the wake-ready signal.
- Keep data traffic off the wake Lambda — only the wake path goes through it; app traffic flows target-direct. (ALB trigger: the default listener action and all non-wake rules forward to the target, not the Lambda.)
- IAM least-privilege per driver — don't grant
ec2:*when the driver only needs ECS, etc.