EC2 and EBS Performance: Find the Ceiling

“The server is slow” usually means it hit a ceiling: CPU, the volume’s IOPS or throughput, or the instance’s own EBS bandwidth. EC2 performance troubleshooting is finding which ceiling, with numbers, before anyone buys a bigger instance that fixes nothing.

You’ll launch a small server with a gp3 data volume, measure CPU and disk with a benchmark and fio, and read the CloudWatch metrics that matter. Then you’ll run out of CPU credits on purpose and hit the gp3 throughput cap, and fix each one properly.

TimeYou needBy the end you can
60 minutesLessons 11, 14, 23 and 43 done (EC2, EBS, CloudWatch, storage for SQL Server), signed in as saat-admin. Cost: a few cents (a t3.micro for an hour, a 20 GiB gp3 volume, and a little extra throughput for a few minutes).Read CPU credit and steal time, measure EBS IOPS and throughput with fio, use the volume and instance “exceeded” metrics, raise gp3 performance with modify-volume, and know when the instance type is the real limit.

What it is, in one line

EC2 performance troubleshooting means measuring CPU, volume and instance limits to find which one is capping your workload, then raising the right one.

In Simple Words

The road, the bridge and the car

Your delivery van is slow. Is it the engine (CPU), the road out of the depot (the volume’s speed limit), or the narrow bridge every van must cross (the instance’s EBS bandwidth)?

Widening the road doesn’t help if the bridge is narrower. A faster van doesn’t help on a road jammed at walking speed.

Burstable instances are like vans with a battery boost: fast for a while, then slow once the battery is flat. That flat battery is the CPU credit balance.

Engineer's View

The ceilings and their metrics

CPU: burstable types (t3, t4g) earn credits below a baseline and spend them above it. In standard mode, an empty balance caps CPU at the baseline (10% per vCPU for t3.micro), and inside the guest you see high steal time (%st). In unlimited mode (the t3 default) the instance keeps bursting and you pay for surplus credits.

Volume: gp3 gives 3,000 IOPS and 125 MiB/s whatever its size, adjustable with modify-volume up to much higher limits, and io2 is provisioned. VolumeIOPSExceededCheck and VolumeThroughputExceededCheck report 1 when your app tries to go past the volume’s limits, and VolumeAvgWriteLatency shows the effect.

Instance: every type has a maximum EBS bandwidth and IOPS, and smaller sizes burst for about 30 minutes a day (EBSByteBalance%, EBSIOBalance%); InstanceEBSThroughputExceededCheck says when you hit it. A volume modification can be made once every 6 hours. When the ceiling is the instance, change the instance type.

How it works, step by step

Here is the order to check a slow instance.

1CPU first

CPUUtilization and CPUCreditBalance from CloudWatch, steal time from inside. A t3 at “10%” with high steal is starved, not idle.

Three performance ceilings between an EC2 app and its data

2Then the volume

Throughput and IOPS against the volume’s limits, the “exceeded” checks, and latency. Flat lines at exactly 125 MiB/s or 3,000 IOPS are a giveaway.

3Then the instance

If raising the volume doesn’t help, check the instance’s EBS limits and burst balances. Then look beyond storage: memory, network, the app.

4Know the signs

Each bottleneck leaves its own fingerprints:

Signs of EC2 and EBS bottlenecks and their fixes

Measure before and after every change. A number that didn’t move tells you the ceiling is somewhere else.

✅ Checkpoint: You can name the three ceilings, the metric for each, and what high steal time on a t3 means.

Hands-on lab: measure perf61 and hit two ceilings

Sign in and launch perf61 with a separate gp3 data volume:

