Skip to content

Commit c200785

Browse files
luqmanbazranhskiba
andauthored
Handle EC2 throttling and capacity errors when launching runners (#19)
Co-authored-by: Henry Skiba <henry.skiba@frgrisk.com>
1 parent 21de94b commit c200785

2 files changed

Lines changed: 153 additions & 68 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ go.work
2424

2525
bootstrap
2626

27+
# Local `go build` output (named after the module directory)
28+
github-runner-autoscaler
29+
2730
.aws-sam
2831

2932
# Local SAM configuration (not committed)

main.go

Lines changed: 150 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/aws/aws-lambda-go/events"
2121
"github.com/aws/aws-lambda-go/lambda"
2222
"github.com/aws/aws-sdk-go-v2/aws"
23+
"github.com/aws/aws-sdk-go-v2/aws/retry"
2324
"github.com/aws/aws-sdk-go-v2/config"
2425
"github.com/aws/aws-sdk-go-v2/service/ec2"
2526
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
@@ -38,16 +39,129 @@ type RunnerConfiguration struct {
3839
KeyName string `json:"key"`
3940
}
4041

41-
// retryableLaunchErrors are EC2 error codes for which launching the runner in
42-
// the next configured subnet (potentially a different AZ) may succeed.
43-
var retryableLaunchErrors = []string{
42+
// launchCycleErrorCodes are EC2 error codes for which launching the runner in
43+
// the next configured subnet (potentially a different AZ) or the next candidate
44+
// instance type may succeed. launchInstance handles these itself by cycling to
45+
// the next subnet/type, so excludeLaunchCycleErrors also marks them
46+
// non-retryable on the client (see there for why).
47+
var launchCycleErrorCodes = []string{
4448
"InsufficientFreeAddressesInSubnet",
4549
"InsufficientInstanceCapacity",
4650
"InvalidSubnetID.NotFound",
4751
"Unsupported",
4852
}
4953

50-
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
54+
// isLaunchCycleError reports whether err is one launchInstance handles by moving
55+
// on to the next subnet/instance type, rather than a fatal error or a throttle
56+
// the SDK's retryer already backed off and retried.
57+
func isLaunchCycleError(err error) bool {
58+
apiErr, ok := errors.AsType[smithy.APIError](err)
59+
60+
return ok && slices.Contains(launchCycleErrorCodes, apiErr.ErrorCode())
61+
}
62+
63+
// excludeLaunchCycleErrors keeps the SDK retryer from retrying the errors that
64+
// launchInstance handles by cycling to the next subnet/instance type.
65+
// InsufficientInstanceCapacity is an HTTP 500, so the default retryer would
66+
// otherwise burn its own attempts (with backoff) retrying the same subnet before
67+
// launchInstance ever gets to try the next one. Returning FalseTernary here
68+
// short-circuits that so the error surfaces immediately; throttling and other
69+
// transient errors fall through to UnknownTernary and keep default retry.
70+
var excludeLaunchCycleErrors = retry.IsErrorRetryableFunc(func(err error) aws.Ternary {
71+
if isLaunchCycleError(err) {
72+
return aws.FalseTernary
73+
}
74+
75+
return aws.UnknownTernary
76+
})
77+
78+
// newEC2Retryer returns the SDK's standard retryer (throttle + transient backoff
79+
// left at defaults) with cycle errors excluded so launchInstance can move to the
80+
// next subnet/type without waiting on same-call retries.
81+
func newEC2Retryer() *retry.Standard {
82+
return retry.NewStandard(func(o *retry.StandardOptions) {
83+
o.Retryables = append(
84+
[]retry.IsErrorRetryable{excludeLaunchCycleErrors},
85+
retry.DefaultRetryables...,
86+
)
87+
})
88+
}
89+
90+
// launchInstance attempts to launch a runner across the candidate instance types
91+
// and subnets. It tries each instance type in turn, sweeping every subnet; a
92+
// capacity or subnet error (see retryableLaunchErrors) moves straight on to the
93+
// next subnet, then the next type, so a type that is out of capacity in every AZ
94+
// falls through to the next candidate. Throttling and other transient errors are
95+
// backed off and retried by the EC2 client's own retryer before they reach here,
96+
// so any error that is not a cycle error is treated as fatal. It returns the
97+
// launched instance ID, or an error if no type/subnet combination succeeds.
98+
func launchInstance(
99+
ctx context.Context,
100+
svc *ec2.Client,
101+
runInput *ec2.RunInstancesInput,
102+
instanceTypes []types.InstanceType,
103+
subnets []string,
104+
) (string, error) {
105+
var lastErr error
106+
107+
for _, instanceType := range instanceTypes {
108+
runInput.InstanceType = instanceType
109+
110+
for _, subnet := range subnets {
111+
runInput.NetworkInterfaces[0].SubnetId = aws.String(subnet)
112+
113+
output, err := svc.RunInstances(ctx, runInput)
114+
if err != nil {
115+
if !isLaunchCycleError(err) {
116+
slog.Error("failed to run instances", "error", err.Error())
117+
118+
return "", err
119+
}
120+
121+
slog.Warn(
122+
"capacity/subnet error, trying next",
123+
"instanceType", instanceType,
124+
"subnet", subnet,
125+
"error", err.Error(),
126+
)
127+
128+
lastErr = err
129+
130+
continue
131+
}
132+
133+
if len(output.Instances) == 0 || output.Instances[0].InstanceId == nil {
134+
slog.Warn(
135+
"no instance created in subnet, trying next",
136+
"instanceType", instanceType,
137+
"subnet", subnet,
138+
)
139+
140+
lastErr = errors.New("run instances returned no instance id")
141+
142+
continue
143+
}
144+
145+
return aws.ToString(output.Instances[0].InstanceId), nil
146+
}
147+
148+
slog.Warn(
149+
"all subnets failed for instance type, trying next type",
150+
"instanceType", instanceType,
151+
)
152+
}
153+
154+
if lastErr == nil {
155+
lastErr = errors.New("failed to launch instance in any subnet")
156+
}
157+
158+
return "", fmt.Errorf("failed to launch instance: %w", lastErr)
159+
}
160+
161+
func handler(
162+
ctx context.Context,
163+
request events.APIGatewayProxyRequest,
164+
) (events.APIGatewayProxyResponse, error) {
51165
var githubEventHeader string
52166

53167
for k, v := range request.MultiValueHeaders {
@@ -103,19 +217,29 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
103217

104218
region := cmp.Or(os.Getenv("AWS_DEFAULT_REGION"), os.Getenv("AWS_REGION"))
105219

106-
instanceType := types.InstanceTypeC7aLarge
107-
instanceTypes := instanceType.Values()
220+
validInstanceTypes := types.InstanceTypeC7aLarge.Values()
221+
222+
// Candidate instance types to try, in the order the labels appear on the
223+
// job. Trying several lets the launch fall back when a type is out of
224+
// capacity (InsufficientInstanceCapacity) in every configured subnet.
225+
var instanceTypes []types.InstanceType
108226

109227
for _, label := range event.GetWorkflowJob().Labels {
110228
if _, ok := runnerConfig[label]; ok {
111229
region = label
112230
}
113231

114-
if slices.Contains(instanceTypes, types.InstanceType(label)) {
115-
instanceType = types.InstanceType(label)
232+
candidate := types.InstanceType(label)
233+
if slices.Contains(validInstanceTypes, candidate) &&
234+
!slices.Contains(instanceTypes, candidate) {
235+
instanceTypes = append(instanceTypes, candidate)
116236
}
117237
}
118238

239+
if len(instanceTypes) == 0 {
240+
instanceTypes = []types.InstanceType{types.InstanceTypeC7aLarge}
241+
}
242+
119243
regionCfg, ok := runnerConfig[region]
120244
if !ok {
121245
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError},
@@ -127,14 +251,16 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
127251
fmt.Errorf("no subnets configured for region %s", region)
128252
}
129253

130-
cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
254+
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
131255
if err != nil {
132256
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
133257
}
134258

135259
slog.Info("creating runner in region", "region", region)
136260

137-
svc := ec2.NewFromConfig(cfg)
261+
svc := ec2.NewFromConfig(cfg, func(o *ec2.Options) {
262+
o.Retryer = newEC2Retryer()
263+
})
138264
sm := secretsmanager.NewFromConfig(cfg)
139265

140266
secretName := os.Getenv("GITHUB_PAT_SECRET_NAME")
@@ -149,7 +275,7 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
149275
}
150276

151277
secretOut, err := sm.GetSecretValue(
152-
context.TODO(),
278+
ctx,
153279
&secretsmanager.GetSecretValueInput{SecretId: aws.String(secretName)},
154280
)
155281
if err != nil {
@@ -194,7 +320,7 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
194320
return events.APIGatewayProxyResponse{StatusCode: http.StatusOK}, nil
195321
}
196322

197-
slog.Info("creating instance", "instanceType", instanceType)
323+
slog.Info("creating instance", "instanceTypes", instanceTypes)
198324

199325
tpl, err := template.New("userdata").Parse(userData)
200326
if err != nil {
@@ -219,7 +345,8 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
219345
EbsOptimized: aws.Bool(true),
220346
ImageId: aws.String(regionCfg.ImageID),
221347
InstanceInitiatedShutdownBehavior: types.ShutdownBehaviorTerminate,
222-
InstanceType: instanceType,
348+
// InstanceType is set per-attempt by launchInstance so it can fall
349+
// back across the candidate instanceTypes on capacity errors.
223350
IamInstanceProfile: &types.IamInstanceProfileSpecification{
224351
Arn: aws.String(instanceProfileArn),
225352
},
@@ -247,67 +374,22 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
247374
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(finalUserData))),
248375
}
249376

250-
var lastErr error
251-
252-
for _, subnet := range regionCfg.SubnetID {
253-
runInput.NetworkInterfaces[0].SubnetId = aws.String(subnet)
254-
255-
output, err := svc.RunInstances(context.TODO(), runInput)
256-
if err != nil {
257-
apiErr, ok := errors.AsType[smithy.APIError](err)
258-
if ok && slices.Contains(retryableLaunchErrors, apiErr.ErrorCode()) {
259-
slog.Warn(
260-
"retrying in next subnet",
261-
"subnet",
262-
subnet,
263-
"reason",
264-
apiErr.ErrorCode(),
265-
)
266-
267-
lastErr = err
268-
269-
continue
270-
}
271-
272-
slog.Error("failed to run instances", "error", err.Error())
273-
274-
return events.APIGatewayProxyResponse{
275-
Body: err.Error(),
276-
StatusCode: http.StatusInternalServerError,
277-
}, err
278-
}
279-
280-
if len(output.Instances) == 0 || output.Instances[0].InstanceId == nil {
281-
slog.Warn(
282-
"no instance created in subnet, trying next",
283-
"subnet",
284-
subnet,
285-
)
286-
287-
lastErr = errors.New("run instances returned no instance id")
288-
289-
continue
290-
}
291-
292-
instanceID := aws.ToString(output.Instances[0].InstanceId)
293-
slog.Info("instance created", "instanceID", instanceID)
377+
instanceID, err := launchInstance(ctx, svc, runInput, instanceTypes, regionCfg.SubnetID)
378+
if err != nil {
379+
slog.Error("failed to launch instance", "error", err.Error())
294380

295381
return events.APIGatewayProxyResponse{
296-
Body: instanceID,
297-
StatusCode: http.StatusOK,
298-
}, nil
299-
}
300-
301-
if lastErr == nil {
302-
lastErr = errors.New("failed to launch instance in any subnet")
382+
Body: err.Error(),
383+
StatusCode: http.StatusInternalServerError,
384+
}, err
303385
}
304386

305-
slog.Error("failed to launch instance in any subnet", "error", lastErr.Error())
387+
slog.Info("instance created", "instanceID", instanceID)
306388

307389
return events.APIGatewayProxyResponse{
308-
Body: lastErr.Error(),
309-
StatusCode: http.StatusInternalServerError,
310-
}, lastErr
390+
Body: instanceID,
391+
StatusCode: http.StatusOK,
392+
}, nil
311393

312394
default:
313395
err = fmt.Errorf("unknown event type %T", event)

0 commit comments

Comments
 (0)