Skip to content

Commit 88ed7bd

Browse files
authored
Merge pull request #2682 from mdzraf/iopsLimitViaErr
Refactor To Get Maximum IOPS for Capping Through Error Message
2 parents d8d65b0 + 079ff50 commit 88ed7bd

File tree

3 files changed

+540
-55
lines changed

3 files changed

+540
-55
lines changed

pkg/cloud/cloud.go

Lines changed: 180 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ import (
2222
"encoding/hex"
2323
"errors"
2424
"fmt"
25+
"math"
2526
"os"
27+
"regexp"
2628
"strconv"
2729
"strings"
2830
"sync"
@@ -66,15 +68,15 @@ const (
6668
// AWS provisioning limits.
6769
// Source: http://docs.aws.amazon.com/AWSEC2/latest/UserGuide/EBSVolumeTypes.html
6870
const (
69-
io1MinTotalIOPS = 100
70-
io1MaxTotalIOPS = 64000
71-
io1MaxIOPSPerGB = 50
72-
io2MinTotalIOPS = 100
73-
io2MaxTotalIOPS = 256000
74-
io2MaxIOPSPerGB = 1000
75-
gp3MaxTotalIOPS = 16000
76-
gp3MinTotalIOPS = 3000
77-
gp3MaxIOPSPerGB = 500
71+
io1MinTotalIOPS = 100
72+
io1FallbackMaxIOPS = 64000
73+
io1MaxIOPSPerGB = 50
74+
io2MinTotalIOPS = 100
75+
io2FallbackMaxIOPS = 256000
76+
io2MaxIOPSPerGB = 1000
77+
gp3FallbackMaxIOPS = 16000
78+
gp3MinTotalIOPS = 3000
79+
gp3MaxIOPSPerGB = 500
7880
)
7981

8082
var (
@@ -90,8 +92,9 @@ var (
9092
)
9193

9294
const (
93-
cacheForgetDelay = 1 * time.Hour
94-
volInitCacheForgetDelay = 6 * time.Hour
95+
cacheForgetDelay = 1 * time.Hour
96+
volInitCacheForgetDelay = 6 * time.Hour
97+
iopsLimitCacheForgetDelay = 12 * time.Hour
9598

9699
dryRunInterval = 3 * time.Hour
97100

@@ -186,6 +189,17 @@ const (
186189
ValidationException = "ValidationException"
187190
)
188191

192+
// Regex Patterns.
193+
var (
194+
// For getting IOPS limit from gp3/io1 error.
195+
// Error example it is used for: "An error occurred (InvalidParameterValue) when calling the CreateVolume operation: Volume iops of 200000 is too high; maximum is 80000".
196+
nonIo2ErrRegex = regexp.MustCompile(`(?i)volume iops.*is too high.*maximum is (\d+)`)
197+
198+
// For getting IOPS limit from io2 error.
199+
// Error example it is used for: "An error occurred (InvalidParameterCombination) when calling the CreateVolume operation: io2 volumes configured with greater than 64 TiB or 256K IOPS or 1000:1 IOPS:GB ratio are not supported".
200+
io2ErrRegex = regexp.MustCompile(`(?i)(\d+)K IOPS`)
201+
)
202+
189203
var invalidParameterErrorCodes = map[string]struct{}{
190204
"InvalidParameter": {},
191205
"InvalidParameterCombination": {},
@@ -236,6 +250,20 @@ type ModifyDiskOptions struct {
236250
Throughput int32
237251
}
238252

253+
// iopsLimits represents the IOPS limits set by EBS of a volume dependent on the volume type.
254+
type iopsLimits struct {
255+
maxIops int32
256+
minIops int32
257+
maxIopsPerGb int32
258+
}
259+
260+
// getVolumeLimitsParams represents the AZ parameters that getVolumeLimits will use to make the DryRun CreateVolume call.
261+
type getVolumeLimitsParams struct {
262+
availabilityZone string
263+
availabilityZoneId string
264+
outpostArn string
265+
}
266+
239267
// ModifyTagsOptions represents parameter to modify the tags of an existing EBS volume.
240268
type ModifyTagsOptions struct {
241269
TagsToAdd map[string]string
@@ -339,6 +367,7 @@ type cloud struct {
339367
likelyBadDeviceNames expiringcache.ExpiringCache[string, sync.Map]
340368
latestClientTokens expiringcache.ExpiringCache[string, int]
341369
volumeInitializations expiringcache.ExpiringCache[string, volumeInitialization]
370+
latestIOPSLimits expiringcache.ExpiringCache[string, iopsLimits]
342371
accountID string
343372
accountIDOnce sync.Once
344373
attemptDryRun atomic.Bool
@@ -412,6 +441,7 @@ func NewCloud(region string, awsSdkDebugLog bool, userAgentExtra string, batchin
412441
likelyBadDeviceNames: expiringcache.New[string, sync.Map](cacheForgetDelay),
413442
latestClientTokens: expiringcache.New[string, int](cacheForgetDelay),
414443
volumeInitializations: expiringcache.New[string, volumeInitialization](volInitCacheForgetDelay),
444+
latestIOPSLimits: expiringcache.New[string, iopsLimits](iopsLimitCacheForgetDelay),
415445
}
416446

417447
// Ensure an EC2 Dry-run API call is made on startup and every dryRunInterval
@@ -572,9 +602,6 @@ func (c *cloud) CreateDisk(ctx context.Context, volumeName string, diskOptions *
572602
iops int32
573603
throughput int32
574604
err error
575-
maxIops int32
576-
minIops int32
577-
maxIopsPerGb int32
578605
requestedIops int32
579606
)
580607

@@ -590,38 +617,10 @@ func (c *cloud) CreateDisk(ctx context.Context, volumeName string, diskOptions *
590617
createType = VolumeTypeGP3
591618
}
592619

593-
switch createType {
594-
case VolumeTypeGP2, VolumeTypeSC1, VolumeTypeST1, VolumeTypeStandard:
595-
case VolumeTypeIO1:
596-
maxIops = io1MaxTotalIOPS
597-
minIops = io1MinTotalIOPS
598-
maxIopsPerGb = io1MaxIOPSPerGB
599-
case VolumeTypeIO2:
600-
maxIops = io2MaxTotalIOPS
601-
minIops = io2MinTotalIOPS
602-
maxIopsPerGb = io2MaxIOPSPerGB
603-
case VolumeTypeGP3:
604-
maxIops = gp3MaxTotalIOPS
605-
minIops = gp3MinTotalIOPS
606-
maxIopsPerGb = gp3MaxIOPSPerGB
607-
throughput = diskOptions.Throughput
608-
default:
609-
return nil, fmt.Errorf("invalid AWS VolumeType %q", diskOptions.VolumeType)
610-
}
611-
612620
if diskOptions.MultiAttachEnabled && createType != VolumeTypeIO2 {
613621
return nil, errors.New("CreateDisk: multi-attach is only supported for io2 volumes")
614622
}
615623

616-
if maxIops > 0 {
617-
if diskOptions.IOPS > 0 {
618-
requestedIops = diskOptions.IOPS
619-
} else if diskOptions.IOPSPerGB > 0 {
620-
requestedIops = diskOptions.IOPSPerGB * capacityGiB
621-
}
622-
iops = capIOPS(createType, capacityGiB, requestedIops, minIops, maxIops, maxIopsPerGb, diskOptions.AllowIOPSPerGBIncrease)
623-
}
624-
625624
tags := make([]types.Tag, 0, len(diskOptions.Tags))
626625
for key, value := range diskOptions.Tags {
627626
tags = append(tags, types.Tag{Key: aws.String(key), Value: aws.String(value)})
@@ -678,6 +677,26 @@ func (c *cloud) CreateDisk(ctx context.Context, volumeName string, diskOptions *
678677
requestInput.OutpostArn = aws.String(diskOptions.OutpostArn)
679678
}
680679

680+
azParams := getVolumeLimitsParams{
681+
availabilityZone: zone,
682+
availabilityZoneId: zoneID,
683+
outpostArn: diskOptions.OutpostArn,
684+
}
685+
686+
iopsLimit, err := c.getVolumeLimits(ctx, createType, azParams)
687+
if err != nil {
688+
return nil, fmt.Errorf("invalid AWS VolumeType %q", diskOptions.VolumeType)
689+
}
690+
691+
if iopsLimit.maxIops > 0 {
692+
if diskOptions.IOPS > 0 {
693+
requestedIops = diskOptions.IOPS
694+
} else if diskOptions.IOPSPerGB > 0 {
695+
requestedIops = diskOptions.IOPSPerGB * capacityGiB
696+
}
697+
iops = capIOPS(createType, capacityGiB, requestedIops, iopsLimit, diskOptions.AllowIOPSPerGBIncrease)
698+
}
699+
681700
if len(diskOptions.KmsKeyID) > 0 {
682701
requestInput.KmsKeyId = aws.String(diskOptions.KmsKeyID)
683702
requestInput.Encrypted = aws.Bool(true)
@@ -2438,28 +2457,139 @@ func getVolumeAttachmentsList(volume types.Volume) []string {
24382457
}
24392458

24402459
// Calculate actual IOPS for a volume and cap it at supported AWS limits.
2441-
func capIOPS(volumeType string, requestedCapacityGiB int32, requestedIops int32, minTotalIOPS, maxTotalIOPS, maxIOPSPerGB int32, allowIncrease bool) int32 {
2460+
func capIOPS(volumeType string, requestedCapacityGiB int32, requestedIops int32, iopsLimits iopsLimits, allowIncrease bool) int32 {
24422461
// If requestedIops is zero the user did not request a specific amount, and the default will be used instead
24432462
if requestedIops == 0 {
24442463
return 0
24452464
}
24462465

24472466
iops := requestedIops
24482467

2449-
if iops < minTotalIOPS {
2468+
if iops < iopsLimits.minIops {
24502469
if allowIncrease {
2451-
iops = minTotalIOPS
2470+
iops = iopsLimits.minIops
24522471
klog.V(5).InfoS("[Debug] Increased IOPS to the min supported limit", "volumeType", volumeType, "requestedCapacityGiB", requestedCapacityGiB, "limit", iops)
24532472
}
24542473
}
2455-
if iops > maxTotalIOPS {
2456-
iops = maxTotalIOPS
2474+
if iops > iopsLimits.maxIops {
2475+
iops = iopsLimits.maxIops
24572476
klog.V(5).InfoS("[Debug] Capped IOPS, volume at the max supported limit", "volumeType", volumeType, "requestedCapacityGiB", requestedCapacityGiB, "limit", iops)
24582477
}
2459-
maxIopsByCapacity := maxIOPSPerGB * requestedCapacityGiB
2460-
if iops > maxIopsByCapacity && maxIopsByCapacity >= minTotalIOPS {
2478+
maxIopsByCapacity := iopsLimits.maxIopsPerGb * requestedCapacityGiB
2479+
if iops > maxIopsByCapacity && maxIopsByCapacity >= iopsLimits.minIops {
24612480
iops = maxIopsByCapacity
2462-
klog.V(5).InfoS("[Debug] Capped IOPS for volume", "volumeType", volumeType, "requestedCapacityGiB", requestedCapacityGiB, "maxIOPSPerGB", maxIOPSPerGB, "limit", iops)
2481+
klog.V(5).InfoS("[Debug] Capped IOPS for volume", "volumeType", volumeType, "requestedCapacityGiB", requestedCapacityGiB, "maxIOPSPerGB", iopsLimits.maxIopsPerGb, "limit", iops)
24632482
}
24642483
return iops
24652484
}
2485+
2486+
// Gets IOPS limits for a specific volume type in a specific Zone and caches it. If the limits are cached, simply return limits.
2487+
func (c *cloud) getVolumeLimits(ctx context.Context, volumeType string, azParams getVolumeLimitsParams) (iopsLimits iopsLimits, err error) {
2488+
cacheKey := fmt.Sprintf("%s|%s|%s|%s", volumeType, azParams.availabilityZone, azParams.availabilityZoneId, azParams.outpostArn)
2489+
if value, ok := c.latestIOPSLimits.Get(cacheKey); ok {
2490+
return *value, nil
2491+
}
2492+
2493+
dryRunRequestInput := &ec2.CreateVolumeInput{
2494+
VolumeType: types.VolumeType(volumeType),
2495+
Size: aws.Int32(4),
2496+
Iops: aws.Int32(math.MaxInt32),
2497+
DryRun: aws.Bool(true),
2498+
// Required by default EBS CSI Driver IAM policy.
2499+
TagSpecifications: []types.TagSpecification{
2500+
{
2501+
ResourceType: types.ResourceTypeVolume,
2502+
Tags: []types.Tag{
2503+
{
2504+
Key: aws.String(VolumeNameTagKey),
2505+
Value: aws.String("IopsLimitDryRun"),
2506+
},
2507+
},
2508+
},
2509+
},
2510+
}
2511+
if azParams.availabilityZone != "" {
2512+
dryRunRequestInput.AvailabilityZone = aws.String(azParams.availabilityZone)
2513+
}
2514+
if azParams.availabilityZoneId != "" {
2515+
dryRunRequestInput.AvailabilityZoneId = aws.String(azParams.availabilityZoneId)
2516+
}
2517+
if azParams.outpostArn != "" {
2518+
dryRunRequestInput.OutpostArn = aws.String(azParams.outpostArn)
2519+
}
2520+
2521+
volType := strings.ToLower(string(dryRunRequestInput.VolumeType))
2522+
_, err = c.ec2.CreateVolume(ctx, dryRunRequestInput, func(o *ec2.Options) {
2523+
o.APIOptions = nil // Don't add our logging/metrics middleware because we expect errors.
2524+
})
2525+
useFallBackLimits := (err == nil) // If DryRun unexpectedly succeeds, we use fallback values.
2526+
2527+
if err != nil {
2528+
maxIops, err := extractMaxIOPSFromError(err.Error(), volType)
2529+
// Default To Hardcoded Limits if we can't get the max IOPS from the error message.
2530+
if err != nil {
2531+
klog.V(5).InfoS("[Debug] error getting IOPS limit, defaulting to hardcoded values", "volumeType", volumeType, "error", err.Error())
2532+
useFallBackLimits = true
2533+
} else {
2534+
iopsLimits.maxIops = maxIops
2535+
}
2536+
}
2537+
2538+
if useFallBackLimits {
2539+
switch volType {
2540+
case VolumeTypeIO1:
2541+
iopsLimits.maxIops = io1FallbackMaxIOPS
2542+
case VolumeTypeIO2:
2543+
iopsLimits.maxIops = io2FallbackMaxIOPS
2544+
case VolumeTypeGP3:
2545+
iopsLimits.maxIops = gp3FallbackMaxIOPS
2546+
}
2547+
}
2548+
2549+
// Set minIops and maxIopsPerGb because we do not fetch these from DryRun Error, we can also catch invalid volume.
2550+
switch volType {
2551+
case VolumeTypeGP2, VolumeTypeSC1, VolumeTypeST1, VolumeTypeStandard:
2552+
case VolumeTypeIO1:
2553+
iopsLimits.minIops = io1MinTotalIOPS
2554+
iopsLimits.maxIopsPerGb = io1MaxIOPSPerGB
2555+
case VolumeTypeIO2:
2556+
iopsLimits.minIops = io2MinTotalIOPS
2557+
iopsLimits.maxIopsPerGb = io2MaxIOPSPerGB
2558+
case VolumeTypeGP3:
2559+
iopsLimits.minIops = gp3MinTotalIOPS
2560+
iopsLimits.maxIopsPerGb = gp3MaxIOPSPerGB
2561+
default:
2562+
return iopsLimits, fmt.Errorf("invalid AWS VolumeType %q", volumeType)
2563+
}
2564+
2565+
if !useFallBackLimits {
2566+
c.latestIOPSLimits.Set(cacheKey, &iopsLimits)
2567+
}
2568+
2569+
return iopsLimits, nil
2570+
}
2571+
2572+
// Get what the maxIops is from DryRun error message.
2573+
func extractMaxIOPSFromError(errorMsg string, volumeType string) (int32, error) {
2574+
// io1 and gp3 have the same error message but io2 has different one, using by default.
2575+
if volumeType == VolumeTypeIO2 {
2576+
if matches := io2ErrRegex.FindStringSubmatch(errorMsg); len(matches) > 1 {
2577+
if val, err := strconv.ParseInt(matches[1], 10, 32); err == nil {
2578+
result := val * 1000
2579+
// No real overflow concern here but adding for safety.
2580+
if result > math.MaxInt32 || result < math.MinInt32 {
2581+
return 0, fmt.Errorf("maximum IOPS value exceeds maximum value of int32: %d", val)
2582+
}
2583+
return int32(result), nil
2584+
}
2585+
}
2586+
} else {
2587+
if matches := nonIo2ErrRegex.FindStringSubmatch(errorMsg); len(matches) > 1 {
2588+
if val, err := strconv.ParseInt(matches[1], 10, 32); err == nil {
2589+
return int32(val), nil
2590+
}
2591+
}
2592+
}
2593+
2594+
return 0, fmt.Errorf("error getting IOPS limit, defaulting to hardcoded values for volume type %s", volumeType)
2595+
}

0 commit comments

Comments
 (0)