ForgeVision Engineer Blog

フォージビジョン エンジニア ブログ

サステナブルアーキテクチャ - クォータが近づいたらアラート通知する!

こんにちは、AWS チームの尾谷です。

AWS はサービスごとに利用制限 (サービスクォータ) が設定されています。

先日、僕はこのサービスクォータを早期に気づくための仕組みづくりを考える機会をいただきました。

とあるサービスのインフラ仕様

「あるサービス」は、お客様 (会社単位) の契約が増えるごとに、AWS CloudFormation のスタックが実行されて、ひとつの AWS アカウント内に以下リソースが自動追加されます。

  • DynamoDB テーブルを 4 つ作成
  • IAM ロールを 3 つ作成
  • Lambda 関数を 1 つ追加

そのサービスは、追加処理が失敗したときにアラートを通知しますし、AWS サポートに上限緩和を申請すれば限界 (ハードリミット) までスケールもできます。
万が一、上限緩和の限界値である「ハードリミット」に当たった場合でも、AWS アカウント自体を増やしてスケールアウトすれば永遠に拡張できるアーキテクチャです。

「これってサステナブル!」

事前の準備で慌てたくないので利用制限に近づいた際にアラート通知を行いたいという要望があり、実装に向けた調査を始めました。

それぞれのサービスクォータ

まずそれぞれのサービスのサービスクォータを調べました。

Amazon DynamoDB のテーブル

DynamoDB のテーブルは、昨年 5/9 に発表があった通り、クォータが 256 から 2,500 に引き上げられているため、2,000 を超えたあたりで一発アラート通知をするのが良いと考えました。

aws.amazon.com

AWS IAM の IAM ロール

IAM ロールは、ソフトリミットが 1,000 で、上限緩和申請すると 5,000 まで引き上げることができると分かりました。 残り 100 を切ったら、申請の準備をしたいと思います。
一旦は、900 で通知をするようにしたいと思います。

docs.aws.amazon.com

AWS Lambda の関数

Lambda は関数を保存する領域同時実行数や、最大メモリなど、実行時の制限がありますが、Lambda 関数自体の作成限界数は見つけることができませんでした。
もしかして、いくつでも作れるのか。

docs.aws.amazon.com

AWS サポートに問い合わせしてみたところ、Lambda 関数の作成数に制限はないが、以下ドキュメントに記載されている通り、アップロードされた関数 (.zip ファイルアーカイブ) とレイヤーのストレージに対してサービスクォータがあるため限界は存在すると教えていただきました。
Lambda 関数をコーディングしているうちは大した容量にならないと思いますが、Lambda レイヤーにライブラリを追加していき、アージョンを繰り返し上げていくとストレージ領域がひっ迫する可能性があります。

今回の「ある環境」は、既に、2.5 GB も消費していました。

上図のダッシュボードにて確認ができるコードのストレージ容量は、GUI からは確認できますが CLI や、CloudWatch メトリクスで提供がされていないため、各関数からそれぞれの容量を取得して合計しないといけないそうです。

これは面倒臭い!標準の機能が提供されるのを渇望します。

通知の仕組み

事前調査が終わったので、次に取得した情報を参照しながらそれぞれのサービスごとに通知方法を考察していきました。

DynamoDB テーブル

Dynamo DB テーブルには、AWS にて通知する機能が用意されていました。

docs.aws.amazon.com

CloudWatch メトリクスのコンソールを開き、すべて > 使用 > AWS リソースと進むと、DynamoDB TableCount がありました。

CloudWatch アラームで、期間を 1 日、統計を最大にしてメトリクスを以下のように設定し、

2,000 を超えたらアラームが通知されるように設定しました。

IAM ロール

ドキュメントを確認しましたが、IAM と Lambda 関数は AWS にて用意された通知設定がありませんでした。

先に設定した DynamoDB テーブルの通知は、契約顧客数が 500 を超えたときに発動します。そのとき、IAM ロール数は 1,500 強になっているはずです。

ということは、先んじて IAM の上限緩和申請を行っておけば通知設定は DynamoDB だけでも良いのでは?とも思いましたが、一旦 AWS Lambda を用いた IAM のしきい値超過通知方法も考えておきました。

IAM ロールの数をカウントして CloudWatch メトリックに値を出力する Lambda です。

import boto3

iam = boto3.client('iam')
cloudwatch = boto3.client('cloudwatch')
int_max_items = 100

def lambda_handler(event, context):

    roles_count = 0
    response = fn_max_items()
    roles_count = len(response["Roles"])
 
    while 'IsTruncated' in response.keys():
        response = fn_max_items(response["Marker"])
        roles_count = roles_count + len(response["Roles"])

    response = cloudwatch.put_metric_data(
        Namespace='AWS-Usage',
        MetricData=[
            {
                'MetricName': 'IamRolesCount',
                'Value': float(roles_count),
                'Unit': 'Count'
            },
        ]
    )

def fn_max_items(marker = ""):
    if marker:
        response = iam.list_roles(
            MaxItems = int_max_items,
            Maker = marker
            )
    else:
        response = iam.list_roles(
            MaxItems = int_max_items
            )
    return response

ロールは以下のように設定しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "IamRolesCountAndPut",
            "Effect": "Allow",
            "Action": [
                "cloudwatch:PutMetricData",
                "iam:ListRoles"
            ],
            "Resource": "*"
        }
    ]
}

IAM の List Roles は boto3 ドキュメント を参考にしました。
CloudWatch の Put Metric も boto3 ドキュメント を参考にしました。

