Back to Blog

Kubernetes Cost Optimization: A Practical Guide for Platform Teams

Most Kubernetes cost advice is either a vendor pitch or a list of twenty tools to evaluate. This guide takes a narrower position: the bulk of wasted Kubernetes spend comes from a handful of specific gaps, and each one closes with configuration you can read and reason about. The examples use Convox, a platform that provisions and operates Kubernetes clusters inside your own cloud account, which means every dollar discussed here is a dollar on your own cloud bill. The mechanics apply even if you run Kubernetes some other way.

Why Kubernetes bills balloon

A Kubernetes bill is mostly a stack of defaults nobody revisited.

Resource requests are guesses that never get corrected. The scheduler packs nodes by requested capacity, not used capacity. A service that requests one full CPU and uses 80 millicores still reserves the whole CPU, and the cluster adds nodes to honor reservations. Multiply one padded request across ten replicas and three environments and you are paying for a phantom workload.

Replica counts are sized for peak, permanently. A static count that handles the Monday morning spike runs all weekend too.

Node capacity is bought at the most expensive rate. On demand pricing is the default everywhere, even for workloads that would tolerate interruption.

Non-production runs 168 hours a week. Staging and development clusters routinely sit idle outside of a 50 hour work week.

Nobody can see cost per service. The cloud invoice arrives aggregated by instance type. When no engineer can name the most expensive service, there is no feedback loop, and requests, counts, and node pools stay padded.

Each of these is a lever. Here is how to pull them.

Lever 1: Right-size what each service requests

In a Convox app, each service declares its CPU and memory in convox.yml, with CPU in units where 1000 equals one full CPU:

services:
  web:
    scale:
      count: 2
      cpu: 250
      memory: 512

The honest problem with right-sizing is that nobody knows the right numbers up front. On AWS racks, Convox supports the Vertical Pod Autoscaler to measure them for you. Enable it once on the rack with convox rack params set vpa_enable=true, then configure it per service:

services:
  web:
    scale:
      count: 3
      vpa:
        updateMode: Initial
        minCpu: "100"
        maxCpu: "2000"
        minMem: "256"
        maxMem: "4096"

updateMode: Off generates recommendations without applying anything, which is the right first step: run it for a week against real traffic, compare the recommendations to what you declared, and adjust. Initial applies recommendations whenever a pod is created, and Recreate evicts pods to apply significant changes. The min and max bounds keep the autoscaler from making decisions you would not make yourself.

Lever 2: Autoscale on real signals, including down to zero

Right-sized replicas still waste money if the count never moves. The baseline fix works on every provider Convox supports, using the native Kubernetes horizontal autoscaler:

services:
  web:
    scale:
      count: 1-10
      targets:
        cpu: 70
        memory: 90

On AWS racks, the newer scale.autoscale block (backed by KEDA, enabled with keda_enable=true) goes further. It scales on CPU, memory, GPU utilization, queue depth from any Prometheus metric, or any custom KEDA trigger, and when several triggers are set, whichever demands the most replicas wins:

services:
  worker:
    scale:
      min: 0
      max: 10
      autoscale:
        queueDepth:
          threshold: 5

min: 0 is the part worth noticing: the service scales to zero when there is no work. A service at zero shows a COLD status, and the first request or trigger activation pays a cold start, so this is not for a latency-sensitive checkout path. It is exactly right for queue workers, scheduled batch processors, internal tools, and staging services, which is where a surprising share of standing capacity usually lives. Scale-down timing is controlled by cooldownPeriod (default 300 seconds) and pollingInterval (default 30 seconds), so a brief quiet spell does not thrash the service off and on.

Lever 3: Buy the same compute cheaper with spot

On AWS, spot capacity sells the same instance types at prices commonly 60 to 90 percent below on demand rates, by AWS's own published numbers. The tradeoff is a two minute reclaim notice. The engineering question is not whether to use spot, it is which workloads tolerate interruption and how to keep a floor under the rest.

The blunt instrument is the rack-wide node_capacity_type parameter: on_demand (the default), spot, or mixed. Mixed creates one on demand node group and two spot groups, and pairs with min_on_demand_count and max_on_demand_count so a spot reclaim can never take your baseline capacity with it.

