On this page
The Cost Problem Is Real, But The Fix Is Not Free
A familiar EKS cost problem appears when every module gets its own ALB-backed Ingress and the number of idle load balancers grows with the number of exposed services. AWS Elastic Load Balancing charges for each hour or partial hour that an Application Load Balancer or Network Load Balancer is running, then adds capacity-unit and related charges on top. If four low-traffic services each get their own ALB, the base hourly floor alone is already four times larger than a single shared edge.
But “use Istio Gateway” is not a complete answer. It moves complexity from cloud load balancer count into gateway operations, certificate ownership, routing correctness, blast radius, and quota management.
The local lab in labs/istio-gateway-cost-lab/ does not create AWS resources or claim to reproduce an AWS bill. It proves the routing shape locally with kind and Istio:
- Four public hostnames.
- Four backend Kubernetes Services.
- One Istio ingress gateway Service.
- One Istio
Gateway. - Four
VirtualServiceobjects attached to the shared gateway.
Then it calculates the base load-balancer hourly floor separately from AWS’s current pricing examples.
The Baseline: One ALB-Style Ingress Per Module
The baseline is easy to understand and easy to operate at small scale:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api-alb
namespace: apps
annotations:
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/target-type: ip
alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:region:account:certificate/api-placeholder
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS":443}]'
spec:
ingressClassName: alb
rules:
- host: api.local.test
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: api
port:
number: 8080
Each service owns its own edge resource. That is attractive because the blast radius is small: if the api ingress is wrong, admin does not necessarily break. DNS, certificate, WAF, and access log ownership are also easier to reason about per module.
The cost downside is the number of load balancers. In the lab’s baseline manifest, four hostnames produce four ALB-style Ingress objects:
api.local.test
admin.local.test
billing.local.test
backoffice.local.test
That is a reasonable choice for high-value production services. It becomes wasteful for low-traffic development, release, admin, or internal-facing modules that still need stable HTTPS exposure.
The Shared Gateway Model
The shared model puts the external edge behind one gateway Service. In EKS, that Service is commonly backed by one NLB. Istio’s ingress gateway then handles host-based routing through Gateway and VirtualService resources.
The lab’s Gateway is intentionally small:
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
name: shared-edge-gateway
namespace: istio-system
spec:
selector:
istio: ingressgateway
servers:
- port:
number: 80
name: http
protocol: HTTP
hosts:
- api.local.test
- admin.local.test
- billing.local.test
- backoffice.local.test
Each application gets a route:
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: billing-route
namespace: apps
spec:
hosts:
- billing.local.test
gateways:
- istio-system/shared-edge-gateway
http:
- route:
- destination:
host: billing.apps.svc.cluster.local
port:
number: 8080
The key difference is ownership. The cloud load balancer is no longer the only routing object. It becomes the entry point to an Envoy gateway, and the actual host routing lives in Istio configuration.
Local Lab
Run the lab:
cd labs/istio-gateway-cost-lab
./scripts/run-lab.sh
The successful rerun used ID 20260710T023921Z.
Tooling:
| Tool | Version from the run |
|---|---|
| Docker | 29.3.0 |
| kind | v0.31.0 |
| kubectl client | v1.34.1 |
| Helm | v4.1.4 |
The script creates a kind cluster, installs Istio with Helm, deploys four hashicorp/http-echo services, creates a local self-signed TLS secret, applies the shared Gateway, VirtualServices, DestinationRule, PDB, and HPA manifests, scales the ingress gateway to two replicas, port-forwards ports 80 and 443, and then runs HTTP, HTTPS, load-loop, failure, and rollback checks.
The gateway Service looked like this in the local cluster:
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE SELECTOR
istio-ingressgateway LoadBalancer 10.96.173.72 <pending> 15021:32402/TCP,80:30705/TCP,443:30744/TCP 20s app=istio-ingressgateway,istio=ingressgateway
The <pending> external IP is expected in a plain kind cluster. The lab uses kubectl port-forward to exercise the gateway locally.
Istio resources:
NAMESPACE NAME AGE
istio-system shared-edge-gateway 1s
NAMESPACE NAME GATEWAYS HOSTS
apps admin-route ["istio-system/shared-edge-gateway"] ["admin.local.test"]
apps api-route ["istio-system/shared-edge-gateway"] ["api.local.test"]
apps backoffice-route ["istio-system/shared-edge-gateway"] ["backoffice.local.test"]
apps billing-route ["istio-system/shared-edge-gateway"] ["billing.local.test"]
NAMESPACE NAME HOST
apps echo-services *.apps.svc.cluster.local
Route checks:
### api.local.test
api service via shared istio gateway
### admin.local.test
admin service via shared istio gateway
### billing.local.test
billing service via shared istio gateway
### backoffice.local.test
backoffice service via shared istio gateway
The full evidence is saved under /resources/blog/post-72-istio-gateway/.
The rerun also verified HTTPS routing through the Istio gateway. The script generated a one-day self-signed certificate for the four *.local.test hostnames and stored it as a Kubernetes TLS secret named shared-local-tls. Then it port-forwarded 18443:443 and called each hostname with curl --resolve:
### api.local.test
api service via shared istio gateway
### admin.local.test
admin service via shared istio gateway
### billing.local.test
billing service via shared istio gateway
### backoffice.local.test
backoffice service via shared istio gateway
That is not a production certificate workflow, but it proves the Istio-side TLS termination path used by the manifest. In production, I would replace this with cert-manager, ACM-backed termination at the NLB, or a centrally managed secret rotation process.
The load loop was intentionally small, but it keeps the route checks from being a single lucky request:
api.local.test ok_requests=20
admin.local.test ok_requests=20
billing.local.test ok_requests=20
backoffice.local.test ok_requests=20
For failure and rollback, the lab patched the billing-route VirtualService to a missing backend host, observed a failed route, then reapplied the known-good manifest:
### before failure
billing service via shared istio gateway
### after bad route patch
http_code=503
body=
### after rollback
billing service via shared istio gateway
That is the operational check I care about. A shared gateway design must have a boring rollback path, not only a happy-path route.
The gateway scale and availability objects were also captured:
NAME READY UP-TO-DATE AVAILABLE
istio-ingressgateway 2/2 2 2
poddisruptionbudget.policy/istio-ingressgateway MIN AVAILABLE 1
horizontalpodautoscaler.autoscaling/istio-ingressgateway MINPODS 2 MAXPODS 4
The HPA shows unknown CPU in kind because the lab does not install metrics-server. That is expected. The manifest still documents the autoscaling contract, while the actual clean run proves that two gateway replicas can be rolled out in the local cluster.
Cost-Floor Model
The lab’s cost script compares the controlled manifests:
| Model | Public hostnames | Load-balancer-shaped resources | 730-hour monthly floor |
|---|---|---|---|
| Per-module ALB Ingress | 4 | 4 | $65.70 |
| Shared Istio Gateway/NLB | 4 | 1 | $16.43 |
| Difference | 0 | -3 | -$49.28 |
The formula is deliberately limited:
load_balancer_count * 0.0225 USD/hour * 730 hours
The 0.0225 USD/hour value comes from the AWS ELB pricing examples for US-East-1, checked on 2026-07-10. AWS also charges for LCU/NLCU usage, data transfer, public IPv4 addresses, and other services that may sit in front of or behind the load balancer. NAT Gateway cost is not part of this public ingress path unless the architecture sends egress or private-subnet flows through NAT; the model lists it separately as excluded rather than pretending it is zero everywhere.
The script also prints a simplified traffic-dependent estimate. It uses processed bytes only, so it is not a bill and it does not include Envoy worker-node cost:
| Profile | Processed bytes | Capacity units used for estimate | 4 ALB model | 1 NLB + Istio model | Difference |
|---|---|---|---|---|---|
| low | 0.3 MB/s | 1.08 | $72.01 | $21.16 | -$50.85 |
| medium | 5.0 MB/s | 18.00 | $170.82 | $95.27 | -$75.56 |
| high | 25.0 MB/s | 90.00 | $591.30 | $410.63 | -$180.68 |
So the claim is not “Istio saves exactly $49.28.” The claim is:
Consolidating four low-traffic public edges into one shared gateway removes three running load-balancer hourly floors before traffic-dependent charges.
That is a narrower and more defensible statement. At higher traffic, the answer depends on bytes, new connections, active connections, rule evaluations, cross-zone behavior, gateway pod size, and whether ALB L7 features such as WAF integration are replacing logic that would otherwise move into Envoy.
TLS And Certificates
A shared NLB path can terminate TLS with attached ACM certificate ARNs, but that is not the only design.
There are two common options:
| TLS model | Where TLS terminates | What to check |
|---|---|---|
| NLB TLS listener | AWS NLB terminates TLS and forwards HTTP/TCP to the gateway | ACM certificate list, listener config, AWS Load Balancer Controller annotations, certificate quota |
| Istio gateway TLS | NLB passes traffic to Envoy and Istio terminates TLS with Kubernetes secrets | Secret ownership, SDS behavior, gateway TLS config, namespace boundaries, cert-manager workflow |
The local lab exercises the second option with a self-signed secret:
apiVersion: networking.istio.io/v1
kind: Gateway
spec:
servers:
- port:
number: 443
name: https
protocol: HTTPS
tls:
mode: SIMPLE
credentialName: shared-local-tls
The sample patch in the lab shows the NLB-annotation style:
{
"metadata": {
"annotations": {
"service.beta.kubernetes.io/aws-load-balancer-type": "external",
"service.beta.kubernetes.io/aws-load-balancer-nlb-target-type": "ip",
"service.beta.kubernetes.io/aws-load-balancer-scheme": "internet-facing",
"service.beta.kubernetes.io/aws-load-balancer-ssl-cert": "arn:aws:acm:region:account:certificate/api-placeholder,arn:aws:acm:region:account:certificate/admin-placeholder",
"service.beta.kubernetes.io/aws-load-balancer-backend-protocol": "http",
"service.beta.kubernetes.io/aws-load-balancer-ssl-ports": "443"
}
}
}
In a real cluster, I would not apply that blindly. I would first decide whether TLS belongs at the AWS edge or in Istio, then verify certificate count, rotation ownership, access logs, and whether the application needs client certificate or SNI-specific behavior.
Quotas Are Part Of The Design
AWS quota pages matter more as soon as one gateway represents many applications.
The NLB quota page lists limits such as certificates per NLB, listeners per NLB, NLBs per Region, and targets per Availability Zone per NLB. The ALB quota page lists its own certificate, rule, target, and load-balancer quotas.
The engineering check is simple:
number of hosts
number of certificates
number of listeners
number of backend Services
number of gateway pods
number of target registrations per AZ
expected connections and bytes
If one shared gateway approaches those limits, the cost-saving design becomes a scaling risk. At that point, split by environment, traffic class, domain ownership, or service criticality.
Operational Trade-Offs
The shared gateway pattern is useful when the services are low traffic, operationally related, and owned by a team that can operate the ingress plane well.
It is risky when unrelated systems are put behind the same edge only because the bill looks cleaner.
The main trade-offs:
| Concern | Per-module ALB | Shared Istio Gateway |
|---|---|---|
| Load-balancer hourly floor | Higher as modules increase | Lower |
| Failure blast radius | Smaller | Larger |
| Routing flexibility | AWS ALB rules and controller behavior | Istio routing model |
| Certificate ownership | Per ingress or group | Centralized or gateway-managed |
| Debug path | AWS LB -> target group -> Service | AWS LB -> Envoy gateway -> VirtualService -> Service |
| Required platform skill | Kubernetes Ingress and AWS LB controller | Istio, Envoy, gateway operations, mesh observability |
For production, I would add at least:
- Two or more ingress gateway replicas.
- PodDisruptionBudget for the gateway.
- HPA or fixed capacity sized from connection and CPU data.
- Readiness checks and synthetic route checks per hostname.
- Access logs and Envoy metrics.
- Alerting on 5xx, route misses, and gateway saturation.
- A documented certificate rotation path.
- A clear split rule for when a service must get its own edge.
The lab now includes the first three as manifests or execution steps: gateway-ha-overlays.yaml defines a PDB, HPA, and DestinationRule; run-lab.sh scales the gateway deployment to two replicas and records the result. It does not prove multi-AZ behavior because kind is a single local cluster, so cross-zone target registration and zonal failure handling remain cloud-environment validation items.
Layer 4 versus Layer 7 is the core architecture decision. An NLB in front of Istio keeps the cloud edge simple and can preserve source IP depending on target type and configuration, but HTTP routing, retries, mTLS policy, and per-host behavior move into Envoy. An ALB Ingress keeps AWS-native L7 routing, health checks, and WAF attachment closer to the edge, but multiplying ALBs increases fixed hourly cost and rule ownership. HTTP/2 and gRPC make this choice sharper: verify protocol negotiation, idle timeouts, and whether TLS terminates at the NLB, ALB, or Istio gateway.
When I Would Use It
I would use a shared Istio Gateway for:
- Development and release environments.
- Low-traffic admin tools.
- Internal dashboards that still need stable DNS and HTTPS.
- Services owned by the same platform or product team.
- Hostnames whose certificate and DNS ownership is already centralized.
I would avoid it, or split the gateway, for:
- High-traffic customer-facing paths.
- Services with separate compliance boundaries.
- Services with incompatible TLS requirements.
- Teams that cannot safely operate Istio routing.
- Any route where a shared gateway outage would be unacceptable.
Sources And Reproduction
- Lab code:
labs/istio-gateway-cost-lab/ - Cost summary:
/resources/blog/post-72-istio-gateway/cost-summary.md - Route checks:
/resources/blog/post-72-istio-gateway/route-checks.txt - TLS route checks:
/resources/blog/post-72-istio-gateway/tls-route-checks.txt - Load loop:
/resources/blog/post-72-istio-gateway/load-loop.txt - Failure and rollback output:
/resources/blog/post-72-istio-gateway/failure-rollback.txt - Gateway HA objects:
/resources/blog/post-72-istio-gateway/gateway-ha-objects.txt - Gateway and VirtualService output:
/resources/blog/post-72-istio-gateway/gateway-virtualservices.txt - AWS ELB pricing: Elastic Load Balancing pricing
- AWS NLB quotas: Quotas for your Network Load Balancers
- AWS ALB quotas: Quotas for your Application Load Balancers
- Istio Gateway reference: Gateway
- Istio VirtualService reference: VirtualService
- Istio Helm install: Install with Helm