cd C:\aws-lab
aws login --profile saat-admin
$env:AWS_PROFILE = 'saat-admin'
$v = Get-Content C:\aws-lab\saat-vpc.json | ConvertFrom-Json
'[{"DeviceName":"/dev/sdf","Ebs":{"VolumeSize":20,"VolumeType":"gp3","Encrypted":true,"DeleteOnTermination":true}}]' | Set-Content bdm61.json
$ami = aws ssm get-parameter --name /aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 --query Parameter.Value --output text
$id = aws ec2 run-instances --image-id $ami --instance-type t3.micro --credit-specification CpuCredits=unlimited --subnet-id $v.PublicA --security-group-ids $v.WebSg --iam-instance-profile Name=saat-ec2-ssm-profile --metadata-options HttpTokens=required --block-device-mappings file://bdm61.json --user-data "#!/bin/bash
dnf install -y fio" --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=perf61},{Key=course,Value=saatvedha-aws},{Key=lesson,Value=61}]' 'ResourceType=volume,Tags=[{Key=course,Value=saatvedha-aws},{Key=lesson,Value=61}]' --query "Instances[0].InstanceId" --output text
do { Start-Sleep 20; $p = aws ssm describe-instance-information --filters "Key=InstanceIds,Values=$id" --query "InstanceInformationList[0].PingStatus" --output text } until ($p -eq 'Online')
$vol = aws ec2 describe-instances --instance-ids $id --query "Reservations[0].Instances[0].BlockDeviceMappings[?DeviceName=='/dev/sdf'].Ebs.VolumeId | [0]" --output text
aws ec2 describe-volumes --volume-ids $vol --query "Volumes[0].[VolumeType,Size,Iops,Throughput]" --output text

You should see something like this:

gp3     20      3000    125

perf61 sits in a public subnet so it reaches Systems Manager without endpoints; it has no inbound rules. The data volume has gp3’s default 3,000 IOPS and 125 MiB/s.

1Measure CPU

Paste this whole block. It adds helpers, then runs a single-core benchmark and reads CPU steal:

function Invoke-Linux([string]$Id, [string[]]$Lines) {
  @{ commands = $Lines; executionTimeout = @('900') } | ConvertTo-Json | Set-Content lx-cmd.json
  $cmd = aws ssm send-command --document-name AWS-RunShellScript --instance-ids $Id --parameters file://lx-cmd.json --query Command.CommandId --output text
  do { Start-Sleep 5; $st = aws ssm get-command-invocation --command-id $cmd --instance-id $Id --query Status --output text 2>$null } until ($st -in 'Success', 'Failed', 'TimedOut')
  aws ssm get-command-invocation --command-id $cmd --instance-id $Id --query StandardOutputContent --output text
}
$bench = 'python3 -c "import time; t = time.time(); s = sum(i * i for i in range(30000000)); print(round(time.time() - t, 1), \"seconds\")"; top -bn2 -d1 | grep "%Cpu" | tail -1'
$parse = ' --output-format=json | python3 -c ''import json, sys; j = json.load(sys.stdin)["jobs"][0]["write"]; print(round(j["bw"] / 1024), "MiB/s", round(j["iops"]), "IOPS")'''
$seq = 'fio --name=seq --filename=/dev/nvme1n1 --rw=write --bs=1M --direct=1 --ioengine=libaio --iodepth=32 --runtime=30 --time_based' + $parse
$rand = 'fio --name=rand --filename=/dev/nvme1n1 --rw=randwrite --bs=4k --direct=1 --ioengine=libaio --iodepth=64 --runtime=30 --time_based' + $parse
function Get-Metric([string]$Ns, [string]$Name, [string]$DimName, [string]$DimValue, [string]$Stat = 'Maximum') {
  $e = (Get-Date).ToUniversalTime(); $s = $e.AddMinutes(-10)
  aws cloudwatch get-metric-statistics --namespace $Ns --metric-name $Name --dimensions "Name=$DimName,Value=$DimValue" --start-time $s.ToString('o') --end-time $e.ToString('o') --period 60 --statistics $Stat --query "max_by(Datapoints, &Timestamp).$Stat" --output text
}
Invoke-Linux $id @($bench)

You should see something like this:

2.9 seconds
%Cpu(s): 50.2 us,  0.3 sy,  0.0 ni, 49.5 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st

About 3 seconds, one of two vCPUs busy, and 0.0 st (steal). This is your baseline. In unlimited mode, a t3 can run at 100% as long as it likes; you just pay for the surplus.

