AWS CDKでEC2のスポットインスタンスを立てるのは実は大変。
立てる方法の説明と、それをライブラリー化したので紹介します。
なお当記事はCDKですがTypeScript限定です、すいません。
お急ぎの方
ライブラリー化してみた をご覧ください。
2023年1月12日追記: ライブラリをTypeScript以外、Python, Java, C#(.NET), Goにも対応させました。
はじめに
マネジメントコンソールでEC2のスポットインスタンスを立てることは簡単です。この画像のように、EC2インスタンスを立てる手順の中で、チェックを入れるだけです。
一方CDKでは、EC2を作るためのInstance
コンストラクトにスポットインスタンス関係のプロパティ等は見つからず1、Web検索で調べてもずばりの情報は出ません。
ただ、気になるページを見つけました。
CloudFormation 一撃で停止できるEC2スポットインスタンスを立ててみた with カスタムリソース
上記記事はCDKではなくCloudFormation(Cfn)ですが、CfnでできるならCDKでも何かしらできるはずです。
結局上記を読み解いて、以下のような手順を踏めばいいことが分かりました。
-
spotOptions
を有効にしたLaunchTemplate(起動テンプレート)を作る -
Instance
をコード上で作成(new)し、先ほど作成した起動テンプレートを紐付ける - 必要があれば、スポットリクエストをキャンセルするためのカスタムリソースを作る
スポットインスタンスに関する背景
なぜCDK(Cfnも)では素直にスポットインスタンスを作れず、このような回りくどい事をする必要があるのか。
マネジメントコンソールの実装から、EC2インスタンスにオンデマンドとスポットのフラグがあってそれで制御されていることをイメージしますが、実はそうではありません。
公式ドキュメントのスポットインスタンス のしくみには、以下のように書かれています。
スポットインスタンスを起動するには、ユーザーがスポットインスタンスリクエストを作成します。
スポットインスタンスを起動するにはまず、インスタンスではなく、「スポットインスタンスリクエスト」というものを作成し、それが受理されるとインスタンスが起動する仕組みになっています。これは、スポットインスタンスの価格がユーザーの指定に合わなければ合うまで待つことになるため、このような二段階の仕組みになっているのでしょう。なお先述のドキュメントの続きには
または、Amazon EC2 が自動的にスポットインスタンスリクエストを作成することもできます。
と書かれていて、これは最初に述べた、スポットインスタンスを立てるためのチェックボックスのことです。あれはインスタンスのフラグを切り替えるのではなく、インスタンスを起動するかスポットインスタンスリクエストを作成するかを切り替えるものだということが分かりました。
AWS CLIからスポットインスタンスを起動する場合、request-spot-instances
を使います。まさにこの仕組みがそのままCLIのインターフェースに現れています。
なお昔はマネジメントコンソールでも、「インスタンス」からはスポットインスタンスを作れず、「スポットリクエスト」からスポットリクエストを作成する必要があった記憶があります。今はこのメニューからはスポットフリートを作成することしか出来ないようです。
CDKでスポットインスタンスを作るには
さて前置きが長くなりましたが、スポットインスタンスをCDKで作ってみましょう。
前提環境
以降は以下の環境で実施しますが、他の環境の人は適宜読み替えてください。
- Ubuntu 22.04.1 LTS
- Node.js 18.12.1
- AWS CDK 2.54.0 (build 9f41881)
- AWS SDK for JavaScript 3.226.0
- TypeScript 4.9.3 (上記CDKで作られるテンプレート上のバージョン)
事前準備
以下の準備が整っているものとします。
- AWSアカウントと、IAMユーザーは準備済み
-
cdk bootstrap
済み -
Instance
コンストラクトを使ってEC2のインスタンスを立てるコードを作成済みの状態
ここでは、以下のようなコードが既にあるとします。
// ほぼEC2インスタンス1個だけのシンプルなスタック
export class SpotDemoStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// 今回はこのスタック用にVPCを簡単に作成することにしてみた。
// 既存VPCを参照するようにしても問題ない。
const vpc = new ec2.Vpc(this, "VPC", { natGateways: 0 });
// ECインスタンスを作成する。必須オプションだけ設定。
const inst = new ec2.Instance(this, "DemoInstance", {
vpc,
instanceType: ec2.InstanceType.of(ec2.InstanceClass.T3A, ec2.InstanceSize.NANO), // t3a.nano
machineImage: new ec2.AmazonLinuxImage(),
});
}
}
スポットインスタンス作成手順
以下手順3まで、先述のlib/spot-demo-stack.ts
のSpotDemoStack
のコンストラクタにコードを追加していきます。
手順1 スポットリクエストを有効にした起動テンプレートを作成
// スポットリクエストを有効にした起動テンプレートを作成
const launchTemplate = new ec2.LaunchTemplate(this, "Template", {
spotOptions: {}
});
まずスポットリクエストを有効にした起動テンプレートを作成します。
上記ではspotOptions
が空なので各種プロパティはデフォルトです。この場合、以下の値になります。
-
maxPrice
: オンデマンド価格と同額 -
requestType
: ワンタイム(一度インスタンスを終了すると終わり) -
intrruptionBehavior
: 終了(スポット価格が高くなると、Terminateされる)
手順2 起動テンプレートをインスタンスに紐付け
// 起動テンプレートを使ってインスタンスを起動するように設定
inst.instance.launchTemplate = {
version: launchTemplate.versionNumber,
launchTemplateId: launchTemplate.launchTemplateId
};
さきほど作った起動テンプレートをインスタンスに紐付けることで、起動テンプレートを使ってインスタンスを起動できます。
Instance
コンストラクトは起動テンプレートを設定する方法がありませんが、instance
プロパティから触れるCfnInstance
(L1コンストラクト)を使うと紐付けできます。
ここまでのコードを追加してcdk deploy
すると、スポットインスタンスが問題無く起動します。
ただし、spotOptions
をspotRequestType.PERSISTENT
、つまり永続リクエストにした場合は問題があります。
スタックをdestroyしてもインスタンスや起動テンプレートが削除されるだけで、スポットリクエストが残ってしまうのです。
その結果、再びインスタンスが勝手に起動してしまいます。
これを回避するためには、手順3以降も実施する必要があります。
手順3 (オプション)スポットリクエストをキャンセルするカスタムリソースを作る
この手順はオプションの割に長いため、折りたたんでいます。
APIでスポットリクエストをキャンセルするLambda関数を用意し、スタックの更新やdestroy時にこれを呼ぶカスタムリソースを用意します。
当記事では、カスタムリソースの作成に、CDKのProvider Frameworkを使用します。
カスタムリソースやLambda関数の作成方法の詳細は、既にたくさんの良質な記事があり、この記事の主旨とも外れますので、ここではざっとな説明に留めます。
下記の順で行います。
# | 作成するもの | 内容 |
---|---|---|
1 | Lambda実行用ロール | 標準のLambda実行用ロールではEC2のスポットリクエストをキャンセルする権限がないため、独自に作成します。 |
2 | スポットリクエストをキャンセルするLambda関数 | 1で作ったロールを指定します。今回はTypeScript(Node.js) + AWS SDKで作りましたが、他の方法でもかまいません。 |
3 | カスタムリソースプロバイダ | 今回は、Provider Frameworkを使ってプロバイダを作ります。2で作ったLambda関数を指定してnewするだけです。 |
4 | カスタムリソース | 3で作ったProviderを設定したカスタムリソースを作成します。 |
// #1 Lambda実行用ロール
const role = new iam.Role(this, "HandlerExecutionRole", {
assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
managedPolicies: [
// Lambda実行用
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole")
],
inlinePolicies: {
ec2: new iam.PolicyDocument({
statements: [
new iam.PolicyStatement({
effect: iam.Effect.ALLOW,
actions: [
// EC2のインスタンスIDからスポットリクエストを引く
"ec2:DescribeInstances",
// スポットリクエストをキャンセルする
"ec2:CancelSpotInstanceRequests"
],
resources: ["*"]
})
]
})
}
});
// #2 スポットリクエストをキャンセルするLambda関数
// このファイルが spot-demo-stack.ts の場合、同じディレクトリにある
// spot-demo-stack.handler.ts をLambda関数のエントリーポイントとする
const onEventHandler = new NodejsFunction(this, "handler", {
role,
// Lambda関数のログ保存期間を設定(設定しないと無期限となるため)
logRetention: RetentionDays.THREE_MONTHS
});
// #3 カスタムリソースプロバイダ
const provider = new Provider(this, "provider", {
onEventHandler,
// Lambda関数のログ保存期間を設定(設定しないと無期限となるため)
logRetention: RetentionDays.THREE_MONTHS
});
// #4 カスタムリソース
new CustomResource(this, "customResource", {
serviceToken: provider.serviceToken,
properties: {
// EC2のインスタンスIDをLambda関数に渡す
ec2InstanceId: inst.instanceId
}
});
Lambda関数の中身は、以下のようにまず、eventのRequestType
がDelete
かUpdate
の場合のみ処理を行います。
eventのResourceProperties
は先ほどCustomResource
をnewするときに渡したプロパティが入ってくるので、インスタンスIDを取り出します。
export const handler = async (event: CdkCustomResourceEvent): Promise<CdkCustomResourceResponse> => {
switch (event.RequestType) {
case "Create":
break;
case "Delete":
await deleteSpotReq(event.ResourceProperties.ec2InstanceId as string;);
break;
case "Update":
await deleteSpotReq(event.OldResourceProperties.ec2InstanceId as string);
break;
}
スポットリクエストをキャンセルする処理は以下のように、AWS SDKを使用し、インスタンスIDからスポットリクエストIDを導き出し、それをキャンセルすれば良いです。
async function deleteSpotReq(instanceId: string) {
const client = new EC2Client({});
try {
const command = new DescribeInstancesCommand({
//インスタンスIDを指定してDescribeInstancesする
InstanceIds: [instanceId]
});
const result = await client.send(command);
// DescribeInstancesの結果から、スポットリクエストのIDを取得
const spotReqId = result.Reservations?.[0]?.Instances?.[0]?.SpotInstanceRequestId;
// なぜかスポットリクエストIDが引けなかった場合
if (spotReqId === undefined) {
console.log(`${instanceId} not found or it has no SpotInstanceRequest`);
return;
}
// スポットリクエストIDを指定してキャンセルするコマンド
const command2 = new CancelSpotInstanceRequestsCommand({
SpotInstanceRequestIds: [spotReqId]
});
// スポットリクエストのキャンセルを実行
const result2 = await client.send(command2);
// キャンセルできたスポットリクエストIDが、キャンセルしたかったものか確認
const succeeded = result2.CancelledSpotInstanceRequests?.[0]?.SpotInstanceRequestId === spotReqId;
if (!succeeded) {
console.log(`seems to have failed to cancel the SpotInstanceRequest ${spotReqId}`);
}
} finally {
client.destroy();
}
}
デプロイしてみる
ここまでのコードを実際にcdk deploy
でデプロイしてみましょう。
無事にスポットインスタンスが起動しています。
destroy時にスポットリクエストをキャンセルするためのLambda関数や、Provider Framework用の関数などが作られています。
削除してみる
今度は、cdk destroy
で削除してみます。
きちんとインスタンスとスポットリクエストが削除されていることが分かります。
ライブラリー化してみた
手順1と手順2は数行程度でシンプルなので、その場で自分のコードに都度さっと追加でも良さそうです。
一方、手順3はそこそこ複雑ですが、永続的なスポットリクエストを使いたい時は無視できません。
そこで、上記の手順を踏んだコードをライブラリー化し、簡単に利用できるようにしてみました。
(TypeScript/JavaScript専用)
利用は簡単で、まずこのライブラリをインストールします。
# Node.js(npm)
$ npm install cdk-ec2-spot-simple
# Python
$ pip install cdk-ec2-spot-simple
# C# (.NET)
$ dotnet add package TksSt.Cdk.Ec2SpotSimple
# Java(maven)
<dependency>
<groupId>st.tks.cdk</groupId>
<artifactId>ec2-spot-simple</artifactId>
</dependency>
# Go
$ go get github.com/tksst/cdk-ec2-spot-simple-go/cdkec2spotsimple/v2
そして、以下のように、EC2インスタンスをnew Instance
で作成しているところをSpotInstance
に置き換えるだけで、デフォルト設定のスポットインスタンス構築ができます。(これはTypeScriptの場合です)
+ import { SpotInstance } from "cdk-ec2-spot-simple"
- new Instance(~);
+ new SpotInstance(~);
ワンタイムリクエスト(デフォルト)の場合はシンプルに起動テンプレートを作ってインスタンスを立ち上げるだけとしました。一方永続リクエストの場合はスポットリクエストをキャンセルするカスタムリソースも合わせて作ってくれるようにしました。
さいごに
いろいろと頑張ってCDKでスポットインスタンスを立てることが出来ましたが、本来はCDKが公式にこの機能を提供してほしいものです。
ただ、こういう「ハック」を見つけるのもまた楽しいものですね。
参考リンク
カスタムリソースをCDKで簡単に作るためのProvider Frameworkのドキュメント
Node.jsで動くLambda関数をCDKで簡単に作るためのパッケージのドキュメント
JavaScript/TypeScriptでEC2のスポットインスタンスリクエストをキャンセルするための、AWS SDK for JavaScript v3のドキュメント
CloudFormationで当記事と同様のことをやっている記事。この記事のおかげで、CDKでスポットインスタンスを立てる方法が分かりました。