AWS Service QuotasのAPIを利用してLambda関数の同時実行数の上限緩和申請を自動化してみた。

# はじめに

最近、`AWS Well-Architected Tool`を利用して、`AWS Well-Architecture Framework`からなるAWSのベストプラクティスに沿った設計および運用チェックを実施する機会があったのですが、その中で以下の設問がありました。

[REL 1: サービスクォータと制約はどのように管理しますか?](https://wa.aws.amazon.com/wellarchitected/2020-07-02T19-33-23/wat.question.REL_1.ja.html)

上記設問に対する管理方法としては、上限のあるAWSリソースに対して`CloudWatch Alarm`を利用して監視を行い、上限値に近づいたことを検知し、AWSサポートに上限緩和申請を出す方法を採用していました。
しかし、上記URLに記された改善計画に **「クォータ管理を自動化する」** 方法が紹介されており、私の管理方法が古いことに気づかされてしまいました。。。

そこで今回は、管理方法改善の第一歩として、クォータ管理の自動化設定を実践してみたいと思います。

# 本記事の実施内容
## 利用ツール
今回は以下のツールを利用してAWSの環境構築及び動作検証を行います。
ツールの説明は本記事では割愛いたしますが、[過去の記事](https://qiita.com/matsumikan/items/ca335c297f2711dce494)で触れておりますので、もしご興味のある方はそちらをご参照ください。
* `AWS CDK`
    * AWSリソースを構築するために利用
* `Locust`
    * Lambda関数の同時実行数を上げる（負荷をかける）ために利用

## 構成図
Lambda関数の同時実行数を一時的に増やすためのRestAPIを構築し、Lambda関数の同時実行数が一定数を超過した場合に`Service Quotas`経由で上限緩和リクエストを送信するようにAWSリソースを準備します。
![aws.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/91114b91-c47e-2ee8-380f-5edee6d5e9d1.png)

## 処理シーケンス
上記構成図の`ServiceQuotasStack`は以下のような処理を実装します。
今回は検証なので`CloudWatch Alarm`のアラート閾値は低め、かつLambda関数の処理はかなり簡素になっていますが、実運用する場合はそのあたりの検討する必要です。
```plantuml
"CloudWatch Alarm" -> "SNS" : Lambdaの同時実行数の使用率（※）が\n1分間最大で10%を超過でSNSに通知
activate "Lambda"
    "SNS" -> "Lambda" : CloudWatchのAlarmを起点に\nLambda関数を呼び出し
activate "Service Quotas"
    "Lambda" -> "Service Quotas" : 現在のLambda関数の同時実行数の上限値を取得
    "Lambda" <-- "Service Quotas"
deactivate "Service Quotas"
alt 
    "Lambda" -> "Service Quotas" :現在のLambda関数の同時実行数の上限値に\n+5した値で上限緩和リクエストを送信
end
```
（※）Lambdaの同時実行数の使用率…Lambda関数の同時実行数の上限値に対して、実際に使用した同時実行数を比較した際の割合を指します。

# 上限緩和自動化を検証
## 実施環境
以降に記した実施手順は、以下の動作環境で行っていきます。
```bash
$ cdk --version
2.65.0 (build 5862f7a)
$ aws --version
aws-cli/2.10.3 Python/3.9.11 Linux/5.10.16.3-microsoft-standard-WSL2 exe/x86_64.ubuntu.20 prompt/off
$ docker --version
Docker version 23.0.0, build e92dd87
$ docker-compose --version
docker-compose version 1.25.0, build unknown
$ uname -a
Linux MyPC 5.10.16.3-microsoft-standard-WSL2 #1 SMP Fri Apr 2 22:23:49 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux
```

## 実施手順
1. 上限緩和申請に必要な情報を事前に確認
`Service Quotas`の管理画面から以下のカテゴリの「サービスコード」と「クォーターコード」を確認しておきます。
    * AWSのサービス：`AWS Lambda`
    * サービスクォータ：`Concurrent executions`（同時実行数）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/81c58b53-4cb5-f74c-2861-387972d845a9.png)
上記情報は後ほど`Service Quotas`のAPIを利用する際に利用します。
1. RestAPI構築（スタック名：`RestApiStack`）
負荷試験ツール（`Locust`）からLambda関数を起動させるために、`Get`メソッドで単純なメッセージが返却される簡素なRestAPIを用意します。
    * ソースコード群
        ```python:rest_api_stack.py
        from aws_cdk import (
            Stack,
            Duration,
            aws_lambda as lambda_,
            aws_lambda_event_sources as lambda_event_sources,
        )
        from constructs import Construct
        
        function_timeout = 3
        function_memory_size = 128
        
        class RestApiStack(Stack):
        
            def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
                super().__init__(scope, construct_id, **kwargs)
        
                lambda_function = lambda_.Function(self, "Function",
                    code=lambda_.Code.from_asset("functions/get_hello_world"),
                    handler="app.lambda_handler",
                    runtime=lambda_.Runtime.PYTHON_3_8,
                    timeout=Duration.minutes(function_timeout),
                    memory_size=function_memory_size,
                )
                lambda_function.add_event_source(lambda_event_sources.ApiEventSource(
                        method="get",
                        path="/hello-world",
                    )
                )
        ```
        ```python:functions/get_hello_world/app.py
        import json
        
        def lambda_handler(event, context):
        
            return {
                "statusCode": 200,
                "body": json.dumps({
                    "message": "hello world from SAM and the CDK!",
                }),
            }
        ```
    * デプロイコマンド
        ```bash
        $ cdk deploy RestApiStack
        ...
        
        RestApiStack: creating CloudFormation changeset...
        
         ✅  RestApiStack
        
        ✨  Deployment time: 65.42s
        
        Outputs:
        RestApiStack.RestApiStackFunction50B5C7FCApiEventSourceA7A86A4FEndpointA8987CBD = https://{api_id}.execute-api.ap-northeast-1.amazonaws.com/prod/
        Stack ARN:
        arn:aws:cloudformation:ap-northeast-1:{account_id}:stack/RestApiStack/b36f4530-e641-11ed-8e98-0ebbca01d889
        
        ✨  Total time: 69.06s
        ```
1. 上限緩和自動化処理構築（スタック名：`ServiceQuotaStack`）
1.にて確認した「サービスコード」と「クォーターコード」を`FUNCTION_ENV_SERVICE_CODE`と`FUNCTION_ENV_QUOTA_CODE`に含めて環境をデプロイします。（本来はパラメーターファイルなどに外だしすべきですが、~~いつも通り~~今回は横着してソースに直書きします。）
具体的な処理内容は **「処理シーケンス」** の章ですでに説明していますが、ソースコード内コメントにも簡単に説明を記述しておりますので、気になる方はご確認ください。
    * ソースコード群
        ```python:service_quota_stack.py
        from aws_cdk import (
            Stack,
            Duration,
            aws_lambda as lambda_,
            aws_lambda_event_sources as lambda_event_sources,
            aws_cloudwatch as cloudwatch,
            aws_cloudwatch_actions as cloudwatch_actions,
            aws_sns as sns,
            aws_iam as iam,
        )
        from constructs import Construct
        
        # Lambda関数のリソース設定
        FUNCTION_TIMEOUT = 10
        FUNCTION_MEMORY_SIZE = 128
        
        # Lambda関数の環境変数
        FUNCTION_ENV_SERVICE_CODE = "lambda"
        FUNCTION_ENV_QUOTA_CODE = "L-B99A9384"
        FUNCTION_ENV_MAX_DESIRED_VALUE = "100"
        FUNCTION_ENV_INCREASE_VALUE = "5"
        
        # CloudWatch Alarmのアラート閾値
        ALART_THERESHLD_PERCENT = 10
        
        class ServiceQuotasStack(Stack):
        
            def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None:
                super().__init__(scope, construct_id, **kwargs)
        
                # CloudWatch Alarmに設定するSNSトピックを作成
                topic = sns.Topic(self, "Topic")
        
                # Service Quotasの申請を行うLambda関数作成
                lambda_function = lambda_.Function(self, "Function",
                    code=lambda_.Code.from_asset("functions/request_quota_increase"),
                    handler="app.lambda_handler",
                    runtime=lambda_.Runtime.PYTHON_3_8,
                    timeout=Duration.minutes(FUNCTION_TIMEOUT),
                    memory_size=FUNCTION_MEMORY_SIZE,
                    environment={
                        "SERVICE_CODE": FUNCTION_ENV_SERVICE_CODE,
                        "QUOTA_CODE": FUNCTION_ENV_QUOTA_CODE,
                        "MAX_DESIRED_VALUE": FUNCTION_ENV_MAX_DESIRED_VALUE,
                        "INCREASE_VALUE": FUNCTION_ENV_INCREASE_VALUE,
                    }
                )
                
                # Lambda関数にService Quotasの操作権限を付与
                lambda_function.add_to_role_policy(iam.PolicyStatement(
                    actions=[
                        "servicequotas:GetServiceQuota", 
                        "servicequotas:RequestServiceQuotaIncrease",
                    ],
                    resources=["*"])
                )
                
                # LambdaのトリガーにSNSトピックを設定
                lambda_function.add_event_source(lambda_event_sources.SnsEventSource(topic))
        
                # Lambda関数の同時実行数の使用率をCloudWatch Metricの数式で作成
                concurrent_executions_rate = cloudwatch.MathExpression(
                    expression="(usage_data/SERVICE_QUOTA(usage_data))*100",
                    using_metrics={
                        "usage_data": cloudwatch.Metric(
                            namespace="AWS/Lambda",
                            metric_name="ConcurrentExecutions",
                            period=Duration.minutes(1),
                            statistic="Maximum",
                        )
                    },
                    period=Duration.minutes(1),
                    label="% 使用率",
                )
                
                # 使用率が閾値を超過した場合、SNSトピックが起動するCloudWatch Alarmを作成
                alarm = concurrent_executions_rate.create_alarm(self, "Alarm",
                    comparison_operator=cloudwatch.ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
                    threshold=ALART_THERESHLD_PERCENT,
                    evaluation_periods=1,
                )
                alarm.add_alarm_action(cloudwatch_actions.SnsAction(topic))
        ```
        ```python:functions/request_quota_increase/app.py
        import os
        import boto3
        
        SERVICE_CODE = os.environ.get("SERVICE_CODE")
        QUOTA_CODE = os.environ.get("QUOTA_CODE")
        MAX_DESIRED_VALUE = os.environ.get("MAX_DESIRED_VALUE")
        INCREASE_VALUE = os.environ.get("INCREASE_VALUE")
        
        client = boto3.client('service-quotas')
        
        def lambda_handler(event, context):
            
            try:
                # 監視対象のサービスクォータの情報を取得
                service_quota_info = client.get_service_quota( 
                    ServiceCode=SERVICE_CODE,
                    QuotaCode=QUOTA_CODE
                )
                
                # 取得したサービスクォータの現在の値が自身が指定した最大値未満の場合、上限緩和申請を実施する
                service_quota_value = service_quota_info["Quota"]["Value"]
                if service_quota_value < int(MAX_DESIRED_VALUE):
                    client.request_service_quota_increase(
                        ServiceCode=SERVICE_CODE,
                        QuotaCode=QUOTA_CODE,
                        DesiredValue=service_quota_value + int(INCREASE_VALUE)
                    )
                    print('Requested Service QuotaIncrease.')
        
                else:
                    print('No requested.')
        
            except Exception as err:
                print('No requested.')
                print(err)
        
        ```
    * デプロイコマンド
        ```bash
        $ cdk deploy ServiceQuotaStack
        ...
        ServiceQuotaStack: creating CloudFormation changeset...
        
         ✅  ServiceQuotaStack
        
        ✨  Deployment time: 24.26s
        
        Stack ARN:
        arn:aws:cloudformation:ap-northeast-1:{account_id}:stack/ServiceQuotaStack/6e6cfc20-e2a9-11ed-9952-0a7b9b30f08f
        
        ✨  Total time: 27.98s
        ```
1. `Locust`から2.で構築したRestAPIに対して負荷をかける
負荷の内容は特に凝ったことはせず、純粋にGUIでしていたRestAPIのHOSTに`Get`メソッドで`10`秒おきにアクセスするだけのテストコード（`locustfile.py`）を用意しています。その他のソースコードおよび起動コマンドは過去に利用したものを流用しているだけですので、特に深い意味はありません。
    * ソースコード群
        ```python:locustfile.py
        from locust import HttpUser, task, constant
        
        class WebAccessUser(HttpUser):
            wait_time = constant(10)
        
            @task
            def web_access_success(self):
                self.client.get("")
        ```
        ```Dockerfile:Dockerfile
        FROM locustio/locust
        WORKDIR /mnt/locust
        COPY *.py /mnt/locust/
        ```
        ```yaml:docker-compose.yml
        version: '3'
        
        services:
          master:
            build: ./
            ports:
             - "8089:8089"
            command: -f /mnt/locust/locustfile.py --master -P 8089
        
          worker:
            build: ./
            command: -f /mnt/locust/locustfile.py --worker --master-host master
        ```
    * 起動コマンド
        ```bash
        $ docker build
        $ docker-compose up --scale worker=2
        ```
    * GUIにて負荷開始！（スクショのみ）
       ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/24d8f628-6185-c81e-e2af-40865ca6ee8a.png)
       ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/b946f0ec-40f3-f9a0-4d48-b6655f681a2e.png)