2Measure the data volume

Paste this whole block. fio writes to the empty data volume for 30 seconds each, in big and small blocks:

Invoke-Linux $id @('lsblk -o NAME,SIZE,MOUNTPOINT /dev/nvme1n1', $seq, $rand)
"VolumeThroughputExceededCheck = $(Get-Metric AWS/EBS VolumeThroughputExceededCheck VolumeId $vol)"

You should see something like this:

NAME    SIZE MOUNTPOINT
nvme1n1  20G
126 MiB/s 126 IOPS
12 MiB/s 3001 IOPS
VolumeThroughputExceededCheck = 1.0

Big blocks stop at gp3’s 125 MiB/s; small blocks stop at 3,000 IOPS. Two different limits on the same volume, and CloudWatch confirms the app tried to go faster. fio on a raw device is only safe on an empty volume like this one.

CloudWatch EBS volume metrics showing throughput flat at 125 MiB/s and then IOPS flat at 3000
The data volume’s metrics during fio: throughput flat at gp3’s 125 MiB/s, then IOPS flat at 3,000.
EC2 performance troubleshooting lab output: CPU benchmark and fio results
CPU and fio measurements on perf61. Something like this.

3Read the CloudWatch side

Paste this whole block:

"CPUUtilization max   = $(Get-Metric AWS/EC2 CPUUtilization InstanceId $id)"
"CPUCreditBalance     = $(Get-Metric AWS/EC2 CPUCreditBalance InstanceId $id Average)"
"VolumeAvgWriteLatency = $(Get-Metric AWS/EBS VolumeAvgWriteLatency VolumeId $vol) ms"
"InstanceEBSThroughputExceededCheck = $(Get-Metric AWS/EC2 InstanceEBSThroughputExceededCheck InstanceId $id)"

You should see something like this:

CPUUtilization max   = 50.8
CPUCreditBalance     = 4.2
VolumeAvgWriteLatency = 10.4 ms
InstanceEBSThroughputExceededCheck = 0.0

The volume, not the instance, was the ceiling during fio. Credit metrics arrive every 5 minutes, so the balance may show None at first; run it again later.

EC2 instance perf61 monitoring with CPU and credit balance charts and no EBS limit reached
perf61’s Monitoring tab: the CPU had room to spare and the instance never hit its EBS limit, so the volume was the ceiling.

✅ Checkpoint: The benchmark takes a few seconds with 0 steal, fio stops at about 125 MiB/s and 3,000 IOPS, and VolumeThroughputExceededCheck shows 1.

Break It, Then Fix It

Two ceilings that cause most “it was fast last week” tickets.

1Break it: run out of CPU credits

Switch perf61 to standard credits, burn what’s left for a few minutes, then run the benchmark again:

aws ec2 modify-instance-credit-specification --instance-credit-specifications "InstanceId=$id,CpuCredits=standard" | Out-Null
Invoke-Linux $id @('timeout 300 yes > /dev/null & timeout 300 yes > /dev/null & wait; echo burned') | Out-Null
Invoke-Linux $id @($bench)

You’ll get something like this:

31.4 seconds
%Cpu(s):  5.6 us,  0.2 sy,  0.0 ni,  5.5 id,  0.0 wa,  0.0 hi,  0.0 si, 88.7 st

Ten times slower, with nearly 90% steal: the hypervisor is holding perf61 at its 10% baseline because the balance is empty. CloudWatch would show CPUUtilization around 10%, which looks idle on a dashboard. Fix: go back to unlimited (or move to a non-burstable type if the load is constant):

aws ec2 modify-instance-credit-specification --instance-credit-specifications "InstanceId=$id,CpuCredits=unlimited" | Out-Null
Start-Sleep 10
Invoke-Linux $id @($bench)

You should see something like this:

2.9 seconds
%Cpu(s): 50.1 us,  0.3 sy,  0.0 ni, 49.6 id,  0.0 wa,  0.0 hi,  0.0 si,  0.0 st

2Break it: the app needs more than gp3’s default

