Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 envshh-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

#RequirementWhat was doneState
1Baseline the billCommand provided in runbook⏳ operator-run
2Measure p95 pod usageVantage usage data used (multi-day) + 1h runbook✅ Vantage-backed
3Right-size server + karafka + 6 sidekiqOverlay patches committed✅ committed (provisional values)
4Tune WEB_CONCURRENCY/RAILS_MAX_THREADSOverlay env patch committed✅ committed
5Delete stale ECR imagesCommands + lifecycle policy in runbook⏳ operator-run
6EBS gp2→gp3Commands + caveat in runbook⏳ operator-run
7CloudWatch log retentionCommands in runbook⏳ operator-run
8Fluent Bit health-check dropset_env.sh change committed✅ committed
9Lower dev-preview replica floorKEDA cron floor patch committed✅ committed
10EKS node group auditCommands + 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.

EnvPR (merged)Base branchVantage $/mo
hh-ballbothh-server #8358hh-ballbot$116.85
hh-venushh-server #8359hh-venus$138.39
hh-engineeringhh-server #8360hh-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/1 ready, /health/readiness 200, and requests reflecting the Vantage values. The ≥20% cost-delta proof remains time-gated to the 2026-07-15 → 07-22 window (see below).

hh-ballbot files (branch chore/hh-server-ballbot-cost-optimization)

FilePurposeReq
manifest/overlays/staging/hh-ballbot-public/patches/hungryhub-server-cost.yamlPuma env + server request right-size3, 4
manifest/overlays/staging/hh-ballbot-public/patches/hungryhub-server-scaledobject.yamlKEDA cron floor 5→1, max 10→39
manifest/overlays/staging/hh-ballbot-public/kustomization.yamlwire the two patches3,4,9
manifest/overlays/staging/hh-ballbot-public/set_env.shappend Fluent Bit health-check drop filter8
manifest/overlays/staging/hh-ballbot-private/patches/cost-rightsize-workers.yamlkarafka + 6 sidekiq request right-size3
manifest/overlays/staging/hh-ballbot-private/kustomization.yamlwire the worker patch3

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_THREADS are set as container env: in the ballbot overlay, not in config/puma_production.rb. env: wins over the CI-generated hungryhub-server-config envFrom, so ballbot gets WEB_CONCURRENCY=1 / RAILS_MAX_THREADS=16 while puma_production.rb keeps its {2}/{5} defaults. If the defaults were edited instead, hh-ballbot → main would silently push WEB_CONCURRENCY=1 into prod — not wanted.
  • Fluent Bit filter is appended in the ballbot set_env.sh, not in the shared manifest/base/staging/public/configmaps/fluent-bit.conf.tmpl. Each env runs its own overlay’s set_env.sh in its own pipeline, so venus/engineering keep the unfiltered stream (hard-skip honored).

Corrections to the brief’s assumptions (found during recon)

  1. KEDA floor is not minReplicaCount: 5. The base ScaledObject already has minReplicaCount: 0; the real daytime floor is the cron trigger desiredReplicas: '5' (07:00–23:00 Asia/Jakarta, Mon–Fri). The committed fix lowers that cron value to 1 and drops maxReplicaCount 10→3. The puma_backlog Prometheus trigger is untouched, so real load still scales out.
  2. karafka + the 6 sidekiq queues live in the private tier (manifest/base/staging/private/…), patched via the hh-ballbot-private overlay — not hh-ballbot-public as the brief’s req #3 text says.
  3. helper is already right-sized in hh-ballbot-private/patches/hungryhub-helper.yaml (250m/750Mi) — left as-is. sidekiq-google-reserve-lp is not in manifest/base/staging/private/kustomization.yaml, i.e. not deployed — skipped. The deployed set is karafka + exactly 6 sidekiq queues, matching the brief.
  4. hungryhub-server-config is 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.
  5. Most of the node-level EC2 savings are already captured. Per EKS Cost Optimization 2026: eks-dev-262 was migrated to a single 100% SPOT MNG on 2026-06-08 (PR#411), and hungryhub-server CPU 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).
  6. Out-of-scope observation (not fixed here): on the current hh-ballbot tip (a4661d78fb) the public overlay’s patchesStrategicMerge: is empty again — the hungryhub-server-mount-config patch that the 2026-07-08 incident restored (commit 1af5b8be) appears to have been dropped by a later merge. If ballbot pods are Ready 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 across eks-dev-262 + eks-prod-21. Filtered to the three Rails 5.2 staging envs it flags on eks-dev-262: ballbot $116.85/mo + venus $138.39/mo + engineering $139.57/mo = $394.81/mo. hungryhub-server is the biggest line in each env ($45–51/mo). Mostly CPU; Vantage flags memory only for helper (all envs) and the google-reserve-lp / sidekiq-partner queues. These numbers are the source for the request values below.
  • aws:ebs:unattached-volume (rcmmndtn_5e1def2cafa732cc, account 079994049689): 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 each VolumeId against 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.

WorkloadCPU before → after (Vantage)Vantage avg / maxMem before → after
hungryhub-server (public)522m → 58m46m / 253m506Mi (unchanged)
hungryhub-sidekiq800m → 84m67m / 408m1000Mi (unchanged)
hungryhub-sidekiq-critical800m → 24m19m / 155m1000Mi (unchanged)
hungryhub-sidekiq-default500m → 64m51m / 370m800Mi (unchanged)
hungryhub-sidekiq-inv800m → 97m78m / 411m1300Mi (unchanged)
hungryhub-sidekiq-kafka800m → 92m73m / 356m1300Mi (unchanged)
hungryhub-helper250m → 38m30m / 171m750Mi → 540Mi
hungryhub-karafkaunchanged (300m)not flaggedunchanged
hungryhub-sidekiq-lpunchangednot flaggedunchanged
  • Memory is only cut where Vantage recommends ithelper (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.

WorkloadCPU before → afterMem before → after
hungryhub-server (public)522m → 61munchanged
hungryhub-sidekiq-critical800m → 46munchanged
hungryhub-sidekiq-default500m → 151munchanged
hungryhub-sidekiq-inv800m → 72munchanged
hungryhub-sidekiq-kafka800m → 130munchanged
hungryhub-helper250m → 35m750Mi → 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.

WorkloadCPU before → afterMem before → after
hungryhub-server (public)522m → 96munchanged
hungryhub-sidekiq800m → 124munchanged
hungryhub-sidekiq-critical800m → 58munchanged
hungryhub-sidekiq-default500m → 129munchanged
hungryhub-sidekiq-inv800m → 164munchanged
hungryhub-sidekiq-kafka800m → 189munchanged
hungryhub-sidekiq-partner(overlay) → 57m(overlay) → 1738Mi
sidekiq-google-reserve-lp(base) → 156m(base) → 1442Mi
hungryhub-helper250m → 63m750Mi → 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.)

ServiceBaseline $ (2026-06-01→07-08)
fillfill

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-volume on a node root disk is undone the next time that node is replaced. The durable fix is to set gp3 (and disk_size) in the MNG launch template in hungryhub-terraform/eks/. modify-volume is 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 the dev-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)

ServiceBefore $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-22 window; 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 live modify-volume on 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 minReplicaCount scale-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.