Under Q3 2025 nådde vår månatliga Kubernetes-faktura $18 400. För en medelstor organisation som körde vad som borde ha varit en blygsam arbetsbelastning - ett par dussin mikrotjänster, viss batchbearbetning och en handfull databaser - var det absurt. Den här artikeln dokumenterar den forensiska revisionen vi genomförde, optimeringarna vi tillämpade, och de $127K i annualiserade besparingar vi uppnådde över fyra månader.
Problemet
Varningstecknen hade funnits där i månader. Vår molnfaktura hade vuxit 15-20% per kvartal, men ingen spårade Kubernetes-kostnaderna specifikt - de var begravda i den övergripande GCP-fakturan tillsammans med BigQuery, Cloud Functions och lagringskostnader. När vi äntligen isolerade GKE-posten var siffran häpnadsväckande: $18 400 per månad för vad som uppgick till 47 poddar som körde på 12 noder.
Rotorsaken var inte ett enstaka misstag utan en ackumulering av små beslut över två år. Varje ny tjänst deployades med generösa resursförfrågningar eftersom ingen ville hantera OOMKills. Nodpooler dimensionerades för toppbelastning och skalades aldrig ner. Namnrymder spred sig utan rensning. Utvecklings- och staging-miljöer kördes dygnet runt på samma dyra nodtyper som produktion. Ingen övervakade faktisk resursanvändning kontra begärda resurser.
Ironin är att Kubernetes var tänkt att spara pengar genom bättre resursutnyttjande. Istället skapade komplexiteten i Kubernetes resurshantering en kultur av överprovisionering. Utvecklare begärde 2 CPU-kärnor och 4 GB RAM för tjänster som faktiskt använde 0,1 kärnor och 200 MB. Multiplicera det med 47 poddar och du får ett kluster som körs på 8% genomsnittligt utnyttjande medan det faktureras för 100%.
Resursutnyttjande före revision
Betalar för 96 CPU-kärnor men använder 8. Det är inte ovanligt - branschundersökningar visar genomsnittligt K8s-utnyttjande på 15-25%.
Problemet var inte tekniskt. Det var organisatoriskt. Ingen ägde molnkostnadsoptimeringen. Plattformsteamet hanterade drifttid, utvecklingsteamet levererade funktioner och ekonomiteamet betalade fakturor. Kostnaden föll i glappet mellan alla tre.
Revisionen
Vi lade två veckor på att genomföra en forensisk revision av varje körande arbetsbelastning. Metoden var enkel: för varje pod, mäta faktisk resursförbrukning över ett 14-dagarsfönster, jämföra med begärda resurser och beräkna slöseriekvoten. Vi använde en kombination av Prometheus-mätvärden, kubectl top-ögonblicksbilder och GKE usage metering.
| 1 | #!/bin/bash |
| 2 | # Generate resource waste report across all namespaces |
| 3 | |
| 4 | echo "=== Kubernetes Resource Waste Audit ===" |
| 5 | echo "Cluster: $(kubectl config current-context)" |
| 6 | echo "Date: $(date -u +%Y-%m-%dT%H:%M:%SZ)" |
| 7 | echo "" |
| 8 | |
| 9 | for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do |
| 10 | echo "--- Namespace: $ns ---" |
| 11 | |
| 12 | kubectl top pods -n "$ns" --no-headers 2>/dev/null | while read pod cpu mem; do |
| 13 | # Get requested resources |
| 14 | req_cpu=$(kubectl get pod "$pod" -n "$ns" -o jsonpath='{.spec.containers[0].resources.requests.cpu}') |
| 15 | req_mem=$(kubectl get pod "$pod" -n "$ns" -o jsonpath='{.spec.containers[0].resources.requests.memory}') |
| 16 | |
| 17 | echo "Pod: $pod" |
| 18 | echo " CPU: actual=$cpu requested=$req_cpu" |
| 19 | echo " Memory: actual=$mem requested=$req_mem" |
| 20 | echo " Waste: ~$(calculate_waste "$cpu" "$req_cpu")% CPU, ~$(calculate_waste "$mem" "$req_mem")% Memory" |
| 21 | done |
| 22 | done |
Revisionen avslöjade fem kategorier av slöseri, var och en krävde olika optimeringsstrategier. Överdimensionerade pod-begäranden stod för 42% av totalt slöseri. Alltid-på-utvecklings/staging-miljöer bidrog med 23%. Oanvända persistent volumes och föräldralösa resurser stod för ytterligare 15%. Suboptimala nodtyper (att använda beräkningsoptimerade instanser för minnesintensiva arbetsbelastningar) representerade 12%. Resterande 8% var diverse: övergivna jobb, bortglömda CronJobs och testdeployments som ingen städade upp.
Slöserikategorier
Med denna data prioriterade vi optimeringar efter påverkan och insats. Rätt dimensionering av pod-begäranden var hög påverkan och låg insats. Konvertering av icke-produktionsmiljöer till spot-instanser var hög påverkan och medelhög insats. Nodtypsoptimering krävde mer planering men hade tydlig avkastning. Vi skapade en fyra månaders implementationsplan och spårade besparingar veckovis.
Rätt dimensionering av arbetsbelastningar
Rätt dimensionering var den enskilt mest effektfulla optimeringen, ansvarig för 48% av totala besparingar. Tillvägagångssättet var metodiskt: för varje deployment analyserade vi P99-resursanvändning över 14 dagar, lade till en 30% buffert för säkerhet och uppdaterade resursförfrågningar och gränser därefter.
Vi deployade också Vertical Pod Autoscaler (VPA) i rekommendationsläge över hela klustret. VPA analyserar faktisk resursförbrukning och föreslår optimala begäranden. Vi aktiverade inte VPA i auto-uppdateringsläge eftersom vi ville ha mänsklig granskning av varje ändring - vissa tjänster har spikmönster som P99-mätvärden inte fångar väl.
| 1 | apiVersion: apps/v1 |
| 2 | kind: Deployment |
| 3 | metadata: |
| 4 | name: api-gateway |
| 5 | namespace: production |
| 6 | spec: |
| 7 | replicas: 3 |
| 8 | template: |
| 9 | spec: |
| 10 | containers: |
| 11 | - name: api-gateway |
| 12 | image: gcr.io/enterprise/api-gateway:v2.14 |
| 13 | resources: |
| 14 | # BEFORE: requests: cpu=2000m, memory=4Gi |
| 15 | # AFTER: based on 14-day P99 + 30% buffer |
| 16 | requests: |
| 17 | cpu: 250m # was 2000m (actual P99: 180m) |
| 18 | memory: 512Mi # was 4Gi (actual P99: 380Mi) |
| 19 | limits: |
| 20 | cpu: 500m # 2x request for burst capacity |
| 21 | memory: 1Gi # 2x request for safety |
| 22 | # Added: Readiness probe to prevent routing during startup |
| 23 | readinessProbe: |
| 24 | httpGet: |
| 25 | path: /healthz |
| 26 | port: 8080 |
| 27 | initialDelaySeconds: 5 |
| 28 | periodSeconds: 10 |
| 29 | # Added: Pod topology spread for availability |
| 30 | topologySpreadConstraints: |
| 31 | - maxSkew: 1 |
| 32 | topologyKey: topology.kubernetes.io/zone |
| 33 | whenUnsatisfiable: DoNotSchedule |
Den svåraste delen av rätt dimensionering var att hantera tjänster med varierande belastningsmönster. Våra batchbearbetningspoddar spikar till 8x sin baslinje under det nattliga datainförselsfönstret. För dessa använde vi en kombination av rätt dimensionerade basbegäranden med Horizontal Pod Autoscaler (HPA)-skalning baserad på anpassade Prometheus-mätvärden snarare än enkel CPU-procent. HPA-konfigurationen använder vårt faktiska ködjupsmätvärde, som korrelerar mycket bättre med faktiska resursbehov än CPU-utnyttjande.
Topp 5 tjänster: Före vs efter rätt dimensionering
Vi rullade ut ändringarna för rätt dimensionering stegvis över tre veckor, med start på de lägsta riskernas tjänster och övervakade latensregressioner, felfrekvenökningar och OOMKill-händelser. Bara två tjänster behövde justering efter den initiala utrullningen - båda på grund av minneshungriga garbage collection-mönster i Java-tjänster som vårt 14-dagars observationsfönster inte hade fångat. Vi ökade deras minnesgränser med 50% och de stabiliserades omedelbart.
Spot-instansstrategi
Spot (preemptible)-instanser på GKE kostar 60-80% mindre än on-demand-instanser. Avvägningen är att Google kan återta dem med 30 sekunders varsel. För tillståndslösa, feltåliga arbetsbelastningar är denna avvägning nästan alltid värd det. För vårt kluster identifierade vi tre kategorier av arbetsbelastningar lämpliga för spot-instanser.
Icke-produktionsmiljöer var den enklaste vinsten. Utvecklings- och staging-kluster körs nu helt på spot-instanser, vilket sparar $4 200/månad med noll påverkan på utvecklarupplevelsen. Om en spot-nod återtas startar poddar om på en ny nod inom 60 sekunder. För utvecklingsarbete är det omärkbart.
Batchbearbetningsarbetsbelastningar - våra datainförsel- och dbt-transformationspoddar - var nästa mål. Dessa är idempotenta av design: om en pod termineras mitt i ett jobb, kör orkestraren om det. Vi konfigurerade batch-nodpoolen som 100% spot, vilket sparade $1 800/månad. Omförsöksfrekvensen ökade från nära noll till cirka 3% av jobben, men den totala bearbetningstiden minskade faktiskt eftersom vi hade råd med fler parallella arbetare till det lägre spot-priset.
För tillståndslösa produktionstjänster använder vi en blandad strategi: 30% on-demand-noder som garanterad baslinje, 70% spot-noder för ytterligare kapacitet. PodDisruptionBudgets säkerställer att spot-återtagning aldrig tar mer än en pod offline per tjänst åt gången. Vi använder också node affinity-regler för att prioritera latenskänsliga tjänster på on-demand-noder.
| 1 | resource "google_container_node_pool" "spot_general" { |
| 2 | name = "spot-general" |
| 3 | cluster = google_container_cluster.primary.name |
| 4 | location = "europe-north1" |
| 5 | |
| 6 | autoscaling { |
| 7 | min_node_count = 0 |
| 8 | max_node_count = 10 |
| 9 | } |
| 10 | |
| 11 | node_config { |
| 12 | spot = true |
| 13 | machine_type = "e2-standard-4" # Changed from n2-standard-8 |
| 14 | |
| 15 | labels = { |
| 16 | "node-type" = "spot" |
| 17 | "workload" = "general" |
| 18 | } |
| 19 | |
| 20 | taint { |
| 21 | key = "cloud.google.com/gke-spot" |
| 22 | value = "true" |
| 23 | effect = "NO_SCHEDULE" |
| 24 | } |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | resource "google_container_node_pool" "ondemand_baseline" { |
| 29 | name = "ondemand-baseline" |
| 30 | cluster = google_container_cluster.primary.name |
| 31 | location = "europe-north1" |
| 32 | |
| 33 | node_count = 3 # Fixed baseline, always available |
| 34 | |
| 35 | node_config { |
| 36 | spot = false |
| 37 | machine_type = "e2-standard-4" |
| 38 | |
| 39 | labels = { |
| 40 | "node-type" = "ondemand" |
| 41 | "workload" = "critical" |
| 42 | } |
| 43 | } |
| 44 | } |
Totala besparingar från spot-instansstrategin: $6 400/månad. Den viktigaste lärdomen: tänk inte på spot som allt-eller-inget. Den blandade strategin med PodDisruptionBudgets ger dig de flesta besparingarna med försumbar tillgänglighetspåverkan. Vi har kört denna konfiguration i fyra månader med noll kundsynliga incidenter hänförbara till spot-återtagning.
Smart schemaläggning
Our dev and staging clusters were running 24/7. Developers work roughly 08:00-18:00 CET. That means these environments were running - and billing - for 14 hours per day with zero users. We implemented automated scaling schedules that scale non-prod environments to zero outside business hours and on weekends.
| 1 | # CronJob: Scale non-prod to zero at 19:00 CET |
| 2 | apiVersion: batch/v1 |
| 3 | kind: CronJob |
| 4 | metadata: |
| 5 | name: scale-down-nonprod |
| 6 | namespace: kube-system |
| 7 | spec: |
| 8 | schedule: "0 19 * * 1-5" # Mon-Fri at 19:00 |
| 9 | jobTemplate: |
| 10 | spec: |
| 11 | template: |
| 12 | spec: |
| 13 | containers: |
| 14 | - name: scaler |
| 15 | image: bitnami/kubectl:1.28 |
| 16 | command: |
| 17 | - /bin/sh |
| 18 | - -c |
| 19 | - | |
| 20 | for ns in dev staging; do |
| 21 | for deploy in $(kubectl get deploy -n $ns -o name); do |
| 22 | # Save current replicas as annotation before scaling down |
| 23 | replicas=$(kubectl get $deploy -n $ns -o jsonpath='{.spec.replicas}') |
| 24 | kubectl annotate $deploy -n $ns --overwrite \ |
| 25 | cost-opt/original-replicas="$replicas" |
| 26 | kubectl scale $deploy -n $ns --replicas=0 |
| 27 | done |
| 28 | done |
| 29 | echo "Scaled down dev and staging at $(date)" |
| 30 | restartPolicy: OnFailure |
| 31 | |
| 32 | --- |
| 33 | # CronJob: Scale non-prod back up at 07:30 CET |
| 34 | apiVersion: batch/v1 |
| 35 | kind: CronJob |
| 36 | metadata: |
| 37 | name: scale-up-nonprod |
| 38 | spec: |
| 39 | schedule: "30 7 * * 1-5" # Mon-Fri at 07:30 |
| 40 | jobTemplate: |
| 41 | spec: |
| 42 | template: |
| 43 | spec: |
| 44 | containers: |
| 45 | - name: scaler |
| 46 | image: bitnami/kubectl:1.28 |
| 47 | command: |
| 48 | - /bin/sh |
| 49 | - -c |
| 50 | - | |
| 51 | for ns in dev staging; do |
| 52 | for deploy in $(kubectl get deploy -n $ns -o name); do |
| 53 | replicas=$(kubectl get $deploy -n $ns \ |
| 54 | -o jsonpath='{.metadata.annotations.cost-opt/original-replicas}') |
| 55 | kubectl scale $deploy -n $ns \ |
| 56 | --replicas=${replicas:-1} |
| 57 | done |
| 58 | done |
This saved $2,100/month. The schedule runs on weekdays only, and we added a Slack command (/wake-up-staging) for engineers who occasionally need the staging environment outside business hours. The command triggers a manual scale-up and automatically scales back down after 2 hours unless extended. Total off-hours usage since implementing the schedule: about 12 hours per month, confirming that the 24/7 running was pure waste.
Cost Monitoring
The biggest lesson from this entire exercise: optimization without monitoring is a one-time event. Costs will creep back up as new services get deployed with generous defaults, as node pools auto-scale and never scale back down, and as abandoned resources accumulate. We built a cost monitoring system that prevents regression.
We deploy Kubecost in our cluster to provide real-time cost attribution per namespace, deployment, and label. Every namespace has a monthly budget configured in Kubecost. When a namespace exceeds 80% of its budget, a Slack alert fires to the team that owns it. At 100%, it escalates to the platform team. This has shifted cost awareness from a quarterly finance conversation to a daily engineering concern.
We also run a weekly automated audit that checks for the most common cost regressions: pods with resource requests more than 3x their actual usage, PersistentVolumeClaims that have not been read in 30 days, and node pools with sustained utilization below 20%. The audit generates a report and opens Jira tickets for the top five offenders each week.
Additionally, we added a CI check that estimates the monthly cost of any new deployment before it merges. The check uses Infracost for Terraform changes and a custom script for Kubernetes manifests that multiplies resource requests by our blended node cost. Any PR that would increase monthly costs by more than $200 requires explicit approval from the platform team lead. This single gate has prevented more cost regressions than any monitoring dashboard.
Savings Timeline
The optimization was rolled out över four months. Here is the cumulative savings timeline, showing each major initiative and its impact.
Orphan cleanup: deleted 8 unused PVs, 3 abandoned namespaces, 12 completed jobs. Scheduled non-prod environments to scale down outside business hours.
Right-sizing Phase 1: updated resource requests for 28 of 47 pods based on VPA recommendations. Moved dev cluster entirely to spot instances.
Right-sizing Phase 2: remaining 19 pods. Production spot strategy deployed (30/70 split). Node pool machine types optimized (n2-standard-8 -> e2-standard-4).
Monitoring and governance deployed. Kubecost budgets enforced. CI cost estimation gate activated. Weekly automated audit started. Monthly steady-state reached at $7,800.
From $18,400/mo to $7,800/mo = $10,600/mo savings = $127,200/year
“Kubernetes does not optimize costs automatically. It optimizes resource scheduling. Cost optimization is a human problem that requires human discipline, continuous monitoring, and organizational accountability.”
- Simon Axelsson
If you are running Kubernetes in production and have never conducted a resource audit, I can almost guarantee you are overspending by 40-60%. The optimizations described here are not exotic - right-sizing, spot instances, scheduling, and monitoring are all well-documented practices. The hard part is not the technology. It is making someone responsible, giving them time, and building the organizational habits that prevent regression. Start with the audit. The numbers will make the business case for everything else.
Simon Axelsson
IT-konsult & teknisk rådgivare
Simon Axelsson is a senior IT consultant and founder of SIAX Technology AB in Angelholm. He helps Nordic companies with cloud infrastructure, data platforms, and AI automation. He believes the best infrastructure is the infrastructure you do not pay for.