A batch job needs 250 MiB/s. Run the big-block test again, then raise the volume’s throughput and test once more:

Invoke-Linux $id @($seq)
aws ec2 modify-volume --volume-id $vol --throughput 250 | Out-Null
do { Start-Sleep 10; $m = aws ec2 describe-volumes-modifications --volume-ids $vol --query "VolumesModifications[0].ModificationState" --output text } until ($m -in 'optimizing', 'completed')
Invoke-Linux $id @($seq)
"InstanceEBSThroughputExceededCheck = $(Get-Metric AWS/EC2 InstanceEBSThroughputExceededCheck InstanceId $id)"

You’ll get something like this:

126 MiB/s 126 IOPS
251 MiB/s 251 IOPS
InstanceEBSThroughputExceededCheck = 0.0
EC2 performance problems: CPU credits exhausted and gp3 throughput cap
Both ceilings and their fixes. Something like this.

Throughput doubled as soon as the modification reached “optimizing”, with no restart. On a t3.micro this only lasts while the instance’s EBS burst lasts (about 30 minutes a day); for steady 250 MiB/s you’d also need an instance type whose baseline EBS bandwidth is high enough. Remember you can only modify a volume once every 6 hours.

Now clean up (the data volume is deleted with the instance):

aws ec2 terminate-instances --instance-ids $id | Out-Null
aws ec2 wait instance-terminated --instance-ids $id
Remove-Item bdm61.json, lx-cmd.json

✅ Checkpoint: You fixed a credit-starved CPU by switching to unlimited and a throughput cap with modify-volume. perf61 and its volume are gone.

This happened at work

The nightly report that ran at 10%

An HR software company ran its payroll reports on a t3.large in standard credit mode to keep costs fixed. The reports took 20 minutes for a year, then suddenly took four hours at every month end.

The CPU graph showed 20% all night, so everyone assumed the database was slow and upgraded it. Nothing changed. A new engineer logged in during a run and saw 75% steal in top: daytime jobs added months earlier were using up the credits, and the report ran at baseline.

They moved the report server to an m7i.large, which cost less than the database upgrade they rolled back. They also added an alarm on CPUCreditBalance for every burstable instance still in standard mode.

Common mistakes and troubleshooting

MistakeWhat happensWhat to do
Reading low CPU% on a t3 as “idle”Missing credit starvationCheck CPUCreditBalance and steal time
Assuming gp3 scales with sizeStuck at 3,000 IOPS / 125 MiB/sRaise IOPS and throughput with modify-volume
Raising the volume when the instance is the limitNo improvementCheck InstanceEBS*ExceededCheck and type limits
Benchmarking only for a minuteBurst hides the real baselineTest longer than the burst window
Running fio on a disk with dataData destroyedOnly on empty volumes, or use files
Modifying a volume twice in a dayError until 6 hours passPlan the final size and performance once

Interview questions

Click a question to open it. Each one is written the way an interviewer would ask it. Under it you will see why they ask it and one way you could answer, in your own words.

Fresher level

1. Can you explain in simple words how CPU credits work on t3 instances?

Why they ask this: They want to check that you understand burstable instances, because credit problems often look like random slowness.

How you might answer: The instance earns credits while it runs below its baseline CPU, and spends them when it goes above. I explain it like a phone data plan: save when you’re quiet, spend when you’re busy.

In standard mode, CPU is held at baseline when the credits run out. In unlimited mode it keeps bursting, and you pay for the extra.

2. What baseline IOPS and throughput do you get with a gp3 volume?

Why they ask this: They want to see that you know the starting numbers for the most common EBS volume type, and that you can tune them.

How you might answer: A gp3 volume gives 3,000 IOPS and 125 MiB/s, no matter how big it is.

If I need more, I can raise IOPS and throughput separately, without making the volume bigger.

3. You run top on a Linux EC2 instance and see high steal time. What does that tell you?

Why they ask this: They want to know you can read a Linux metric and connect it to what’s happening on the AWS side.

How you might answer: Steal time means the hypervisor isn’t giving the guest the CPU time it’s asking for. On EC2 that’s usually credit throttling on a burstable instance.

