AWS 公式テンプレートを使って CloudFormation で実装する ELB の LCU 予約と自動スケジューリングをやってみた

AWS 公式テンプレートを使って CloudFormation で実装する ELB の LCU 予約と自動スケジューリングをやってみた

Clock Icon2025.03.21

はじめに

テクニカルサポートの 片方 です。
今回は AWS 公式 GitHub 記載、YAML 形式のテンプレートを利用して、ELB の LCU 予約と自動スケジューリングの実装 を CloudFormation でやってみました。
ELB は負荷に合わせてスケールする機能がありますが ELB のスケールにはある程度の時間がかかり リクエストが瞬間的に増えたときは ELB のスケールが間に合わないことがあり、その際 ELB は HTTP 503 を返します。
そのため従来の回避策として、あらかじめ TV やメディアによる露出など急激なアクセス増が予想される場合に ELB の Prewarm リクエスト(暖気申請)をサポートケースにて申請する必要がございました。

https://dev.classmethod.jp/articles/elastic-load-balancing-pre-warming

現在は、サポートケースへ暖気申請することなく、ロードバランサーキャパシティユニット(LCU)予約 を活用してトラフィック急増への対処が可能です。
一方で、Prewarm リクエスト(暖気申請)と異なり、期間指定でスケールさせることは叶いません。そのため期間を指定する必要がある場合 EventBridge Scheduler + Lambda 等を利⽤した構成が考えられますが、
本ブログで紹介する AWS 公式 YAML 形式のテンプレートを利用すると、ELB の LCU 予約と自動スケジューリングが実装可能です。(CloudFormation を利用して実装)

https://github.com/aws/elastic-load-balancing-tools/tree/master/scheduler-lcu-reservation

注意点

もし、引き上げていない場合は LCU 予約が叶わず、CloudTrail イベント履歴 ModifyCapacityReservation より以下のエラーが発生することが想定されます。

"errorCode": "CapacityUnitsLimitExceededException",
"errorMessage": "For load balancer type 'application`, CapacityUnits exceeds the maximum value of '0`.",

また、後述する EMAIL アドレスを記載している場合、SNS より以下の通知がされます。(一部マスクします)

{"status": "FAILURE", "message": "Failed to modify capacity reservation for ELB: arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:loadbalancer/app/ALB-scheduler-lcu-reservation/23bac998ff3d9c83. Error: An error occurred (CapacityUnitsLimitExceeded) when calling the ModifyCapacityReservation operation: For load balancer type 'application`, CapacityUnits exceeds the maximum value of '0`.", "elb_arn": "arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:loadbalancer/app/ALB-scheduler-lcu-reservation/23bac998ff3d9c83", "desired_lcu": 100}

※私は引き上げを忘れた為、発生しました

今回は検証のため、予約するロードバランサーキャパシティユニット(LCU)の量を "100" で行いました。

01

本番環境では、PeakLCUs (必要な最⼤容量を⽰すメトリクス) をご参考に、過去の ALB 使⽤状況を加味して予約するべき LCU 数を⾒積りください。

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/capacity-unit-reservation.html

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/load-balancer-cloudwatch-metrics.html

PeakLCUs
特定の時点でロードバランサーが使用するロードバランサーキャパシティユニット (LCU) の最大数。LCU 予約を使用する場合にのみ適用されます。

ReservedLCUs
LCUs 予約を使用してロードバランサー用に予約されたロードバランサーキャパシティユニット (LCU) の数。

capacity-reservation-scheduler.yml について

CloudFormation で展開するため、GitHub より YAML 形式の当該ファイルをダウンロードしてください。

capacity-reservation-scheduler.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: AWS CloudFormation Template for ELB Capacity Unit Reservation scheduling

