infra-finops — AWS Reserved Instances & Savings Plans
Repo: hungryhub-team/infra-finops
AWS account: prod (202255947274)
Region: ap-southeast-1
First purchase: 2026-06-10 — 1-year No Upfront RIs for 7 ElastiCache Valkey nodes + 2 RDS Aurora MySQL instances
EC2 Compute Savings Plan: 2026-06-10 — 1-year No Upfront, $2.50/hr (prod/ec2-csp, SP id b3074e90-b219-4950-ba2c-0834ee67e79c)
Topped up from $1.70 on 2026-07-06 (issue #21 — see Top-up log below).
Why this repo exists
On-demand pricing for long-running production nodes has no upside — we always need them. Reserved Instances (1-year No Upfront) cut costs ~30–40% for ElastiCache and ~30% for RDS with zero commitment beyond the year term. A dedicated repo keeps the financial purchases separate from application infrastructure and makes the irreversibility explicit.
What is reserved
ElastiCache — Valkey clusters (prod)
| Cluster | Node type | RI offering ID |
|---|---|---|
| prod-app-lru-valkey | cache.r6gd.xlarge | 96160d5e-0481-4261-b8d9-bf87fb60e7f5 |
| prod-metarank-valkey | cache.r6g.large | d6c0e8a0-47ca-4d34-adcc-190c8df3bd73 |
| prod-sidekiq-valkey | cache.r6g.large | d6c0e8a0-47ca-4d34-adcc-190c8df3bd73 |
| prod-menu-valkey | cache.t4g.medium | 143f4def-3b20-45ba-9748-956a4508dc48 |
| prod-puma-tiger-valkey | cache.t4g.medium | 143f4def-3b20-45ba-9748-956a4508dc48 |
| prod-hh-felidae-valkey | cache.t4g.small | 7211ce06-6099-4f22-80f4-d17d98c7875a |
| prod-scrape-valkey | cache.t4g.small | 7211ce06-6099-4f22-80f4-d17d98c7875a |
RDS — Aurora MySQL (prod)
| Instance | Role | Class | RI offering ID |
|---|---|---|---|
| hungryhub-prod-aurora-mysql-1 | writer | db.r6g.xlarge | 6894d1f4-d3a5-466f-bb89-410d0842b6ad |
| hungryhub-prod-aurora-mysql-2 | reader | db.r6g.large | 8c44e6dc-7c03-4178-b373-448fca949089 |
Autoscale replicas (up to 10×
db.r6g.xlarge) are not covered — purchase additionaldb.r6g.xlargeRIs separately if scaling is permanent.
Source of truth for cluster configs: hungryhub-terraform/valkey/local.tf and hungryhub-terraform/rds/aurora.tf.
EC2 — Compute Savings Plan (prod)
Two Compute Savings Plans cover prod EC2 on-demand compute. Unlike an RI, a Savings Plan is a $/hr commitment that auto-applies across instance families/sizes (and Fargate/Lambda) — it is not pinned to an instance type.
| Setting | Value |
|—|—|—|
| Plan type | Compute |
| Term / payment | 1 year / No Upfront |
| Commitment (base) | $1.70/hr — SP b3074e90 (2026-06-10) |
| Commitment (top-up) | $0.80/hr — SP 33eebfdc (2026-07-08, issue #21) |
| Total | $2.50/hr |
| Offering ID | cea057b3-71c5-47cc-8d5b-307019b0c6ba |
| Active | 2026-06-10 → 2027-06-10 (base), 2026-07-08 → 2027-07-08 (top-up) |
Why CSP over EC2 RI: for the prod EKS on-demand fleet (m5.xlarge) + non-EKS always-on (c6a.2xlarge, t3a.small), the Compute SP rate beats the 1yr-No-Upfront RI rate by ~15–17 pp on every type (≈53% vs ≈37% off on-demand), and the SP follows the fleet through Graviton migration / right-sizing without re-purchase. Full analysis: infra-finops/analysis/ri-recommendation.md.
Why two-tier approach (issue #21): an m5-only EC2Instance SP ($0.80/hr) was purchased manually via the AWS console on 2026-07-06, bypassing IaC. It was cancelled within the 7-day return window and replaced by a second additive Compute SP at the same $0.80/hr — achieving identical m5 coverage with Compute SP’s broader scope (EC2/Fargate/Lambda, all families/sizes). See Top-up log below.
Excluded: EKS spot baseline (Spot already discounted, SPs don’t apply), workerenv-* sandbox, EKS control plane, RDS/ElastiCache (already RI’d above).
Repo structure
prod/
elasticache-ri/ 7 Valkey nodes — aws_elasticache_reserved_cache_node (for_each)
rds-ri/ 2 Aurora MySQL — aws_rds_reserved_instance + data sources
ec2-csp/ Compute Savings Plan — aws_savingsplans_savings_plan
analysis/ cost-analysis snapshots (NOT terraform) — ri-recommendation.md,
csp-resolution-plan.md, ec2_raw.json
scripts/
lookup-offerings.sh queries AWS CLI for ElastiCache offering IDs (no TF data source)
lookup-csp-offering.sh queries AWS CLI for the Compute SP offering ID (no TF data source)
.github/workflows/
terraform.yml plan on PR; apply on workflow_dispatch with prod-ri-purchase gate
Provider version:
prod/ec2-csprequires AWS provider ≥ 6.x (theaws_savingsplans_savings_planresource does not exist in 5.x). All three modules are pinned to6.49.0. The RI resources re-validated clean on 6.x once theignore_changesbelow were added.
Terraform state
| Module | S3 key | DynamoDB table |
|---|---|---|
| elasticache-ri | prod/elasticache-ri/state.tfstate | terraform-backend-prod-lock |
| rds-ri | prod/rds-ri/state.tfstate | terraform-backend-prod-lock |
| ec2-csp | prod/ec2-csp/state.tfstate | terraform-backend-prod-lock |
Bucket: hungryhub-prod-terraform-202255947274 (same as hungryhub-terraform prod state).
GitHub Actions workflow
Triggers
| Event | Jobs run |
|---|---|
PR to main (paths: prod/**) | Both plan jobs — posts sticky PR comment |
workflow_dispatch + action=plan | Selected module plan(s) |
workflow_dispatch + action=apply | Selected module plan(s) → apply (gated by prod-ri-purchase environment) |
OIDC authentication
The workflow assumes the infra-finops-ci IAM role (created by hungryhub-iam/stacks/infra-finops-ci). No stored credentials — GitHub’s OIDC provider issues short-lived JWT tokens.
Trusted OIDC subjects:
repo:hungryhub-team/infra-finops:pull_requestrepo:hungryhub-team/infra-finops:ref:refs/heads/mainrepo:hungryhub-team/infra-finops:environment:prod-ri-purchase
Secrets required
| Secret | Value |
|---|---|
OIDC_ROLE_ARN_PROD | arn:aws:iam::202255947274:role/infra-finops-ci |
S3_BUCKET_PROD | hungryhub-prod-terraform-202255947274 |
DYNAMO_TABLE_PROD | terraform-backend-prod-lock |
IAM role — infra-finops-ci
Managed in hungryhub-iam/stacks/infra-finops-ci/. Deployed via the IAM Terraform Stacks workflow (stack=infra-finops-ci, environment=prod).
Policy grants:
elasticache:Describe*/List*on*(read-only offering lookup)elasticache:PurchaseReservedCacheNodesOffering+AddTagsToResourcescoped toarn:aws:elasticache:*:202255947274:reserved-instance:*rds:Describe*/List*on*rds:PurchaseReservedDBInstancesOffering+AddTagsToResourcescoped toarn:aws:rds:*:202255947274:ri:*savingsplans:Describe*+ListTagsForResourceon*(read — offering lookup + plan)savingsplans:CreateSavingsPlan+TagResourceon*(purchase — SPs are account-level, no resource-level scoping)- S3 read/write on
hungryhub-prod-terraform-202255947274/prod/* - DynamoDB lock on
terraform-backend-prod-lock
The
savingsplans:*grant was added in hungryhub-iam PR #42 (2026-06-10).CreateSavingsPlan/TagResourceare mutating but cannot be ARN-scoped, so they are allow-listed inhungryhub-iam’stests/test_iam_guardrails.py(STAR_ALLOWED_ACTIONS).
Non-obvious design decisions
ElastiCache + Savings Plan offering IDs must be looked up at runtime
Unlike RDS, there is no Terraform data source for ElastiCache RI offerings or Savings Plan offerings. The workflow runs the AWS CLI and exports the result as a TF_VAR_* environment variable:
- ElastiCache:
aws elasticache describe-reserved-cache-nodes-offerings→TF_VAR_offering_id_*(scripts/lookup-offerings.sh) - Compute SP:
aws savingsplans describe-savings-plans-offerings --plan-types Compute --durations 31536000 --payment-options "No Upfront"→TF_VAR_savings_plan_offering_id(scripts/lookup-csp-offering.sh)
The ec2-csp job also injects TF_VAR_hourly_commitment_usd (from the CSP_COMMITMENT_USD workflow env, default 1.70) — the hourly_commitment_usd variable has no default so CI must supply it.
aws_savingsplans_savings_plan provider-v6 quirks — ignore_changes is mandatory
The aws_savingsplans_savings_plan resource (AWS provider v6, see terraform-provider-aws#46381) has two behaviours that will silently try to re-purchase an active commitment if left unhandled:
- Decimal normalization — the API returns
commitment = "1.70000000"(8 decimals) andupfront_payment_amount = "0.00000000", while config sets"1.70"/null. Withoutignore_changes, plan shows a perpetual diff andupfront_payment_amount "0.00000000" -> nullforces replacement. - Inconsistent result on create — the apply that purchases the SP succeeds in AWS but Terraform then errors with
Provider produced inconsistent result after apply(the same decimal mismatch) and taints the resource. The next plan wants to destroy + recreate (= cancel + re-buy), blocked only byprevent_destroy.
The module sets:
lifecycle {
prevent_destroy = true
ignore_changes = [savings_plan_offering_id, commitment, upfront_payment_amount]
}
If you ever see the SP tainted / marked for replacement after an apply:
- Confirm the SP is live:
aws savingsplans describe-savings-plans --profile prod --region ap-southeast-1— ifstate=active, the purchase already happened, do not re-apply blindly. - The resource is already in state (the failed apply still wrote it). Run
terraform untaint aws_savingsplans_savings_plan.ec2_compute. terraform planshould then show onlyChanges to Outputs:(0 resource changes). If it still forces replacement, an attribute is missing fromignore_changes.
This was the actual sequence on the 2026-06-10 first purchase (fixed in infra-finops PR #9).
prevent_destroy = true on every resource
All RI resources have lifecycle { prevent_destroy = true }. A terraform destroy or accidental resource removal will fail with a hard error — deliberate protection since RIs are irreversible financial purchases.
Purchases are idempotent after the first apply
AWS does not allow cancelling or refunding RIs after purchase. Terraform state tracks each purchase — re-running apply with the same offering ID is a no-op (resource already exists in state). If the state is lost and apply is re-run, AWS will create a second RI for the same node type.
Runbook: buying more RIs (e.g. adding a new cluster)
- Identify the node type of the new cluster from
hungryhub-terraform. - Run
scripts/lookup-offerings.sh --profile prodlocally to get the offering ID. - Add an entry to
local.reservationsinprod/elasticache-ri/main.tf(or add a new data source + resource inprod/rds-ri/). - Open a PR — the plan job runs automatically and shows cost impact.
- After review, trigger Actions → Terraform RI → Run workflow with
action=apply. Approve theprod-ri-purchaseenvironment gate.
Runbook: topping up the Compute Savings Plan
SP commitments are additive — to increase coverage you buy a second plan, you do not modify the first.
- Re-run the steady-state analysis (issue #4 playbook): pull 2+ weeks of EKS on-demand node count + AWS Compute Optimizer, confirm the floor.
- Decide the increment (cap at the full-fleet
$3.73/hrfromanalysis/ri-recommendation.mdunless a fresh analysis raises it). - Bump
CSP_COMMITMENT_USDin the workflow (orhourly_commitment_usdin tfvars) and add a secondaws_savingsplans_savings_planresource — do not edit the existing one (itscommitmentis inignore_changesand it isprevent_destroy). - PR → plan → apply through the
prod-ri-purchasegate. - Record the new SP id + commitment here.
Issue #21 took the additive approach —
UpdateSavingsPlanis unavailable via SDK/CLI, so a secondaws_savingsplans_savings_planresource was created at $0.80/hr alongside the existing $1.70 SP. The cancelled EC2Instance SP ($0.80/hr) was returned within its 7-day window. Total effective commitment: $2.50/hr across two SPs. This additive approach is the canonical pattern for all future top-ups.
HOLD the top-up if a Graviton migration / Rails-upgrade fleet reshape is imminent — re-evaluate post-migration so you don’t strand commitment on instance types you’re about to retire.
Top-up log
| Date | Event | Commitment Δ | SP type | Rationale |
|---|---|---|---|---|
| 2026-06-10 | Initial purchase | $1.70/hr | Compute SP | Conservative floor for baseline fleet |
| 2026-07-06 | ❌ Manual EC2Instance SP purchased via console (issue #21) | +$0.80/hr | EC2Instance (m5 only) | Bypassed IaC; contradicted CSP-first strategy |
| 2026-07-06 | ✅ EC2Instance SP cancelled — full refund | -$0.80/hr | — | Returned within 7-day window |
| 2026-07-08 | ✅ Additive Compute SP purchased (two-tier) | +$0.80/hr (total $2.50) | Compute SP | Second resource alongside existing $1.70 SP — replaces cancelled m5 SP with broader Compute coverage |
Verifying purchases in AWS console
- ElastiCache: AWS Console → ElastiCache → Reserved Cache Nodes
- RDS: AWS Console → RDS → Reserved Instances
- Compute SP: AWS Console → Cost Explorer → Savings Plans
Or via CLI:
aws elasticache describe-reserved-cache-nodes --profile prod --region ap-southeast-1
aws rds describe-reserved-db-instances --profile prod --region ap-southeast-1
aws savingsplans describe-savings-plans --profile prod --region ap-southeast-1
Related
infra-finops/analysis/ri-recommendation.md— full RI-vs-CSP-vs-on-demand cost analysis (2026-06-10)infra-finops/analysis/csp-resolution-plan.md— CSP rollout plan + issue dependency graph (#2–#7)- Vantage MCP for AI Agents — cost monitoring (re-eval cadence, budget alerts)
hungryhub-iam/stacks/infra-finops-ci/— the CI role + itssavingsplans:*grant