Skip to content

Commit 21de94b

Browse files
luqmanbazranhskiba
andcommitted
Retry different subnet when runner deployment to one of them fails (#18)
We have been experiencing intermittent GitHub Actions runner provisioning failures where the runner Lambda is unable to create a new runner instance. These failures are typically caused by capacity-related issues, such as insufficient availability for a particular instance type or within the selected subnet. This PR makes the `subnet` field in `RunnerConfiguration` a list and adds a fallback so the Lambda attempts runner creation in the next configured subnet (potentially a different AZ) when a recoverable error occurs. ## Core change - `RunnerConfiguration.subnet` is now a JSON array (`["subnet-…"]`) instead of a single string. - On a recoverable launch error the Lambda retries in the next configured subnet. Recoverable errors: `InsufficientFreeAddressesInSubnet`, `InsufficientInstanceCapacity`, `InvalidSubnetID.NotFound`, `Unsupported`. ## Hardening - Validates the matched region has at least one configured subnet before attempting a launch. - Preserves the underlying AWS error when all subnets are exhausted instead of returning a generic error. - Guards against a nil `InstanceId` and logs the dereferenced instance ID (previously logged a pointer address). - Caps the Lambda timeout at 29s to match the API Gateway REST integration timeout. ## Modernization / tooling - Builds the `RunInstancesInput` once and mutates only the subnet per attempt (was rebuilt — and user data re-encoded — per subnet). - Adopts Go 1.26 idioms (`errors.AsType`, `cmp.Or`, `slices.Contains`); `go.mod` bumped to `go 1.26`. - golangci-lint: replaced deprecated `gomodguard` with `gomodguard_v2`; bumped `golangci-lint-action` to `v9`. > [!IMPORTANT] > **Breaking config change.** The `subnet` field must now be a JSON array. Existing deployments must update their `RunnerConfiguration` parameter from `"subnet":"subnet-…"` to `"subnet":["subnet-…"]` before/at deploy, otherwise the Lambda will fail to parse the configuration. README and `samconfig.example.yaml` are updated; deployed `samconfig.yaml` values must be migrated manually. Co-Authored-By: Henry Skiba <henry.skiba@frgrisk.com>
1 parent 25f0327 commit 21de94b

7 files changed

Lines changed: 117 additions & 74 deletions

File tree

.github/workflows/code_quality.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
go-version-file: "go.mod"
1818

1919
- name: golangci-lint .
20-
uses: golangci/golangci-lint-action@v7
20+
uses: golangci/golangci-lint-action@v9
2121
with:
2222
version: latest
2323
args: --timeout 5m --config=${{ github.workspace }}/.golangci.yaml

.golangci.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ version: "2"
22
linters:
33
default: all
44
enable:
5+
- gomodguard_v2
56
- wsl_v5
67
disable:
78
- depguard
89
- exhaustruct
910
- gochecknoglobals
1011
- gocognit
12+
- gomodguard
1113
- wrapcheck
1214
- varnamelen
1315
- err113

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ EC2 key pair used for the runner. You may also specify additional runner labels:
3232
sam deploy \
3333
--parameter-overrides GitHubPATSecretName=my-github-pat \
3434
ExtraRunnerLabels="gpu" \
35-
RunnerConfiguration='{\"us-east-2\":{\"ami\":\"0c0c88099397fccb4\",\"subnet\":\"subnet-0123456789def\",\"sg\":[\"sg-0123456789def\"],\"key\":\"terraform-2025051801\"},\"ap-southeast-5\":{\"ami\":\"0c0c88099397fccb4\",\"subnet\":\"subnet-0123456789def\",\"sg\":[\"sg-0123456789def\"],\"key\":\"terraform-2025051801\"}}'
35+
RunnerConfiguration='{\"us-east-2\":{\"ami\":\"0c0c88099397fccb4\",\"subnet\":[\"subnet-0123456789def\"],\"sg\":[\"sg-0123456789def\"],\"key\":\"terraform-2025051801\"},\"ap-southeast-5\":{\"ami\":\"0c0c88099397fccb4\",\"subnet\":[\"subnet-0123456789def\"],\"sg\":[\"sg-0123456789def\"],\"key\":\"terraform-2025051801\"}}'
3636
```
3737

3838
The `ExtraRunnerLabels` parameter is optional. When supplied, the labels are

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
module github.com/frgrisk/github-runner-autoscaler
22

3-
go 1.23
3+
go 1.26
44

55
require (
66
github.com/aws/aws-lambda-go v1.51.1
77
github.com/aws/aws-sdk-go-v2 v1.41.0
88
github.com/aws/aws-sdk-go-v2/config v1.32.6
99
github.com/aws/aws-sdk-go-v2/service/ec2 v1.279.0
1010
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0
11+
github.com/aws/smithy-go v1.24.0
1112
github.com/google/go-github/v60 v60.0.0
1213
)
1314

@@ -23,6 +24,5 @@ require (
2324
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
2425
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
2526
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
26-
github.com/aws/smithy-go v1.24.0 // indirect
2727
github.com/google/go-querystring v1.2.0 // indirect
2828
)

main.go

Lines changed: 104 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main //nolint: revive
22

33
import (
44
"bytes"
5+
"cmp"
56
"context"
67
_ "embed"
78
"encoding/base64"
@@ -23,6 +24,7 @@ import (
2324
"github.com/aws/aws-sdk-go-v2/service/ec2"
2425
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
2526
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
27+
"github.com/aws/smithy-go"
2628
"github.com/google/go-github/v60/github"
2729
)
2830

@@ -31,11 +33,20 @@ var userData string
3133

3234
type RunnerConfiguration struct {
3335
ImageID string `json:"ami"`
34-
SubnetID string `json:"subnet"`
36+
SubnetID []string `json:"subnet"`
3537
SecurityGroups []string `json:"sg"`
3638
KeyName string `json:"key"`
3739
}
3840

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{
44+
"InsufficientFreeAddressesInSubnet",
45+
"InsufficientInstanceCapacity",
46+
"InvalidSubnetID.NotFound",
47+
"Unsupported",
48+
}
49+
3950
func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
4051
var githubEventHeader string
4152

@@ -83,17 +94,14 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
8394

8495
err := json.Unmarshal([]byte(runnerCfg), &runnerConfig)
8596
if err != nil {
86-
slog.Error("invalid RUNNER_CONFIGURATION JSON", "error", err.Error()) //nolint:gosec
97+
slog.Error("invalid RUNNER_CONFIGURATION JSON", "error", err.Error())
8798

8899
return events.APIGatewayProxyResponse{
89100
StatusCode: http.StatusInternalServerError,
90101
}, fmt.Errorf("RUNNER_CONFIGURATION contains invalid JSON: %w", err)
91102
}
92103

93-
region := os.Getenv("AWS_DEFAULT_REGION")
94-
if region == "" {
95-
region = os.Getenv("AWS_REGION")
96-
}
104+
region := cmp.Or(os.Getenv("AWS_DEFAULT_REGION"), os.Getenv("AWS_REGION"))
97105

98106
instanceType := types.InstanceTypeC7aLarge
99107
instanceTypes := instanceType.Values()
@@ -103,12 +111,8 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
103111
region = label
104112
}
105113

106-
for i := range instanceTypes {
107-
if label == string(instanceTypes[i]) {
108-
instanceType = instanceTypes[i]
109-
110-
break
111-
}
114+
if slices.Contains(instanceTypes, types.InstanceType(label)) {
115+
instanceType = types.InstanceType(label)
112116
}
113117
}
114118

@@ -118,12 +122,16 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
118122
fmt.Errorf("no config for region %s", region)
119123
}
120124

125+
if len(regionCfg.SubnetID) == 0 {
126+
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError},
127+
fmt.Errorf("no subnets configured for region %s", region)
128+
}
129+
121130
cfg, err := config.LoadDefaultConfig(context.TODO(), config.WithRegion(region))
122131
if err != nil {
123132
return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err
124133
}
125134

126-
//nolint:gosec
127135
slog.Info("creating runner in region", "region", region)
128136

129137
svc := ec2.NewFromConfig(cfg)
@@ -145,7 +153,7 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
145153
&secretsmanager.GetSecretValueInput{SecretId: aws.String(secretName)},
146154
)
147155
if err != nil {
148-
slog.Error( //nolint:gosec // G706: err is from AWS SDK
156+
slog.Error(
149157
"failed to get secret", "secret", secretName, "error", err.Error(),
150158
)
151159

@@ -205,69 +213,101 @@ func handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyRespo
205213

206214
finalUserData := buf.String()
207215

208-
output, err := svc.RunInstances(
209-
context.TODO(),
210-
&ec2.RunInstancesInput{
211-
MinCount: aws.Int32(1),
212-
MaxCount: aws.Int32(1),
213-
EbsOptimized: aws.Bool(true),
214-
ImageId: aws.String(regionCfg.ImageID),
215-
InstanceInitiatedShutdownBehavior: types.ShutdownBehaviorTerminate,
216-
InstanceType: instanceType,
217-
IamInstanceProfile: &types.IamInstanceProfileSpecification{
218-
Arn: aws.String(instanceProfileArn),
216+
runInput := &ec2.RunInstancesInput{
217+
MinCount: aws.Int32(1),
218+
MaxCount: aws.Int32(1),
219+
EbsOptimized: aws.Bool(true),
220+
ImageId: aws.String(regionCfg.ImageID),
221+
InstanceInitiatedShutdownBehavior: types.ShutdownBehaviorTerminate,
222+
InstanceType: instanceType,
223+
IamInstanceProfile: &types.IamInstanceProfileSpecification{
224+
Arn: aws.String(instanceProfileArn),
225+
},
226+
NetworkInterfaces: []types.InstanceNetworkInterfaceSpecification{
227+
{
228+
AssociatePublicIpAddress: aws.Bool(true),
229+
DeleteOnTermination: aws.Bool(true),
230+
DeviceIndex: aws.Int32(0),
231+
Groups: regionCfg.SecurityGroups,
219232
},
220-
NetworkInterfaces: []types.InstanceNetworkInterfaceSpecification{
221-
{
222-
AssociatePublicIpAddress: aws.Bool(true),
223-
SubnetId: aws.String(regionCfg.SubnetID),
224-
DeleteOnTermination: aws.Bool(true),
225-
DeviceIndex: aws.Int32(0),
226-
Groups: regionCfg.SecurityGroups,
227-
},
233+
},
234+
KeyName: aws.String(regionCfg.KeyName),
235+
Monitoring: &types.RunInstancesMonitoringEnabled{Enabled: aws.Bool(true)},
236+
TagSpecifications: []types.TagSpecification{
237+
{
238+
ResourceType: types.ResourceTypeInstance,
239+
Tags: tags,
228240
},
229-
KeyName: aws.String(regionCfg.KeyName),
230-
Monitoring: &types.RunInstancesMonitoringEnabled{Enabled: aws.Bool(true)},
231-
TagSpecifications: []types.TagSpecification{
232-
{
233-
ResourceType: types.ResourceTypeInstance,
234-
Tags: tags,
235-
},
236-
{
237-
ResourceType: types.ResourceTypeVolume,
238-
Tags: tags,
239-
},
241+
{
242+
ResourceType: types.ResourceTypeVolume,
243+
Tags: tags,
240244
},
241-
// base64 encode user data
242-
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(finalUserData))),
243245
},
244-
)
245-
if err != nil {
246-
//nolint:gosec
247-
slog.Error(err.Error())
248-
249-
return events.APIGatewayProxyResponse{
250-
Body: err.Error(),
251-
StatusCode: http.StatusInternalServerError,
252-
}, err
246+
// base64 encode user data
247+
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte(finalUserData))),
253248
}
254249

255-
if len(output.Instances) == 0 {
256-
slog.Error("no instance created")
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)
257294

258295
return events.APIGatewayProxyResponse{
259-
Body: "no instance created",
260-
StatusCode: http.StatusInternalServerError,
296+
Body: instanceID,
297+
StatusCode: http.StatusOK,
261298
}, nil
262299
}
263300

264-
//nolint:gosec
265-
slog.Info("instance created", "instanceID", output.Instances[0].InstanceId)
301+
if lastErr == nil {
302+
lastErr = errors.New("failed to launch instance in any subnet")
303+
}
304+
305+
slog.Error("failed to launch instance in any subnet", "error", lastErr.Error())
266306

267307
return events.APIGatewayProxyResponse{
268-
Body: *output.Instances[0].InstanceId,
269-
StatusCode: http.StatusOK,
270-
}, nil
308+
Body: lastErr.Error(),
309+
StatusCode: http.StatusInternalServerError,
310+
}, lastErr
271311

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

samconfig.example.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,13 @@ dev:
1414
RunnerConfiguration={
1515
"us-east-2":{
1616
"ami":"ami-0c0c88099397fccb4",
17-
"subnet":"subnet-0123456789def",
17+
"subnet":["subnet-0123456789def"],
1818
"sg":["sg-0123456789def"],
1919
"key":"terraform-2025051801"
2020
},
2121
"ap-southeast-5":{
2222
"ami":"ami-0c0c88099397fccb4",
23-
"subnet":"subnet-0123456789def",
23+
"subnet":["subnet-0123456789def"],
2424
"sg":["sg-0123456789def"],
2525
"key":"terraform-2025051801"
2626
}
@@ -40,13 +40,13 @@ prod:
4040
RunnerConfiguration={
4141
"us-east-2":{
4242
"ami":"ami-0c0c88099397fccb4",
43-
"subnet":"subnet-0123456789def",
43+
"subnet":["subnet-0123456789def"],
4444
"sg":["sg-0123456789def"],
4545
"key":"terraform-2025051801"
4646
},
4747
"ap-southeast-5":{
4848
"ami":"ami-0c0c88099397fccb4",
49-
"subnet":"subnet-0123456789def",
49+
"subnet":["subnet-0123456789def"],
5050
"sg":["sg-0123456789def"],
5151
"key":"terraform-2025051801"
5252
}

template.yaml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,13 @@ Parameters:
1111
Description: Additional comma separated labels for the runner
1212
RunnerConfiguration:
1313
Type: String
14-
Description: JSON string defining per-region runner settings (AMI, subnet, security group, SSH key).
14+
Description: JSON string defining per-region runner settings (AMI, subnets array, security groups, SSH key).
1515

1616
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
1717
Globals:
1818
Function:
19-
Timeout: 5
19+
# Capped at 29s to match the API Gateway REST integration timeout.
20+
Timeout: 29
2021
MemorySize: 128
2122

2223
Resources:

0 commit comments

Comments
 (0)