Cloud Cost Audit skill for Claude Code
Find the money, prove it, cut it with sign-off. Claude breaks the bill down by service, usage type and tag, explains the handful of items that make up most of the spend, and hunts idle disks, unused IPs, log groups without retention and NAT traffic that a gateway endpoint would remove. It rightsizes before recommending commitments, never deletes without approval, and delivers a savings report with evidence for every number.
- name
cloud-cost-audit - size169 lines · ≈2,200 tokens
- files3
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Audits and reduces cloud spend on AWS, GCP and Azure - cost breakdown by service, account and tag, Pareto analysis of billing exports, idle and orphaned resources, rightsizing, storage and log retention, data transfer and NAT costs, Graviton and gp3 migrations, Savings Plans, reserved and committed-use discounts, budgets and anomaly alerts, and a prioritized savings report. Use when the cloud bill jumped, when asked to cut infrastructure costs, before buying commitments, or when setting up cost allocation tags and budgets. Try asking
- Our AWS bill went up 40% last month. Find out why
- Audit our cloud spend and give me a prioritized list of savings
- Should we buy Savings Plans? Analyze our usage first
Use it when
- The cloud bill jumped or keeps creeping up
- Leadership asks for infrastructure cost reductions
- Before purchasing Savings Plans, reservations or CUDs
- Setting up cost allocation tags, budgets and anomaly alerts
Skip it when
- The cost is LLM token spend — use prompt-caching-costs
- You need application performance profiling — use node-profiling or python-profiling
How to check the result
Each recommendation cites billing rows or metrics, cost data for the following days shows the expected drop for changed usage types, and budgets and anomaly alerts reach the right channel.
The common mistake it prevents
Buying commitments before removing waste and rightsizing, which locks in payment for capacity that should have been deleted.
A cost audit is done when every major line item has an owner and an explanation, waste is removed with sign-off, the remaining baseline is rightsized and then covered by commitments, and budgets plus anomaly alerts catch the next surprise within a day. Savings are reported in monthly currency with evidence, not as percentages of guesses.
Workflow
- [ ] 1. Get read-only billing access and the last 3-6 months of cost data
- [ ] 2. Break down spend: service -> account/project -> usage type -> tag
- [ ] 3. Explain the top items covering ~80% of spend (Pareto)
- [ ] 4. Find waste: idle, orphaned, over-retained, over-provisioned
- [ ] 5. Find architecture costs: data transfer, NAT, logging, chatty services
- [ ] 6. Rightsize, then decide commitments on the remaining steady baseline
- [ ] 7. Tagging, budgets, anomaly detection
- [ ] 8. Report with savings, effort, risk; execute changes only with approval
1-3. Get the data and find the big items
Use the provider’s cost tool first, then the detailed export for slicing:
| Provider | Quick view | Detailed export |
|---|---|---|
| AWS | Cost Explorer (group by service, linked account, usage type, tag) | CUR 2.0 via Data Exports to S3, query with Athena |
| GCP | Cloud Billing reports | Billing export to BigQuery (standard or detailed usage) |
| Azure | Cost Management cost analysis | Scheduled cost exports to a storage account |
aws ce get-cost-and-usage --time-period Start=2026-07-01,End=2026-10-01 \
--granularity MONTHLY --metrics UnblendedCost \
--group-by Type=DIMENSION,Key=SERVICE \
--query 'ResultsByTime[].Groups[?to_number(Metrics.UnblendedCost.Amount) > `100`].[Keys[0],Metrics.UnblendedCost.Amount]' --output text
For any CSV export, scripts/cost_pareto.py streams the file and prints the groups that
make up the chosen share of spend, plus how much is untagged:
python3 scripts/cost_pareto.py cur.csv.gz --list-columns
python3 scripts/cost_pareto.py cur.csv.gz --cost line_item_unblended_cost \
--group product_servicecode --group line_item_usage_type --top 25
python3 scripts/cost_pareto.py cur.csv.gz --cost line_item_unblended_cost --group resource_tags_user_team
For each top item, answer: what is it, who owns it, why does it cost this much (usage driver: hours, GB, requests), and is that expected? A month-over-month jump: compare usage types between months to see whether price, quantity, or a new resource changed.
4. Waste checklist
Provider-specific queries for each item are in references/provider-commands.md; read it when you start checking a specific account.
| Waste | Typical signal |
|---|---|
Unattached block volumes (EBS available, unattached GCP PDs, Azure unattached managed disks) | Billed every month, often after instance deletion |
| Old snapshots and AMIs/images | Snapshots of deleted volumes, no retention policy |
| Unused public IPv4 addresses | AWS charges for every public IPv4 address, attached or not |
| Idle load balancers, NAT gateways, VPN endpoints | Near-zero traffic for 30 days |
| Stopped instances with large disks | Compute stopped, storage still billed |
| Non-production running 24/7 | Dev/staging with business-hours usage pattern |
| Logs with no retention | CloudWatch log groups “Never expire”, GCP/Azure logs in expensive tiers |
| Object storage without lifecycle | Incomplete multipart uploads, noncurrent versions, cold data in standard class |
| Over-provisioned databases | CPU under 20% at p95, memory headroom huge, provisioned IOPS unused |
| Idle Kubernetes capacity | Node utilization low; requests far above actual usage |
| Forgotten experiments | Resources without tags or owners in sandbox accounts |
5. Architecture costs
- NAT gateway data processing: traffic from private subnets to AWS services (S3, DynamoDB, ECR, CloudWatch) through NAT is charged per GB. Add VPC gateway endpoints for S3 and DynamoDB (free) and interface endpoints where volume justifies them.
- Cross-AZ and inter-region transfer: chatty services across AZs, replicas in other
regions, cross-AZ Kafka/Elasticsearch replication. Check usage types containing
DataTransfer-Regional-Bytesor inter-region. - Internet egress: serve static assets and downloads through a CDN; consider R2 or CDN-origin offload for heavy egress.
- Logging and metrics: high-cardinality custom metrics, DEBUG logs in production, ingesting the same logs twice (CloudWatch and a third-party tool).
- Managed service premiums: per-request pricing at high steady volume (API Gateway, Lambda) can exceed container costs; compare at actual volume before migrating.
6. Rightsize, then commit
Order matters: rightsize and remove waste first, then buy commitments for what remains.
| Lever | Typical saving | Notes |
|---|---|---|
| Rightsize from utilization (Compute Optimizer, GCP Recommender, Azure Advisor) | 20-50% on affected resources | Use p95/p99 CPU and memory over 2-4 weeks, not averages |
| Graviton/Arm instances (EC2, RDS, ElastiCache, Lambda arm64) | Often around 20% better price-performance | Requires Arm-compatible images and dependencies |
| gp2 to gp3 volumes | About 20% cheaper per GB, baseline IOPS included | Online modification, no downtime |
| Current-generation instance families | 10-40% | Older generations cost more per unit of performance |
| Schedules for non-prod | Up to ~65% for business-hours-only | Instance Scheduler, scale-to-zero, cron |
| Spot / preemptible for fault-tolerant work | 60-90% | Batch, CI runners, stateless workers with interruption handling |
| Storage class and lifecycle | Varies | Intelligent-Tiering for unknown patterns; mind minimum durations and per-object fees |
Commitments:
- AWS: Compute Savings Plans (flexible across EC2 family/region, Fargate, Lambda) as the default; EC2 Instance Savings Plans or RIs for higher discount on fixed families; RDS, ElastiCache, OpenSearch, Redshift need their own reservations.
- GCP: committed use discounts (resource-based or spend-based flexible CUDs); sustained use discounts apply automatically to some machine types.
- Azure: savings plan for compute, or reservations for fixed VM sizes and databases.
- Cover the steady baseline (the minimum hourly usage over recent months), commonly 60-80% of it, with 1-year terms unless usage is very stable. Check current coverage and utilization reports first; unused commitments are pure waste.
- Buying commitments is a financial decision: present the options and let the user decide.
7. Allocation and guardrails
- Tag standard:
owner,team,env,service,cost-center. Enforce with tag policies/SCPs (AWS), organization policies and labels (GCP), Azure Policy. - Activate cost allocation tags in AWS Billing (tags are not in cost data until activated, and activation is not retroactive).
- Budgets per account/team with forecast alerts; AWS Cost Anomaly Detection, GCP budget alerts with Pub/Sub automation, Azure cost alerts and anomaly alerts.
- Show teams their own spend monthly; costs fall when owners see them.
Hard rules
- Read-only first. Never delete, stop, resize or modify resources without explicit user approval of a specific list; snapshot or back up before deleting storage.
- Verify “idle” with at least 14-30 days of metrics and an owner check; some resources are idle until month-end jobs or DR events.
- Report savings as monthly amounts computed from actual usage and current prices, with the query or data behind each number.
- Do not trade reliability for savings silently (single-AZ databases, removing replicas, aggressive spot) without stating the risk.
Verification
- Every recommendation cites the data (cost export rows, metric graphs, CLI output).
- After changes, the next daily cost data shows the expected drop for those usage types; track for a full billing cycle.
- Budgets and anomaly alerts exist and a test notification reaches the right channel.
- Untagged spend share (from
cost_pareto.pyby tag column) decreases over time.
Output contract
# Cloud cost audit: <account/org>, <period>
**Monthly spend:** $X (trend: +Y% over 3 months) **Identified savings:** $Z/month
## Top spend drivers
| Service / usage type | Monthly | Owner | Driver | Expected? |
## Recommendations (sorted by savings / effort)
| # | Action | Resources | Est. monthly saving | Effort | Risk | Evidence |
## Commitments
Current coverage/utilization, recommended purchase (if any), break-even.
## Guardrails added
Tags, budgets, anomaly alerts.
## Needs owner decision
Items blocked on confirmation (deletions, downsizing prod). Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
references/provider-commands.md
# Provider commands for cost audits
All commands are read-only. Run per region where resources are regional
(`for r in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do ...; done`).
## AWS
```bash
# Spend by service last full month
aws ce get-cost-and-usage --time-period Start=2026-09-01,End=2026-10-01 --granularity MONTHLY \
--metrics UnblendedCost --group-by Type=DIMENSION,Key=SERVICE --output json \
| jq -r '.ResultsByTime[0].Groups[] | [.Keys[0], (.Metrics.UnblendedCost.Amount|tonumber)] | @tsv' | sort -t$'\t' -k2 -nr | head -20
# Usage types within one service (find what drives EC2-Other, data transfer, etc.)
aws ce get-cost-and-usage --time-period Start=2026-09-01,End=2026-10-01 --granularity MONTHLY \
--metrics UnblendedCost --filter '{"Dimensions":{"Key":"SERVICE","Values":["EC2 - Other"]}}' \
--group-by Type=DIMENSION,Key=USAGE_TYPE
# Unattached EBS volumes
aws ec2 describe-volumes --filters Name=status,Values=available \
--query 'Volumes[].[VolumeId,Size,VolumeType,CreateTime]' --output table
# gp2 volumes (candidates for gp3)
aws ec2 describe-volumes --filters Name=volume-type,Values=gp2 --query 'Volumes[].[VolumeId,Size]' --output text
# Snapshots owned by the account, oldest first
aws ec2 describe-snapshots --owner-ids self \
--query 'sort_by(Snapshots,&StartTime)[].[SnapshotId,VolumeSize,StartTime,Description]' --output text | head -50
# Elastic IPs not associated
aws ec2 describe-addresses --query 'Addresses[?AssociationId==null].[PublicIp,AllocationId]' --output table
# Stopped instances (still paying for EBS)
aws ec2 describe-instances --filters Name=instance-state-name,Values=stopped \
--query 'Reservations[].Instances[].[InstanceId,InstanceType,LaunchTime,Tags[?Key==`Name`]|[0].Value]' --output table
# Load balancers: list, then check RequestCount / ProcessedBytes in CloudWatch for 30 days
aws elbv2 describe-load-balancers --query 'LoadBalancers[].[LoadBalancerName,Type,CreatedTime]' --output table
# NAT gateways and their processed bytes (BytesOutToDestination metric per NAT)
aws ec2 describe-nat-gateways --query 'NatGateways[].[NatGatewayId,VpcId,State]' --output table
# Log groups without retention, largest first
aws logs describe-log-groups \
--query 'logGroups[?retentionInDays==null].[logGroupName,storedBytes]' --output text | sort -k2 -nr | head -30
# S3 buckets: check lifecycle presence
for b in $(aws s3api list-buckets --query 'Buckets[].Name' --output text); do
aws s3api get-bucket-lifecycle-configuration --bucket "$b" >/dev/null 2>&1 || echo "no lifecycle: $b"
done
# Rightsizing recommendations
aws compute-optimizer get-ec2-instance-recommendations \
--query 'instanceRecommendations[].[instanceArn,finding,currentInstanceType,recommendationOptions[0].instanceType]' --output table
# Savings Plans coverage and utilization
aws ce get-savings-plans-coverage --time-period Start=2026-09-01,End=2026-10-01 --granularity MONTHLY
aws ce get-savings-plans-utilization --time-period Start=2026-09-01,End=2026-10-01
aws ce get-savings-plans-purchase-recommendation --savings-plans-type COMPUTE_SP \
--term-in-years ONE_YEAR --payment-option NO_UPFRONT --lookback-period-in-days THIRTY_DAYS
```
Athena on CUR 2.0 (column names per Data Exports schema; check your table):
```sql
SELECT product['servicecode'] AS service, line_item_usage_type,
ROUND(SUM(line_item_unblended_cost), 2) AS cost
FROM cur2
WHERE billing_period = '2026-09'
GROUP BY 1, 2
ORDER BY cost DESC
LIMIT 30;
```
## Google Cloud
```bash
# Unattached persistent disks
gcloud compute disks list --filter="-users:*" --format="table(name,zone,sizeGb,type,creationTimestamp)"
# Static external IPs not in use
gcloud compute addresses list --filter="status=RESERVED" --format="table(name,region,address)"
# Snapshots, oldest first
gcloud compute snapshots list --sort-by=creationTimestamp --format="table(name,diskSizeGb,creationTimestamp)"
# Idle VM and rightsizing recommendations (per zone)
gcloud recommender recommendations list --project=$PROJECT --location=$ZONE \
--recommender=google.compute.instance.IdleResourceRecommender
gcloud recommender recommendations list --project=$PROJECT --location=$ZONE \
--recommender=google.compute.instance.MachineTypeRecommender
# Log buckets and retention
gcloud logging buckets list --location=global --format="table(name,retentionDays)"
```
BigQuery on the billing export:
```sql
SELECT service.description AS service, sku.description AS sku,
ROUND(SUM(cost) + SUM(IFNULL((SELECT SUM(c.amount) FROM UNNEST(credits) c), 0)), 2) AS net_cost
FROM `billing_dataset.gcp_billing_export_v1_XXXXXX`
WHERE invoice.month = '202609'
GROUP BY 1, 2
ORDER BY net_cost DESC
LIMIT 30;
```
## Azure
```bash
# Unattached managed disks
az disk list --query "[?diskState=='Unattached'].{name:name, rg:resourceGroup, sizeGb:diskSizeGB, sku:sku.name}" -o table
# Public IPs not associated
az network public-ip list --query "[?ipConfiguration==null].{name:name, rg:resourceGroup, sku:sku.name}" -o table
# Deallocated vs stopped VMs (stopped-but-allocated still bills compute)
az vm list -d --query "[?powerState!='VM running'].{name:name, rg:resourceGroup, state:powerState, size:hardwareProfile.vmSize}" -o table
# Advisor cost recommendations
az advisor recommendation list --category Cost -o table
# Snapshots
az snapshot list --query "[].{name:name, rg:resourceGroup, sizeGb:diskSizeGB, created:timeCreated}" -o table
```
Cost Management query: use the portal's cost analysis grouped by service name and
resource group, or schedule exports to storage and analyze the CSV with
`scripts/cost_pareto.py --cost costInBillingCurrency --group meterCategory --group resourceGroupName`
(column names vary by export type; check with `--list-columns`).
## Kubernetes
- Compare requested vs used CPU/memory per namespace (`kubectl top pods -A` for a
snapshot; Prometheus `container_cpu_usage_seconds_total` vs
`kube_pod_container_resource_requests` for history).
- Tools: OpenCost or Kubecost for per-namespace allocation; cluster autoscaler or
Karpenter consolidation to remove underused nodes.
scripts/cost_pareto.py
#!/usr/bin/env python3
"""Pareto summary of a cloud cost CSV export: which groups make up most of the spend.
Works with any CSV that has a cost column and one or more grouping columns, e.g.
AWS CUR 2.0 / Data Exports (line_item_unblended_cost, product_servicecode,
line_item_usage_type, resource_tags_user_team), GCP billing export to CSV
(cost, service.description, sku.description), or Azure cost exports (costInBillingCurrency,
meterCategory, resourceGroupName). Untagged/empty group values are reported as "(none)".
Usage:
python3 cost_pareto.py export.csv --cost line_item_unblended_cost \
--group product_servicecode [--group line_item_usage_type] [--top 20] [--threshold 0.8]
python3 cost_pareto.py export.csv --list-columns
Exit codes: 0 ok, 2 bad input (missing file/columns, no numeric costs).
Standard library only; streams the file, so multi-GB exports are fine.
"""
from __future__ import annotations
import argparse
import csv
import gzip
import sys
from collections import defaultdict
def open_any(path: str):
if path.endswith(".gz"):
return gzip.open(path, "rt", newline="", encoding="utf-8-sig")
return open(path, newline="", encoding="utf-8-sig")
def parse_cost(raw: str) -> float | None:
if raw is None:
return None
s = raw.strip().replace(",", "").replace("$", "").replace("€", "").replace("£", "")
if not s:
return None
try:
return float(s)
except ValueError:
return None
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("csv_path")
ap.add_argument("--cost", help="name of the cost column")
ap.add_argument("--group", action="append", default=[], help="grouping column (repeatable)")
ap.add_argument("--top", type=int, default=20, help="rows to print (default 20)")
ap.add_argument("--threshold", type=float, default=0.8, help="cumulative share to mark (default 0.8)")
ap.add_argument("--list-columns", action="store_true", help="print header columns and exit")
args = ap.parse_args()
try:
fh = open_any(args.csv_path)
except OSError as e:
print(f"cannot open {args.csv_path}: {e}", file=sys.stderr)
return 2
with fh:
reader = csv.DictReader(fh)
header = reader.fieldnames or []
if args.list_columns:
print("\n".join(header))
return 0
if not args.cost or not args.group:
print("--cost and at least one --group are required (use --list-columns to see names)", file=sys.stderr)
return 2
missing = [c for c in [args.cost, *args.group] if c not in header]
if missing:
print(f"columns not found: {', '.join(missing)}", file=sys.stderr)
return 2
totals: dict[tuple[str, ...], float] = defaultdict(float)
rows = skipped = 0
for row in reader:
cost = parse_cost(row.get(args.cost, ""))
if cost is None:
skipped += 1
continue
key = tuple((row.get(g) or "").strip() or "(none)" for g in args.group)
totals[key] += cost
rows += 1
grand = sum(totals.values())
if rows == 0 or grand == 0:
print("no numeric cost values found", file=sys.stderr)
return 2
ranked = sorted(totals.items(), key=lambda kv: kv[1], reverse=True)
width = max(len(" / ".join(k)) for k, _ in ranked[: args.top])
width = min(max(width, 10), 70)
print(f"Total cost: {grand:,.2f} across {rows:,} line items ({skipped:,} skipped), {len(ranked):,} groups")
print(f"{'#':>3} {'group':<{width}} {'cost':>14} {'share':>6} {'cum':>6}")
cum = 0.0
crossed_at = None
for i, (key, cost) in enumerate(ranked, 1):
cum += cost
if crossed_at is None and cum / grand >= args.threshold:
crossed_at = i
if i <= args.top:
label = " / ".join(key)
label = label if len(label) <= width else label[: width - 3] + "..."
mark = " <" if crossed_at == i else ""
print(f"{i:>3} {label:<{width}} {cost:>14,.2f} {cost / grand:>6.1%} {cum / grand:>6.1%}{mark}")
print(f"\n{crossed_at} of {len(ranked)} groups account for {args.threshold:.0%} of spend.")
untagged = sum(c for k, c in ranked if "(none)" in k)
if untagged:
print(f"Groups with an empty value in a grouping column: {untagged:,.2f} ({untagged / grand:.1%} of spend)")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.