EC2 Spot Block を Lambda (Python) から起動してみた

AmazonLambda

こんにちは、菊池です。

起動時間保証つきで低価格にEC2を利用可能なSpot Blockを、Lambdaから起動することで自動化が可能かなと思い、やってみました。

Spot Block とは

うまく使うことでEC2利用料を劇的に下げることができるスポットインスタンスですが、一方でスポット価格の変動によりいつTerminateされるかわからないというリスクがありました。

Spot Blockはスポットインスタンスをリクエストする際のオプションで、通常のスポットよりか価格は上がるものの、一度起動すれば最大6時間まで(1時間単位)の起動が保証されます。

[アップデート]Amazon EC2の新しいSpotインスタンス 「Spot Block」が発表されました! #reinvent

このSpot Block、処理時間が一定の日時バッチ処理などに使いたいと思ったのですが、現時点でマネジメントコンソールからの操作では毎日の繰り返しやAutoScalingへの登録はできません。

そこで、Lambdaを使ってリクエストを発行することで、外部からのトリガやcronでの実行をできようにします。

処理イメージ

 

launch-spotblock-with-lambda

スポットインスタンスの起動は、リクエスト後に入札が行われますのでインスタンスの起動までに数分かかります。そのため、以下の2つのファンクションを5分程度の時間差で実行するようにします。

  • スポットリクエストを発行し、リクエストIDをSQSに送信するLambdaファンクション
  • SQSからリクエストIDを取得し、入札結果をSNSに送信するLambdaファンクション
    • インスタンスの起動に成功した場合には起動したインスタンスIDを、失敗した場合はエラーメッセージを送信

 

Lambda関数

実行させるLambda関数(Python)です。

スポットリクエスト実行:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import boto3
import datetime

# Spot request Specification
spot_price        = "0.096"
instance_count    = 4
request_type      = "one-time"
duration_minutes  = 60
valid_until       = datetime.datetime.now() + datetime.timedelta(minutes = duration_minutes)
image_id          = "ami-29160d47"
security_groups   = ["sg-b69b93d2"]
incetance_type    = "m3.medium"
availability_zone = "ap-northeast-1a"
subnet_id         = "subnet-36fb3640"
queue_name        = "spot_que"

client            = boto3.client('ec2')
sqs               = boto3.resource('sqs')

def lambda_handler(event, context):

    # Request Spot Block
    response = client.request_spot_instances(
        SpotPrice            = spot_price,
        InstanceCount        = instance_count,
        Type                 = request_type,
        ValidUntil           = valid_until,
        BlockDurationMinutes = block_duration_minutes,
        LaunchSpecification  = {
            "ImageId"          : image_id,
            "SecurityGroupIds" : security_groups,
            "InstanceType"     : incetance_type,
            "Placement"        : {
                "AvailabilityZone" : availability_zone
            },
            "SubnetId"         : subnet_id
        }
    )

    request_ids  =[]
    request_body = response[response.keys()[0]]
    for request_id in request_body:
        request_ids.append( request_id['SpotInstanceRequestId'] )

    # Send Request IDs to SQS
    queue        = sqs.get_queue_by_name(QueueName = queue_name)
    que_response = queue.send_message( MessageBody = ",".join(request_ids))

    return request_ids

 

入札結果取得・通知:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import boto3

sqs         = boto3.resource('sqs')
queue_name  = "spot_que"
queue       = sqs.get_queue_by_name(QueueName = queue_name)

spot_client = boto3.client('ec2')
sns         = boto3.client('sns')

def lambda_handler(event, context):

    #Receive Spot Request IDs from SQS
    
    entries     = []
    messages    = queue.receive_messages()
    entries.append({
        "Id"            : messages[0].message_id,
        "ReceiptHandle" : messages[0].receipt_handle
    })
    receive_msg   = messages[0].body
    request_ids   = receive_msg.split(",")

    if len(entries) != 0:
        response  = queue.delete_messages(Entries = entries)

    spot_response = spot_client.describe_spot_instance_requests(
        SpotInstanceRequestIds = request_ids
    )

    instance_ids  = []
    err_message   = []
    spot_request  = spot_response[spot_response.keys()[0]]

    for result in spot_request:
        if 'InstanceId' in result:
            instance_ids.append( result['InstanceId'] )
        else:
            request_status = result['Status']
            err_message.append( {result['SpotInstanceRequestId']:request_status['Message']} )

    # Send Result to SNS
    
    if len(instance_ids) != 0:
        request = {
            'TopicArn' : "arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:Success",
            'Message'  : str(instance_ids),
            'Subject'  : "Spot request is fulfilled"
        }
        response = boto3.client('sns').publish(**request)

    if len(err_message) != 0:
        request = {
            'TopicArn' : "arn:aws:sns:ap-northeast-1:xxxxxxxxxxxx:Failed",
            'Message'  : str(err_message),
            'Subject'  : "Spot request is Not fulfilled"
        }
        response = boto3.client('sns').publish(**request)

実行結果

1つめのファンクションが実行されることでSpot Blockのリクエストが行われます。

リクエスト直後のマネジメントコンソールにはこのようにpendingのステータスで表示されます。

spot_request_01

起動に成功した場合

落札に成功し、インスタンスが起動するとステータスがfulfilledになります。

spot_request_02

この状態で2つめのファンクションが実行されることでSNSには起動したインスタンスのIDが通知されます。

['i-013c4f8104eb4268b', 'i-089789f89077a4ef7', 'i-0b132c4f3a324f142', 'i-0cd624f4ecc1414e0']

設定した起動希望時間を経過するとインスタンスはTerminateされリクエストはcancelledの状態となります。

terminate_instances

spot_request_04

起動に失敗した場合

入札価格が低く、インスタンスが起動できなかった場合、ステータスはprice-too-lowとなります。

spot_request_03

この状態で2つめのファンクションが実行されると、SNSにはリクエストIDと落札失敗のメッセージを通知します。

[
{'sir-02wpawaf': 'Your Spot request price of 0.01 is lower than the minimum required Spot request fulfillment price of 0.053.'}, 
{'sir-02wqankz': 'Your Spot request price of 0.01 is lower than the minimum required Spot request fulfillment price of 0.053.'}, 
{'sir-02wlpf4l': 'Your Spot request price of 0.01 is lower than the minimum required Spot request fulfillment price of 0.053.'}, 
{'sir-02wrs4ev': 'Your Spot request price of 0.01 is lower than the minimum required Spot request fulfillment price of 0.053.'}
]

設定した起動希望時間を経過するとリクエストはcancelledの状態となります。

spot_request_05

 

まとめ

2つのLambdaファンクションをcron起動に設定することで、毎日同じ時間に起動する、などが実現可能になります。

結果をSNSに通知しますので、価格高騰でインスタンスを起動できなかった場合に、後続処理としてオンデマンドインスタンスを起動するLambdaを用意しておきカバーすることも可能かと思います。