CloudFormation 一撃で停止できるEC2スポットインスタンスを立ててみた with カスタムリソース

2021.02.27

AWS事業本部梶原@福岡オフィスです。

ちょっと手軽にスポットインスタンスでAmazonLinux2の検証がしたいっていうときの起動テンプレートです。

普通に停止できるEC2スポットインスタンスを立てようとして永続リクエストを有効にして、CloudFormationでEC2インスタンスをたてるとその際に永続的なスポットリクエストが生成されます。

こちらはCloudFormationの管理外となり、

CloudFormationのスタックを削除しても残ります。残ります。残ります。

残った永続的なスポットリクエストにより、一定時間たつと新しいEC2インスタンスが立ち上がります。

まじか(滝汗

ということで、CloudFormationのスタックを消した際には一緒に永続的なスポットリクエストも消したくなりました
イベント検知とかいろいろ試行錯誤したんですが、結局、伝家の宝刀諸刃の剣のカスタムリソースでバッサリ消しました。

結構危うい処理なので、あえてカスタムリソースとは?とか、スポットインスタンスとは?とかは、割愛させていただきます。 リスクをご承知の上で存分にご活用いただければと思います。

クイック作成リンク

AWSコンソールにログイン後

こちらをクリック

すると下記記載のEC2がスポットインスタンスで起動します。 停止することも可能です。不要になった際はCloudFormationのスタック毎消してください。カスタムリソースでスポットリクエストを消してますが、動作保証しません。一応消えているか確認してください。

使用しているテンプレートの全体はページ末尾に記載しています。

EC2スポットインスタンス

項目 備考
AMI Amazon Linux 2 SSMパラメータより最新を取得
InstanceType t3.micro パラメータにしているので指定してください
スポット価格最高値 指定なし 未指定ですのでオンデマンド価格で入札です
ネットーワークとか 指定なし 標準のVPCで起動します
接続方法 SSM 必要なRole(AmazonSSMManagedInstanceCore)はテンプレートで作成しています

スポットリクエストキャンセルカスタムリソース

項目 備考
Runtime python3.6
MemorySize 128MB
Timeout 120
Role AWSLambdaBasicExecutionRole, AmazonEC2FullAccess

Lambdaで以下の動作をさせています。

パラメータで渡されたインスタンスIDよりスポットリクエストIDを取得します。 各処理正常終了時はスポットリクエストIDを返します

新規作成時

スポットリクエストIDを取得しているだけです。

response = client.describe_instances(**params)
SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']

更新時

更新前インスタンスのスポットリクエストのキャンセルを行います。
更新後のスポットリクエストIDを取得します

old_params = dict([(k, v) for k, v in event['OldResourceProperties'].items() if k != 'ServiceToken'])
response = client.describe_instances(**old_params)
SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']
response = client.cancel_spot_instance_requests(
    SpotInstanceRequestIds=[
        SpotInstanceRequestId,
    ],
)
response = client.describe_instances(**params)
SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']

削除時

スポットリクエストのキャンセルを行います。

response = client.describe_instances(**params)
SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']
response = client.cancel_spot_instance_requests(
    SpotInstanceRequestIds=[
        SpotInstanceRequestId,
    ],
)

まとめ

手軽にスポットインスタンスを使おうとしていたのですが、ちょっと大掛かりになってしまいました。 とはいえ、これでポチっとすれば、安心して、スポットインスタンスで最大%OFFな感じで検証できて スタック消せばきれいに消えるのでお手軽です。

参考URL

EC2スポットインスタンスで停止と再開ができるようになりました!

Boto3 Cancels one or more Spot Instance requests.

これでスポットリクエストのキャンセルし忘れとさらばだ!

テンプレート

https://pub-devio-blog-qrgebosd.s3-ap-northeast-1.amazonaws.com/template/cfn-ec2-spot-instance.yml

Parameters:
  AMIId:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2

  InstanceType:
    Type: String
    Default: t3.micro

Resources:

  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref AMIId
      IamInstanceProfile: !Ref AmazonSSMManagedInstanceProfile
      LaunchTemplate:
        LaunchTemplateId: !Ref ECSplotLaunchTemplate
        Version: !GetAtt ECSplotLaunchTemplate.LatestVersionNumber

  ECSplotLaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateData:
        InstanceType: !Ref InstanceType
        InstanceMarketOptions:
          MarketType: spot
          SpotOptions:
            InstanceInterruptionBehavior: stop
            MaxPrice: !Ref 'AWS::NoValue' # OnDemand
            SpotInstanceType: persistent

  AmazonSSMManagedInstanceCoreRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
          
  AmazonSSMManagedInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref AmazonSSMManagedInstanceCoreRole

  CancelSpotInstanceRequestsLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import json
          import boto3
          import cfnresponse
          def handler(event, context):
            try:
              print(event)
              params = dict([(k, v) for k, v in event['ResourceProperties'].items() if k != 'ServiceToken'])
              client = boto3.client('ec2')
              if event['RequestType'] == 'Create':
                response = client.describe_instances(**params)
                print(response)

                SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']
                responseData = {}
                responseData['SpotInstanceRequestId'] = SpotInstanceRequestId
                cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData, SpotInstanceRequestId)

              if event['RequestType'] == 'Delete':
                response = client.describe_instances(**params)
                print(response)

                SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']
                response = client.cancel_spot_instance_requests(
                    SpotInstanceRequestIds=[
                        SpotInstanceRequestId,
                    ],
                )
                print(response)

                responseData = {}
                responseData['SpotInstanceRequestId'] = SpotInstanceRequestId
                cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData, SpotInstanceRequestId)

              if event['RequestType'] == 'Update':
                old_params = dict([(k, v) for k, v in event['OldResourceProperties'].items() if k != 'ServiceToken'])
                response = client.describe_instances(**old_params)
                print(response)

                SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']
                response = client.cancel_spot_instance_requests(
                    SpotInstanceRequestIds=[
                        SpotInstanceRequestId,
                    ],
                )
                print(response)

                response = client.describe_instances(**params)
                print(response)
                
                SpotInstanceRequestId = response['Reservations'][0]['Instances'][0]['SpotInstanceRequestId']
                responseData = {}
                responseData['SpotInstanceRequestId'] = SpotInstanceRequestId
                cfnresponse.send(event, context, cfnresponse.SUCCESS, responseData, SpotInstanceRequestId)

            except Exception as e:
              print(e)
              cfnresponse.send(event, context, cfnresponse.FAILED, {})
      Handler: index.handler
      MemorySize: 128
      Role: !GetAtt AmazonEC2FullAccessRoleforLambda.Arn
      Runtime: python3.6
      Timeout: 120

  AmazonEC2FullAccessRoleforLambda:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
        - "arn:aws:iam::aws:policy/AmazonEC2FullAccess"

  CancelSpotInstanceRequests:
    Type: Custom::CancelSpotInstanceRequests
    Properties:
      ServiceToken: !GetAtt CancelSpotInstanceRequestsLambda.Arn
      InstanceIds: 
        - !Ref EC2Instance

Outputs:
  EC2Instance:
    Value: !Ref EC2Instance
  SpotInstanceRequestId:
    Value: !GetAtt CancelSpotInstanceRequests.SpotInstanceRequestId