Parameters:
  LoadBalancerArn:
    Type: String
    Description: The ARN of the ALB or NLB to reserve capacity
    AllowedPattern: '^arn:(aws|aws-cn|aws-us-gov):elasticloadbalancing:[a-z0-9-]+:\d{12}:loadbalancer/(app|net)/[a-zA-Z0-9-]+/[a-zA-Z0-9-]+$'
  ProvisionedCapacityScheduleStart:
    Type: String
    Default: "0 12 * * ? *"
    Description: The schedule in Cron format - https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-scheduled-rule-pattern.html
  ProvisionedCapacityScheduleStop:
    Type: String
    Default: "0 13 * * ? *"
    Description: The schedule in Cron format - https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-scheduled-rule-pattern.html
  StartDate:
    Type: String
    Description: The date, in UTC (yyyy-MM-ddTHH:mm:ss.SSSZ), before which the schedule will start (immediate if blank)
  EndDate:
    Type: String
    Description: The date, in UTC (yyyy-MM-ddTHH:mm:ss.SSSZ), before which the schedule will end (ignored if blank)
  CapacityReservation:
    Type: Number
    Default: 100
    MinValue: 100 # min NLB=5500 ALB=100
    MaxValue: 99999 # max NLB=15000 ALB=1500
    Description: Ammount of Load Balancer Capacity Units (LCU) to provision
  CapacityReset:
    Type: Number
    Default: 0
    MinValue: 0 # min NLB=5500 ALB=100
    MaxValue: 99999 # max NLB=15000 ALB=1500
    Description: Set capacity reservation to original value (or 0 to reset)
  TimeZone:
    Type: String
    Description: Time Zone - https://www.iana.org/time-zones
    Default: UTC
  ScheduleGroup:
    Type: String
    Description: Specify ScheduleGroup (if left blank, one will be created)
  Tag:
    Type: String
    Description: Tag to associate with resources. Thee ELB name will be used if left blank.
  NotificationEmail:
    Type: String
    Description: Email for notifications (via SNS)
    AllowedPattern: '^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$'

Conditions:
  CreateScheduleGroup: !Equals [!Ref ScheduleGroup, ""]
  HasNotificationEmail: !Not [!Equals ['', !Ref NotificationEmail]]
  HasTag: !Not [!Equals ['', !Ref Tag]]
  HasStartDate: !Not [!Equals ['', !Ref StartDate]]
  HasEndDate: !Not [!Equals ['', !Ref EndDate]]