So I check the CPU credit balance in CloudWatch next.

2–5 years experience

4. An app on EC2 has become slow, but CPU looks low. What would you check?

Why they ask this: They want to see that you look past CPU and check each possible limit in a sensible order.

How you might answer: First I check CPU credits and steal time, then the volume exceeded checks and EBS latency. After that I look at the instance EBS exceeded checks and burst balances.

If those are fine, I check memory and swap, then the network, and only then the app itself.

5. A volume is too slow and the business can’t accept downtime. How would you give it more performance?

Why they ask this: They want to see that you know EBS can be changed live, and that you know the limits of doing it.

How you might answer: I’d use modify-volume to change the size, IOPS, throughput or even the type, while the instance keeps running. The new performance applies during the optimizing state.

I only get one modification per volume every 6 hours, so I pick the right numbers the first time.

6. How do you tell whether you’re hitting a volume limit or an instance limit?

Why they ask this: They want to see that you know where the real bottleneck is before you spend money on a bigger volume.

How you might answer: I compare VolumeIOPSExceededCheck and VolumeThroughputExceededCheck with InstanceEBSIOPSExceededCheck and InstanceEBSThroughputExceededCheck. That shows which side is at its ceiling.

Another clue is if I raised the volume’s performance and nothing changed. Then the instance is the limit.

Senior level

7. A database needs 20,000 IOPS and 800 MiB/s. How would you size the storage and instance?

Why they ask this: They want to see that you size both the disk and the instance, because either one can become the ceiling.

How you might answer: I’d provision gp3 to those values, or use io2 if the workload is very sensitive to latency. Then I’d pick an instance type whose baseline EBS bandwidth is above 800 MiB/s.

I’d only stripe volumes if one volume can’t reach the numbers. After that I’d monitor the exceeded checks and latency.

8. When would you tell a team not to use burstable instances?

Why they ask this: They want to see that you can judge cost against risk and explain why the cheapest option isn’t always right.

How you might answer: I’d avoid them when CPU stays above baseline for long periods, for latency-sensitive production, or for batch jobs that drain the credits.

In those cases I’d move to general-purpose or compute-optimized types instead.

Quick quiz

Q1. A t3 in standard mode with 0 credits runs at?

  1. Full speed
  2. Its baseline
  3. Zero
  4. Double speed
Show answer

B — It is held at baseline.

Q2. What showed the CPU starvation inside the guest?

  1. %id
  2. %st (steal)
  3. %wa
  4. %hi
Show answer

B — Steal time.

Q3. gp3 default throughput is?

  1. 250 MiB/s
  2. 125 MiB/s
  3. 1,000 MiB/s
  4. Depends on size
Show answer

B — 125 MiB/s and 3,000 IOPS.

Q4. Which metric says the instance, not the volume, is the limit?

  1. VolumeIOPSExceededCheck
  2. InstanceEBSThroughputExceededCheck
  3. CPUCreditUsage
  4. NetworkIn
Show answer

B — Instance-level EBS limit.

Q5. How often can you modify the same EBS volume?

  1. Any time
  2. Once every 6 hours
  3. Once a month
  4. Never
Show answer

B — There is a cooldown.

Quick recap

  • Find the ceiling: CPU, the volume, or the instance’s EBS bandwidth.
  • On t3/t4g, check CPUCreditBalance and steal time; unlimited mode avoids starvation.
  • gp3 gives 3,000 IOPS and 125 MiB/s by default; raise them with modify-volume.
  • Exceeded-check metrics tell you which limit you hit.
  • Measure before and after every change.

Next lesson

Lesson 62: Can’t connect to RDS SQL Server → You’ll trace RDS connection timeouts through security groups, routes, Publicly Accessible and Flow Logs.

Further reading: EC2 performance troubleshooting

Want to go deeper into EC2 performance troubleshooting? Read Burstable performance instances and Amazon CloudWatch metrics for Amazon EBS. You can also see the full plan for this course on the AWS Cloud Administration course page.

Scroll to Top