AWS GCP Azure FinOps cloud sprawl cost optimization multi-cloud governance

Cloud sprawl audit - how to identify and reduce uncontrolled cloud infrastructure growth

Identify and reduce cloud sprawl: audit orphaned resources, unused accounts, untagged infrastructure, and duplicate services. CLI commands for AWS, GCP, and Azure with cost recovery estimates.

Jerzy Kopaczewski ·
Your cloud bill keeps growing but nobody can explain why. There are accounts nobody owns, EC2 instances tagged "test-jan-2024" still running, three different monitoring tools across teams, and an Azure subscription someone created for a PoC that was abandoned six months ago. This isn't multi-cloud strategy - it's cloud sprawl. This runbook walks you through finding and eliminating it.

Cloud sprawl is uncontrolled growth of cloud resources, accounts, and services without governance. Unlike deliberate multi-cloud architecture, sprawl happens organically: a developer spins up an account “for testing”, a team adopts a new tool without decommissioning the old one, or an acquisition adds infrastructure nobody inventoried.

For guidance on when multiple clouds are a deliberate architectural choice vs. sprawl, see our multi-cloud strategy guide.

Symptoms of cloud sprawl

You likely have cloud sprawl if three or more of these are true:

  • Monthly cloud bill growing 10%+ without corresponding business growth
  • Resources exist with no owner (nobody knows who created them or why)
  • Multiple accounts/subscriptions with no naming convention or tagging
  • Duplicate services (e.g. two logging stacks, three monitoring tools)
  • “Shadow IT” cloud accounts outside the organisation’s billing
  • Abandoned PoC environments still incurring costs
  • Cost allocation: >30% of spend is untagged

Phase 1: Inventory - what do you actually have?

AWS: list all accounts in the organisation

# List all AWS accounts in the organisation
aws organizations list-accounts \
  --query 'Accounts[].{Id:Id, Name:Name, Email:Email, Status:Status}' \
  --output table

# Find accounts with no recent activity (no CloudTrail events in 90 days)
for account_id in $(aws organizations list-accounts --query 'Accounts[].Id' --output text); do
  event_count=$(aws cloudtrail lookup-events \
    --lookup-attributes AttributeKey=AccountId,AttributeValue=$account_id \
    --start-time $(date -d '90 days ago' +%Y-%m-%dT%H:%M:%S) \
    --max-results 1 --query 'length(Events)' --output text 2>/dev/null)
  if [ "$event_count" = "0" ]; then
    echo "INACTIVE: $account_id"
  fi
done

AWS: find orphaned resources

# Unattached EBS volumes (paying for storage, used by nothing)
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[].{VolumeId:VolumeId, Size:Size, CreateTime:CreateTime}' \
  --output table

# Elastic IPs not attached to anything ($3.60/mo each since Feb 2024)
aws ec2 describe-addresses \
  --query 'Addresses[?AssociationId==null].{PublicIp:PublicIp, AllocationId:AllocationId}' \
  --output table

# Stopped EC2 instances (still paying for EBS)
aws ec2 describe-instances \
  --filters Name=instance-state-name,Values=stopped \
  --query 'Reservations[].Instances[].{Id:InstanceId, Name:Tags[?Key==`Name`]|[0].Value, StoppedSince:StateTransitionReason}' \
  --output table

# Load Balancers with no targets
aws elbv2 describe-target-groups \
  --query 'TargetGroups[?length(LoadBalancerArns)==`0`].{Arn:TargetGroupArn, Name:TargetGroupName}' \
  --output table

# RDS snapshots older than 90 days (manual, forgotten)
aws rds describe-db-snapshots \
  --snapshot-type manual \
  --query "DBSnapshots[?SnapshotCreateTime<='$(date -d '90 days ago' +%Y-%m-%d)'].{Id:DBSnapshotIdentifier, Size:AllocatedStorage, Created:SnapshotCreateTime}" \
  --output table