1. マネージメントコンソールから想定通り動いているか確認
まずは`Service Quotas`の画面を確認。
4.で実施したタイミングで使用率が10%を超えている、かつクォータ増加リクエストが想定通りの値（現在の上限値`50`+`5`=`55`）で申請されていることが確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/7f4dd87c-199d-1cd4-7c37-addb6f5286d7.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/646cf779-2adf-c066-4097-fe12014b85a5.png)
念のため`CloudWatch Alarm`の画面も確認すると、`Service Quitas`のメトリクスと同時刻（2023-04-29 04:30ごろ）に`SNS`へのアクションが実行されたことも確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/4049a4d4-3aa9-2967-ba94-63fd1bc9fb03.png)
5. さらに時間経過で`Service Quotas`の画面を確認
記事をまとめている間（3時間程度経過後？）にもう一度`Service Quotas`の画面を確認したところ、適用されたクォータ値が`50`から`55`に更新されていることも確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/d08b42aa-2c0e-9fcf-1a48-9270ca5b98ad.png)
AWSサポート画面でも「対応したよ」というメッセージが確認できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/d2e2f1c9-6bc8-bf81-dd2a-ffd4366eb42e.png)
これで検証は終了です。
ちなみに`Service Quotas`を確認した際には、リクエストから3時間程度で対応いただいた（`Service Quotas`の画面上は変更されていた）のですが、サポートの返信はそれ以上の時間がかかっているようでした。
もしかすると上限緩和処理システムで自動化されていて、サポートの返信は人がやっている…などのことがあるのかなと思いました。