Resources: 

  EventBridgeSchedulerExecRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 
        - "${AWS::StackName}-eventbridge-${LoadBalancerName}"
        - LoadBalancerName: !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - scheduler.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Description: Execution role for the EventBridge scheduler lambda target invocation.
      Policies:
      - PolicyName: InvokeSchedulerLambda
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
              - 'lambda:InvokeFunction'
              Resource:
              - !GetAtt LambdaSetProvisionCapacity.Arn
      Tags:
        - Key: LoadBalancerName
          Value: !Sub 
            - "${AWS::StackName}-${LoadBalancerName}"
            - LoadBalancerName: !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]

  ProvisionCapacityScheduleGroup:
    Condition: CreateScheduleGroup
    Type: AWS::Scheduler::ScheduleGroup
    Properties:
      Name: !Sub ${AWS::StackName}-ScheduleGroup

  ProvisionCapacityScheduleStart:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub ${AWS::StackName}-PCSchedulerStart
      Description: This schedule runs the Lambda function to start LCU Capacity Reservation
      StartDate: !If [HasStartDate, !Ref StartDate, !Ref "AWS::NoValue"]
      EndDate: !If [HasEndDate, !Ref EndDate, !Ref "AWS::NoValue"]
      FlexibleTimeWindow:
        Mode: "FLEXIBLE"
        MaximumWindowInMinutes: 1
      GroupName: !If [CreateScheduleGroup, !Ref ProvisionCapacityScheduleGroup, !Ref ScheduleGroup]
      ScheduleExpression: !Sub "cron(${ProvisionedCapacityScheduleStart})"
      ScheduleExpressionTimezone: !Ref TimeZone
      State: ENABLED
      Target:
        Arn: !GetAtt LambdaSetProvisionCapacity.Arn
        Input: !Sub '{"ELB": "${LoadBalancerArn}", "DesiredLCU": ${CapacityReservation}}'
        RoleArn: !GetAtt EventBridgeSchedulerExecRole.Arn
        RetryPolicy:
          MaximumEventAgeInSeconds: 60
          MaximumRetryAttempts: 10

  ProvisionCapacityScheduleStop:
    Type: AWS::Scheduler::Schedule
    Properties:
      Name: !Sub ${AWS::StackName}-PCSchedulerStop
      Description: This schedule runs the Lambda function to stop LCU Capacity Reservation
      StartDate: !If [HasStartDate, !Ref StartDate, !Ref "AWS::NoValue"]
      EndDate: !If [HasEndDate, !Ref EndDate, !Ref "AWS::NoValue"]
      FlexibleTimeWindow:
        Mode: "FLEXIBLE"
        MaximumWindowInMinutes: 1
      GroupName: !If [CreateScheduleGroup, !Ref ProvisionCapacityScheduleGroup, !Ref ScheduleGroup]
      ScheduleExpression: !Sub "cron(${ProvisionedCapacityScheduleStop})"
      ScheduleExpressionTimezone: !Ref TimeZone
      State: ENABLED
      Target:
        Arn: !GetAtt LambdaSetProvisionCapacity.Arn
        Input: !Sub '{"ELB": "${LoadBalancerArn}", "DesiredLCU":${CapacityReset}}'
        RoleArn: !GetAtt EventBridgeSchedulerExecRole.Arn
        RetryPolicy:
          MaximumEventAgeInSeconds: 60
          MaximumRetryAttempts: 10

  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub 
        - "${AWS::StackName}-lambdaexec-${LoadBalancerName}"
        - LoadBalancerName: !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
        - Effect: Allow
          Principal:
            Service: lambda.amazonaws.com
          Action: sts:AssumeRole
      Policies:
      - PolicyName: LambdaExecutionPolicy
        PolicyDocument:
          Version: '2012-10-17'
          Statement:
          - Effect: Allow
            Action:
            - logs:CreateLogGroup
            - logs:CreateLogStream
            - logs:PutLogEvents
            Resource: arn:aws:logs:*:*:*
          - Effect: Allow
            Action:
            - elasticloadbalancing:ModifyCapacityReservation
            Resource: !Ref LoadBalancerArn
          - Effect: Allow
            Action:
            - elasticloadbalancing:DescribeCapacityReservation
            Resource: '*' 
          - !If 
            - HasNotificationEmail
            - Effect: Allow
              Action:
                - sns:Publish
              Resource: !Ref NotificationTopic
            - !Ref AWS::NoValue

  NotificationTopic:
    Type: AWS::SNS::Topic
    Condition: HasNotificationEmail
    Properties:
      DisplayName: !Sub 
        - "${AWS::StackName}-${LoadBalancerName}"
        - LoadBalancerName: !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]
      TopicName: !Sub 
        - "${AWS::StackName}-${LoadBalancerName}"
        - LoadBalancerName: !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]
      Subscription:
        - Endpoint: !Ref NotificationEmail
          Protocol: email
      Tags:
        - Key: LoadBalancerName
          Value: !If [HasTag, !Ref Tag, !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]]

  LambdaSetProvisionCapacity:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub 
        - "${AWS::StackName}-${LoadBalancerName}"
        - LoadBalancerName: !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]
      Description: !Sub 
        - "Reserved Capacity function for ELB: ${LoadBalancerName}"
        - LoadBalancerName: !Select [5, !Split [":", !Ref LoadBalancerArn]]
      Code:
        ZipFile: |
          import os
          import json
          import logging
          import time

          logger = logging.getLogger()
          logger.setLevel(logging.INFO)

          # Temporary until Lambda runtime supports newer boto3 versions
          def _install_boto3():
              import sys
              os.system("mkdir -p /tmp/packages")
              logger.info("Installling boto3")
              os.system(
                  f"{sys.executable} -m pip install "
                  f"--no-cache-dir --target /tmp/packages "
                  f"--only-binary :all: --no-color "
                  f"--no-warn-script-location --upgrade boto3==1.35.68")
              sys.path.insert(0, "/tmp/packages")

          def lambda_handler(event, context):

              _install_boto3()
              import boto3
              logger.info(f"event: {event}")
              logger.info(f"boto3 version: {boto3.__version__}")

              # Extract parameters from the event
              elb_arn = event.get('ELB')
              desired_lcu = event.get('DesiredLCU')

              # Get SNS Topic ARN from environment variable 
              sns_topic_arn = os.environ.get('SNS_TOPIC_ARN')

              reset_capacity_reservation = True if desired_lcu == 0 else False

              elbv2_client = boto3.client('elbv2')
              sns_client = boto3.client('sns')

              try:
                  # Create base parameters
                  params = {
                      'LoadBalancerArn': elb_arn,
                      'ResetCapacityReservation': reset_capacity_reservation
                  }

                  # Add MinimumLoadBalancerCapacity only if desired_lcu is not 0
                  if desired_lcu != 0:
                      params['MinimumLoadBalancerCapacity'] = {
                          'CapacityUnits': desired_lcu
                      }

                  # Make the API call with the constructed parameters
                  response = elbv2_client.modify_capacity_reservation(**params)

                  # Wait for capacity reservation to be provisioned or failed
                  while True:
                      response = elbv2_client.describe_capacity_reservation(
                          LoadBalancerArn=elb_arn
                      )

                      logger.info(f"describe_capacity_reservation response: {response}")

                      if response['CapacityReservationState']:
                          status_code = response['CapacityReservationState'][0]['State']['Code']

                          if status_code in ['provisioned', 'failed']:
                              break

                      time.sleep(5)

                  if status_code == 'provisioned':
                      status = "SUCCESS"
                      message = f"Successfully modified and provisioned capacity reservation for ELB: {elb_arn}"
                  else:
                      status = "FAILURE" 
                      message = f"Failed to provision capacity reservation for ELB: {elb_arn}"

                  logger.info(f"Status: {status}, Message: {message}")

              except Exception as e:
                  status = "FAILURE"
                  message = f"Failed to modify capacity reservation for ELB: {elb_arn}. Error: {str(e)}"
                  logger.info(f"Status: {status}, Message: {message}")

              # Prepare SNS message
              sns_message = {
                  "status": status,
                  "message": message,
                  "elb_arn": elb_arn,
                  "desired_lcu": desired_lcu
              }

              # Send SNS notification if SNS_TOPIC_ARN is provided
              if sns_topic_arn:
                  try:
                      sns_client.publish(
                          TopicArn=sns_topic_arn,
                          Message=json.dumps(sns_message),
                          Subject=f"ELB Capacity Modification {status}"
                      )
                      logger.info(f"SNS notification sent: {json.dumps(sns_message)}")
                  except Exception as e:
                      logger.info(f"Failed to send SNS notification. Error: {str(e)}")
              else:
                  logger.info("SNS_TOPIC_ARN not provided. Skipping SNS notification.")

              return {
                  "statusCode": 200 if status == "SUCCESS" else 500,
                  "body": json.dumps({"status": status, "message": message})
              }
      Handler: "index.lambda_handler"
      Runtime: python3.13
      Timeout: 900
      Role: !GetAtt LambdaExecutionRole.Arn
      Environment:
        Variables:
          SNS_TOPIC_ARN: !If [HasNotificationEmail, !Ref NotificationTopic, !Ref "AWS::NoValue"]
      Tags:
        - Key: LoadBalancerName
          Value: !If [HasTag, !Ref Tag, !Select [2, !Split ["/", !Select [5, !Split [":", !Ref LoadBalancerArn]]]]]

