AWS CDK でEC2のスポットインスタンスを立てる方法

AWS CDKでEC2のスポットインスタンスを立てるのは実は大変。
立てる方法の説明と、それをライブラリー化したので紹介します。

なお当記事はCDKですがTypeScript限定です、すいません。

# お急ぎの方

[ライブラリー化してみた](#ライブラリー化してみた) をご覧ください。

2023年1月12日追記： ライブラリをTypeScript以外、Python, Java, C#(.NET), Goにも対応させました。

# はじめに

マネジメントコンソールでEC2のスポットインスタンスを立てることは簡単です。この画像のように、EC2インスタンスを立てる手順の中で、チェックを入れるだけです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221308/367ac4a5-4b46-4d42-a8a8-81e0a9bf3fa3.png)

一方CDKでは、EC2を作るための[`Instance`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Instance.html)コンストラクトにスポットインスタンス関係のプロパティ等は見つからず[^1]、Web検索で調べてもずばりの情報は出ません。

[^1]: リファレンス https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Instance.html

ただ、気になるページを見つけました。

[CloudFormation 一撃で停止できるEC2スポットインスタンスを立ててみた with カスタムリソース](https://dev.classmethod.jp/articles/cfn-ec2-spot-instance/)

https://dev.classmethod.jp/articles/cfn-ec2-spot-instance/

上記記事はCDKではなくCloudFormation(Cfn)ですが、CfnでできるならCDKでも何かしらできるはずです。

結局上記を読み解いて、以下のような手順を踏めばいいことが分かりました。

- [`spotOptions`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.LaunchTemplate.html#spotoptions)を有効にした[LaunchTemplate](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.LaunchTemplate.html)（起動テンプレート）を作る
- [`Instance`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Instance.html)をコード上で作成(new)し、先ほど作成した起動テンプレートを紐付ける
- 必要があれば、スポットリクエストをキャンセルするためのカスタムリソースを作る

# スポットインスタンスに関する背景

なぜCDK(Cfnも)では素直にスポットインスタンスを作れず、このような回りくどい事をする必要があるのか。

マネジメントコンソールの実装から、EC2インスタンスにオンデマンドとスポットのフラグがあってそれで制御されていることをイメージしますが、実はそうではありません。
公式ドキュメントの[スポットインスタンス のしくみ](https://docs.aws.amazon.com/ja_jp/AWSEC2/latest/UserGuide/how-spot-instances-work.html)には、以下のように書かれています。

> スポットインスタンスを起動するには、ユーザーがスポットインスタンスリクエストを作成します。

スポットインスタンスを起動するにはまず、インスタンスではなく、「スポットインスタンスリクエスト」というものを作成し、それが受理されるとインスタンスが起動する仕組みになっています。これは、スポットインスタンスの価格がユーザーの指定に合わなければ合うまで待つことになるため、このような二段階の仕組みになっているのでしょう。なお先述のドキュメントの続きには

> または、Amazon EC2 が自動的にスポットインスタンスリクエストを作成することもできます。

と書かれていて、これは最初に述べた、スポットインスタンスを立てるためのチェックボックスのことです。あれはインスタンスのフラグを切り替えるのではなく、インスタンスを起動するかスポットインスタンスリクエストを作成するかを切り替えるものだということが分かりました。

AWS CLIからスポットインスタンスを起動する場合、[`request-spot-instances`](https://awscli.amazonaws.com/v2/documentation/api/latest/reference/ec2/request-spot-instances.html)を使います。まさにこの仕組みがそのままCLIのインターフェースに現れています。

なお昔はマネジメントコンソールでも、「インスタンス」からはスポットインスタンスを作れず、「スポットリクエスト」からスポットリクエストを作成する必要があった記憶があります。今はこのメニューからはスポットフリートを作成することしか出来ないようです。
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221308/99a14028-1113-67d2-d219-684bcebadcd9.png">


# 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のインスタンスを立てるコードを作成済みの状態

ここでは、以下のようなコードが既にあるとします。

```typescript: lib/spot-demo-stack.ts
// ほぼ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 スポットリクエストを有効にした起動テンプレートを作成

```typescript
// スポットリクエストを有効にした起動テンプレートを作成
const launchTemplate = new ec2.LaunchTemplate(this, "Template", {
  spotOptions: {}
});
```

まずスポットリクエストを有効にした起動テンプレートを作成します。
上記では[`spotOptions`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.LaunchTemplate.html#spotoptions)が空なので各種プロパティはデフォルトです。この場合、以下の値になります。

- [`maxPrice`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.LaunchTemplateSpotOptions.html#maxprice)： オンデマンド価格と同額
- [`requestType`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.LaunchTemplateSpotOptions.html#requesttype)： ワンタイム(一度インスタンスを終了すると終わり)
- [`intrruptionBehavior`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.LaunchTemplateSpotOptions.html#interruptionbehavior)： 終了(スポット価格が高くなると、Terminateされる)

### 手順2 起動テンプレートをインスタンスに紐付け

```typescript
// 起動テンプレートを使ってインスタンスを起動するように設定
inst.instance.launchTemplate = {
  version: launchTemplate.versionNumber,
  launchTemplateId: launchTemplate.launchTemplateId
};
```

さきほど作った起動テンプレートをインスタンスに紐付けることで、起動テンプレートを使ってインスタンスを起動できます。
`Instance`コンストラクトは起動テンプレートを設定する方法がありませんが、[`instance`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.Instance.html#instance)プロパティから触れる[`CfnInstance`](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.CfnInstance.html)(L1コンストラクト)を使うと紐付けできます。

ここまでのコードを追加して`cdk deploy`すると、スポットインスタンスが問題無く起動します。
ただし、`spotOptions`を`spotRequestType.PERSISTENT`、つまり永続リクエストにした場合は問題があります。
スタックをdestroyしてもインスタンスや起動テンプレートが削除されるだけで、スポットリクエストが残ってしまうのです。
その結果、再びインスタンスが勝手に起動してしまいます。

これを回避するためには、手順3以降も実施する必要があります。

### 手順3 (オプション)スポットリクエストをキャンセルするカスタムリソースを作る

<details><summary>この手順はオプションの割に長いため、折りたたんでいます。</summary><div>

APIでスポットリクエストをキャンセルするLambda関数を用意し、スタックの更新やdestroy時にこれを呼ぶカスタムリソースを用意します。

当記事では、カスタムリソースの作成に、CDKの[Provider Framework](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources-readme.html#provider-framework)を使用します。
カスタムリソースやLambda関数の作成方法の詳細は、既にたくさんの良質な記事があり、この記事の主旨とも外れますので、ここではざっとな説明に留めます。

下記の順で行います。

|#|作成するもの|内容|
|:----|:----|:----|
|1|Lambda実行用ロール|標準のLambda実行用ロールではEC2のスポットリクエストをキャンセルする権限がないため、独自に作成します。|
|2|スポットリクエストをキャンセルするLambda関数|1で作ったロールを指定します。今回はTypeScript(Node.js) + AWS SDKで作りましたが、他の方法でもかまいません。|
|3|カスタムリソースプロバイダ|今回は、Provider Frameworkを使ってプロバイダを作ります。2で作ったLambda関数を指定してnewするだけです。|
|4|カスタムリソース|3で作ったProviderを設定したカスタムリソースを作成します。|


```typescript
// #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を取り出します。

```typescript
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を導き出し、それをキャンセルすれば良いです。

```typescript
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();
  }
}
```

</div></details>

## デプロイしてみる

ここまでのコードを実際に`cdk deploy`でデプロイしてみましょう。

インスタンス一覧：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221308/f084e0ce-8cee-ef48-442c-764ac58bf077.png)

スポットリクエスト一覧：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221308/a52c7c3a-7429-3bc6-409c-b6406e4fe63b.png)

無事にスポットインスタンスが起動しています。

Lambda一覧：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221308/ed6df3a6-d91a-118f-6dc3-441528f84031.png)

destroy時にスポットリクエストをキャンセルするためのLambda関数や、Provider Framework用の関数などが作られています。

## 削除してみる

今度は、`cdk destroy`で削除してみます。

インスタンス一覧：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221308/58e1ecac-f169-644e-5b7b-0f10ee86c8cc.png)

スポットリクエスト一覧：
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/221308/b4fe4046-fd03-5be6-76c7-c8abeb92fb5b.png)

きちんとインスタンスとスポットリクエストが削除されていることが分かります。

# ライブラリー化してみた

手順1と手順2は数行程度でシンプルなので、その場で自分のコードに都度さっと追加でも良さそうです。
一方、手順3はそこそこ複雑ですが、永続的なスポットリクエストを使いたい時は無視できません。

そこで、上記の手順を踏んだコードをライブラリー化し、簡単に利用できるようにしてみました。
~~(TypeScript/JavaScript専用)~~

https://github.com/tksst/cdk-ec2-spot-simple

https://www.npmjs.com/package/cdk-ec2-spot-simple

利用は簡単で、まずこのライブラリをインストールします。

```shell
# 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の場合です)

```diff
+ import { SpotInstance } from "cdk-ec2-spot-simple"

- new Instance(～);
+ new SpotInstance(～);
```

ワンタイムリクエスト(デフォルト)の場合はシンプルに起動テンプレートを作ってインスタンスを立ち上げるだけとしました。一方永続リクエストの場合はスポットリクエストをキャンセルするカスタムリソースも合わせて作ってくれるようにしました。

# さいごに

いろいろと頑張ってCDKでスポットインスタンスを立てることが出来ましたが、本来はCDKが公式にこの機能を提供してほしいものです。
ただ、こういう「ハック」を見つけるのもまた楽しいものですね。

# 参考リンク

カスタムリソースをCDKで簡単に作るためのProvider Frameworkのドキュメント

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.custom_resources-readme.html#provider-framework

Node.jsで動くLambda関数をCDKで簡単に作るためのパッケージのドキュメント

https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs-readme.html

JavaScript/TypeScriptでEC2のスポットインスタンスリクエストをキャンセルするための、AWS SDK for JavaScript v3のドキュメント

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/clients/client-ec2/index.html

CloudFormationで当記事と同様のことをやっている記事。この記事のおかげで、CDKでスポットインスタンスを立てる方法が分かりました。

https://dev.classmethod.jp/articles/cfn-ec2-spot-instance/