eks-dev (Rails 5.2 / Puma) Cost Optimization — hh-ballbot / hh-venus / hh-engineering — 2026-07-09
Goal: reduce the monthly AWS bill for the eks-dev-262 cluster
(arn:aws:eks:ap-southeast-1:079994049689:cluster/eks-dev-262, account
079994049689, sandbox profile) by ≥20% while keeping every dev-preview pod
(hh-server public + private workers) 1/1 ready and serving 200 on
/health/readiness.
Scope: the three Rails 5.2 staging envs — hh-ballbot, hh-venus, and
hh-engineering (each deploys from its own branch + overlays; prod bases
untouched). The work started on hh-ballbot (originally the only in-scope env)
and was extended to venus + engineering once they were cleared. Changes were
made Code/PR + runbook style: manifest/config changes land as a PR; the
live/destructive AWS + kubectl steps are provided as a copy-paste runbook for a
human operator to run (nothing live was mutated by the agent).
TL;DR status
| # | Requirement | What was done | State |
|---|---|---|---|
| 1 | Baseline the bill | Command provided in runbook | ⏳ operator-run |
| 2 | Measure p95 pod usage | Vantage usage data used (multi-day) + 1h runbook | ✅ Vantage-backed |
| 3 | Right-size server + karafka + 6 sidekiq | Overlay patches committed | ✅ committed (provisional values) |
| 4 | Tune WEB_CONCURRENCY/RAILS_MAX_THREADS | Overlay env patch committed | ✅ committed |
| 5 | Delete stale ECR images | Commands + lifecycle policy in runbook | ⏳ operator-run |
| 6 | EBS gp2→gp3 | Commands + caveat in runbook | ⏳ operator-run |
| 7 | CloudWatch log retention | Commands in runbook | ⏳ operator-run |
| 8 | Fluent Bit health-check drop | set_env.sh change committed | ✅ committed |
| 9 | Lower dev-preview replica floor | KEDA cron floor patch committed | ✅ committed |
| 10 | EKS node group audit | Commands + expected findings in runbook | ⏳ operator-run |
| — | Cost acceptance criterion (≥20% down) | Cannot be measured yet | ⛔ deferred → after 2026-07-22 |
Honest headline: the acceptance criterion as written cannot be satisfied on
2026-07-09. Its “after” window is Start=2026-07-15,End=2026-07-22, which is in
the future — Cost Explorer returns nothing for a period that has not happened. The
earliest the ≥20% delta can be proven is after 2026-07-22, which lines up with the
planned hh-ballbot → main prod merge. Everything that can be done now is done
and validated at the manifest/config level; the cost proof is the one open item,
and it is time-gated, not blocked on work.
Committed change set — three PRs (one per Rails 5.2 env), all merged
Each env deploys from its own branch + overlays, so each was a separate PR against
its env branch. All three hh-server PRs are merged (2026-07-09) — the changes go
live on each env’s next CodePipeline deploy. All three apply the same four
levers: Puma env (WEB_CONCURRENCY=1/RAILS_MAX_THREADS=16), Vantage CPU
right-size (memory only where Vantage flags it), KEDA cron floor 5→1 + max 10→3, and
a Fluent Bit health-probe log drop (appended in that env’s set_env.sh). All scoped
strictly to that env’s hh-<env>-public / hh-<env>-private overlays; base and prod
untouched. All six overlays validated with kubectl kustomize (exit 0); no live
cluster contacted.
| Env | PR (merged) | Base branch | Vantage $/mo |
|---|---|---|---|
| hh-ballbot | hh-server #8358 | hh-ballbot | $116.85 |
| hh-venus | hh-server #8359 | hh-venus | $138.39 |
| hh-engineering | hh-server #8360 | hh-engineering | $139.57 |
| combined | $394.81 |
Post-deploy verification (run once each env’s pipeline has rolled out the merge): the acceptance block at the end of this doc — pods
1/1ready,/health/readiness200, and requests reflecting the Vantage values. The ≥20% cost-delta proof remains time-gated to the2026-07-15 → 07-22window (see below).
hh-ballbot files (branch chore/hh-server-ballbot-cost-optimization)
| File | Purpose | Req |
|---|---|---|
manifest/overlays/staging/hh-ballbot-public/patches/hungryhub-server-cost.yaml | Puma env + server request right-size | 3, 4 |
manifest/overlays/staging/hh-ballbot-public/patches/hungryhub-server-scaledobject.yaml | KEDA cron floor 5→1, max 10→3 | 9 |
manifest/overlays/staging/hh-ballbot-public/kustomization.yaml | wire the two patches | 3,4,9 |
manifest/overlays/staging/hh-ballbot-public/set_env.sh | append Fluent Bit health-check drop filter | 8 |
manifest/overlays/staging/hh-ballbot-private/patches/cost-rightsize-workers.yaml | karafka + 6 sidekiq request right-size | 3 |
manifest/overlays/staging/hh-ballbot-private/kustomization.yaml | wire the worker patch | 3 |
Both overlays validated locally with kubectl kustomize (exit 0). No live cluster
was contacted.
Why these are overlay patches, not base/config edits
WEB_CONCURRENCY/RAILS_MAX_THREADSare set as containerenv:in the ballbot overlay, not inconfig/puma_production.rb.env:wins over the CI-generatedhungryhub-server-configenvFrom, so ballbot getsWEB_CONCURRENCY=1/RAILS_MAX_THREADS=16whilepuma_production.rbkeeps its{2}/{5}defaults. If the defaults were edited instead,hh-ballbot → mainwould silently pushWEB_CONCURRENCY=1into prod — not wanted.- Fluent Bit filter is appended in the ballbot
set_env.sh, not in the sharedmanifest/base/staging/public/configmaps/fluent-bit.conf.tmpl. Each env runs its own overlay’sset_env.shin its own pipeline, so venus/engineering keep the unfiltered stream (hard-skip honored).
Corrections to the brief’s assumptions (found during recon)
- KEDA floor is not
minReplicaCount: 5. The baseScaledObjectalready hasminReplicaCount: 0; the real daytime floor is the cron triggerdesiredReplicas: '5'(07:00–23:00 Asia/Jakarta, Mon–Fri). The committed fix lowers that cron value to1and dropsmaxReplicaCount10→3. Thepuma_backlogPrometheus trigger is untouched, so real load still scales out. - karafka + the 6 sidekiq queues live in the
privatetier (manifest/base/staging/private/…), patched via thehh-ballbot-privateoverlay — nothh-ballbot-publicas the brief’s req #3 text says. helperis already right-sized inhh-ballbot-private/patches/hungryhub-helper.yaml(250m/750Mi) — left as-is.sidekiq-google-reserve-lpis not inmanifest/base/staging/private/kustomization.yaml, i.e. not deployed — skipped. The deployed set is karafka + exactly 6 sidekiq queues, matching the brief.hungryhub-server-configis not a checked-in configmap — it is generated by the CI pipeline (--from-env-file), which is why the env override goes in an overlay patch rather than by editing a file.- Most of the node-level EC2 savings are already captured. Per
EKS Cost Optimization 2026:
eks-dev-262was migrated to a single 100% SPOT MNG on 2026-06-08 (PR#411), andhungryhub-serverCPU was already cut 850m→300m in prod. The remaining dev-preview lever is fewer + smaller pods (this work), which reduces node count through Cluster Autoscaler bin-packing (memory is the binding CA constraint per that doc). - Out-of-scope observation (not fixed here): on the current
hh-ballbottip (a4661d78fb) the public overlay’spatchesStrategicMerge:is empty again — thehungryhub-server-mount-configpatch that the 2026-07-08 incident restored (commit1af5b8be) appears to have been dropped by a later merge. If ballbot pods areReady 1/2, check this before blaming the cost changes.
Vantage findings (workspace Default, wrkspc_b5a5e9b51d815234)
Pulled via the Vantage MCP on 2026-07-09 (read/write token “EKS v1.6.0 v2”).
kubernetes:workload:rightsizing(rcmmndtn_58180af7f61367c9): 60 workloads, $1,107.84/mo total potential savings acrosseks-dev-262+eks-prod-21. Filtered to the three Rails 5.2 staging envs it flags oneks-dev-262: ballbot $116.85/mo + venus $138.39/mo + engineering $139.57/mo = $394.81/mo.hungryhub-serveris the biggest line in each env ($45–51/mo). Mostly CPU; Vantage flags memory only forhelper(all envs) and thegoogle-reserve-lp/sidekiq-partnerqueues. These numbers are the source for the request values below.aws:ebs:unattached-volume(rcmmndtn_5e1def2cafa732cc, account079994049689): 13 unattached volumes, $15.91/mo. ⚠️ Do not blindly delete — see KB Sandbox: Available EBS Volume Is a Live ClickHouse PV — Do Not Delete. “Available” / “unattached” can be a live PV between pod reschedules. Vet eachVolumeIdagainst known PVs before removing. Handled in the runbook, not auto-actioned.
Right-sizing values (committed)
Requests set directly to Vantage’s rightsize recommendation
(rcmmndtn_58180af7f61367c9, kubernetes:workload:rightsizing, eks-dev-262 /
dev-preview-ballbot-*, multi-day usage window). Limits unchanged everywhere so
bursts (observed max/peak) are still absorbed.
| Workload | CPU before → after (Vantage) | Vantage avg / max | Mem before → after |
|---|---|---|---|
| hungryhub-server (public) | 522m → 58m | 46m / 253m | 506Mi (unchanged) |
| hungryhub-sidekiq | 800m → 84m | 67m / 408m | 1000Mi (unchanged) |
| hungryhub-sidekiq-critical | 800m → 24m | 19m / 155m | 1000Mi (unchanged) |
| hungryhub-sidekiq-default | 500m → 64m | 51m / 370m | 800Mi (unchanged) |
| hungryhub-sidekiq-inv | 800m → 97m | 78m / 411m | 1300Mi (unchanged) |
| hungryhub-sidekiq-kafka | 800m → 92m | 73m / 356m | 1300Mi (unchanged) |
| hungryhub-helper | 250m → 38m | 30m / 171m | 750Mi → 540Mi |
| hungryhub-karafka | unchanged (300m) | not flagged | unchanged |
| hungryhub-sidekiq-lp | unchanged | not flagged | unchanged |
- Memory is only cut where Vantage recommends it —
helper(750Mi→540Mi) is the single memory rightsize Vantage flags. Vantage found no memory overprovisioning on the others, so their memory requests are left at base rather than guessed down (this also avoids the OOM-eviction risk called out in the CPU-rightsizing KB doc). - karafka and sidekiq-lp are NOT in Vantage’s recommendation, so they are left
at base sizing — no data to justify a change. Listed as a follow-up (re-check once
they have a Vantage sample, esp.
sidekiq-lp’s 2.4G memory request). - Plus web-pod count during business hours 5 → 1 (KEDA cron floor). The pod-count reduction is the dominant win; the Vantage CPU right-size compounds it via CA bin-packing. Reported Vantage savings for the flagged workloads: $116.85/mo.
hh-venus (PR #8359) — CPU set to Vantage rightsize, requests only
Same four levers. Vantage-flagged workloads only; memory only where Vantage flags it.
hungryhub-sidekiq (plain), karafka, sidekiq-lp are not flagged → left at base.
sidekiq-google-reserve-lp is deployed in venus (unlike ballbot) and is flagged.
| Workload | CPU before → after | Mem before → after |
|---|---|---|
| hungryhub-server (public) | 522m → 61m | unchanged |
| hungryhub-sidekiq-critical | 800m → 46m | unchanged |
| hungryhub-sidekiq-default | 500m → 151m | unchanged |
| hungryhub-sidekiq-inv | 800m → 72m | unchanged |
| hungryhub-sidekiq-kafka | 800m → 130m | unchanged |
| hungryhub-helper | 250m → 35m | 750Mi → 358Mi |
| sidekiq-google-reserve-lp | (base) → 78m | (base) → 1332Mi |
Vantage savings: $138.39/mo (server $51.13/mo).
hh-engineering (PR #8360) — CPU set to Vantage rightsize, requests only
Engineering’s overlay is richer: it defines an extra sidekiq-partner queue and
already had a server ScaledObject patch + sidekiq-kafka patch. The cron-floor and
kafka-CPU changes were merged into those existing patches (no duplicate targets).
karafka/sidekiq-lp not flagged → left at base.
| Workload | CPU before → after | Mem before → after |
|---|---|---|
| hungryhub-server (public) | 522m → 96m | unchanged |
| hungryhub-sidekiq | 800m → 124m | unchanged |
| hungryhub-sidekiq-critical | 800m → 58m | unchanged |
| hungryhub-sidekiq-default | 500m → 129m | unchanged |
| hungryhub-sidekiq-inv | 800m → 164m | unchanged |
| hungryhub-sidekiq-kafka | 800m → 189m | unchanged |
| hungryhub-sidekiq-partner | (overlay) → 57m | (overlay) → 1738Mi |
| sidekiq-google-reserve-lp | (base) → 156m | (base) → 1442Mi |
| hungryhub-helper | 250m → 63m | 750Mi → 349Mi |
Vantage savings: $139.57/mo (server $45.71/mo).
RUNBOOK — operator-run steps (nothing below was executed by the agent)
Run every block as export AWS_PROFILE=sandbox and with kube-context
arn:aws:eks:ap-southeast-1:079994049689:cluster/eks-dev-262. Verify identity
first:
export AWS_PROFILE=sandbox
aws sts get-caller-identity --query Account --output text # must print 079994049689
kubectl config use-context arn:aws:eks:ap-southeast-1:079994049689:cluster/eks-dev-262
Req #1 — Baseline the bill (record the “before”)
aws --profile sandbox ce get-cost-and-usage \
--time-period Start=2026-06-01,End=2026-07-08 --granularity MONTHLY \
--metrics UnblendedCost --group-by Type=DIMENSION,Key=SERVICE \
| jq -r '.ResultsByTime[0].Groups | sort_by(.Metrics.UnblendedCost.Amount|tonumber) | reverse
| .[0:5][] | "\(.Keys[0])\t$\(.Metrics.UnblendedCost.Amount)"'
Record the total and top-5 services below. (Cost Explorer is account-wide; the
eks-dev share is EC2 + EBS + CloudWatch + data-transfer for account 079994049689.)
| Service | Baseline $ (2026-06-01→07-08) |
|---|---|
| fill | fill |
Req #2 — Measure p95 pod usage (3 reads, 20 min apart, over 1 hour)
for ns in dev-preview-ballbot-public dev-preview-ballbot-private; do
for i in 1 2 3; do
echo "=== $ns read $i $(date -u +%H:%M) ==="
kubectl top pods -n "$ns" --containers 2>/dev/null | grep -E "hungryhub-(server|karafka|sidekiq)"
[ $i -lt 3 ] && sleep 1200 # 20 min
done
done
Take the max across the 3 reads per workload as the p95 proxy. New request
should be ≥ p95 × 1.5. If any p95×1.5 exceeds the committed request in the table
above, bump that value in the overlay patch before merging.
Req #5 — Delete stale ECR images (Passenger-era + superseded Puma)
Record the before count, delete everything except the 3 keeper Puma tags, re-count:
aws --profile sandbox ecr describe-images --repository-name hh-server \
--query 'length(imageDetails)' # BEFORE count — record it
# stale ballbot tags, excluding the 3 keepers
aws --profile sandbox ecr list-images --repository-name hh-server --filter tagStatus=TAGGED \
--query 'imageIds[?starts_with(imageTag, `dev-preview-hh-ballbot-`)
&& imageTag != `dev-preview-hh-ballbot-06a99a`
&& imageTag != `dev-preview-hh-ballbot-f155a9`
&& imageTag != `dev-preview-hh-ballbot-efee26f`].imageTag' --output text \
| tr '\t' '\n' | while read -r t; do
[ -n "$t" ] && aws --profile sandbox ecr batch-delete-image \
--repository-name hh-server --image-ids imageTag="$t" --output text
done
aws --profile sandbox ecr describe-images --repository-name hh-server \
--query 'length(imageDetails)' # AFTER count — must be lower
Prefer making this durable with a lifecycle policy (keeps last N per prefix) so cleanup is automatic instead of a one-off:
aws --profile sandbox ecr put-lifecycle-policy --repository-name hh-server \
--lifecycle-policy-text '{"rules":[{"rulePriority":10,
"description":"keep last 5 dev-preview-hh-ballbot images",
"selection":{"tagStatus":"tagged","tagPrefixList":["dev-preview-hh-ballbot-"],
"countType":"imageCountMoreThan","countNumber":5},
"action":{"type":"expire"}}]}'
Do not delete the tags currently referenced by a running deployment. Check
kubectl get deploy -n dev-preview-ballbot-public hungryhub-server -o jsonpath='{..image}'and keep that tag regardless of the filter.
Req #6 — EBS gp2 → gp3
aws --profile sandbox ec2 describe-volumes --filters Name=volume-type,Values=gp2 \
--query 'Volumes[].{id:VolumeId,size:Size,az:AvailabilityZone}' --output table
# for each gp2 id:
aws --profile sandbox ec2 modify-volume --volume-id <vol-id> --volume-type gp3
aws --profile sandbox ec2 describe-volumes-modifications --volume-id <vol-id> \
--query 'VolumesModifications[].ModificationState' # modifying → optimizing → completed
Caveat that matters here: the cluster is a single SPOT MNG. Node root volumes are created from the node group launch template, and SPOT nodes are recycled frequently — a live
modify-volumeon a node root disk is undone the next time that node is replaced. The durable fix is to setgp3(anddisk_size) in the MNG launch template inhungryhub-terraform/eks/.modify-volumeis only worth it for long-lived volumes (PVCs / non-ephemeral). List first; if every gp2 is an ephemeral node root, skip the live modify and do the terraform change instead (see req #10 finding). gp3 is ~20% cheaper per GB at the same baseline IOPS.
Unattached volumes (from Vantage rec rcmmndtn_5e1def2cafa732cc, $15.91/mo, 13 vols).
List and vet — do NOT bulk-delete (an “available” volume may be a live ClickHouse PV):
aws --profile sandbox ec2 describe-volumes --filters Name=status,Values=available \
--query 'Volumes[].{id:VolumeId,size:Size,az:AvailabilityZone,tags:Tags}' --output json
# cross-check each id against known PVs before any delete:
kubectl get pv -A -o jsonpath='{range .items[*]}{.spec.csi.volumeHandle}{"\n"}{end}'
See Sandbox: Available EBS Volume Is a Live ClickHouse PV.
Req #7 — CloudWatch log retention (dev = 7 days is plenty)
aws --profile sandbox logs describe-log-groups \
--query 'logGroups[?retentionInDays==`null`].logGroupName' --output text \
| tr '\t' '\n' | while read -r g; do
[ -n "$g" ] && aws --profile sandbox logs put-retention-policy \
--log-group-name "$g" --retention-in-days 7
done
# verify: must print 0
aws --profile sandbox logs describe-log-groups \
--query 'logGroups[?retentionInDays==`null`].logGroupName' | jq 'length'
Scope to the eks-dev cluster’s groups (
/aws/eks/eks-dev-262/…, and the app log groups for thedev-preview-*namespaces). Do not blanket-apply to prod account groups.
Req #10 — EKS node group + disk audit (report-only)
aws --profile sandbox eks describe-cluster --name eks-dev-262 --query 'cluster.status'
for ng in $(aws --profile sandbox eks list-nodegroups --cluster-name eks-dev-262 \
--query 'nodegroups[]' --output text); do
echo "== $ng =="
aws --profile sandbox eks describe-nodegroup --cluster-name eks-dev-262 --nodegroup-name "$ng" \
--query 'nodegroup.{instanceTypes:instanceTypes,scaling:scalingConfig,disk:diskSize,capacity:capacityType}'
done
Flag: diskSize > 100 (dev runs almost nothing stateful — 50GB is plenty) and any
multi-family instanceTypes list you don’t actually need. Do not change here; feed
findings into a hungryhub-terraform/eks/ PR.
Expected from KB: single MNG eks-dev-262-mng-spot-only-*, ~19 nodes, capacityType SPOT, m5/m5a/m6a/m6i/m6in.large. If diskSize is 100/200, that’s the gp3 +
size-reduction opportunity to wire into the launch template.
Acceptance criteria — how to close them out (after 2026-07-22)
# 1. Cost went down (run on/after 2026-07-22)
aws --profile sandbox ce get-cost-and-usage \
--time-period Start=2026-07-15,End=2026-07-22 --granularity MONTHLY \
--metrics UnblendedCost --group-by Type=DIMENSION,Key=SERVICE \
| jq '.ResultsByTime[0].Total.UnblendedCost' # target < 80% of req#1 baseline
# 2. ballbot still healthy
kubectl get deploy -n dev-preview-ballbot-public hungryhub-server \
-o jsonpath='{.spec.template.spec.containers[0].resources}' # expect cpu req 58m (mem 506Mi unchanged)
kubectl get pods -n dev-preview-ballbot-public -l app=hungryhub-server \
-o jsonpath='{range .items[*]}{.status.containerStatuses[0].ready}{"\n"}{end}' | sort -u # all true
# 3. /health/readiness still 200
for pod in $(kubectl get pods -n dev-preview-ballbot-public -l app=hungryhub-server -o name); do
kubectl exec -n dev-preview-ballbot-public "$pod" -c service -- \
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/health/readiness
done # all 200
# 4. ECR count dropped (compare to req#5 BEFORE)
aws --profile sandbox ecr describe-images --repository-name hh-server --query 'length(imageDetails)'
# 5. CloudWatch retention set
aws --profile sandbox logs describe-log-groups \
--query 'logGroups[?retentionInDays==`null`].logGroupName' | jq 'length' # 0
Before / after cost table (fill after 2026-07-22)
| Service | Before $ | After $ | Δ $ | Δ % |
|---|---|---|---|---|
| fill | ||||
| Total | (target ≤ −20%) |
Deferred / can’t-be-met-today
- Cost acceptance criterion (≥20% down): time-gated on the future
2026-07-15→07-22window; not blocked on work. Unblocker: run the acceptance block above after 2026-07-22 and fill the table. - Req #2 exact p95: the 1-hour live sample must be run by an operator (agent ran Code/PR + runbook only). Committed request values are provisional until then.
- Reqs #5/#6/#7/#10: live/destructive AWS ops — provided as runbook for a human
to run against
sandbox.
Suggested follow-ups (out of scope)
- gp3 + disk-size in the MNG launch template (
hungryhub-terraform/eks/) is the durable version of reqs #6/#10 — more robust than livemodify-volumeon SPOT node roots. - Karpenter could bin-pack the dev cluster tighter than Cluster Autoscaler (consolidation on underutilised SPOT nodes), plausibly another ~10–15% — but it’s a bigger migration than this sprint.
- KEDA
minReplicaCountscale-to-zero off-hours already works (base is 0); the win is real only if the cron floor is low, which this PR now sets to 1. - Extend the Fluent Bit health-check drop cluster-wide (venus/engineering/prod) once proven on ballbot — same noise exists everywhere; kept ballbot-only here to respect the hard-skip.
- Roll the confirmed right-sizing into the shared base after the 1-hour sample, so venus/engineering/prod benefit on the Rails 5.2 rollout instead of ballbot only.