EC2 anchor right-size plan
Phase 0 of the elastic-compute epic (#3712), tracked in #3810. This is a plan and has not been applied. anchorInstanceType is still m7i.xlarge. A resize, a deploy of it, and any Savings Plan purchase each need Larry’s explicit approval.
Where things stand
- EC2 anchor: one
m7i.xlarge(4 vCPU, 16 GB, x86_64, about $147/mo list) in a fixed-size Auto Scaling group. It’s defined ininfra/cdk/lib/stacks/anchor-stack.ts, and its size comes fromanchorInstanceTypeininfra/cdk/lib/env-config.ts. - Load seen so far: hourly average CPU ran 1.5% to 8.2%, peaking at 38%, over the first 23 hours. The last memory reading came from the old office server: 2.8 GB average and 5.7 GB peak. Until #3810 there was no memory metric on the anchor.
- New memory metric (#3810): the CloudWatch agent publishes to namespace
Gevell/Anchorwith dimensionAutoScalingGroupNameonly, so the series continues across instance replacements:mem_used_percentmem_available(bytes)disk_used_percent(root volume; extra dimensionspath=/,fstype=xfs)
- Dashboard and alarm: the dashboard is
accessible-pdf-production-anchor. Alarmaccessible-pdf-production-anchor-memory-highfires when the 5-minute average ofmem_used_percentstays above 85% for 15 minutes, and notifies the existingaccessible-pdf-production-alarmstopic. It treats missing data as missing (INSUFFICIENT_DATA), never as OK. A second alarm,accessible-pdf-production-anchor-memory-metric-missing, fires on the same topic whenmem_used_percenthas no samples for 15 minutes, meaning the CloudWatch agent is not reporting and the sizing window has a gap. - How the agent gets installed: an SSM State Manager association (
accessible-pdf-production-anchor-cloudwatch-agent) installs it, not user data. The ASG has no rolling-update policy, so a user-data change would only reach the next instance. The association runs on the current instance right away, on any replacement the ASG launches, and again daily. Deploying it does not replace the instance.
The compose memory limits are a second ceiling
infra/anchor/docker-compose.anchor.yml sets per-container limits that add up to 25.5 GB on a 16 GB host:
| Containers | Limit each | Total |
|---|---|---|
| api-node-1, api-node-2 | 7 GB (Node --max-old-space-size=6144) | 14 GB |
| flyer-tagger | 5 GB | 5 GB |
| docx2acroform, verapdf, weasyprint, markitdown, flowchart-extract, course-map-api | 1 GB | 6 GB |
| logo-extractor | 512 MB | 0.5 GB |
These limits are caps, not reservations, so they only matter once real use approaches them. But any move to 8 GB or smaller has to lower them in the same change (the two Node heaps first), or one busy container can push the host into OOM.
Options
Prices are us-east-1 list, on-demand, Linux.
| Instance | vCPU | RAM | Arch | $/mo | Notes |
|---|---|---|---|---|---|
| m7i.xlarge (today) | 4 | 16 GB | x86_64 | 147 | No change needed |
| r7g.large | 2 | 16 GB | arm64 | 78 | Same RAM for about half the cost, but only 2 vCPU. Needs arm64 (see below) |
| t4g.xlarge | 4 | 16 GB | arm64 | 98 | Burstable. Needs arm64 |
| m7i.large | 2 | 8 GB | x86_64 | 74 | The only true one-line change that halves RAM |
| m7g.large | 2 | 8 GB | arm64 | 60 | Needs arm64 |
| t4g.large | 2 | 8 GB | arm64 | 49 | Burstable. Needs arm64 |
| t4g.medium | 2 | 4 GB | arm64 | 25 | Burstable. Needs arm64 |
| t4g.small | 2 | 2 GB | arm64 | 12 | Burstable. Needs arm64 |
arm64 (every g type, including all t4g)
Every step on the #3712 ladder below 16 GB is a t4g, so the arm64 work is a prerequisite for most of the ladder, not just for r7g. Switching to Graviton is more than one line:
- Images:
.github/workflows/build-anchor-images.ymlbuildsplatforms: linux/amd64only. All nine images ininfra/cdk/anchor-images.jsonneedlinux/arm64builds (multi-arch manifests are simplest), and each needs a smoke test on arm64. Watch these:- flyer-tagger: Python wheels (PyMuPDF, pikepdf, and pdfpolish’s native dependencies)
- weasyprint: Pango and Cairo
- verapdf: JVM, should be fine
- api-node: Chromium or Puppeteer,
sharp, and any other native Node modules - course-map-api
- The AMI:
anchor-stack.tsuseslatestAmazonLinux2023({ cpuType: X86_64 }), which must becomeARM_64. - User data: it downloads
docker-compose-linux-x86_64and its checksum, which must becomedocker-compose-linux-aarch64. - Third-party images:
traefik:v3is already multi-arch.
The AMI and user data both live in the launch template, so a replacement instance picks them up, which the resize needs anyway.
Burstable (t4g, and t3 or t3a if staying x86)
- CPU credits: a t4g earns credits toward a baseline CPU level: 40% per vCPU on t4g.xlarge, 30% on t4g.large, and 20% on t4g.medium and t4g.small. The anchor’s measured hourly average of 1.5–8.2% fits well under every baseline. Heavy jobs spend credits, and the credits refill while the box is idle.
- Unlimited mode: t4g launches in
unlimitedmode by default. If the box runs past its baseline for long enough to use up its credits, AWS bills surplus credits at about $0.04 per vCPU-hour, so it slows nothing but costs more. WatchCPUSurplusCreditsChargedfor the first month after a move to t4g. - Staying on x86: the burstable x86 types need no image work, at a higher price: t3.large (8 GB) is about $61/mo and t3a.large about $55/mo.
Downsizing ladder (from #3712)
Size is set by the largest single job, so the heavy jobs have to move off first.
| Anchor memory | Cheapest fit | Monthly | Possible once |
|---|---|---|---|
| 16 GB | m7i.xlarge / r7g.large | $147 / $78 | now (r7g needs arm64) |
| 8 GB | t4g.large (m7i.large on x86) | $49 ($74) | large-document assembly and the flyer long lane are off the box |
| 4 GB | t4g.medium | $25 | conversion chunks and audits are off the box |
| 2 GB | t4g.small | $12 | helper services have moved, leaving the API and timed sweeps |
| none | none | $0 | the API is on Lambda and sweeps are on EventBridge |
Choosing a size from a week of the metric
Collect at least 7 days after the association first runs. Before trusting the week, make sure it includes the rare heavy work, because a week without a heavy job will undersize the box. Check in Supabase that at least one flyer long-lane job (over 32 pages) and at least one conversion over 30 pages ran in that window. If neither ran, extend the window or run one on purpose (the #3712 “measure assembly at scale” step does both).
Pull the hourly peaks. aws sso login is needed first, and these calls are read-only:
ASG=$(aws cloudformation describe-stacks --stack-name AccessiblePdfProd-Anchor \ --query 'Stacks[0].Outputs[?OutputKey==`AnchorAsgName`].OutputValue' --output text)
# Highest memory-used % in each hour, for 7 daysaws cloudwatch get-metric-statistics --namespace Gevell/Anchor --metric-name mem_used_percent \ --dimensions Name=AutoScalingGroupName,Value="$ASG" \ --start-time "$(date -u -v-7d +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \ --period 3600 --statistics Maximum Average --output table
# Lowest memory available in each hour (bytes)aws cloudwatch get-metric-statistics --namespace Gevell/Anchor --metric-name mem_available \ --dimensions Name=AutoScalingGroupName,Value="$ASG" \ --start-time "$(date -u -v-7d +%FT%TZ)" --end-time "$(date -u +%FT%TZ)" \ --period 3600 --statistics Minimum --output table(date -v-7d is the macOS form. On Linux, use date -u -d '7 days ago' +%FT%TZ.)
Work out the size from the peak:
- Peak used: 16 GB × (highest
mem_used_percentMaximum) ÷ 100. Or use 16 GB minus the lowestmem_available, which is the more direct figure. - Needed: the peak used × 1.3 for headroom. The largest known single consumer is the flyer long lane (flyer-tagger capped at 5 GB), so if that lane still runs on the box, the peak must include a long-lane job.
- Choose: the smallest rung whose RAM is at least the needed figure, and whose “possible once” condition in the ladder is true. The metric can’t show a job that didn’t run that week.
Worked example: the office server’s 5.7 GB peak × 1.3 is about 7.4 GB, which just fits 8 GB. That is too close to act on without this week of data, and the compose limits would have to come down first.
After any resize, keep the 85% alarm. If it fires more than once in the first two weeks, go back up a rung.
The CDK change
x86 targets (m7i.large, t3.large, t3a.large): one line in infra/cdk/lib/env-config.ts, production block:
anchorInstanceType: 'm7i.large',Graviton targets (r7g, m7g, t4g): the same line, plus the arm64 work above (multi-arch images, cpuType: ARM_64, the aarch64 compose binary). Ship it as one PR only after the arm64 images are built and smoke-tested.
Either way: lower the compose memory limits in infra/anchor/docker-compose.anchor.yml in the same PR when going below 16 GB.
Deploying it, and the expected downtime
Merging anything under infra/cdk/** or infra/anchor/** to main triggers Deploy AWS (.github/workflows/deploy-aws.yml, cdk deploy --all). Merging a resize PR is the production deploy, so merge only with approval.
That deploy does not resize the running instance. The ASG has no rolling-update policy (its only UpdatePolicy is AutoScalingScheduledAction), so CloudFormation just points the group at the new launch-template version. The swap is a separate step:
-
Pick a quiet window and check that no jobs are active. A rebuild interrupts conversions in flight, and orphan re-drive can lose the PDF (#3787). Also check the flyer long queue and in-progress
chunk_jobs. -
Start an instance refresh with at most one instance, terminating before launching:
Terminal window aws autoscaling start-instance-refresh --auto-scaling-group-name "$ASG" \--preferences '{"MinHealthyPercentage":0,"MaxHealthyPercentage":100,"InstanceWarmup":900}'Do not use a launch-before-terminate refresh (
MaxHealthyPercentage200). Both instances would run the compose stack withAPI_NODE_1_ROLE=leader, which means two chunk schedulers and two sets of queue consumers at once. -
Expect about 5–15 minutes of downtime for
api.gevell.comand the SQS consumers. That covers the boot, user data (docker, compose, cloudflared), the SSM association installing the agent, andanchor-up.shpulling nine images from ECR. SQS holds messages during the gap, so nothing is lost as long as no job is mid-flight. The Lambda API (api-pdf.gevell.com) stays up the whole time. -
Verify:
- The ASG shows 1 InService instance of the new type.
/readyreturns 200 through the tunnel.- The
Gevell/Anchormetrics resume on the dashboard. - A small conversion completes.
Rollback
-
Normal path: revert the
anchorInstanceTypeline (and any compose-limit change), merge with approval so Deploy AWS runs, then run the same instance refresh. The downtime is the same, about 5–15 minutes. -
Emergency, faster than CI: point the ASG back at the previous launch-template version, then refresh:
Terminal window aws autoscaling update-auto-scaling-group --auto-scaling-group-name "$ASG" \--launch-template LaunchTemplateId=<id>,Version=<previous>This leaves CloudFormation drifted, so follow up with the revert PR.
-
arm64 moves: keep the amd64 image tags. ECR keeps the last 100 tags, so rolling back to x86 needs no rebuild.
Savings Plan sizing (read-only)
Status 2026-09-27: AWS has no recommendation yet. Don’t buy now. The read-only recommendation returned an empty summary for Compute plans with 1-year and 3-year terms and 7-, 30- and 60-day lookbacks. The anchor has only been running since 2026-09-24, so Cost Explorer has about three days of steady compute to work from.
What the account showed on 2026-09-27:
| Item | Value |
|---|---|
| Running EC2 | one m7i.xlarge (the anchor), on-demand, launched 2026-09-24 |
| EC2 compute cost | about $4.84 a day, roughly $0.20 an hour on-demand |
| EC2 other (EBS and similar) | about $0.27 a day |
| Lambda | $0 (inside the free tier) |
| Active Savings Plans | none |
Before 2026-09-24, EC2 compute was a few cents a day, so any lookback longer than a week is mostly empty.
Recommendation: wait. Walk the resize ladder first, then let about 30 days of post-resize usage build up and re-run the command below. Committing now would size the plan to the full m7i.xlarge, which the resize is meant to retire. With round-down, the post-resize commitment will be a fraction of the current $0.20 an hour.
aws ce get-savings-plans-purchase-recommendation \ --savings-plans-type COMPUTE_SP --term-in-years ONE_YEAR \ --payment-option NO_UPFRONT --lookback-period-in-days THIRTY_DAYSWhen it returns a summary, record SavingsPlansPurchaseRecommendationSummary.HourlyCommitmentToPurchase, EstimatedMonthlySavingsAmount and EstimatedSavingsPercentage here.
Guidance for reading it:
- Buy Compute, not EC2 Instance, Savings Plans. A Compute plan covers EC2 of any family, size or region, plus Fargate and Lambda. The discount follows work as #3712 moves it off the anchor. An EC2 Instance plan is locked to the m7i family and would be stranded by a move to t4g or r7g.
- The 30-day lookback includes the m7i.xlarge at full size, so the recommendation will overshoot the post-resize floor. Commit no more than the spend that will remain after the next planned rung. For example, if the anchor goes to t4g.large (about $0.067/h on-demand), the anchor’s share of a commitment should be well under that. Lambda and Fargate baselines add to it only where they are steady.
- The trade-off: under-committing only leaves some savings unclaimed, while over-committing is paid every hour for a year. So round down.
- Buying needs Larry’s explicit approval. Nothing in #3810 purchases anything.