On AWS racks, Karpenter is the sharper instrument. With karpenter_enabled=true and karpenter_capacity_types=on-demand,spot, it selects the cheapest instance type and capacity type that satisfies your pods, falls back to on demand when spot is unavailable, and keeps system nodes on demand regardless. Consolidation is on by default: underutilized nodes are drained and removed about 30 seconds after they qualify, which also cleans up the slow node-pool bloat that accumulates between deploys. Build nodes scale back to zero after the last build completes, so you stop paying for a build machine that compiles twenty minutes a day. You can constrain it with karpenter_instance_families and karpenter_instance_sizes if your compliance or performance envelope requires it.

The targeted approach works per workload. Define a spot node group with additional_node_groups_config:

[
  {
    "id": 102,
    "type": "c5.large",
    "capacity_type": "SPOT",
    "min_size": 0,
    "max_size": 10,
    "label": "batch-workers"
  }
]

Then pin the interruption-tolerant service to it:

services:
  worker:
    nodeSelectorLabels:
      convox.io/label: batch-workers

Retry-safe queue consumers run on spot nodes that scale to zero when idle. The API keeps its on demand nodes. Nobody has to be brave about it.

Lever 4: See spend per app, then cap it

None of the levers above stay pulled without visibility. Convox racks on AWS, Azure, and GCP include built-in cost tracking, enabled with a single parameter:

$ convox rack params set cost_tracking_enable=true

Every 10 minutes, each running pod is charged for its share of its node's hourly rate, and the charges roll up per service and per app. convox cost --app myapp shows the breakdown in the terminal, the Convox Console shows it in a budget panel, and daily history is kept per service (62 days by default). One caveat worth knowing before you quote these numbers in a meeting: costs come from list-price tables built into the rack, with spot nodes estimated at a fraction of the on demand rate rather than live spot market prices. It is a feedback loop for engineers, not a replacement for your invoice. For ground truth, the cluster is in your own account, so your cloud provider's own billing tools see every instance too.

Visibility enables the lever Kubernetes does not give you natively: a per-app budget cap.

$ convox budget set myapp --monthly-cap 500 --alert-at 75 --at-cap-action block-new-deploys

When an app hits its monthly cap, the action you chose fires. alert-only, the default, sends events and webhooks. block-new-deploys rejects new deploys, promotes, scales, and runs while leaving running services untouched. auto-shutdown starts a countdown and then scales eligible services to zero in a defined order, with agent and stateful services exempt. convox budget simulate-shutdown myapp previews exactly what would happen without touching anything, and convox budget reset restores scaled-down services once a human has looked. Raising or setting a cap requires an admin, and spend resets on the first of the month. For a platform team running dozens of apps for other teams, this converts cost review from a monthly archaeology exercise into a boundary that enforces itself.

Two smaller levers worth taking

Schedule non-production down. The rack parameters schedule_rack_scale_down and schedule_rack_scale_up take cron expressions in UTC. Scaling a staging rack down on Friday evening and up on Monday morning removes roughly a third of its weekly hours without anyone changing their workflow.

Share and release GPUs (AWS racks). GPU instances are the most expensive capacity you can leave idle. On AWS racks, nvidia_device_time_slicing_replicas=4 divides each physical GPU into four shared virtual replicas, which suits development work and bursty inference, though not workloads needing guaranteed isolation, since GPU memory is shared across tenants. And the gpuUtilization autoscale trigger combined with min: 0 lets an inference service release its GPU capacity entirely when idle instead of holding the most expensive instance in your fleet overnight.

Where the savings actually land

Convox runs these clusters in your own infrastructure, under your own cloud account. That changes the economics of every lever above: the savings appear on your own invoice where you can verify them, there is no managed-hosting margin layered on top of your compute, and on AWS, any Savings Plans or committed-use discounts you already hold keep applying, because it is still your account's usage.

If you would rather test the levers than read about them: install a rack in your account, deploy an app, set cost_tracking_enable=true, and look at convox cost after a day of real traffic. The padded request you find first usually pays for the afternoon. Start at console.convox.com or with the installation docs.

Let your team focus on what matters.