GameWith Developer Blog

GameWith のエンジニア、デザイナーが技術について日々発信していきます。

Spot Instance の価格を CloudWatch で記録する

はじめまして。GameWith のエンジニアの @serima です。 普段は GameWith というゲームメディアの機能開発やインフラに目を向けるお仕事をしています。

GameWith では EC2 インスタンスのスケールアウトを Auto Scaling を使用せずに独自のスクリプトでインスタンス数をコントロールしています。*1

それなりの数のインスタンス数を起動しようとしたときに、その Availability Zone におけるそのインスタンスタイプが枯渇しているという状態が一時的に存在し、目的のインスタンスが起動できなかった事がありました。*2

具体的には、以下のようなエラーメッセージが返されました。

InsufficientInstanceCapacity: We currently do not have sufficient c4.xlarge capacity in the Availability Zone you requested (ap-northeast-1a). Our system will be working on provisioning additional capacity. You can currently get c4.xlarge capacity by not specifying an Availability Zone in your request or choosing ap-northeast-1c. status code: 500, request id:

高負荷が予測されるタイミングでインスタンスを想定通り起動できていない状態になってしまうと、サービス運営に影響があるため、回避したい問題となります。

その Availability Zone における残りのインスタンス数などを取得する API などは用意されていないため、いかにその状態を予見するかという解決策を探りました。 何か手がかりになるものはないかとメトリクスを見ていると、枯渇していたタイミングの前には Spot Instance の価格が高騰しているという状態が観測できました。

EC2 のコンソールから、スポットリクエスト -> 価格設定履歴から以下のようなグラフを参照することができます。

これを監視し、高騰したタイミングでアラート通知できれば、インスタンス起動のタイミングを早くするなど別のオプションを選ぶことができるようになります。 GameWith では CloudWatch のアラームを PagerDuty に飛ばすような運用をしていますので、そのフローにのせられるとスムースです。

しかし、SpotInstance の価格はそのままでは CloudWatch のメトリクスとして設定できません。 そこで、AWS Lambda を利用して、CloudWatch にカスタムメトリクスとして 1 分おきに値を送信するようにしました。

具体的なスクリプトは以下です。(なお、ランタイムは Python 2.7 を使用しています。)

import boto3
import time
import os
from datetime import datetime, timedelta

region = os.getenv('AWS_REGION', 'ap-northeast-1')

ec2 = boto3.client('ec2', region_name=region)
cloudwatch = boto3.client('cloudwatch', region_name=region)

aws_availability_zone = 'ap-northeast-1a'

def put_cloudwatch(namespace, metric_name, instance_type, timestamp, value):
    metric_data = {
        'MetricName': metric_name,
        'Timestamp': timestamp,
        'Value': value,
        'Unit': 'None',
        'Dimensions': [
            {
                'Name': 'InstanceType',
                'Value': instance_type
            }
        ]
    }
    r = cloudwatch.put_metric_data(
        Namespace = namespace,
        MetricData = [metric_data]
    )
    return r

def lambda_handler(event, context):
    ec2_instance_types = [
        'c4.large',
        'c4.xlarge'
    ]

    metric_name = 'SpotInstancePrice'

    filters = [
        {'Name': 'availability-zone', 'Values': [aws_availability_zone]},
        {'Name': 'instance-type', 'Values': ec2_instance_types},
        {'Name': 'product-description', 'Values': ['Linux/UNIX']},
    ]

    now = datetime.now()
    one_min_ago = now - timedelta(minutes=1)

    spot_price_history = ec2.describe_spot_price_history(Filters=filters, StartTime=one_min_ago).get('SpotPriceHistory')

    for history in spot_price_history:
        instance_type = history.get('InstanceType')
        spot_price = float(history.get('SpotPrice'))
        put_cloudwatch('EC2', metric_name, instance_type, now, spot_price)

    return str(spot_price_history)

Lambda は、現在では定期実行できるようになっており、指定した時間間隔ごとにイベントを発火させることができます。 少し分かりづらいのですが、定期実行を設定する場合は、Lambda のコンソールではなく、CloudWatch のコンソールに移動し、サイドバーの「ルール」から設定することになります。

ちなみに、このスクリプトを処理するロールを新規で作成し、以下のようなポリシーをアタッチしました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "cloudwatch:PutMetricData"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "logs:CreateLogGroup",
                "logs:CreateLogStream",
                "logs:PutLogEvents"
            ],
            "Resource": [
                "*"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ec2:DescribeSpotPriceHistory"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

カスタムメトリクスとして送信された値を元に、無事に以下のようなグラフを作成することができました。 今回は、On-Demand Price を一定時間超えていたらアラーム通知を行うように設定しました。

このような小ネタも引き続き、こちらのブログで公開していきたいと思います。

参考

*1:語ると長くなってしまうので、こちらについては後日別記事で書きたいと思います

*2:レイテンシの関係上、Single AZ で運用しています