GCP: find orphaned resources

# List all projects (look for ones nobody recognises)
gcloud projects list --format="table(projectId, name, lifecycleState, createTime)"

# Unattached persistent disks
gcloud compute disks list --filter="NOT users:*" \
  --format="table(name, zone, sizeGb, status, lastAttachTimestamp)"

# Static IPs not in use
gcloud compute addresses list --filter="status=RESERVED" \
  --format="table(name, address, region, status)"

# GKE clusters with zero workloads
for cluster in $(gcloud container clusters list --format="value(name,zone)"); do
  name=$(echo $cluster | cut -f1)
  zone=$(echo $cluster | cut -f2)
  pods=$(gcloud container clusters get-credentials $name --zone $zone 2>/dev/null && \
    kubectl get pods --all-namespaces --no-headers 2>/dev/null | grep -v kube-system | wc -l)
  echo "$name ($zone): $pods non-system pods"
done

Azure: find orphaned resources

# List all subscriptions
az account list --query "[].{Name:name, Id:id, State:state}" --output table

# Unattached managed disks
az disk list --query "[?managedBy==null].{Name:name, Size:diskSizeGb, ResourceGroup:resourceGroup}" \
  --output table

# Public IPs not associated
az network public-ip list \
  --query "[?ipConfiguration==null].{Name:name, IP:ipAddress, ResourceGroup:resourceGroup}" \
  --output table

# App Service Plans with no apps
az appservice plan list \
  --query "[?numberOfSites==\`0\`].{Name:name, Sku:sku.name, ResourceGroup:resourceGroup}" \
  --output table

Phase 2: Cost analysis - where is money leaking?

Find untagged spend

# AWS: percentage of untagged cost (last 30 days)
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity MONTHLY \
  --metrics BlendedCost \
  --group-by Type=TAG,Key=team \
  --query 'ResultsByTime[0].Groups[?Keys[0]==``].Metrics.BlendedCost.Amount' \
  --output text
# If this number is > 30% of total spend, you have a tagging problem

Cost per account/project

# AWS: cost breakdown by linked account
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity MONTHLY \
  --metrics BlendedCost \
  --group-by Type=DIMENSION,Key=LINKED_ACCOUNT \
  --query 'ResultsByTime[0].Groups[].{Account:Keys[0], Cost:Metrics.BlendedCost.Amount}' \
  --output table

# GCP: cost by project (last 30 days via billing export in BigQuery)
bq query --use_legacy_sql=false '
  SELECT project.name, SUM(cost) as total_cost
  FROM `billing_dataset.gcp_billing_export`
  WHERE DATE(usage_start_time) >= DATE_SUB(CURRENT_DATE(), INTERVAL 30 DAY)
  GROUP BY project.name
  ORDER BY total_cost DESC
'

Typical findings and savings

Issue Prevalence Typical monthly waste
Orphaned EBS volumes 60% of orgs $50-500/mo
Stopped instances with attached EBS 40% of orgs $100-1,000/mo
Forgotten RDS snapshots 50% of orgs $50-300/mo
Unused Elastic IPs 30% of orgs $10-50/mo
Abandoned test accounts/projects 25% of orgs $200-2,000/mo
Duplicate monitoring tools 40% of orgs $300-1,500/mo

Phase 3: Governance - prevent sprawl from returning

Tagging policy (minimum viable)

Every resource must have at minimum:

# Minimum required tags - enforce via AWS Organizations SCP or Azure Policy
tags:
  team: "platform"          # Who owns this?
  environment: "production" # prod / staging / dev / sandbox
  service: "api-gateway"    # What business service does this support?
  cost-centre: "eng-012"    # Who pays for this?

Enforcement:

# AWS: SCP to deny resource creation without required tags
# Apply to all accounts except the management account
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyUntaggedEC2",
      "Effect": "Deny",
      "Action": ["ec2:RunInstances"],
      "Resource": ["arn:aws:ec2:*:*:instance/*"],
      "Condition": {
        "Null": {
          "aws:RequestTag/team": "true",
          "aws:RequestTag/environment": "true"
        }
      }
    }
  ]
}

