The metric that lies by omission
Open the CloudWatch console for almost any EC2 dev box and you'll see CPUUtilization hovering at 1-3% for hours. Someone on the team will glance at that graph and call the instance idle. Sometimes they're right. Sometimes there's a monitoring agent polling every 60 seconds, a cron job doing a health check, or a background sync process keeping just enough CPU alive to look active without anyone actually using the machine.
The reverse is just as common. An instance running a long compile or a data import will show low CPU in the 5-minute average even though someone is actively waiting on it, because CloudWatch's default granularity smooths over the bursts. A single number sampled every five minutes and averaged is not evidence of anything by itself. It's a hint, and hints need corroboration.
This matters because "idle" is the load-bearing word in most cost-cutting decisions - shut it down, right-size it, schedule it off. If the measurement is wrong, the action is wrong. So before writing any automation that acts on idle time, it's worth building a small script that actually checks idle time properly, using more than one CloudWatch metric, and understanding exactly where that check will still fool you.
Why CPUUtilization alone isn't enough
Three things conspire against a single-metric idle check:
Granularity. Standard CloudWatch resolution for EC2 is a 5-minute period by default (1-minute with detailed monitoring, which costs extra per instance). A short burst of real work inside a 5-minute window gets averaged down into something that looks like noise.
Background processes. Configuration management agents, log shippers, and container runtimes all poll on a schedule. None of that is a human or a workload doing anything meaningful, but it keeps CPU consistently nonzero, which defeats a naive "CPU == 0" check.
Workload shape. A database connection pool, an idle web server holding open sockets, or a box running nothing but waiting on a lock will all show near-zero CPU while the resource is still "in use" in some sense that matters to whoever owns it.
The fix isn't a smarter CPU threshold. It's corroborating CPU with a second, independent signal - network traffic is the most available one on every EC2 instance without any extra configuration.
A two-signal idle check
Here's a script that pulls CPUUtilization and network I/O for a single instance over a lookback window, and only calls a 5-minute period "idle" if both signals agree.
import boto3
from datetime import datetime, timedelta
cw = boto3.client("cloudwatch")
INSTANCE_ID = "i-0123456789abcdef0"
LOOKBACK_HOURS = 24
PERIOD = 300 # 5-minute granularity, matches CloudWatch's default resolution
def get_metric(metric_name, stat="Average"):
end = datetime.utcnow()
start = end - timedelta(hours=LOOKBACK_HOURS)
resp = cw.get_metric_statistics(
Namespace="AWS/EC2",
MetricName=metric_name,
Dimensions=[{"Name": "InstanceId", "Value": INSTANCE_ID}],
StartTime=start,
EndTime=end,
Period=PERIOD,
Statistics=[stat],
)
return sorted(resp["Datapoints"], key=lambda d: d["Timestamp"])
cpu = get_metric("CPUUtilization")
net_in = get_metric("NetworkIn", stat="Sum")
net_out = get_metric("NetworkOut", stat="Sum")
CPU_THRESHOLD = 2.0 # percent
NET_THRESHOLD = 50_000 # bytes per 5-minute window, roughly 800 bytes/sec
idle_windows = 0
for c, nin, nout in zip(cpu, net_in, net_out):
low_cpu = c["Average"] < CPU_THRESHOLD
low_net = (nin["Sum"] + nout["Sum"]) < NET_THRESHOLD
if low_cpu and low_net:
idle_windows += 1
total_windows = len(cpu)
idle_hours = idle_windows * PERIOD / 3600
print(f"{idle_windows}/{total_windows} five-minute windows idle on both signals")
print(f"roughly {idle_hours:.1f} of {LOOKBACK_HOURS} hours below threshold")
Run this against a real dev instance and you'll typically get an output like: 168 of 288 five-minute windows idle on both signals, roughly 14.0 of 24 hours below threshold. That's arithmetic anyone can check against the raw datapoints - not a modeled estimate, just two thresholds applied consistently across a day. Fourteen of twenty-four hours below both thresholds means the box was doing nothing that used the network or the CPU for well over half the day, and that's the number worth taking to a conversation about right-sizing or scheduling, not a vibe from staring at a dashboard.
Where the two-signal check still fails
It's more honest than CPU alone, but it's not activity-driven - meaning it still has no idea whether a human being is actually at the keyboard using this resource, only whether the resource itself is generating load. Those are different questions, and the gap between them is exactly where naive automation gets embarrassing.
A few concrete failure modes worth testing for before you trust this script's output:
Health checks and heartbeats. A load balancer health check every 30 seconds generates a trickle of NetworkIn that will sit just under most thresholds - or just over them if you set the threshold too tight. Log the actual bytes-per-window for a known-idle instance before picking NET_THRESHOLD; don't guess it.
SSH sessions with no activity. An open terminal with nothing typed generates near-zero traffic, so this check will correctly call it idle even though someone "has a session open." Decide up front whether that counts as idle for your purposes - for cost decisions, it almost always should.
EBS-backed workloads with quiet network. A process reading and writing heavily to an attached EBS volume can look network-idle and CPU-idle simultaneously. If your workloads are disk-bound, add EBSReadOps and EBSWriteOps as a third signal before drawing conclusions.
Auto Scaling Groups. If you're running this against instances behind an ASG, don't average across the group - pull metrics per InstanceId. A fleet average will hide the one instance that's been coasting at zero for a week behind three others carrying real traffic.
Extending the same logic to ECS and RDS
The pattern holds outside EC2, with different metric names. For an ECS service, CPUUtilization and MemoryUtilization come from the AWS/ECS namespace at the service level, but they average across all running tasks, so a service with fluctuating task count needs the check applied per-task via Container Insights if you want it to mean anything at 2am when task count itself might be scaling down. For RDS, CPUUtilization from AWS/RDS paired with DatabaseConnections gives you the same two-signal structure: low CPU with zero active connections for hours at a stretch is a much stronger idle claim than either metric alone - and it's the exact shape of the classic staging-database-idle-all-weekend problem, just made auditable instead of assumed.
None of this requires anything beyond boto3 and a CloudWatch API key with read access. It's worth building before you build anything that acts on the result, because every automated shutdown or rightsizing recommendation is only as good as the idle signal underneath it.
If you'd rather not maintain this kind of script per account and per resource type, Trigops does this measurement continuously across EC2, ECS, RDS, and ASGs and adds a real-presence signal on top of resource metrics - you can see what that looks like at trigops.com.
Top comments (0)