# 補足
## `CloudWatch Alarm`の数式で利用した`SERVICE_QUOTA`関数について
実は、スタック名`ServiceQuotaStack`の中で定義した`CloudWatch Alarm`の数式は以下のように`SERVICE_QUOTA`関数を使用して使用率を算出していました。
* 数式：`usage_data/SERVICE_QUOTA(usage_data))*100`
* メトリクス
    * `usage_data`
        * 統計: 最大
        * 名前空間: `AWS/Lambda`
        * 期間: 1分
        * メトリクス名: `ConcurrentExecutions`

`SERVICE_QUOTA`関数は[AWSドキュメント](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/using-metric-math.html)によると以下の説明が書かれており、特定の使用状況メトリクスを利用して、サービスクォータの使用率を監視＋通知する`CloudWatch Alarm`を作成することが可能となります。

> 特定の使用状況メトリクスのサービスクォータを返します。これを使用して、現在の使用状況とクォータの比較を可視化し、クォータに近づいたときに警告するアラームを設定できます。詳細については、「AWS 使用状況メトリクス」を参照してください。

ちなみに利用可能な使用状況メトリクスが[こちら](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-Quotas-Visualize-Alarms.html)に書かれているのですが、2023年04月時点では`AWS Lambda`が含まれていない模様です。（英語版のドキュメントも書かれていないです）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/865dd828-1839-c38c-2b97-26c70d2f137e.png)
ドキュメントの改訂が間に合っていないだけだと思いますが、もしここに書かれていないAWSサービスを監視したい場合は、`Service Quotas`の各サービスクォータの詳細画面で使用率が利用できるか確認できそうなので、そちらを確認してみるのも良いと思います。
以下スクショは利用できない例ですが、Lambda関数の同時実行数はここにメトリクス値が表示されていました。（実施手順内のスクショ参照）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/3203e13d-022a-e40d-2d62-b6ef018ff021.png)