Account/project lifecycle policy

  1. Creation - requires approval + documented purpose + owner + expiry date
  2. Review - quarterly check: is this account still needed? Does it have active workloads?
  3. Sunset - if no activity for 90 days: notify owner → 30-day grace period → decommission
  4. Sandbox accounts - auto-nuke after 7 days (use tools like aws-nuke or cloud-nuke)
# Install cloud-nuke for automated sandbox cleanup
# https://github.com/gruntwork-io/cloud-nuke
cloud-nuke aws --older-than 168h --config .cloud-nuke.yml --dry-run

Monitoring sprawl indicators

Set up alerts for early sprawl detection:

  • New AWS account created without required tags
  • Monthly cost increase > 15% without matching deployment activity
  • Resources older than 90 days with no CloudTrail activity
  • New subscriptions/projects created outside approved process

Phase 4: Reduction - what to delete and what to consolidate

Decision matrix

Resource status Action Risk
No owner + no traffic for 90 days Delete (after snapshot) Low
Has owner + no traffic for 90 days Notify owner, 30-day deadline Low
Duplicate tool (2 monitoring stacks) Consolidate to one Medium
Abandoned test account Decommission entirely Low
Account with active prod workload but no governance Add tags, add to organisation, assign owner None

Safe deletion process

# Before deleting anything: create a final snapshot/backup
# EBS volume
aws ec2 create-snapshot --volume-id vol-xxx --description "pre-deletion backup"
aws ec2 delete-volume --volume-id vol-xxx

# RDS snapshot cleanup (keep only the latest, delete older)
aws rds delete-db-snapshot --db-snapshot-identifier old-snapshot-name

# Elastic IP release
aws ec2 release-address --allocation-id eipalloc-xxx

Consolidation candidates

If you find duplicate services, consolidate to one:

Duplicate Consolidate to Reason
CloudWatch + Datadog + Grafana Pick one (Datadog OR Grafana Cloud) Three tools = triple cost, nobody looks at all three
AWS accounts with identical workloads Merge into shared account with namespacing Reduces account management overhead
Multiple CI/CD tools (Jenkins + GitHub Actions + CodePipeline) GitHub Actions (or your primary) One pipeline standard, one set of credentials

Validation

After cleanup, verify:

# Check: no more unattached EBS volumes
aws ec2 describe-volumes --filters Name=status,Values=available --query 'length(Volumes)'
# Expected: 0

# Check: no unattached Elastic IPs
aws ec2 describe-addresses --query 'length(Addresses[?AssociationId==null])'
# Expected: 0

# Check: tagging compliance (should be > 90%)
aws ce get-cost-and-usage \
  --time-period Start=$(date -d '30 days ago' +%Y-%m-%d),End=$(date +%Y-%m-%d) \
  --granularity MONTHLY \
  --metrics BlendedCost \
  --group-by Type=TAG,Key=team \
  --query 'ResultsByTime[0].Groups[?Keys[0]!=``].Metrics.BlendedCost.Amount' --output text
# Calculate: tagged_cost / total_cost * 100 > 90%

Schedule this audit quarterly. First run typically saves 15-30% of monthly cloud spend through orphaned resource cleanup alone.

Next steps

  • Set up automated alerts for sprawl indicators (AWS Budgets, GCP Budget alerts)
  • Implement tag-based cost allocation dashboards (see our showback model guide)
  • Consider AWS Organizations with SCPs for governance (or GCP Organisation Policies)
  • Review multi-cloud architecture: is it deliberate or sprawl? (see our multi-cloud strategy guide)

If your cloud infrastructure has grown beyond what your team can govern effectively, we can run this audit with you and implement the governance framework. Book a free consultation to discuss your situation.