パラメーター

02

CloudFormation でのスタック作成時には上からの順に、以下のパラメーターを指定します。

  • CapacityReservation
    予約するロードバランサーキャパシティユニット(LCU)の量を指定します。
    PeakLCUs メトリクス値などを参考に、適切な値を設定してください。

  • CapacityReset
    LCU 予約終了時に設定する値です。デフォルト: 0
    通常 0 で使用するケースが多いことが想定されます。

  • EndDate [オプション]
    スケジュール全体の終了日時(空白の場合は無期限)

  • LoadBalancerArn
    LCU 予約 ALB または NLB の ARN

  • NotificationEmail [オプション]
    Email アドレスを記載すると、こちらをエンドポイントにしたサブスクリプションと SNS トピックが自動で作成されます。

  • ProvisionedCapacityScheduleStart
    各日の LCU 予約開始時刻(cron式)

  • ProvisionedCapacityScheduleStop
    各日の LCU 予約停止時刻(cron式)

ProvisionedCapacitySchedule (Start/Stop) は eventbridge のスケジュール機能で制御します。cron 式の記述方法は以下をご参考ください。
https://docs.aws.amazon.com/ja_jp/eventbridge/latest/userguide/eb-scheduled-rule-pattern.html

  • ScheduleGroup [オプション]
    スケジュールグループがある場合は指定します。存在しない場合は空白のままにすることで、新しいスケジュールグループが作成されます。

  • StartDate [オプション]
    スケジュール全体の開始日時(空白の場合は即時開始)

  • Tag [オプション]
    リソースに関連付けるタグを指定します。空白の場合は ELB 名が使用されます。

  • TimeZone [オプション]
    タイムゾーンを記載。デフォルト: UTC

