Dev API 503 — consolidated EC2 wedge & self-heal¶
Incident: 2026-08-14, ~10:33–11:57 UTC (~84 min). Dev only. Impact: the entire dev API returned 503 Service Unavailable; tradai-dashboard unusable. Trigger: the single consolidated EC2 instance wedged at the OS level. Not rate limiting, a bad deploy, or a downstream (MLflow/RDS) outage.
What happened¶
Dev runs backend-api, data-collection, mlflow, and strategy-service as four Docker containers on one 2 GB t3.small behind ASG tradai-consolidated-asg-dev (min=max=desired=1). After ~14 h uptime the instance froze:
- backend stopped answering — even the unauthenticated
/api/v1/healthhung with no response - container logs went silent (nothing for the final period)
- SSM
PingStatus=ConnectionLost— the OS itself was unreachable, not just the app - CPU held ~30% (that metric comes from the hypervisor, so it reports even when the OS is wedged)
With no healthy backend target, the ALB served 503 for every route.
Why it didn't self-heal (the real bug)¶
The ASG used health_check_type="EC2". That only reacts to EC2 status checks (system + instance reachability) — which stayed green the whole time (StatusCheckFailed = 0). So although the ALB target was unhealthy / Target.Timeout for 12+ minutes, the ASG never knew the app was dead and never replaced the box. A dead backend could not recover on its own.
How it was diagnosed (repeatable)¶
Distinguishing this from rate-limiting / a downstream outage:
| Signal | This incident | Rate limiting | Downstream (MLflow/RDS) |
|---|---|---|---|
| Status code | blanket 503 | 429 / 403, per-IP | 503/hang on data routes only |
/api/v1/health | hangs (all paths) | 200 | 200 (doesn't touch RDS) |
apigw.dev auth route, no token | fast 401* | varies | fast 401 |
ALB UnHealthyHostCount | 1, sustained | 0 | 0 |
| SSM ping | ConnectionLost | connected | connected |
* the Cognito authorizer rejects at the gateway before reaching the dead backend — a useful "is the edge up but the backend down?" tell.
Immediate mitigation (what to do if it recurs before the fix ships)¶
The box is unreachable, so SSM / docker restart won't work — replace the instance:
AWS_PROFILE=tradai aws autoscaling terminate-instance-in-auto-scaling-group \
--region eu-central-1 \
--instance-id <wedged-id> \
--no-should-decrement-desired-capacity
--no-should-decrement-desired-capacity keeps desired=1, so the ASG launches a fresh instance immediately. Healthy in ~3 min. Verify:
The durable fix (this PR)¶
- ASG self-heals on app health.
health_check_typeEC2→ELB+ grace period300→600s to cover a full cold start (~190 s observed) without a boot-kill loop. An ALB-unhealthy backend is now replaced automatically. - Only the
backend-apitarget group gates ASG health. The ASG fails an instance if any attached target group is unhealthy, so the ASG attaches the backend TG alone. Attaching mlflow's TG too would let a crash-looping mlflow (recurring RDS-auth failures — see the catalog/MLflow RDS outage) fail ELB health and drive an endless terminate/relaunch of the whole box, taking the healthy backend down with it. mlflow stays reachable in-cluster via Cloud Map (http://mlflow:5000); its ALB/mlflowroute is a non-critical dev convenience and is intentionally not wired to ASG health. - Instance-refresh deploy race is workflow-bounded, not Pulumi-bounded. ELB health means a refresh waits for the new instance to pass the backend TG within the 600 s grace. The routine image-pull refresh is driven by
docker-build.yml, which cancels any in-flight refresh before starting a new one (#632/#663) — that cancel-before-start is what prevents a hung refresh from blocking the next deploy (InstanceRefreshInProgress, PR #690). Gating on the backend TG only also means a slow non-critical service can no longer stall the refresh health-wait. If a refresh ever wedges, cancel it:aws autoscaling cancel-instance-refresh --auto-scaling-group-name tradai-consolidated-asg-dev --region eu-central-1. - Memory is now observable. The CloudWatch agent publishes
mem_used_percent/swap_used_percentto theCWAgentnamespace, with anAutoScalingGroupNamerollup (stable across replacement). Two alarms page the alerts SNS topic, and which one fires tells you what to do:
| Alarm | Fires when | Means |
|---|---|---|
tradai-consolidated-high-memory-<env> | > 85% for 15 min | A leak is building. Find the service before the box wedges. |
tradai-consolidated-memory-not-reporting-<env> | no datapoints for 30 min | We are blind. Either the box is wedged (the 2026-08-14 signature) or the agent is misconfigured. |
High-memory treats missing data as missing, not breaching: docker-build.yml refreshes this single-instance ASG on every dev push, so a 5-10 min metric gap is routine and paging on it just gets the channel muted. Silence is the not-reporting alarm's job, on a window long enough to clear a refresh.
- One agent config, and a deploy gate that proves it works. The agent merges the canonical
etc/amazon-cloudwatch-agent.jsonwith everything in the.ddirectory and rejects the whole merge when two configs disagree on a value. That is #1196: #1129 appended a second config whosemetrics.namespaceandmetrics_collected.memconflicted with the AMI-baked one, the append was discarded, and the alarm sat inALARMon zero datapoints for days while every deploy stayed green.
So the slim userdata clears .d, overwrites the canonical config, and fetch-config -s that single file. Two things enforce it:
- Grep the boot log for the marker. Widen the window past the instance's launch: this is written once, at boot, and you reach this step after ≥30 min of silence on a box that may have been up for days.
aws logs tail /tradai/consolidated/user-data --region eu-central-1 --since 7d \ | grep -E 'CWAGENT_CONFIG_FAILED|Different values are specified'Different values are specifiedis the merge conflict itself, in the agent's own words. An empty result is only meaningful if that log group has recent lines from this instance at all — the marker is shipped by the very agent whose health it reports on. If the group is silent, read it on the box instead: - In CI,
infra/asg-verify.shfails the deploy red if the exact series the alarms watch has no fresh datapoint within 10 min of the ASG converging. It usesget-metric-statistics, notlist-metrics, becauselist-metrics --dimensionsis a subset filter and would pass on the per-instance series the alarms cannot match. Mind the scope: that gate runs fromdeploy-infra.yml, which is path-filtered toinfra/**. The image-pull ASG refreshesdocker-build.ymlfires on every dev push are not gated — the not-reporting alarm is the backstop there. Runningjust asg-verifyby hand skips the metric gate by default (it is the incident path); opt in withSKIP_METRIC_VERIFY=0 just asg-verify.
Still open — root cause of the wedge¶
Why the instance exhausted memory after ~14 h is not yet proven (no memory metric existed at the time; the console log was gone post-termination). The strong hypothesis is a slow leak in one of the four services. The box has since been resized 2 GB t3.small → 8 GB t3.large and has not wedged again, but that neither proves nor disproves the leak — it only buys time. The memory metrics and alarms exist to catch the next occurrence with evidence (and since #1196 they are actually wired to data). If it recurs, identify the leaking service (per-container docker stats shipped to CloudWatch) before resizing again — the box is already at 8 GB, so another bump is not the cheap answer it was at 2 GB.
Tracking: see the "consolidated EC2 memory leak" follow-up issue.