IAM の boto3 はレスポンスが戻るまで時間がかかるため、Lambda の実行時間を 10 秒に延ばしました。
また一度に 100 件しか取得できないため、IsTruncated を使いました。

以下は、個人アカウントで動かした結果です。
検証環境は Amplify などを動かして削除していないロールが多くあり、以下のような形で出力されました。

あるアカウントに実装する際には、EventBridge スケジューラで 1 日に 1 回動かして、900 を超えたときにアラームを通知するのが良さそうです。

Lambda 関数

前述の通り、関数の数ではなくコードのストレージを監視する方法を考えました。
※ Lambda レイヤーで存在しないバージョンを取得した際にエラーになるので、try ... exception を追加しました。

import boto3
from botocore.exceptions import ClientError

lambda_client = boto3.client('lambda')
cloudwatch = boto3.client('cloudwatch')
int_maxitems = 50

def lambda_handler(event, context):

    # #################### Lambda Functions
    
    f_total_size = 0
    
    response = fn_list_functions(int_maxitems)
    f_total_size = fn_count_function_size(response)
    
    while 'NextMarker' in response.keys():
        response = fn_list_functions(int_maxitems, response["NextMarker"])
        f_total_size = f_total_size + fn_count_function_size(response)

    # #################### Lambda Layer
    
    response = fn_list_function_layers(int_maxitems)
    f_total_size = f_total_size + fn_count_function_layer_size(response)

    while 'NextMarker' in response.keys():
        response = fn_list_function_layers(int_maxitems, response["NextMarker"])
        f_total_size = f_total_size + fn_count_function_layer_size(response)

    response = cloudwatch.put_metric_data(
        Namespace='AWS-Usage',
        MetricData=[
            {
                'MetricName': 'LambdaColdStorageSize',
                'Value': float(f_total_size),
                'Unit': 'Count'
            },
        ]
    )

def fn_list_functions(int_maxitems, NextMaker = ""):
    if NextMaker:
        response = lambda_client.list_functions(
            FunctionVersion='ALL',
            MaxItems = int_maxitems,
            Marker = NextMaker
        )
    else:
        response = lambda_client.list_functions(
            FunctionVersion='ALL',
            MaxItems = int_maxitems
        )
    return response

def fn_count_function_size(response):
    fn_total_size = 0
    for f in response['Functions']:
        fn_versions = lambda_client.list_versions_by_function(
            FunctionName=f['FunctionName']
        )
        for v in fn_versions['Versions']:
            fn_total_size = fn_total_size + v['CodeSize']
    return fn_total_size

def fn_list_function_layers(int_maxitems, NextMaker = ""):
    if NextMaker:
        response = lambda_client.list_layers(
            MaxItems = int_maxitems,
            Marker = NextMaker
        )
    else:
        response = lambda_client.list_layers(
            MaxItems = int_maxitems
        )
    return response

def fn_count_function_layer_size(response):
    fn_total_size = 0
    for f in response['Layers']:
        version_number = int(f['LatestMatchingVersion']['Version'])
        for num in range(1, version_number + 1):
            try:
                fn_layers_version = lambda_client.get_layer_version(
                    LayerName = f['LayerName'],
                    VersionNumber = num
                )
                fn_total_size = fn_total_size + fn_layers_version['Content']['CodeSize']
            except ClientError as e:
                if e.response['Error']['Code'] == 'ResourceNotFoundException':
                    print("Layer Not Found")
                else:
                    print("Another Error")
    return fn_total_size

検証環境で実行したところ、CloudWatch メトリクスには、LambdaColdStorageSize が 42,900,718 として記録されました。

ダッシュボードを確認する限り、若干の誤差がありますが、

MiB と MB の差によるものでした。

バージョニングされた関数と Lambda レイヤーのサイズをそれぞれカウントする必要があり、苦戦しました。
もしかしたら、全てをカウントできていないかも知れません。

おまけ

こちらはおまけです。
Lambda 関数の数をカウントする Lambda 関数を作成してみましたが、関数の作成数に制限はないということで、以下コードは不要になりました。
一応、供養としてアップさせてください。

IAM ロールのパジネーションと違い、次のページがないと NextMaker が出力されないので、少しハマりました。

import boto3

iam = boto3.client('iam')
cloudwatch = boto3.client('cloudwatch')
int_max_items = 100

def lambda_handler(event, context):

    roles_count = 0
    response = fn_max_items()
    roles_count = len(response["Roles"])

    while response["IsTruncated"]:
        response = fn_max_items(response["Marker"])
        roles_count = roles_count + len(response["Roles"])

    response = cloudwatch.put_metric_data(
        Namespace='AWS-Usage',
        MetricData=[
            {
                'MetricName': 'IamRolesCount',
                'Value': float(roles_count),
                'Unit': 'Count'
            },
        ]
    )

def fn_max_items(marker = ""):
    if marker:
        response = iam.list_roles(
            MaxItems = int_max_items,
            Marker = marker
            )
    else:
        response = iam.list_roles(
            MaxItems = int_max_items
            )
    return response

ロールは以下のように設定しました。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "LambdaFunctionsCountAndPut",
            "Effect": "Allow",
            "Action": [
                "lambda:ListFunctions",
                "cloudwatch:PutMetricData"
            ],
            "Resource": "*"
        }
    ]
}

こんな感じで出力されました。

運用してみて不具合等を見つけましたら、記事を更新したいと思います。
特に、Lambda の実行時間が 10 秒だとリソースが増えた際にタイムアウトするようになるかも知れません。
あくまで補助的な機能ですが、Lambda 自体の実行エラーにも通知をつけようと思います。

最後までお読みくださりありがとうございました。