やってみた

※ CloudFormation で展開するため、GitHub より YAML 形式の当該ファイルをダウンロードしてください。

https://console.aws.amazon.com/cloudformation で AWS CloudFormation コンソール を開きます。
画面の上部のナビゲーションバーで、スタックを作成する AWS リージョンを選択します。
[スタック] ページでは、右上の [スタックの作成] を選択してから、[新しいリソースを使用 (標準)] を選択します。

03

既存のテンプレートを選択し、テンプレートの指定では、テンプレートファイルのアップロードを選択します。
その後、該当の YAML 形式のファイルを選択します。

04

前途で紹介した内容を参考に、パラメーターを指定し完了します。

05

次のページでは、特段なにも設定は行わず、下部へスクロールしてチェックボックスにチェックを入れます。

06

次のページで、パラメーター値などを確認して問題なければ [送信] を押します。

07

08

これで完了です!非常に簡単ですね。
もし、パラメーターの NotificationEmail 欄に記載をした場合は、サブスクリプションの確認をしてください。

確認してみた

対象 ELB、arn に指定した ALB を確認します。
以下の通り、指定した日時で 100 LCU が予約されており、2 AZ に対して 50 づつ予約されていました。

09

また、指定した終了日時を過ぎると、以下の通り自動で解除されていました。

10

CloudWatch メトリクスも確認したところ、想定通りに増加しているので成功しています。
12

13

11

なお、LCU 予約が成功した場合、SNS より "ELB Capacity Modification SUCCESS" というタイトルで以下の通知がされます。(一部マスクします)

{"status": "SUCCESS", "message": "Successfully modified and provisioned capacity reservation for ELB: arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:loadbalancer/app/ALB-scheduler-lcu-reservation/23bac998ff3d9c83", "elb_arn": "arn:aws:elasticloadbalancing:ap-northeast-1:xxxxxxxxxxxx:loadbalancer/app/ALB-scheduler-lcu-reservation/23bac998ff3d9c83", "desired_lcu": 100}

まとめ

非常に便利で簡単に実装できますね。本ブログが誰かの参考になれば幸いです。

参考資料

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社WEBサイトをご覧ください。

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.