When Kubernetes becomes the problem it was supposed to solve
Here's a question worth asking before you adopt Kubernetes: do you actually have the problem it solves? A six-person SaaS team didn't, and it took them two years and EUR 14,200 a month to notice.
This is a breakdown of how we helped them move off Kubernetes entirely and cut their infrastructure bill by 46%, with faster deploys and better uptime as a side effect.
The setup
The product: a B2B analytics platform built on Laravel, Vue, PostgreSQL (primary plus one replica), Redis, and a few background workers handling webhooks and scheduled reports.
The infrastructure: three managed Kubernetes clusters (production, staging, and an "experiments" cluster nobody could justify), each with its own node pools, ingress controller, cert-manager instance, and full Prometheus/Grafana stack.
The team had adopted Kubernetes two years earlier on a contractor's recommendation, during a growth phase. The contractor was long gone. Nobody had revisited whether the setup still made sense.
Symptoms:
- Deploys took 8-12 minutes and often failed at the Helm chart stage with vague errors.
- Only 2 of 6 engineers could confidently debug the manifests under pressure.
- Autoscaling never behaved as expected, so the team hardcoded static replica counts, which quietly defeated the purpose of having autoscaling at all.
- Nobody knew the recovery plan if the Kubernetes control plane went down.
No outages yet, but the team was burning roughly 15 hours a week just keeping the clusters alive. That's a real cost, even if it never shows up on an invoice.
What the audit found
Two weeks of pure observation before touching anything: resource requests vs. actual usage, node utilization across a full business cycle, and a full pass through the cloud bill.
- Average CPU utilization across production nodes: 11%
- Average memory utilization: 23%
- Peak traffic over 90 days never came close to what a single well-sized server could handle
- 42 pods running across three clusters for what mapped out to exactly 6 logical services
- Node pools sized for an 8x traffic spike projected 18 months earlier that never materialized
Also found: three separate managed load balancers, a duplicated logging pipeline shipping the same data to two destinations (leftover from an incomplete migration), and a staging cluster running 24/7 at near-production size for about six hours of actual weekly use.
None of this was negligence. It's what happens when infrastructure gets provisioned for a future that doesn't arrive, and nobody goes back to check.
Why we moved off Kubernetes instead of fixing it
Kubernetes earns its complexity when you need multi-team deployment isolation, workloads that scale from zero to hundreds of pods, or shared infra across many independently-owned services. This team had six services, six engineers, and two deploys a week. None of that applied.
We proposed a simpler stack: a small fleet of dedicated servers behind a load balancer, running the app via Docker Compose and systemd, with PostgreSQL and Redis managed directly instead of as in-cluster StatefulSets.
The reasoning:
- Operational match. A team of six with two Kubernetes-literate engineers was running infra built for a team of thirty. Simplifying meant everyone could debug production, not just two people.
- Cost shape. Control planes, redundant node pools, and managed add-ons carry fixed overhead no matter the load. At 11% utilization, that overhead buys nothing.
- Reasoning about failure. Every engineer already understood "systemd restarts a crashed process." Nobody wanted to debug a CrashLoopBackOff caused by a readiness probe fighting an HPA.
To be clear: this isn't a "Kubernetes bad" take. If this team ran 20+ independently deployed services or needed to scale a component to hundreds of replicas during traffic spikes, we'd have kept Kubernetes and just fixed the sizing. The decision follows the workload, not a philosophy.
The migration, phase by phase
Zero-downtime migration over six weeks, not a cutover weekend.
Phase 1: right-size based on observed peak, not theoretical peak
- 2x app servers (8 vCPU, 16 GB RAM) behind HAProxy, running Docker Compose
- 1x PostgreSQL primary (8 vCPU, 32 GB RAM) with a streaming replica
- 1x Redis instance with persistence enabled
- 2x worker nodes, scaled by systemd timers and a basic job-count check instead of an HPA
That's roughly a third of the node count the Kubernetes setup needed, because there's no pod scheduling overhead, no duplicated system pods per node, and no headroom reserved for a scaling event that never happened.
Phase 2: deployment pipeline
Replaced the Helm-based CI/CD with build-and-rsync:
deploy:
stage: deploy
script:
- docker build -t app:$CI_COMMIT_SHA .
- docker save app:$CI_COMMIT_SHA | gzip | ssh deploy@app-01 'docker load'
- ssh deploy@app-01 'docker compose up -d --no-deps app'
- ./scripts/healthcheck.sh app-01
- docker save app:$CI_COMMIT_SHA | gzip | ssh deploy@app-02 'docker load'
- ssh deploy@app-02 'docker compose up -d --no-deps app'
HAProxy pulls each node out of rotation during its deploy window, so the API stays up throughout:
backend app_servers
balance roundrobin
option httpchk GET /health
server app-01 10.0.1.10:8080 check inter 2s fall 2 rise 3
server app-02 10.0.1.11:8080 check inter 2s fall 2 rise 3
Phase 3: database and cache
PostgreSQL moved to dedicated hardware with tuned parameters (shared_buffers at 25% of RAM) and proper connection pooling via PgBouncer, which had been missing entirely in the cluster setup. Redis moved to a standalone instance with AOF persistence; previously it ran as an ephemeral pod that lost all queue state on every restart. That data loss bug had been open for four months before the audit.
Phase 4: monitoring and runbooks
Consolidated three separate Prometheus/Grafana stacks into one, and wrote runbooks for the five most common failure scenarios: database failover, worker backlog, disk pressure, certificate renewal, and application rollback.
Results, six months later
| Metric | Before (K8s) | After (dedicated servers) |
|---|---|---|
| Monthly infra cost | EUR 14,200 | EUR 7,650 |
| Deploy time | 8-12 min | 90 sec |
| p95 API response time | 410ms | 190ms |
| Engineers who can debug production | 2 of 6 | 6 of 6 |
| Weekly ops time (team-wide) | ~15 hours | ~4 hours |
| Uptime (trailing 6 months) | 99.91% | 99.97% |
Takeaway
Kubernetes isn't the villain here. The villain is infrastructure decisions that outlive the assumptions that justified them. If your cluster utilization is sitting in the teens and only two people on the team can debug a failed deploy, it might be worth asking whether you're solving a problem you actually have.
Full writeup with more context: binadit.com
Originally published on binadit.com
Top comments (0)