Skip to main content
Source

This page is generated from devops-agent/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 TypeHourly 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

#CheckDefault ThresholdSeverity Logic
1Zero-replica Deployments idle for extended periods0 replicas for > 7 daysBy count × resource allocation
2LoadBalancer Services with no healthy endpoints0 healthy endpointsHIGH (Req 10.5) — per LB hourly cost
3Namespaces with no running workloads but allocated quotas0 running pods + active quotaMEDIUM
4Orphaned ConfigMaps/Secrets not referenced by running workloadsNot mounted/envFrom by any running podLOW–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

Use the Kubernetes API to list all Deployments across non-system namespaces. Filter for spec.replicas == 0. Check metadata.annotations and Events API for last-scale-down timestamp. Inspect status.conditions[].lastTransitionTime on the Available condition to determine 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

ConditionSeverity
Zero-replica Deployment idle > 30 days with associated PVCsHIGH
Zero-replica Deployment idle > 30 days, no PVCsMEDIUM
Zero-replica Deployment idle 7–30 daysLOW
Multiple (5+) zero-replica Deployments in same namespaceMEDIUM (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

Use the Kubernetes API to list Services of type LoadBalancer and their corresponding Endpoints. Identify services with empty endpoint subsets (zero healthy addresses). Determine load balancer type from annotations (service.beta.kubernetes.io/aws-load-balancer-type).

Cross-reference with the ELBv2 DescribeTargetHealth API for backend health status. Use ELBv2 DescribeLoadBalancers to get load balancer details and ELB DescribeLoadBalancers for Classic Load Balancers.

Analysis logic

For each Service with spec.type == "LoadBalancer":
endpoints = get Endpoints object with same name/namespace
healthy_count = count(endpoints.subsets[].addresses[])

If healthy_count == 0:
# Determine LB type from annotations
lb_type = annotations["service.beta.kubernetes.io/aws-load-balancer-type"]

If lb_type in ["nlb", "external", "nlb-ip"]:
hourly_cost = 0.0225 # NLB idle cost
Else if lb_type == "alb" or ingress-controller managed:
hourly_cost = 0.0225 # ALB idle cost
Else:
hourly_cost = 0.025 # CLB idle cost (legacy)

monthly_cost = hourly_cost × 730
→ Generate HIGH severity finding (per Req 10.5)

Severity classification

ConditionSeverity
Any LoadBalancer Service with 0 healthy endpointsHIGH
Multiple idle LoadBalancers (3+)HIGH (compounding waste)

Per Requirement 10.5: Idle LoadBalancer Services always generate HIGH severity findings because they represent direct, ongoing AWS billing with zero value.

Per-hour cost breakdown

LB TypeHourly Idle CostMonthly Idle CostAnnual Idle Cost
NLB~$0.0225/hr~$16.43/month~$197.10/year
ALB~$0.0225/hr~$16.43/month~$197.10/year
CLB~$0.025/hr~$18.25/month~$219.00/year

These are base charges. LCU/NLCU charges are zero when there is no traffic, but the hourly fee applies regardless.

Remediation

# Delete the idle LoadBalancer Service (removes AWS LB)
kubectl delete service <name> -n <namespace>

# Or switch to ClusterIP if the LB is no longer needed externally
kubectl patch service <name> -n <namespace> -p '{"spec": {"type": "ClusterIP"}}'

# Verify the AWS LB was actually deleted after Service removal
aws elbv2 describe-load-balancers --query 'LoadBalancers[*].LoadBalancerName'
# If the Service should have backends, check why pods are unhealthy
kubectl get pods -n <namespace> -l <service-selector-labels>
kubectl describe endpoints <service-name> -n <namespace>

Check 3: Namespaces with No Running Workloads but Allocated Quotas

What it detects

Namespaces that have ResourceQuotas or LimitRanges defined but contain no running pods. This indicates abandoned environments that still reserve cluster capacity through quotas or create governance overhead.

Data collection

Use the Kubernetes API to list all namespaces, then for each, count running pods. Identify namespaces with ResourceQuotas or LimitRanges but zero running pods. For flagged namespaces, retrieve ResourceQuota spec.hard to determine reserved capacity.

Analysis logic

For each non-system namespace:
running_pods = count pods with status.phase == "Running"
has_quota = (ResourceQuotas exist in namespace)
has_limitrange = (LimitRanges exist in namespace)

If running_pods == 0 AND (has_quota OR has_limitrange):
# Calculate reserved capacity from quota spec
reserved_cpu = quota.spec.hard["requests.cpu"]
reserved_mem = quota.spec.hard["requests.memory"]

→ Generate finding
# Note: quotas don't directly consume resources, but indicate
# governance overhead and often mean the namespace was intended for workloads

Severity classification

ConditionSeverity
Empty namespace with quota + no pods for > 30 daysMEDIUM
Empty namespace with quota + associated PVCs still boundHIGH
Empty namespace with quota only (no PVCs)LOW–MEDIUM
Multiple (3+) empty namespaces with quotasMEDIUM (governance gap)

Remediation

# Review and delete empty namespaces (CAUTION: deletes all resources in namespace)
kubectl delete namespace <namespace>

# Or remove just the quota if the namespace may be reused
kubectl delete resourcequota <quota-name> -n <namespace>
kubectl delete limitrange <limitrange-name> -n <namespace>

# Before deleting, check for any non-running resources (PVCs, Secrets, etc.)
kubectl get all,pvc,configmap,secret,serviceaccount -n <namespace>

Check 4: Orphaned ConfigMaps and Secrets Not Referenced by Running Workloads

What it detects

ConfigMaps and Secrets that exist in non-system namespaces but are not referenced by any running pod — either as volume mounts, environment variable sources (envFrom/valueFrom), or projected volumes. These represent configuration debt and potential security exposure (orphaned secrets).

Data collection

Use the Kubernetes API to list all ConfigMaps and Secrets in non-system namespaces. Cross-reference with all running Pods' spec.volumes[].configMap, spec.volumes[].secret, spec.volumes[].projected.sources[], spec.containers[].envFrom, and spec.containers[].env[].valueFrom to identify unreferenced resources. Exclude system-managed objects (kube-root-ca.crt, service-account-token secrets, Helm release secrets).

Exclusions

Always exclude from orphan detection:

  • ConfigMaps named kube-root-ca.crt (system-managed)
  • Secrets of type kubernetes.io/service-account-token (auto-created)
  • Secrets matching ^default-token- pattern (legacy SA tokens)
  • ConfigMaps/Secrets in system namespaces (kube-*, amazon-*, aws-*)
  • Helm release secrets (type helm.sh/release.v1) — these track Helm state
  • ConfigMaps with label app.kubernetes.io/managed-by: Helm that match a deployed release

Analysis logic

For each non-system namespace:
referenced_cms = set of ConfigMap names used by running pods (volumes, envFrom, valueFrom)
referenced_secrets = set of Secret names used by running pods (volumes, envFrom, valueFrom)

all_cms = set of all ConfigMaps in namespace (excluding system CMs)
all_secrets = set of all Secrets in namespace (excluding SA tokens, Helm releases)

orphaned_cms = all_cms - referenced_cms
orphaned_secrets = all_secrets - referenced_secrets

If len(orphaned_cms) + len(orphaned_secrets) > 0:
→ Generate finding
# Secrets carry more risk (potential credential exposure)
# ConfigMaps are lower risk (just configuration debt)

Severity classification

ConditionSeverity
> 10 orphaned Secrets in a namespaceMEDIUM
Orphaned Secrets (any count)LOW
> 20 orphaned ConfigMaps in a namespaceMEDIUM
Orphaned ConfigMaps (any count)LOW
Total orphaned objects across cluster > 50MEDIUM (cluster hygiene)

Note: Orphaned ConfigMaps/Secrets have minimal direct cost impact but indicate configuration debt, potential security exposure (stale credentials in Secrets), and complicate cluster operations.

Remediation

# Review orphaned ConfigMaps before deleting
kubectl get configmap <name> -n <namespace> -o yaml

# Delete confirmed orphaned ConfigMaps
kubectl delete configmap <name> -n <namespace>

# Delete confirmed orphaned Secrets (review first!)
kubectl get secret <name> -n <namespace> -o yaml | head -20 # Check type and annotations
kubectl delete secret <name> -n <namespace>
# Bulk cleanup: delete all orphaned ConfigMaps in a namespace (use with caution)
# First generate the list, review it, then delete
comm -23 /tmp/all_configmaps.txt /tmp/referenced_configs.txt | \
grep "^<namespace>/" | \
sed 's|.*/configmap/||' | \
xargs -I{} kubectl delete configmap {} -n <namespace> --dry-run=client

# Remove --dry-run=client after confirming the list is correct

Scoring Contribution

The idle resources 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

Dimension status

ConditionStatus
All checks completedASSESSED
Some checks partially completedASSESSED (with note)
No Kubernetes API accessCannot assess — skill halts at pre-flight

This dimension is never SKIPPED because all checks require only the Kubernetes API (no metrics sources needed).


Decision Tree

START

├─ Check 1: Zero-replica Deployments
│ ├─ List all Deployments with spec.replicas == 0
│ ├─ Check idle duration via status.conditions[].lastTransitionTime
│ ├─ If idle > 7 days → Generate finding (severity by duration + PVCs)
│ └─ Check for associated PVCs still bound

├─ Check 2: Idle LoadBalancer Services
│ ├─ List all Services with spec.type == "LoadBalancer"
│ ├─ For each, check Endpoints object for healthy addresses
│ ├─ If 0 healthy endpoints → Generate HIGH severity finding
│ ├─ Calculate per-hour cost: NLB ~$0.0225/hr, ALB ~$0.0225/hr
│ └─ Optionally verify via AWS ELBv2 API for target group health

├─ Check 3: Empty Namespaces with Quotas
│ ├─ List namespaces with ResourceQuotas or LimitRanges
│ ├─ For each, count running pods
│ ├─ If 0 running pods + quota exists → Generate finding
│ └─ Check for bound PVCs in empty namespace (escalates severity)

├─ Check 4: Orphaned ConfigMaps/Secrets
│ ├─ Build reference map from all running pods
│ ├─ List all ConfigMaps/Secrets (excluding system objects)
│ ├─ Diff: all - referenced = orphaned
│ ├─ Exclude Helm releases, SA tokens, system CMs
│ └─ Generate finding if orphan count exceeds threshold

└─ Aggregate findings → Calculate dimension deduction (max 15 pts)

Examples

Example Finding: Idle Load Balancer

finding:
id: "idle-lb-staging-api-gateway"
dimension: "idle"
severity: "HIGH"
affected_resource: "staging/api-gateway (Service type=LoadBalancer)"
current_state: "NLB provisioned with 0 healthy endpoints for 14 days"
monthly_cost: 16.43
monthly_waste: 16.43
monthly_savings: 16.43
effort: "Low"
fix_summary: "Delete unused LoadBalancer Service or fix backend pods"
remediation: "kubectl delete service api-gateway -n staging"
confidence: "high"
data_sources: ["kubernetes_api", "aws_elb_api"]

Example Finding: Zero-Replica Deployment

finding:
id: "idle-deploy-dev-old-backend"
dimension: "idle"
severity: "MEDIUM"
affected_resource: "dev/old-backend (Deployment, 0 replicas since 2024-08-15)"
current_state: "Scaled to 0 replicas for 45 days, 2 PVCs still bound (50Gi total)"
monthly_cost: 5.00
monthly_waste: 5.00
monthly_savings: 5.00
effort: "Low"
fix_summary: "Delete abandoned deployment and associated PVCs"
remediation: "kubectl delete deployment old-backend -n dev && kubectl delete pvc old-backend-data-0 old-backend-data-1 -n dev"
confidence: "medium"
data_sources: ["kubernetes_api"]

Example Finding: Orphaned Secrets

finding:
id: "idle-orphaned-secrets-production"
dimension: "idle"
severity: "LOW"
affected_resource: "production namespace (8 orphaned Secrets)"
current_state: "8 Secrets not referenced by any running pod"
monthly_cost: 0
monthly_waste: 0
monthly_savings: 0
effort: "Low"
fix_summary: "Review and delete orphaned Secrets to reduce configuration debt"
remediation: "Review each Secret and delete if no longer needed"
confidence: "medium"
data_sources: ["kubernetes_api"]