This page is generated from skills/eks-cost-intelligence/references/idle-resources.md. Edit the source, not this page.
Idle Resources
Part of: eks-cost-intelligence Purpose: Checks for zero-replica Deployments idle for extended periods, LoadBalancer Services with no healthy endpoints, namespaces with no running workloads but allocated quotas, orphaned ConfigMaps/Secrets not referenced by running workloads, and per-hour cost of unused load balancers
Overview
Idle resources is a mid-weight dimension (15 points max deduction). It detects resources that are provisioned and potentially incurring cost but serving no active workload — forgotten deployments scaled to zero, load balancers with no backends, empty namespaces with quotas, and orphaned configuration objects.
Unlike compute efficiency (which identifies under-utilized resources), this dimension targets resources that are doing nothing at all — pure waste with zero productive value.
Cost Reference: Unused Load Balancers
| Load Balancer Type | Hourly Cost (idle) | Monthly Cost (idle) |
|---|---|---|
| Network Load Balancer (NLB) | ~$0.0225/hr | ~$16.43/month |
| Application Load Balancer (ALB) | ~$0.0225/hr | ~$16.43/month |
| Classic Load Balancer (CLB) | ~$0.025/hr | ~$18.25/month |
Prices are US East (N. Virginia) baseline. Actual costs vary by region but the idle hourly rate applies even with zero traffic.
Checks Summary
| # | Check | Default Threshold | Severity Logic |
|---|---|---|---|
| 1 | Zero-replica Deployments idle for extended periods | 0 replicas for > 7 days | By count × resource allocation |
| 2 | LoadBalancer Services with no healthy endpoints | 0 healthy endpoints | HIGH (Req 10.5) — per LB hourly cost |
| 3 | Namespaces with no running workloads but allocated quotas | 0 running pods + active quota | MEDIUM |
| 4 | Orphaned ConfigMaps/Secrets not referenced by running workloads | Not mounted/envFrom by any running pod | LOW–MEDIUM by count |
Pre-requisites
These checks require:
- kubectl access to the cluster (for Deployments, Services, Endpoints, Namespaces, ConfigMaps, Secrets)
- AWS CLI access for
elasticloadbalancing:DescribeLoadBalancers,elasticloadbalancing:DescribeTargetHealth(optional — enriches LB cost data) - No metrics-server required — all checks use Kubernetes API state inspection only
Check 1: Zero-Replica Deployments Idle for Extended Periods
What it detects
Deployments that have been scaled to zero replicas and appear to have been in this state for an extended period (> 7 days). These represent forgotten or abandoned workloads that may still have associated resources (PVCs, ConfigMaps, Secrets, ServiceAccounts) consuming cluster overhead.
Data collection
Via kubectl:
# Find all Deployments with 0 replicas in non-system namespaces
kubectl get deployments --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.spec.replicas == 0) |
{
namespace: .metadata.namespace,
name: .metadata.name,
replicas: .spec.replicas,
last_updated: (.metadata.annotations["kubectl.kubernetes.io/last-applied-configuration"] // "unknown"),
generation: .metadata.generation,
conditions: [.status.conditions[]? | {type: .type, lastTransitionTime: .lastTransitionTime}]
}'
# Check when the Deployment was last scaled (using events or annotations)
kubectl get events --all-namespaces --field-selector reason=ScalingReplicaSet \
--sort-by='.lastTimestamp' -o json | \
jq -r '
.items[] |
select(.involvedObject.kind == "Deployment") |
select(.message | contains("Scaled down replica set") and contains(" to 0")) |
"\(.involvedObject.namespace)/\(.involvedObject.name) scaled-to-zero at \(.lastTimestamp)"
' | tail -50
# Alternative: check last transition time on Available condition
kubectl get deployments --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.spec.replicas == 0) |
. as $dep |
($dep.status.conditions[]? | select(.type == "Available")) as $cond |
"\($dep.metadata.namespace)/\($dep.metadata.name) | lastTransition=\($cond.lastTransitionTime // "unknown")"
'
Via EKS MCP Server:
list_k8s_resources(
cluster_name="<cluster>",
kind="Deployment",
api_version="apps/v1",
namespace="all"
)
# Filter results for spec.replicas == 0
# Check status.conditions[].lastTransitionTime for idle duration
Analysis logic
For each Deployment in non-system namespaces:
If spec.replicas == 0:
idle_since = status.conditions["Available"].lastTransitionTime
idle_days = (now - idle_since).days
If idle_days > 7:
→ Generate finding
# Estimate residual cost (associated resources still consuming)
associated_pvcs = count PVCs referenced by the Deployment's pod template
associated_secrets = count Secrets referenced by the Deployment's pod template
residual_monthly_cost = sum(pvc_costs) + quota_overhead
Severity classification
| Condition | Severity |
|---|---|
| Zero-replica Deployment idle > 30 days with associated PVCs | HIGH |
| Zero-replica Deployment idle > 30 days, no PVCs | MEDIUM |
| Zero-replica Deployment idle 7–30 days | LOW |
| Multiple (5+) zero-replica Deployments in same namespace | MEDIUM (governance gap) |
Remediation
# Review and delete abandoned Deployments
kubectl delete deployment <name> -n <namespace>
# Or scale back up if it should be running
kubectl scale deployment <name> -n <namespace> --replicas=1
# List all associated resources before cleanup
kubectl get all,pvc,configmap,secret -n <namespace> -l app=<deployment-label>
# Bulk identification: list all zero-replica deploys older than 30 days
kubectl get deployments --all-namespaces -o json | \
jq -r '
.items[] |
select(.metadata.namespace | test("^kube-|^amazon-|^aws-") | not) |
select(.spec.replicas == 0) |
select(.status.conditions[]? |
select(.type == "Available") |
(.lastTransitionTime | fromdateiso8601) < (now - 2592000)) |
"\(.metadata.namespace)/\(.metadata.name)"
'
Check 2: LoadBalancer Services with No Healthy Endpoints
What it detects
Kubernetes Services of type LoadBalancer that have no healthy backend endpoints. These services provision AWS load balancers (NLB or ALB) that incur hourly charges (~$0.0225/hr for NLB, ~$0.0225/hr for ALB) even with zero traffic and no healthy targets.
Severity: HIGH (per Requirement 10.5) — Each idle load balancer costs ~$16.43/month minimum regardless of traffic.
Data collection
Via kubectl:
# Find all LoadBalancer Services
kubectl get services --all-namespaces -o json | \
jq -r '
.items[] |
select(.spec.type == "LoadBalancer") |
{
namespace: .metadata.namespace,
name: .metadata.name,
lb_type: (if .metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-type"] == "nlb"
or .metadata.annotations["service.beta.kubernetes.io/aws-load-balancer-type"] == "external"
then "NLB" else "CLB/ALB" end),
external_ip: (.status.loadBalancer.ingress[0].hostname // "pending"),
selector: .spec.selector
}'
# Check endpoints health for each LoadBalancer Service
kubectl get endpoints --all-namespaces -o json | \
jq -r '
.items[] |
select(.subsets == null or .subsets == [] or
(.subsets[]?.addresses == null or .subsets[]?.addresses == [])) |
{
namespace: .metadata.namespace,
name: .metadata.name,
ready_addresses: 0,
not_ready: ([.subsets[]?.notReadyAddresses[]?] | length)
}'
# Combined: LoadBalancer Services with zero healthy endpoints
kubectl get services --all-namespaces -o json | \
jq -r '
.items[] |
select(.spec.type == "LoadBalancer") |
.metadata.namespace as $ns |
.metadata.name as $name |
"\($ns)/\($name)"
' | while read svc; do
ns=$(echo "$svc" | cut -d'/' -f1)
name=$(echo "$svc" | cut -d'/' -f2)
ready=$(kubectl get endpoints "$name" -n "$ns" -o json 2>/dev/null | \
jq '[.subsets[]?.addresses[]?] | length')
if [ "${ready:-0}" -eq 0 ]; then
echo "IDLE_LB: $svc (0 healthy endpoints)"
fi
done
Via AWS CLI (enriches with actual LB details):
# List ELBv2 load balancers and check target group health
aws elbv2 describe-load-balancers --query 'LoadBalancers[*].{
Name:LoadBalancerName,
ARN:LoadBalancerArn,
Type:Type,
State:State.Code,
DNSName:DNSName
}' --output table
# For each LB, check target group health
aws elbv2 describe-target-groups \
--load-balancer-arn <lb-arn> \
--query 'TargetGroups[*].TargetGroupArn' --output text | \
while read tg_arn; do
echo "Target Group: $tg_arn"
aws elbv2 describe-target-health \
--target-group-arn "$tg_arn" \
--query 'TargetHealthDescriptions[*].{Target:Target.Id,Health:TargetHealth.State}'
done
# List Classic Load Balancers and check instance health
aws elb describe-load-balancers --query 'LoadBalancerDescriptions[*].{
Name:LoadBalancerName,
Instances:Instances[*].InstanceId
}' --output json | \
jq '.[] | select(.Instances == null or (.Instances | length) == 0) | .Name'
Via EKS MCP Server:
list_k8s_resources(
cluster_name="<cluster>",
kind="Service",
api_version="v1",
namespace="all"
)
# Filter for spec.type == "LoadBalancer"
# Then check endpoints for each:
list_k8s_resources(
cluster_name="<cluster>",
kind="Endpoints",
api_version="v1",
namespace="<namespace>"
)
# Check subsets[].addresses length for each matching endpoint