## 同じサービスクォータの申請が複数できないよう制御されている
自動化で懸念される一つとして、「`CloudWatch Alarm`のアクションが大量に発生してしまった場合、AWSにも大量の上限緩和リクエストを送信してしまうのではないか」というものだと思っています。
そこで念のため、上限緩和リクエストを行った状態で、もう一度`CloudWatch Alarm`が起動するように`Locust`を動かして動作検証を行ったところ、Lambda関数で以下のエラーが発生しました。
```bash
An error occurred (ResourceAlreadyExistsException) when calling the RequestServiceQuotaIncrease operation: Only one open service quota increase request is allowed per quota.
```
`Service Quotas`のAPI側で多重リクエストできないような制御が含まれているようなので、無限リクエストは発生しない仕様になっていました。
仕様的にセーブされてはいるものの、`CloudWatch Alarm`が大量に発生してしまうのはそもそも考慮不足ではありますので、万が一そのようなことが発生した場合には放置せずに閾値を見直しましょう。
ちなみに、`Service Quotas`の画面から`CloudWatch Alarm`を作成することができるのですが、その際には使用率を`50%`以上を指定するように求められ、作成された`CloudWatch Alarm`の「アラームを実行するデータポイント」は`10/10`が指定されるようでした。
おそらくその数値はAWS側が「これぐらいの閾値がよさそう」という暗黙の提案をされているのでは、という気がしますので、もし閾値に迷われる場合はこちらの値を参考にされるとよいかもしれません。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2601200/4e94f8df-2381-9f45-3d52-7117c2fab1dd.png)

# さいごに

今回は`AWS Well-Architected Tool`を契機に、上限緩和申請の自動化を試みました。
今回の検証で、サービスクォータの使用率を`CloudWatch`で知ることや、`Service Quotas`のAPIで上限緩和申請が手軽に行えることなどが知れて良かったと思うと同時に、そのあたりのアップデート情報のキャッチアップが漏れていたことに少しショックを受けました。。。
AWSサービスの進化は目まぐるしいのでキャッチアップが漏れてしまうのは仕方ないかもしれませんが、そういった最新情報を拾うきっかけとして、定期的な設計・運用の見直しは行うべきなのだなと感じた次第です。

こちらからは以上です。