Back to case studies

FinOps · Cost Optimization

Kubernetes to Cloud Run — and why it was the right call

A GKE cluster running one pod per service was billing for two always-on worker nodes it didn't need. Moving to Cloud Run cut the bill from $400 to $220 a month — and the security posture came out stronger, not weaker.

Technologies

GCP · Cloud Run · GKE · Cloud Load Balancing · Cloud Armor · Cloudflare · Cloud SQL

Kubernetes to Cloud Run — and why it was the right call architecture diagram

Overview

The setup was a GKE cluster with two e2-standard-4 worker nodes carrying three containerized services — a frontend, a backend API, and an internal portal — alongside a handful of Kubernetes CronJobs, with Cloud SQL already living outside the cluster. It cost around $400 a month and, most of the time, was running exactly one pod per service.

That is the whole problem in a sentence. There was no burst traffic to absorb, no horizontal scaling to orchestrate, and no stateful workload that needed to sit on a node. What there was instead was a bill for two worker nodes that stayed alive whether anyone was using the product or not, plus development and sandbox environments charged for twenty-four hours a day even when nobody had touched them in a week — and a standing tax of cluster upgrades, node management, and capacity planning paid on behalf of three stateless HTTP services.

Moving those services to Cloud Run brought the monthly bill to roughly $220, a 45% reduction. The application code was essentially untouched; what changed was where it ran. And because the migration was an opportunity to rebuild the ingress path rather than lift it across unchanged, the result is also more locked down than what it replaced: nothing is publicly reachable, traffic is filtered through several layers before it arrives, and the database never had a public endpoint to begin with.

The Challenge

Context

The product ran on a GKE cluster with two e2-standard-4 worker nodes hosting three containerized services — frontend, backend API, and an internal portal — plus several Kubernetes CronJobs for background work, with Cloud SQL already provisioned outside the cluster. It cost roughly $400 a month. The uncomfortable detail was that most of the time there was a single pod running for each service: no burst traffic to absorb, no autoscaling events to orchestrate, nothing that justified a cluster. On top of that, development and sandbox environments were billed around the clock even when nobody had opened them in days, and the team was paying an ongoing operational tax — Kubernetes upgrades, worker node management, capacity planning — on behalf of three stateless HTTP services.

Problem Space

Bring the bill down substantially without rewriting the application, without introducing cold-start latency on the paths users actually feel, and without weakening a single security control in the process. Just because Kubernetes can run a workload does not mean it should — but proving that in practice means the replacement has to be genuinely better on every axis, not just cheaper.

Approach

The platform decision came from the workload, not the other way round. Cloud Run fit for three specific and checkable reasons: the services were already containerized, they were stateless, and they communicated over HTTP. Those three properties are exactly what a request-driven serverless container platform is built for, and they were already true — which meant the migration was a change of venue rather than a rewrite. The application code remained almost entirely untouched; what changed was where it ran. Nothing about the move required the team to adopt a new framework, restructure the services, or take on a migration project that would outlast the savings it produced. The reasoning generalizes: the best architecture is not the most advanced one available, it is the one that matches the shape of the workload in front of you, and a cluster orchestrating one pod per service is a mismatch no amount of tuning will fix.

Environments were tuned separately, because they have different jobs. The three production services were deployed to Cloud Run with a minimum instance count of one. That keeps a warm container resident at all times, which means cold starts never reach a user and the perceived performance of the product is unchanged from the cluster it replaced — the point of the exercise was to cut cost, not to quietly trade it for latency. Development and sandbox environments were configured the opposite way, with minimum instances set to zero. When nobody is using them they simply disappear: no running containers, no idle CPU, no charge. This single split is where a large share of the saving comes from, because those environments had previously been billed continuously for capacity that went unused most of the week.

A concurrency problem surfaced, and the fix was counterintuitive. Shortly after the move, the backend started behaving oddly: under a light load of logins, Cloud Run would spin up three and sometimes four containers almost immediately, despite utilization being nowhere near a level that warranted it. The cause turned out to be the CPU allocation. At 0.5 vCPU, Cloud Run does not allow request concurrency to be configured the way it does at 1 vCPU, so each instance was effectively handling very few concurrent requests and the platform was compensating by scaling out horizontally. Raising the backend to 1 vCPU and setting an appropriate concurrency value fixed it: a single container then handled multiple simultaneous login requests exactly as expected. The lesson is worth stating plainly — allocating less CPU is not automatically cheaper, because an under-provisioned instance can force the platform to run several of them where one properly sized instance would have done.

The ingress path was rebuilt in layers rather than lifted across. Traffic now passes through several independent filters before it reaches any application code. Cloudflare sits at the edge and provides automatic HTTPS, TLS termination, DDoS mitigation, a web application firewall, rate limiting, bot protection, DNS proxying, origin IP hiding, and IP reputation filtering — a substantial amount of protection that arrives essentially for free. Behind it, a Google Cloud Load Balancer acts as the single entry point into the platform, and Cloud Armor is configured to accept requests only from Cloudflare's published IP ranges, which closes the obvious hole of an attacker discovering the origin and going around the edge entirely. The Cloud Run services themselves are not publicly accessible at all; they only ever receive requests from the load balancer. Each layer is independently useful, and a request has to satisfy all of them.

Database connectivity moved to Direct VPC Egress. Reaching Cloud SQL privately from a serverless platform traditionally meant a Serverless VPC Access Connector, which quietly runs its own always-on connector VMs and bills for them regardless of traffic — precisely the kind of idle cost the migration existed to remove. Direct VPC Egress replaces it: Google charges only for the network traffic itself, there are no permanently running connector instances to pay for, and it delivers roughly twice the throughput of the legacy connector. Cloud Run containers now talk to Cloud SQL entirely over Google's private network, and the database has no public endpoint at all. It is a rare change that improves cost, throughput, and security simultaneously, and it removed the last always-on component from the architecture.

What was left over was operational simplicity. With the worker nodes gone, an entire category of recurring work disappeared with them: no Kubernetes version upgrades to schedule and test, no node pools to size or drain, no cluster capacity planning to revisit as the product changed. The architecture ended up with meaningfully fewer moving pieces than it started with, which makes it easier to reason about and easier to operate day to day. That is the part that does not show up on an invoice but tends to matter most over a year — the same three services, doing the same work, on a platform that asks far less of the people maintaining it.

Outcomes

  • Monthly GCP spend fell from roughly $400 to $220 — a 45% reduction
  • Both worker nodes eliminated, along with cluster upgrades, node management, and capacity planning
  • Development and sandbox environments scale to zero and cost nothing when idle
  • Production kept a warm instance, so user-facing performance was unchanged by the move
  • Cloud Run services are not publicly accessible; Cloud Armor admits only Cloudflare IP ranges
  • Cloud SQL reachable only over private networking via Direct VPC Egress — no public database endpoint, no always-on connector VMs

More Work

View all

Facing a similar architectural challenge?

A 30-minute architecture review usually surfaces the quickest reliability and FinOps wins — no pitch, just a look at what you've got.

Ready for dispatch

Click to establish handshake link

Or find me here
Abdullah