Skip to content
This repository was archived by the owner on Jan 16, 2025. It is now read-only.

Commit 8ba0a82

Browse files
npalmphilips-labs-pr|botstuartp44
authored
feat: add spot termination handler (#4176)
## Description This PR adds a lambda to to log and metric spot termination based on the cloudtrail event `BidEvictedEvent`. The feature is experimental and disabled by default. ## Future directions The current implemenation only helps to make spot termination visible to an admin team. For the future we want to enrich a runner with information via tagging what job is active, This allows to let the termination handler also inform the user by adding a job annotation once a spot termination occurs. ## Migration No migration is required. By default the watcher is disabled. - logging for the watcher is changed - resources will be recreated for notification warning watcher --------- Co-authored-by: philips-labs-pr|bot <philips-labs-pr[bot]@users.noreply.github.com> Co-authored-by: Stuart Pearson <[email protected]>
1 parent 3fb1729 commit 8ba0a82

35 files changed

+861
-221
lines changed

Diff for: README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ Talk to the forestkeepers in the `runners-channel` on Slack.
165165
| <a name="input_instance_max_spot_price"></a> [instance\_max\_spot\_price](#input\_instance\_max\_spot\_price) | Max price price for spot instances per hour. This variable will be passed to the create fleet as max spot price for the fleet. | `string` | `null` | no |
166166
| <a name="input_instance_profile_path"></a> [instance\_profile\_path](#input\_instance\_profile\_path) | The path that will be added to the instance\_profile, if not set the environment name will be used. | `string` | `null` | no |
167167
| <a name="input_instance_target_capacity_type"></a> [instance\_target\_capacity\_type](#input\_instance\_target\_capacity\_type) | Default lifecycle used for runner instances, can be either `spot` or `on-demand`. | `string` | `"spot"` | no |
168-
| <a name="input_instance_termination_watcher"></a> [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the instance termination watcher. This feature is Beta, changes will not trigger a major release as long in beta.<br/><br/>`enable`: Enable or disable the spot termination watcher.<br/>`memory_size`: Memory size linit in MB of the lambda.<br/>`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.<br/>`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.<br/>`timeout`: Time out of the lambda in seconds.<br/>`zip`: File location of the lambda zip file. | <pre>object({<br/> enable = optional(bool, false)<br/> enable_metric = optional(string, null) # deprectaed<br/> memory_size = optional(number, null)<br/> s3_key = optional(string, null)<br/> s3_object_version = optional(string, null)<br/> timeout = optional(number, null)<br/> zip = optional(string, null)<br/> })</pre> | `{}` | no |
168+
| <a name="input_instance_termination_watcher"></a> [instance\_termination\_watcher](#input\_instance\_termination\_watcher) | Configuration for the instance termination watcher. This feature is Beta, changes will not trigger a major release as long in beta.<br/><br/>`enable`: Enable or disable the spot termination watcher.<br/>'features': Enable or disable features of the termination watcher.<br/>`memory_size`: Memory size linit in MB of the lambda.<br/>`s3_key`: S3 key for syncer lambda function. Required if using S3 bucket to specify lambdas.<br/>`s3_object_version`: S3 object version for syncer lambda function. Useful if S3 versioning is enabled on source bucket.<br/>`timeout`: Time out of the lambda in seconds.<br/>`zip`: File location of the lambda zip file. | <pre>object({<br/> enable = optional(bool, false)<br/> enable_metric = optional(string, null) # deprectaed<br/> features = optional(object({<br/> enable_spot_termination_handler = optional(bool, true)<br/> enable_spot_termination_notification_watcher = optional(bool, true)<br/> }), {})<br/> memory_size = optional(number, null)<br/> s3_key = optional(string, null)<br/> s3_object_version = optional(string, null)<br/> timeout = optional(number, null)<br/> zip = optional(string, null)<br/> })</pre> | `{}` | no |
169169
| <a name="input_instance_types"></a> [instance\_types](#input\_instance\_types) | List of instance types for the action runner. Defaults are based on runner\_os (al2023 for linux and Windows Server Core for win). | `list(string)` | <pre>[<br/> "m5.large",<br/> "c5.large"<br/>]</pre> | no |
170170
| <a name="input_job_queue_retention_in_seconds"></a> [job\_queue\_retention\_in\_seconds](#input\_job\_queue\_retention\_in\_seconds) | The number of seconds the job is held in the queue before it is purged. | `number` | `86400` | no |
171171
| <a name="input_job_retry"></a> [job\_retry](#input\_job\_retry) | Experimental! Can be removed / changed without trigger a major release.Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the insances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the reate limit of the GitHub app.<br/><br/>`enable`: Enable or disable the job retry feature.<br/>`delay_in_seconds`: The delay in seconds before the job retry check lambda will check the job status.<br/>`delay_backoff`: The backoff factor for the delay.<br/>`lambda_memory_size`: Memory size limit in MB for the job retry check lambda.<br/>`lambda_timeout`: Time out of the job retry check lambda in seconds.<br/>`max_attempts`: The maximum number of attempts to retry the job. | <pre>object({<br/> enable = optional(bool, false)<br/> delay_in_seconds = optional(number, 300)<br/> delay_backoff = optional(number, 2)<br/> lambda_memory_size = optional(number, 256)<br/> lambda_timeout = optional(number, 30)<br/> max_attempts = optional(number, 1)<br/> })</pre> | `{}` | no |
@@ -257,6 +257,7 @@ Talk to the forestkeepers in the `runners-channel` on Slack.
257257
| Name | Description |
258258
|------|-------------|
259259
| <a name="output_binaries_syncer"></a> [binaries\_syncer](#output\_binaries\_syncer) | n/a |
260+
| <a name="output_instance_termination_handler"></a> [instance\_termination\_handler](#output\_instance\_termination\_handler) | n/a |
260261
| <a name="output_instance_termination_watcher"></a> [instance\_termination\_watcher](#output\_instance\_termination\_watcher) | n/a |
261262
| <a name="output_queues"></a> [queues](#output\_queues) | SQS queues. |
262263
| <a name="output_runners"></a> [runners](#output\_runners) | n/a |

Diff for: docs/configuration.md

+18-4
Original file line numberDiff line numberDiff line change
@@ -215,21 +215,35 @@ In case the setup does not work as intended, trace the events through this seque
215215

216216
### Termination watcher
217217

218-
This feature is in early stage and therefore disabled by default.
218+
This feature is in early stage and therefore disabled by default. To enable the watcher, set `instance_termination_watcher.enable = true`.
219219

220-
The termination watcher is currently watching for spot termination notifications. The module is only taken events into account for instances tagged with `ghr:environment` by default when deployment the module as part of one of the main modules (root or multi-runner). The module can also be deployed stand-alone, in that case the tag filter needs to be tunned.
220+
The termination watcher is currently watching for spot terminations. The module is only taken events into account for instances tagged with `ghr:environment` by default when deployment the module as part of one of the main modules (root or multi-runner). The module can also be deployed stand-alone, in this case, the tag filter needs to be tunned.
221+
222+
### Termination notification
223+
224+
The watcher is listening for spot termination warnings and create a log message and optionally a metric. The watcher is disabled by default. The feature is enabled once the watcher is enabled, the feature can be disabled explicit by setting `instance_termination_watcher.features.enable_spot_termination_handler = false`.
221225

222226
- Logs: The module will log all termination notifications. For each warning it will look up instance details and log the environment, instance type and time the instance is running. As well some other details.
223227
- Metrics: Metrics are disabled by default, this to avoid costs. Once enabled a metric will be created for each warning with at least dimensions for the environment and instance type. THe metric name space can be configured via the variables. The metric name used is `SpotInterruptionWarning`.
224228

225-
#### Log example
229+
### Termination handler
230+
231+
!!! warning
232+
This feature will only work once the CloudTrail is enabled.
233+
234+
The termination handler is listening for spot terminations by capture the `BidEvictedEvent` via CloudTrail. The handler will log and optionally create a metric for each termination. The intend is to enhance the logic to inform the user about the termination via the GitHub Job or Workflow run. The feature is disabled by default. The feature is enabled once the watcher is enabled, the feature can be disabled explicit by setting `instance_termination_watcher.features.enable_spot_termination_handler = false`.
235+
236+
- Logs: The module will log all termination notifications. For each warning it will look up instance details and log the environment, instance type and time the instance is running. As well some other details.
237+
- Metrics: Metrics are disabled by default, this to avoid costs. Once enabled a metric will be created for each termination with at least dimensions for the environment and instance type. THe metric name space can be configured via the variables. The metric name used is `SpotTermination`.
238+
239+
### Log example (both warnings and terminations)
226240

227241
Below an example of the the log messages created.
228242

229243
```
230244
{
231245
"level": "INFO",
232-
"message": "Received spot notification warning:",
246+
"message": "Received spot notification for ${metricName}",
233247
"environment": "default",
234248
"instanceId": "i-0039b8826b3dcea55",
235249
"instanceType": "c5.large",

Diff for: examples/default/main.tf

+2-2
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ module "runners" {
7878
# Let the module manage the service linked role
7979
# create_service_linked_role_spot = true
8080

81-
instance_types = ["m5.large", "c5.large"]
81+
instance_types = ["m7a.large", "m5.large"]
8282

8383
# override delay of events in seconds
8484
delay_webhook_event = 5
@@ -122,7 +122,7 @@ module "runners" {
122122
# metric = {
123123
# enable_spot_termination_warning = true
124124
# enable_job_retry = false
125-
# enable_github_app_rate_limit = true
125+
# enable_github_app_rate_limit = false
126126
# }
127127
# }
128128

Diff for: lambdas/functions/termination-watcher/src/ConfigResolver.ts

+2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createChildLogger } from '@aws-github-runner/aws-powertools-util';
22

33
export class Config {
44
createSpotWarningMetric: boolean;
5+
createSpotTerminationMetric: boolean;
56
tagFilters: Record<string, string>;
67
prefix: string;
78

@@ -11,6 +12,7 @@ export class Config {
1112
logger.debug('Loading config from environment variables', { env: process.env });
1213

1314
this.createSpotWarningMetric = process.env.ENABLE_METRICS_SPOT_WARNING === 'true';
15+
this.createSpotTerminationMetric = process.env.ENABLE_METRICS_SPOT_TERMINATION === 'true';
1416
this.prefix = process.env.PREFIX ?? '';
1517
this.tagFilters = { 'ghr:environment': this.prefix };
1618

+93
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { EC2Client, DescribeInstancesCommand, DescribeInstancesResult } from '@aws-sdk/client-ec2';
2+
import { mockClient } from 'aws-sdk-client-mock';
3+
import { getInstances, tagFilter } from './ec2';
4+
5+
const ec2Mock = mockClient(EC2Client);
6+
7+
describe('getInstances', () => {
8+
beforeEach(() => {
9+
ec2Mock.reset();
10+
});
11+
12+
it('should return the instance when found', async () => {
13+
const instanceId = 'i-1234567890abcdef0';
14+
const instance = { InstanceId: instanceId };
15+
ec2Mock.on(DescribeInstancesCommand).resolves({
16+
Reservations: [{ Instances: [instance] }],
17+
});
18+
19+
const result = await getInstances(new EC2Client({}), [instanceId]);
20+
expect(result).toEqual([instance]);
21+
});
22+
23+
describe('should return null when the instance is not found', () => {
24+
it.each([{ Reservations: [] }, {}, { Reservations: undefined }])(
25+
'with %p',
26+
async (item: DescribeInstancesResult) => {
27+
const instanceId = 'i-1234567890abcdef0';
28+
ec2Mock.on(DescribeInstancesCommand).resolves(item);
29+
30+
const result = await getInstances(new EC2Client({}), [instanceId]);
31+
expect(result).toEqual([]);
32+
},
33+
);
34+
});
35+
});
36+
37+
describe('tagFilter', () => {
38+
describe('should return true when the instance matches the tag filters', () => {
39+
it.each([{ Environment: 'production' }, { Environment: 'prod' }])(
40+
'with %p',
41+
(tagFilters: Record<string, string>) => {
42+
const instance = {
43+
Tags: [
44+
{ Key: 'Name', Value: 'test-instance' },
45+
{ Key: 'Environment', Value: 'production' },
46+
],
47+
};
48+
49+
const result = tagFilter(instance, tagFilters);
50+
expect(result).toBe(true);
51+
},
52+
);
53+
});
54+
55+
it('should return false when the instance does not have all the tags', () => {
56+
const instance = {
57+
Tags: [{ Key: 'Name', Value: 'test-instance' }],
58+
};
59+
const tagFilters = { Name: 'test', Environment: 'prod' };
60+
61+
const result = tagFilter(instance, tagFilters);
62+
expect(result).toBe(false);
63+
});
64+
65+
it('should return false when the instance does not have any tags', () => {
66+
const instance = {};
67+
const tagFilters = { Name: 'test', Environment: 'prod' };
68+
69+
const result = tagFilter(instance, tagFilters);
70+
expect(result).toBe(false);
71+
});
72+
73+
it('should return true if the tag filters are empty', () => {
74+
const instance = {
75+
Tags: [
76+
{ Key: 'Name', Value: 'test-instance' },
77+
{ Key: 'Environment', Value: 'production' },
78+
],
79+
};
80+
const tagFilters = {};
81+
82+
const result = tagFilter(instance, tagFilters);
83+
expect(result).toBe(true);
84+
});
85+
86+
it('should return false if instance is null', () => {
87+
const instance = null;
88+
const tagFilters = { Name: 'test', Environment: 'prod' };
89+
90+
const result = tagFilter(instance, tagFilters);
91+
expect(result).toBe(false);
92+
});
93+
});

Diff for: lambdas/functions/termination-watcher/src/ec2.ts

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { DescribeInstancesCommand, EC2Client, Instance } from '@aws-sdk/client-ec2';
2+
3+
export async function getInstances(ec2: EC2Client, instanceId: string[]): Promise<Instance[]> {
4+
const result = await ec2.send(new DescribeInstancesCommand({ InstanceIds: instanceId }));
5+
const instances = result.Reservations?.[0]?.Instances;
6+
return instances ?? [];
7+
}
8+
9+
export function tagFilter(instance: Instance | null, tagFilters: Record<string, string>): boolean {
10+
return Object.keys(tagFilters).every((key) => {
11+
return instance?.Tags?.find((tag) => tag.Key === key && tag.Value?.startsWith(tagFilters[key]));
12+
});
13+
}

Diff for: lambdas/functions/termination-watcher/src/lambda.test.ts

+73-5
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import { Context } from 'aws-lambda';
33
import { mocked } from 'jest-mock';
44

55
import { handle as interruptionWarningHandlerImpl } from './termination-warning';
6-
import { interruptionWarning } from './lambda';
7-
import { SpotInterruptionWarning, SpotTerminationDetail } from './types';
6+
import { handle as terminationHandlerImpl } from './termination';
7+
import { interruptionWarning, termination } from './lambda';
8+
import { BidEvictedDetail, BidEvictedEvent, SpotInterruptionWarning, SpotTerminationDetail } from './types';
89

910
jest.mock('./termination-warning');
11+
jest.mock('./termination');
1012

1113
process.env.POWERTOOLS_METRICS_NAMESPACE = 'test';
1214
process.env.POWERTOOLS_TRACE_ENABLED = 'true';
13-
const event: SpotInterruptionWarning<SpotTerminationDetail> = {
15+
const spotInstanceInterruptionEvent: SpotInterruptionWarning<SpotTerminationDetail> = {
1416
version: '0',
1517
id: '1',
1618
'detail-type': 'EC2 Spot Instance Interruption Warning',
@@ -25,6 +27,42 @@ const event: SpotInterruptionWarning<SpotTerminationDetail> = {
2527
},
2628
};
2729

30+
const bidEvictedEvent: BidEvictedEvent<BidEvictedDetail> = {
31+
version: '0',
32+
id: '186d7999-3121-e749-23f3-c7caec1084e1',
33+
'detail-type': 'AWS Service Event via CloudTrail',
34+
source: 'aws.ec2',
35+
account: '123456789012',
36+
time: '2024-10-09T11:48:46Z',
37+
region: 'eu-west-1',
38+
resources: [],
39+
detail: {
40+
eventVersion: '1.10',
41+
userIdentity: {
42+
accountId: '123456789012',
43+
invokedBy: 'sec2.amazonaws.com',
44+
},
45+
eventTime: '2024-10-09T11:48:46Z',
46+
eventSource: 'ec2.amazonaws.com',
47+
eventName: 'BidEvictedEvent',
48+
awsRegion: 'eu-west-1',
49+
sourceIPAddress: 'ec2.amazonaws.com',
50+
userAgent: 'ec2.amazonaws.com',
51+
requestParameters: null,
52+
responseElements: null,
53+
requestID: 'ebf032e3-5009-3484-aae8-b4946ab2e2eb',
54+
eventID: '3a15843b-96c2-41b1-aac1-7d62dc754547',
55+
readOnly: false,
56+
eventType: 'AwsServiceEvent',
57+
managementEvent: true,
58+
recipientAccountId: '123456789012',
59+
serviceEventDetails: {
60+
instanceIdSet: ['i-12345678901234567'],
61+
},
62+
eventCategory: 'Management',
63+
},
64+
};
65+
2866
const context: Context = {
2967
awsRequestId: '1',
3068
callbackWaitsForEmptyEventLoop: false,
@@ -48,22 +86,52 @@ const context: Context = {
4886

4987
// Docs for testing async with jest: https://jestjs.io/docs/tutorial-async
5088
describe('Handle sport termination interruption warning', () => {
89+
beforeEach(() => {
90+
jest.clearAllMocks();
91+
});
92+
5193
it('should not throw or log in error.', async () => {
5294
const mock = mocked(interruptionWarningHandlerImpl);
5395
mock.mockImplementation(() => {
5496
return new Promise((resolve) => {
5597
resolve();
5698
});
5799
});
58-
await expect(interruptionWarning(event, context)).resolves.not.toThrow();
100+
await expect(interruptionWarning(spotInstanceInterruptionEvent, context)).resolves.not.toThrow();
59101
});
60102

61103
it('should not throw only log in error in case of an exception.', async () => {
62104
const logSpy = jest.spyOn(logger, 'error');
63105
const error = new Error('An error.');
64106
const mock = mocked(interruptionWarningHandlerImpl);
65107
mock.mockRejectedValue(error);
66-
await expect(interruptionWarning(event, context)).resolves.toBeUndefined();
108+
await expect(interruptionWarning(spotInstanceInterruptionEvent, context)).resolves.toBeUndefined();
109+
110+
expect(logSpy).toHaveBeenCalledTimes(1);
111+
});
112+
});
113+
114+
describe('Handle sport termination (BidEvictEvent', () => {
115+
beforeEach(() => {
116+
jest.clearAllMocks();
117+
});
118+
119+
it('should not throw or log in error.', async () => {
120+
const mock = mocked(terminationHandlerImpl);
121+
mock.mockImplementation(() => {
122+
return new Promise((resolve) => {
123+
resolve();
124+
});
125+
});
126+
await expect(termination(bidEvictedEvent, context)).resolves.not.toThrow();
127+
});
128+
129+
it('should not throw only log in error in case of an exception.', async () => {
130+
const logSpy = jest.spyOn(logger, 'error');
131+
const error = new Error('An error.');
132+
const mock = mocked(terminationHandlerImpl);
133+
mock.mockRejectedValue(error);
134+
await expect(termination(bidEvictedEvent, context)).resolves.toBeUndefined();
67135

68136
expect(logSpy).toHaveBeenCalledTimes(1);
69137
});

Diff for: lambdas/functions/termination-watcher/src/lambda.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { logMetrics } from '@aws-lambda-powertools/metrics/middleware';
44
import { Context } from 'aws-lambda';
55

66
import { handle as handleTerminationWarning } from './termination-warning';
7-
import { SpotInterruptionWarning, SpotTerminationDetail } from './types';
7+
import { handle as handleTermination } from './termination';
8+
import { BidEvictedDetail, BidEvictedEvent, SpotInterruptionWarning, SpotTerminationDetail } from './types';
89
import { Config } from './ConfigResolver';
910

1011
const config = new Config();
@@ -24,6 +25,18 @@ export async function interruptionWarning(
2425
}
2526
}
2627

28+
export async function termination(event: BidEvictedEvent<BidEvictedDetail>, context: Context): Promise<void> {
29+
setContext(context, 'lambda.ts');
30+
logger.logEventIfEnabled(event);
31+
logger.debug('Configuration of the lambda', { config });
32+
33+
try {
34+
await handleTermination(event, config);
35+
} catch (e) {
36+
logger.error(`${(e as Error).message}`, { error: e as Error });
37+
}
38+
}
39+
2740
const addMiddleware = () => {
2841
const middleware = middy(interruptionWarning);
2942

0 commit comments

Comments
 (0)