Skip to main content
Source

This page is generated from skills/eks-cost-intelligence/references/networking-costs.md. Edit the source, not this page.

Networking Costs

Part of: eks-cost-intelligence Purpose: Checks for topology-aware routing configuration, instance mode vs IP mode on load balancers, VPC endpoints for ECR/S3/STS, cross-AZ traffic potential based on service topology and pod distribution, and NAT Gateway cost estimation


Overview

Networking costs is a mid-weight dimension (15 points max deduction). It evaluates whether the cluster minimizes cross-AZ data transfer charges ($0.01/GB each direction), avoids unnecessary NAT Gateway processing fees ($0.045/GB), and uses efficient load balancer target modes.

Cross-AZ traffic is often the largest hidden cost in multi-AZ EKS clusters. A single service with pods spread across 3 AZs and no topology-aware routing can generate significant monthly charges that are invisible without deliberate inspection.

Checks Summary

#CheckDefault ThresholdSeverity Logic
1Topology-aware routing on cross-AZ servicesMissing on services with cross-AZ podsBy estimated cross-AZ cost
2Instance mode vs IP mode on load balancersInstance mode with cross-AZ targetsMEDIUM per LB
3VPC endpoints for ECR, S3, STSMissing endpointMEDIUM (per Req 7.5)
4Cross-AZ traffic potentialServices with pods in multiple AZsBy estimated monthly cost
5NAT Gateway cost estimationAWS service traffic without VPC endpointsBy estimated NAT cost

Pre-requisites

These checks require:

  • kubectl access to the cluster (for service specs, pod distribution, annotations)
  • AWS CLI access for ec2:DescribeVpcEndpoints, ec2:DescribeSubnets, ec2:DescribeNatGateways
  • Optional: CloudWatch metrics for actual data transfer volumes (improves estimate accuracy)

No metrics-server is required — checks use configuration inspection and traffic estimation.


Check 1: Topology-Aware Routing Configuration

What it detects

Services with pods distributed across multiple availability zones that do not have topology-aware routing enabled, causing unnecessary cross-AZ traffic at $0.01/GB in each direction ($0.02/GB round-trip).

Background

Kubernetes topology-aware routing (formerly Topology Aware Hints) instructs kube-proxy to prefer routing traffic to endpoints in the same AZ as the client pod. Without it, traffic is distributed randomly across all healthy endpoints regardless of AZ placement.

Data collection

Via kubectl:

# Find services WITHOUT topology-aware routing annotations/hints
kubectl get services --all-namespaces -o json | \
jq -r '
.items[] |
select(.spec.type != "ExternalName") |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(
(.metadata.annotations["service.kubernetes.io/topology-mode"] // "none") == "none" and
(.metadata.annotations["service.kubernetes.io/topology-aware-hints"] // "none") == "none"
) |
"\(.metadata.namespace)/\(.metadata.name) type=\(.spec.type) selector=\(.spec.selector | to_entries | map("\(.key)=\(.value)") | join(","))"
'

# Check which of those services have pods in multiple AZs
for svc in $(kubectl get services --all-namespaces -o json | \
jq -r '.items[] | select(.spec.type != "ExternalName") |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select((.metadata.annotations["service.kubernetes.io/topology-mode"] // "none") == "none" and
(.metadata.annotations["service.kubernetes.io/topology-aware-hints"] // "none") == "none") |
"\(.metadata.namespace)|\(.metadata.name)|\(.spec.selector | to_entries | map("\(.key)=\(.value)") | join(","))"'); do

NS=$(echo "$svc" | cut -d'|' -f1)
NAME=$(echo "$svc" | cut -d'|' -f2)
SELECTOR=$(echo "$svc" | cut -d'|' -f3)

# Count unique AZs for pods matching this service selector
AZ_COUNT=$(kubectl get pods -n "$NS" -l "$SELECTOR" -o json 2>/dev/null | \
jq '[.items[].spec.nodeName] | unique | length')

if [ "$AZ_COUNT" -gt 1 ]; then
echo "CROSS-AZ: $NS/$NAME has pods in $AZ_COUNT AZs without topology routing"
fi
done

Simplified single-command approach:

# Get all services and their endpoint zone distribution in one pass
kubectl get endpoints --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.subsets != null) |
select([.subsets[].addresses[]?.nodeName // empty] | unique | length > 1) |
{
namespace: .metadata.namespace,
name: .metadata.name,
endpoint_count: ([.subsets[].addresses[]?] | length),
zones: ([.subsets[].addresses[]?.zone // empty] | unique)
} |
select(.zones | length > 1) |
"\(.namespace)/\(.name) endpoints=\(.endpoint_count) zones=\(.zones | join(","))"
'

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="Service",
api_version="v1",
namespace="all"
)
# Filter for services missing topology-mode annotation
# Then check EndpointSlices for zone distribution:

list_k8s_resources(
cluster_name="<cluster>",
kind="EndpointSlice",
api_version="discovery.k8s.io/v1",
namespace="<namespace>"
)
# Check .endpoints[].zone for multi-AZ distribution

Analysis logic

For each Service in non-system namespaces:
has_topology_routing = (
annotations["service.kubernetes.io/topology-mode"] == "Auto" OR
annotations["service.kubernetes.io/topology-aware-hints"] == "Auto"
)

If NOT has_topology_routing:
Get EndpointSlice for this service
zones = unique zones from endpoints

If len(zones) > 1:
endpoint_count = total endpoints
# Estimate: without topology routing, ~66% of traffic crosses AZ in a 3-AZ setup
cross_az_fraction = 1 - (1 / len(zones))

→ Flag for topology-aware routing recommendation
→ Estimate cross-AZ cost (see Check 4 for detailed calculation)

Severity classification

Estimated Monthly Cross-AZ CostSeverity
> $500CRITICAL
$200–$500HIGH
$50–$200MEDIUM
< $50LOW

Remediation

# Enable topology-aware routing on a service (Kubernetes 1.27+)
kubectl annotate service <service-name> -n <namespace> \
service.kubernetes.io/topology-mode=Auto

# For Kubernetes 1.27–1.30, use the topology-mode annotation (trafficDistribution field not available)
kubectl annotate service <service-name> -n <namespace> \
service.kubernetes.io/topology-aware-hints=Auto
# Service manifest with topology-aware routing enabled
apiVersion: v1
kind: Service
metadata:
name: <service-name>
namespace: <namespace>
annotations:
service.kubernetes.io/topology-mode: "Auto"
spec:
selector:
app: <app-label>
ports:
- port: 80
targetPort: 8080

Important: Topology-aware routing requires that each zone has a roughly equal number of endpoints. If pod distribution is heavily skewed (e.g., 10 pods in us-east-1a, 1 pod in us-east-1b), Kubernetes may disable hints automatically. Ensure balanced pod distribution across AZs using topology spread constraints.


Check 2: Instance Mode vs IP Mode on Load Balancers

What it detects

AWS Load Balancer Controller targets configured in "instance" mode, where traffic routes to the node's NodePort and then kube-proxy forwards to the pod — potentially crossing AZ boundaries. IP mode routes directly to the pod IP, eliminating the extra hop and any cross-AZ forwarding by kube-proxy.

Background

  • Instance mode (default): LB → NodePort on any node → kube-proxy → pod (may cross AZ)
  • IP mode: LB → pod IP directly (no cross-AZ hop from kube-proxy)

With instance mode, if the LB sends traffic to a node in AZ-a but the target pod is in AZ-b, you pay $0.01/GB for that cross-AZ hop. IP mode eliminates this entirely.

Data collection

Via kubectl:

# Find Ingress resources using instance target type (or missing the annotation = defaults to instance)
kubectl get ingress --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
{
namespace: .metadata.namespace,
name: .metadata.name,
target_type: (.metadata.annotations["alb.ingress.kubernetes.io/target-type"] // "instance"),
class: (.spec.ingressClassName // .metadata.annotations["kubernetes.io/ingress.class"] // "unknown")
} |
select(.target_type == "instance") |
"\(.namespace)/\(.name) class=\(.class) target-type=\(.target_type)"
'

# Find Services of type LoadBalancer using instance target type
kubectl get services --all-namespaces -o json | \
jq -r '
.items[] |
select(.spec.type == "LoadBalancer") |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
{
namespace: .metadata.namespace,
name: .metadata.name,
target_type: (.metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-nlb-target-type"] //
.metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-target-type"] // "instance"),
lb_type: (.metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-type"] // "classic")
} |
select(.target_type == "instance") |
"\(.namespace)/\(.name) lb-type=\(.lb_type) target-type=\(.target_type)"
'

# Check TargetGroupBindings for target type
kubectl get targetgroupbindings --all-namespaces -o json 2>/dev/null | \
jq -r '
.items[] |
select(.spec.targetType == "instance") |
"\(.metadata.namespace)/\(.metadata.name) targetType=\(.spec.targetType)"
'

Via AWS CLI (verify from AWS side):

# List target groups and their target type
aws elbv2 describe-target-groups \
--query 'TargetGroups[?contains(TargetGroupName, `k8s`)].{Name:TargetGroupName,Type:TargetType,ARN:TargetGroupArn}' \
--output table

# For each instance-mode target group, check cross-AZ targets
aws elbv2 describe-target-health \
--target-group-arn <target-group-arn> \
--query 'TargetHealthDescriptions[].Target.{Id:Id,AZ:AvailabilityZone}'

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="Ingress",
api_version="networking.k8s.io/v1",
namespace="all"
)
# Check annotations for alb.ingress.kubernetes.io/target-type

list_k8s_resources(
cluster_name="<cluster>",
kind="Service",
api_version="v1",
namespace="all"
)
# Filter type=LoadBalancer, check target-type annotations

Analysis logic

For each Ingress or LoadBalancer Service:
target_type = annotation value (default: "instance" if not specified)

If target_type == "instance":
# Check if backend pods span multiple AZs
Get pods matching the service selector
pod_zones = unique AZs of those pods

If len(pod_zones) > 1:
→ Finding: instance mode with cross-AZ pod distribution
severity = MEDIUM (per-LB, cross-AZ hops on every request)

If len(pod_zones) == 1:
→ No finding (single AZ, no cross-AZ risk from target mode)

Severity classification

ConditionSeverity
Instance mode + pods in 3+ AZs + high-traffic serviceHIGH
Instance mode + pods in 2+ AZsMEDIUM
Instance mode + pods in 1 AZ onlyNo finding

Remediation

# For ALB Ingress — switch to IP mode
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: <ingress-name>
namespace: <namespace>
annotations:
alb.ingress.kubernetes.io/target-type: "ip" # Changed from "instance"
spec:
ingressClassName: alb
rules:
- host: example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: <service>
port:
number: 80
# For NLB Service — switch to IP mode
apiVersion: v1
kind: Service
metadata:
name: <service-name>
namespace: <namespace>
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: "external"
service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: "ip" # Changed from "instance"
service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing"
spec:
type: LoadBalancer
selector:
app: <app-label>
ports:
- port: 443
targetPort: 8443

Note: Switching from instance to IP mode requires the AWS Load Balancer Controller (not the legacy in-tree cloud provider). Ensure the controller is installed before changing target types.


Check 3: VPC Endpoints for ECR, S3, STS

What it detects

Missing VPC endpoints for frequently accessed AWS services (ECR, S3, STS). Without VPC endpoints, all traffic to these services routes through NAT Gateways, incurring $0.045/GB processing fees plus $0.045/hour per NAT Gateway.

EKS clusters access these services constantly:

  • ECR — Every pod pull fetches container images (can be GBs per deployment)
  • S3 — ECR image layers are stored in S3; also used by many workloads directly
  • STS — Every IRSA/Pod Identity token exchange calls STS (high frequency, low bandwidth)

Severity

MEDIUM — Per Requirement 7.5, missing VPC endpoints for ECR or S3 always generates a MEDIUM severity finding with estimated NAT Gateway cost savings.

Data collection

Via AWS CLI:

# Get the VPC ID for the EKS cluster
CLUSTER_VPC=$(aws eks describe-cluster \
--name <cluster> \
--query 'cluster.resourcesVpcConfig.vpcId' \
--output text)

# List existing VPC endpoints in the cluster's VPC
aws ec2 describe-vpc-endpoints \
--filters "Name=vpc-id,Values=$CLUSTER_VPC" \
--query 'VpcEndpoints[].{Service:ServiceName,Type:VpcEndpointType,State:State,Id:VpcEndpointId}' \
--output table

# Check specifically for required endpoints
REGION=$(aws configure get region)
REQUIRED_SERVICES=(
"com.amazonaws.${REGION}.ecr.api"
"com.amazonaws.${REGION}.ecr.dkr"
"com.amazonaws.${REGION}.s3"
"com.amazonaws.${REGION}.sts"
)

EXISTING_ENDPOINTS=$(aws ec2 describe-vpc-endpoints \
--filters "Name=vpc-id,Values=$CLUSTER_VPC" \
--query 'VpcEndpoints[].ServiceName' \
--output text)

for svc in "${REQUIRED_SERVICES[@]}"; do
if echo "$EXISTING_ENDPOINTS" | grep -q "$svc"; then
echo "✅ $svc — endpoint exists"
else
echo "❌ $svc — MISSING (traffic routes through NAT Gateway)"
fi
done

Check NAT Gateway existence and usage (for cost estimation):

# List NAT Gateways in the VPC
aws ec2 describe-nat-gateways \
--filter "Name=vpc-id,Values=$CLUSTER_VPC" \
--query 'NatGateways[?State==`available`].{Id:NatGatewayId,SubnetId:SubnetId,AZ:ConnectivityType}' \
--output table

# Get NAT Gateway data transfer metrics (last 7 days)
NAT_GW_ID="<nat-gateway-id>"
aws cloudwatch get-metric-statistics \
--namespace "AWS/NATGateway" \
--metric-name "BytesOutToDestination" \
--dimensions "Name=NatGatewayId,Value=$NAT_GW_ID" \
--start-time "$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%S)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%S)" \
--period 604800 \
--statistics Sum \
--region <region>

Via EKS MCP Server:

# Get cluster VPC info
get_eks_cluster(cluster_name="<cluster>")
# Extract resourcesVpcConfig.vpcId

# Then use AWS CLI for VPC endpoint checks (no direct MCP equivalent)
# The MCP server does not have ec2:DescribeVpcEndpoints — fall back to AWS CLI

Analysis logic

required_endpoints = [
"com.amazonaws.<region>.ecr.api", # ECR API calls
"com.amazonaws.<region>.ecr.dkr", # ECR Docker registry (image pulls)
"com.amazonaws.<region>.s3", # S3 (ECR layers + workload data)
"com.amazonaws.<region>.sts" # STS (IRSA/Pod Identity token exchange)
]

existing_endpoints = list VPC endpoints in cluster VPC

missing = required_endpoints - existing_endpoints

If "ecr.api" OR "ecr.dkr" OR "s3" in missing:
→ Generate MEDIUM severity finding (per Req 7.5)
→ Estimate NAT Gateway cost for ECR/S3 traffic (see Check 5)

If "sts" in missing:
→ Generate LOW severity finding (STS traffic is low bandwidth)
→ Note: high frequency but small payload, cost impact is minimal

If ALL required endpoints present:
→ No finding for this check

Severity classification

Missing EndpointSeverityRationale
ECR (ecr.api or ecr.dkr)MEDIUMImage pulls route through NAT — high bandwidth
S3MEDIUMECR layers + workload data through NAT
STSLOWHigh frequency but tiny payloads
Multiple missingMEDIUM (combined finding)Aggregate NAT cost estimate

Per Requirement 7.5: Missing VPC endpoints for ECR or S3 SHALL always generate a MEDIUM severity finding with estimated NAT Gateway cost savings.

Remediation

# Terraform — Create VPC endpoints for EKS
resource "aws_vpc_endpoint" "s3" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.s3"
vpc_endpoint_type = "Gateway"
route_table_ids = var.private_route_table_ids

tags = {
Name = "${var.cluster_name}-s3-endpoint"
}
}

resource "aws_vpc_endpoint" "ecr_api" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.ecr.api"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true

tags = {
Name = "${var.cluster_name}-ecr-api-endpoint"
}
}

resource "aws_vpc_endpoint" "ecr_dkr" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.ecr.dkr"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true

tags = {
Name = "${var.cluster_name}-ecr-dkr-endpoint"
}
}

resource "aws_vpc_endpoint" "sts" {
vpc_id = var.vpc_id
service_name = "com.amazonaws.${var.region}.sts"
vpc_endpoint_type = "Interface"
subnet_ids = var.private_subnet_ids
security_group_ids = [aws_security_group.vpc_endpoints.id]
private_dns_enabled = true

tags = {
Name = "${var.cluster_name}-sts-endpoint"
}
}

# Security group for interface endpoints
resource "aws_security_group" "vpc_endpoints" {
name_prefix = "${var.cluster_name}-vpc-endpoints-"
vpc_id = var.vpc_id

ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.vpc_cidr]
}
}
# AWS CLI — Create S3 Gateway endpoint
aws ec2 create-vpc-endpoint \
--vpc-id <vpc-id> \
--service-name com.amazonaws.<region>.s3 \
--vpc-endpoint-type Gateway \
--route-table-ids <private-rtb-id-1> <private-rtb-id-2>

# AWS CLI — Create ECR Interface endpoints
aws ec2 create-vpc-endpoint \
--vpc-id <vpc-id> \
--service-name com.amazonaws.<region>.ecr.api \
--vpc-endpoint-type Interface \
--subnet-ids <subnet-1> <subnet-2> <subnet-3> \
--security-group-ids <sg-id> \
--private-dns-enabled

Check 4: Cross-AZ Traffic Potential

What it detects

Services with pods distributed across multiple availability zones that are likely generating cross-AZ data transfer charges. This check quantifies the potential cost based on service topology, pod distribution, and estimated traffic volume.

Cost model

Traffic TypeCost per GBDirection
Cross-AZ within VPC$0.01Per direction (sender pays + receiver pays)
Total round-trip cross-AZ$0.02Request + response

Data collection

Via kubectl:

# Get all services with their endpoint distribution across AZs
kubectl get endpointslices --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.endpoints != null) |
{
namespace: .metadata.namespace,
service: (.metadata.labels["kubernetes.io/service-name"] // "unknown"),
total_endpoints: (.endpoints | length),
zones: [.endpoints[].zone // empty] | group_by(.) | map({zone: .[0], count: length}),
unique_zones: ([.endpoints[].zone // empty] | unique | length)
} |
select(.unique_zones > 1) |
"\(.namespace)/\(.service) endpoints=\(.total_endpoints) zones=\(.unique_zones) distribution=\(.zones | map("\(.zone):\(.count)") | join(","))"
'

# Get pod distribution by zone for high-replica deployments
kubectl get pods --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.status.phase == "Running") |
{
namespace: .metadata.namespace,
pod: .metadata.name,
node: .spec.nodeName,
owner: (.metadata.ownerReferences[0].name // "standalone")
}' | \
jq -s '
# Correlate with node zones
group_by(.namespace + "/" + .owner) |
map(select(length > 1)) |
map({
workload: (.[0].namespace + "/" + .[0].owner),
replicas: length,
nodes: [.[].node] | unique
}) |
.[] | select(.nodes | length > 1) |
"\(.workload) replicas=\(.replicas) nodes=\(.nodes | length)"
'

# Get node-to-zone mapping
kubectl get nodes -o json | \
jq -r '.items[] | "\(.metadata.name) \(.metadata.labels["topology.kubernetes.io/zone"])"'

Estimate traffic volume (when Container Insights available):

# Get pod network bytes (requires Container Insights)
aws cloudwatch get-metric-data \
--metric-data-queries '[
{
"Id": "net_rx",
"MetricStat": {
"Metric": {
"Namespace": "ContainerInsights",
"MetricName": "pod_network_rx_bytes",
"Dimensions": [
{"Name": "ClusterName", "Value": "<cluster>"},
{"Name": "Namespace", "Value": "<namespace>"},
{"Name": "Service", "Value": "<service>"}
]
},
"Period": 604800,
"Stat": "Sum"
}
}
]' \
--start-time "$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%S)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%S)" \
--region <region>

Via EKS MCP Server:

list_k8s_resources(
cluster_name="<cluster>",
kind="EndpointSlice",
api_version="discovery.k8s.io/v1",
namespace="all"
)
# Analyze zone distribution across endpoints

get_cloudwatch_metrics(
cluster_name="<cluster>",
metric_name="pod_network_rx_bytes",
namespace="ContainerInsights",
dimensions={"ClusterName": "<cluster>", "Namespace": "<ns>", "Service": "<svc>"},
period=604800,
stat="Sum"
)

Analysis logic

For each Service with endpoints in multiple AZs:
zone_count = number of unique AZs
endpoint_count = total endpoints
has_topology_routing = Check 1 result for this service

# Estimate cross-AZ traffic fraction
If has_topology_routing:
cross_az_fraction = 0.05 # Small residual (imperfect balancing)
Else:
cross_az_fraction = 1 - (1 / zone_count)
# 3 AZs → 66% cross-AZ, 2 AZs → 50% cross-AZ

# Estimate monthly traffic volume
If Container Insights available:
monthly_bytes = pod_network_rx_bytes (7-day sum) × (30/7)
Else:
# Conservative estimate based on replica count and service type
If service is ClusterIP (internal):
estimated_monthly_gb = endpoint_count × 10 # 10 GB/month per endpoint baseline
If service is LoadBalancer/NodePort (external-facing):
estimated_monthly_gb = endpoint_count × 50 # Higher traffic assumption

# Calculate cross-AZ cost
cross_az_gb = estimated_monthly_gb × cross_az_fraction
monthly_cross_az_cost = cross_az_gb × 0.02 # $0.01 each direction

If monthly_cross_az_cost > threshold:
→ Generate finding with estimated cost

Severity classification

Estimated Monthly Cross-AZ CostSeverity
> $500CRITICAL
$200–$500HIGH
$50–$200MEDIUM
< $50LOW

Confidence levels

Data SourceConfidenceNotes
Container Insights network metricsHIGHActual measured traffic
VPC Flow Logs (if available)HIGHActual cross-AZ bytes
Estimation from replica countMEDIUMAssumes baseline traffic per pod
No traffic data availableLOWConservative estimate only

Remediation

# 1. Enable topology-aware routing (see Check 1)
# 2. Use topology spread constraints to balance pods
apiVersion: apps/v1
kind: Deployment
metadata:
name: <deployment>
namespace: <namespace>
spec:
replicas: 6
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: <app-label>
# Check current pod zone distribution for a deployment
kubectl get pods -n <namespace> -l app=<app-label> -o wide | \
awk '{print $7}' | sort | uniq -c

Check 5: NAT Gateway Cost Estimation

What it detects

Estimated NAT Gateway costs for AWS service traffic that could be eliminated with VPC endpoints. NAT Gateways charge $0.045/GB for data processing plus $0.045/hour (~$32.40/month) per gateway.

Note: NAT Gateway data processing is $0.045/GB for the first 10 TB/month, then tiered lower ($0.04/GB for next 30 TB, $0.035/GB thereafter). The skill uses $0.045/GB as a conservative estimate suitable for most EKS clusters under 10 TB/month of NAT traffic.

Cost model

NAT Gateway ComponentCost
Hourly charge$0.045/hour (~$32.40/month per gateway)
Data processing$0.045/GB processed
Cross-AZ data (if NAT in different AZ)Additional $0.01/GB

Data collection

Via AWS CLI:

# Get NAT Gateways in the cluster VPC
CLUSTER_VPC=$(aws eks describe-cluster --name <cluster> \
--query 'cluster.resourcesVpcConfig.vpcId' --output text)

aws ec2 describe-nat-gateways \
--filter "Name=vpc-id,Values=$CLUSTER_VPC" "Name=state,Values=available" \
--query 'NatGateways[].{Id:NatGatewayId,SubnetId:SubnetId,State:State}' \
--output json

# Get NAT Gateway data transfer over last 30 days
NAT_GW_IDS=$(aws ec2 describe-nat-gateways \
--filter "Name=vpc-id,Values=$CLUSTER_VPC" "Name=state,Values=available" \
--query 'NatGateways[].NatGatewayId' --output text)

for NAT_ID in $NAT_GW_IDS; do
echo "=== $NAT_ID ==="

# Bytes out to destination (internet/AWS services)
aws cloudwatch get-metric-statistics \
--namespace "AWS/NATGateway" \
--metric-name "BytesOutToDestination" \
--dimensions "Name=NatGatewayId,Value=$NAT_ID" \
--start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%S)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%S)" \
--period 2592000 \
--statistics Sum \
--output json

# Bytes in from destination
aws cloudwatch get-metric-statistics \
--namespace "AWS/NATGateway" \
--metric-name "BytesInFromDestination" \
--dimensions "Name=NatGatewayId,Value=$NAT_ID" \
--start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%S)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%S)" \
--period 2592000 \
--statistics Sum \
--output json

# Connection attempts (indicates traffic volume)
aws cloudwatch get-metric-statistics \
--namespace "AWS/NATGateway" \
--metric-name "ConnectionAttemptCount" \
--dimensions "Name=NatGatewayId,Value=$NAT_ID" \
--start-time "$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%S)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%S)" \
--period 2592000 \
--statistics Sum \
--output json
done

Estimate ECR/S3 traffic portion (when VPC endpoints are missing):

# Get VPC Flow Logs for NAT Gateway ENI (if flow logs enabled)
# This shows destination IPs which can be correlated to AWS service IP ranges
# Note: This is optional and requires VPC Flow Logs to be enabled

# Alternative: estimate from container image sizes and pull frequency
# Get image sizes for running pods
kubectl get pods --all-namespaces -o json | \
jq -r '
.items[] |
select(.status.phase == "Running") |
.status.containerStatuses[]? |
.imageID' | \
sort | uniq -c | sort -rn | head -20

# Count pod restarts (each restart = image pull if imagePullPolicy != IfNotPresent)
kubectl get pods --all-namespaces -o json | \
jq -r '
.items[] |
select(.status.phase == "Running") |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
{
namespace: .metadata.namespace,
name: .metadata.name,
restarts: ([.status.containerStatuses[]?.restartCount // 0] | add),
pull_policy: [.spec.containers[].imagePullPolicy] | unique
} |
select(.restarts > 0 or (.pull_policy | any(. == "Always"))) |
"\(.namespace)/\(.name) restarts=\(.restarts) policy=\(.pull_policy | join(","))"
'

Via EKS MCP Server:

# Get cluster VPC for NAT Gateway lookup
get_eks_cluster(cluster_name="<cluster>")
# Extract VPC ID, then use AWS CLI for NAT Gateway metrics

get_cloudwatch_metrics(
cluster_name="<cluster>",
metric_name="BytesOutToDestination",
namespace="AWS/NATGateway",
dimensions={"NatGatewayId": "<nat-gw-id>"},
period=2592000,
stat="Sum"
)

Analysis logic

# Step 1: Calculate total NAT Gateway cost
nat_gateways = list NAT Gateways in cluster VPC
nat_hourly_cost = len(nat_gateways) × $0.045/hour
nat_monthly_fixed = nat_hourly_cost × 730 # hours/month

For each NAT Gateway:
bytes_processed = BytesOutToDestination + BytesInFromDestination (30-day sum)
gb_processed = bytes_processed / (1024^3)
processing_cost = gb_processed × $0.045

total_nat_monthly = nat_monthly_fixed + sum(processing_cost for each NAT)

# Step 2: Estimate portion attributable to AWS services (saveable with VPC endpoints)
If VPC endpoints for ECR/S3 are MISSING (from Check 3):
# ECR/S3 typically accounts for 60-80% of NAT traffic in EKS clusters (based on field experience across production EKS deployments)
# Conservative estimate: 50% of NAT traffic is ECR/S3
estimated_aws_service_fraction = 0.50

saveable_cost = total_nat_processing_cost × estimated_aws_service_fraction

# If we can measure actual image pull sizes:
If image_pull_data_available:
monthly_image_pulls_gb = (avg_image_size × daily_pulls × 30) / 1024
ecr_nat_cost = monthly_image_pulls_gb × $0.045
saveable_cost = ecr_nat_cost # More accurate estimate

→ Generate finding with saveable_cost as monthly_savings

# Step 3: Check if NAT Gateways are even needed
If VPC endpoints exist for ALL required services AND no internet egress needed:
→ Consider if NAT Gateways can be removed entirely
→ monthly_savings = nat_monthly_fixed (gateway hourly charges)

Severity classification

Estimated Saveable NAT CostSeverity
> $500/monthCRITICAL
$200–$500/monthHIGH
$50–$200/monthMEDIUM
< $50/monthLOW

Note: This check's severity combines with Check 3 (VPC endpoints). If VPC endpoints are missing AND NAT costs are high, the combined finding severity may escalate.

Worked example

Cluster: 3 NAT Gateways (one per AZ), no VPC endpoints for ECR/S3

Fixed cost:
3 × $0.045/hour × 730 hours = $98.55/month

Data processing (measured from CloudWatch):
NAT-1: 150 GB processed → $6.75
NAT-2: 200 GB processed → $9.00
NAT-3: 180 GB processed → $8.10
Total processing: $23.85/month

Total NAT cost: $98.55 + $23.85 = $122.40/month

Estimated saveable (50% is ECR/S3 traffic):
Processing savings: $23.85 × 0.50 = $11.93/month

Note: Fixed NAT cost ($98.55) remains unless ALL traffic can use VPC endpoints
and no internet egress is needed.

Finding:
severity: MEDIUM (saveable amount < $50)
monthly_savings: ~$12/month from data processing
additional_note: "VPC endpoints also reduce latency for ECR pulls"

Remediation

See Check 3 remediation for VPC endpoint creation.

Additional NAT Gateway optimization:

# Check if NAT Gateways can be consolidated (if traffic is low)
# Review per-AZ NAT usage — if one AZ has minimal traffic,
# consider routing through another AZ's NAT (trade-off: cross-AZ cost vs NAT fixed cost)

# Check NAT Gateway idle connections
aws cloudwatch get-metric-statistics \
--namespace "AWS/NATGateway" \
--metric-name "ActiveConnectionCount" \
--dimensions "Name=NatGatewayId,Value=<nat-gw-id>" \
--start-time "$(date -u -d '7 days ago' +%Y-%m-%dT%H:%M:%S)" \
--end-time "$(date -u +%Y-%m-%dT%H:%M:%S)" \
--period 3600 \
--statistics Maximum \
--output json

Scoring Contribution

The networking costs dimension has a maximum deduction of 15 points.

Deduction calculation

deduction = 0

For each finding in this dimension:
If severity == CRITICAL: deduction += 15 × 0.6 = 9.0
If severity == HIGH: deduction += 15 × 0.3 = 4.5
If severity == MEDIUM: deduction += 15 × 0.15 = 2.25
If severity == LOW: deduction += 15 × 0.05 = 0.75

actual_deduction = min(deduction, 15) # Cap at maximum

Typical finding combinations and their score impact

ScenarioFindingsTotal Deduction
No VPC endpoints + no topology routing + instance mode LBs1 MEDIUM (VPC) + 1 MEDIUM (topology) + 1 MEDIUM (LB)min(6.75, 15) = 6.75
Missing VPC endpoints only1 MEDIUM2.25
High cross-AZ traffic without topology routing1 HIGH4.5
All optimized (VPC endpoints + topology routing + IP mode)No findings0
Worst case: high cross-AZ + no VPC endpoints + instance mode1 CRITICAL + 2 MEDIUMmin(9.0 + 4.5, 15) = 13.5

Dimension status

ConditionStatus
All checks completedASSESSED
VPC endpoint check failed (permission denied)ASSESSED (partial, with note)
kubectl unavailableSKIPPED

If the dimension is fully SKIPPED, it contributes zero deduction and is excluded from the score denominator.


Cross-AZ Cost Calculation Reference

Formula

monthly_cross_az_cost = cross_az_gb_transferred × $0.01 × 2
= cross_az_gb_transferred × $0.02 (both directions)

Where:
cross_az_gb_transferred = total_service_traffic_gb × cross_az_fraction

cross_az_fraction (without topology routing):
2 AZs: 0.50 (50% of traffic crosses AZ)
3 AZs: 0.67 (67% of traffic crosses AZ)
4 AZs: 0.75 (75% of traffic crosses AZ)

cross_az_fraction (with topology routing):
~0.05 (5% residual due to imperfect balancing)

Worked example

Service: payment-api
Replicas: 9 (3 per AZ across 3 AZs)
Monthly traffic: 500 GB (measured from Container Insights)
Topology routing: NOT enabled

cross_az_fraction = 1 - (1/3) = 0.67
cross_az_gb = 500 × 0.67 = 335 GB
monthly_cost = 335 × $0.02 = $6.70/month

With topology routing enabled:
cross_az_gb = 500 × 0.05 = 25 GB
monthly_cost = 25 × $0.02 = $0.50/month

Savings from enabling topology routing: $6.20/month for this service

Aggregate across all services:
If 20 services × avg $5/month cross-AZ = $100/month cluster-wide
→ MEDIUM severity finding

Traffic estimation when metrics unavailable

When Container Insights or VPC Flow Logs are not available, use conservative estimates:

Service TypeEstimated Monthly Traffic per Endpoint
Internal API (ClusterIP)10 GB
Database proxy (ClusterIP)50 GB
External-facing (LoadBalancer)50 GB
Message queue consumer20 GB
gRPC service (high-frequency)30 GB

These are conservative baselines. Actual traffic may be significantly higher for data-intensive services.


Decision Tree

START

├─ Can we access the cluster VPC info? (eks:DescribeCluster)
│ ├─ YES → Continue
│ └─ NO → Skip VPC endpoint checks (3, 5), still run checks 1, 2, 4

├─ Run Check 3: VPC Endpoints
│ ├─ ec2:DescribeVpcEndpoints available?
│ │ ├─ YES → Check for ECR, S3, STS endpoints
│ │ └─ NO → Skip Check 3, note permission gap
│ └─ Missing endpoints found?
│ ├─ YES → Generate MEDIUM finding (per Req 7.5)
│ └─ NO → Pass

├─ Run Check 1: Topology-Aware Routing
│ ├─ Get all services and their endpoint zone distribution
│ ├─ For services with multi-AZ endpoints:
│ │ ├─ Has topology-mode annotation? → Pass
│ │ └─ Missing annotation? → Estimate cross-AZ cost → Generate finding
│ └─ No multi-AZ services? → Pass (single-AZ cluster)

├─ Run Check 2: Instance vs IP Mode
│ ├─ Get Ingress and LoadBalancer Services
│ ├─ For each with target-type=instance:
│ │ ├─ Backend pods in multiple AZs? → Generate MEDIUM finding
│ │ └─ Backend pods in single AZ? → Pass
│ └─ All using IP mode? → Pass

├─ Run Check 4: Cross-AZ Traffic Potential
│ ├─ Container Insights available?
│ │ ├─ YES → Use actual network metrics for cost calculation
│ │ └─ NO → Use estimation based on replica count and service type
│ ├─ Calculate aggregate cross-AZ cost across all services
│ └─ Generate finding if above threshold

├─ Run Check 5: NAT Gateway Cost Estimation
│ ├─ NAT Gateways exist in VPC?
│ │ ├─ YES → Get CloudWatch metrics for data processed
│ │ └─ NO → Skip (no NAT cost)
│ ├─ VPC endpoints missing (from Check 3)?
│ │ ├─ YES → Estimate saveable portion of NAT traffic
│ │ └─ NO → NAT traffic is non-AWS-service (can't save with endpoints)
│ └─ Generate finding with estimated savings

└─ Aggregate findings → Calculate dimension deduction (capped at 15)

Common Patterns and Quick Wins

Pattern 1: New EKS cluster with default networking

Symptoms: No VPC endpoints, no topology routing, instance mode on LBs Typical cost impact: $50–$200/month for medium clusters Quick wins:

  1. Create S3 Gateway endpoint (free, immediate savings)
  2. Create ECR Interface endpoints ($7.20/month per endpoint per AZ, but saves NAT processing)
  3. Annotate high-traffic services with topology-mode=Auto

Pattern 2: Large cluster with many internal services

Symptoms: 50+ services, pods spread across 3 AZs, no topology routing Typical cost impact: $200–$1000/month in cross-AZ charges Quick wins:

  1. Enable topology-aware routing on top-10 highest-traffic services
  2. Ensure topology spread constraints balance pods evenly across AZs
  3. Consider zone-aware service mesh (Istio locality load balancing)

Pattern 3: CI/CD heavy cluster with frequent deployments

Symptoms: High image pull frequency, no ECR VPC endpoint, large images Typical cost impact: $100–$500/month in NAT processing for ECR pulls Quick wins:

  1. Create ECR VPC endpoints (ecr.api + ecr.dkr + s3)
  2. Use imagePullPolicy: IfNotPresent where possible
  3. Consider ECR pull-through cache for third-party images

This reference is loaded on-demand when the networking costs dimension is being assessed. See report-generation.md for how findings from this dimension contribute to the overall Cost Score.