1. Find where the bytes are
S3 Storage Lens (free metrics at account/org level; advanced metrics optional) shows per bucket: total bytes, object count, % noncurrent version bytes, incomplete multipart upload bytes, and growth.
Quick per-bucket size from CloudWatch daily storage metrics:
aws cloudwatch get-metric-statistics --namespace AWS/S3 --metric-name BucketSizeBytes \
--dimensions Name=BucketName,Value=app-exports Name=StorageType,Value=StandardStorage \
--start-time "$(date -u -d '-2 days' +%F)" --end-time "$(date -u +%F)" --period 86400 --statistics Average
Look for: big buckets in Standard that are rarely read, buckets where noncurrent versions are a large share, and incomplete multipart bytes.
2. Intelligent-Tiering or lifecycle transitions?
| Situation | Choice |
|---|---|
| Access pattern unknown or changing | Intelligent-Tiering: moves objects between tiers automatically by access |
| Known pattern (logs: hot for 30 days, then rarely read) | Lifecycle transitions to IA/Glacier classes |
| Archive you can wait hours to read | Glacier Flexible Retrieval / Deep Archive |
| Archive that must be read in milliseconds occasionally | Glacier Instant Retrieval |
Intelligent-Tiering notes: a small monthly monitoring fee per object; objects under 128 KB aren't monitored and stay in the frequent tier; no retrieval fees for the automatic tiers. Optional archive tiers need opt-in.
3. Lifecycle configuration example
{
"Rules": [
{
"ID": "logs-tiering",
"Filter": { "Prefix": "logs/" },
"Status": "Enabled",
"Transitions": [
{ "Days": 30, "StorageClass": "STANDARD_IA" },
{ "Days": 90, "StorageClass": "GLACIER_IR" },
{ "Days": 365, "StorageClass": "DEEP_ARCHIVE" }
],
"Expiration": { "Days": 2555 }
},
{
"ID": "versions-and-uploads",
"Filter": {},
"Status": "Enabled",
"NoncurrentVersionExpiration": { "NoncurrentDays": 30, "NewerNoncurrentVersions": 3 },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 },
"Expiration": { "ExpiredObjectDeleteMarker": true }
}
]
}
aws s3api put-bucket-lifecycle-configuration --bucket app-exports --lifecycle-configuration file://lifecycle.json
4. Traps
| Trap | Detail |
|---|---|
| Minimum storage duration | IA classes 30 days, Glacier IR/Flexible 90, Deep Archive 180. Deleting earlier is charged as if stored |
| Minimum billable object size | IA and Glacier IR bill small objects as 128 KB. Millions of tiny files can cost more after transition |
| Transition request charges | Per-object lifecycle requests; huge counts of tiny objects make transitions expensive. Aggregate first |
| Retrieval fees | IA and Glacier classes charge per GB retrieved; frequently read data costs more there |
| Versioning without expiry | Every overwrite keeps a full copy forever |
| Replication buckets | Lifecycle on the destination too, or it grows forever |
Use object size filters in lifecycle rules (ObjectSizeGreaterThan) to skip tiny objects.
5. Other levers
- Delete data nobody owns (after confirming): old build artefacts, temporary exports, test data.
- Compress and use columnar formats (Parquet) for analytics data. Smaller and cheaper to scan.
- S3 Express One Zone only where you need its latency; it's a different price model.
- Request costs: chatty apps doing many LIST/GET on small objects may cost more in requests than storage.
6. Measure
Storage Lens dashboard by storage class, monthly. Expect a gradual drop as lifecycle rules run (once a day, asynchronously).
Caution
Expiration and noncurrent version expiration permanently delete data. Confirm retention with data owners, and remember Object Lock/legal holds override lifecycle deletes, which is good for compliance but confusing when you expect savings.
Checklist
- Storage Lens reviewed for big, cold and versioned buckets
- Intelligent-Tiering vs explicit lifecycle chosen per bucket/prefix
- Noncurrent versions and incomplete uploads expired
- Small-object and minimum-duration traps checked
- Owners confirmed deletes
- Monthly review of storage by class