AWS ALBでの流量制限を実装してみました

#はじめに
キャンペーン等でWebサーバに対して想定数以上のアクセスが発生した場合に、Webサーバが高負荷となりレスポンスが悪化することがあると思います。そのような場合には、負荷を低減するために、一定数以上のアクセスを抑止する方法が考えられます。(他には、"Webサーバの前段に高機能のロードバランサを導入しアクセスを抑止"したり、"Webサーバをスケールアウトする"方法もあります)
今回、AWS ALB(Application Load Balancer)及びAWSマネージメントサービスのみでこれを実装してみました。
なお、調査した限り、実際には"一定数以上のアクセスを抑止する"といったことはできませんでした(AWS SA様へも確認済)。代わりの方法として、特定パスに対するアクセスを一時的に抑止することで、Webサイト全体のレスポンス悪化を回避する方法を採用しています。

#環境
構成と抑止の挙動は以下の通りです。
####前提
- (キャンペーン等で)アクセスの増加が見込まれるコンテンツが事前に把握できている。今回の対象コンテンツを「campaign.html」とする。
- 抑止をトリガするためのメトリクスをALB「NewConnectionCount※」とする<br>
※NewConnectionCount：クライアントからロードバランサーへ、およびロードバランサーからターゲットへの、新たに確立された TCP 接続の総数。

####構成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/25cd6741-2d98-5266-2a0a-4a54fafe5e0c.png)

#構築

- VPC、ALB、Webサーバは既に構築済みとします。<br>
※これらを含む、環境構築用CloudFormationテンプレートは最後の補足を参照してください。

#### 動作確認
環境構築用CloudFormationを設定後、以下URLへアクセスし、トップページ及びキャンペーンページ・非キャンペーンページが表示することを確認します。

- トップページ：http://{ALB Public DNS}/index.html
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/90e2f9d0-85bb-ae7b-cf74-4631b704e523.png)

- キャンペーンページ：トップページからリンク
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/a5408880-eaaa-58a3-cf99-4b8afc64f271.png)

- 非キャンペーンページ：トップページからリンク
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/40ff9e84-9ef2-0fe1-197d-da5abc2ab770.png)

#### 「campaign.html」向けALBリスナールールの作成
後述のLambda Functionから制御するため、事前にリスナールールを作成します。ルール内容は、「転送」とします。

- AWSマネジメントコンソールから
[EC2]-[ロードバランサ]-[{今回作成したALB}]-[リスナー]-[デフォルト:転送先 test-siteルールの表示/編集]の順にクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/e13be5f5-6350-bfe6-46e3-d3c1e8813897.png)

- [+]クリック、[ルールの挿入]をクリック後、以下のようにルールを設定し、[保存]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/1233336b-5057-4dcd-c063-05cac8fa2c51.png)
設定後の状態
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/287f7008-ff51-9157-a5f0-0bd07873883d.png)
- 合わせて、今回作成したルールのARNをコピーし控える。※Lambda Function作成時に使用するため
また、今回作成したTarget GroupのARNもコピーし控える。※Lambda Function作成時に使用するため

#### IAM Roleの作成
Lambda Function用のIAM Roleを作成します。また、ALBリスナールールを修正するために必要な権限を付与します。

- AWSマネジメントコンソールから
[IAM]-[アクセス管理]-[ロール]の順にクリック
[ロールの作成]をクリック後、[ロールの作成]では[AWSサービス]、[Lambda]を選択し、[次のステップ]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/1599caef-9c7c-3844-df73-4fa53ff0f56a.png)
- [ElasticLoadBalancingFullAccess]を選択し、[次のステップ]をクリック<br>
※検証のため緩い権限としています。本番導入時にはベストプラクティスに沿って最小権限としましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/45ddc2d5-d82a-ecea-ea9c-d2471007e0ba.png)
- [タグの追加]は何もせず、[次のステップ]をクリック
ロール名は(任意名)"TestALBRateLimitIAMRole"を入力し、[ロールの作成]をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/18d4ac81-ef24-d7df-ea8f-1d40157edb21.png)

#### Lambda Functionの作成
CloudWatch Alarmの状態に応じて上記リスナールールを修正するLambda Functionを2つ作成します。
##### 1つ目(Alarmが発火(ALARM))　※リスナールールを"固定レスポンスを返す(503)"へ修正
- AWSマネジメントコンソールから
[Lambda]-[関数]-[関数の作成]の順にクリック
「関数の作成」では以下の流れで設定し、[関数の作成]をクリック

 - 関数名：(任意)TestALBRateLimitALARMFunction
 - ランタイム：Python 3.6
 - アクセス権限：[既存のロールを使用する]-[TestALBRateLimitIAMRole]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/18e9b512-e773-d182-25bc-33c73d0be42c.png)

- [コード]-[コードソース]から[lambda_function.py]をダブルクリックし、中身を以下内容で上書き後、[Deploy]をクリック
"RuleArn"は上記で控えたALBリスナールールのARNを指定
※AWS SDK for Python (Boto3)を使用
参考:https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/elbv2.html#ElasticLoadBalancingv2.Client.modify_rule

````lambda_function.py
import boto3

def lambda_handler(event, context):
    client = boto3.client('elbv2')
    response = client.modify_rule(
    RuleArn='arn:aws:elasticloadbalancing:us-east-1:{accont id}:listener-rule/{........}',
    Actions=[
        {
            'Type': 'fixed-response',
            'FixedResponseConfig': {
                'StatusCode': '503'
            }
        }
    ]
)
````
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/80104a29-4f5b-17aa-9b21-461c61433be4.png)


- [Test]を実行し、対象のALBリスナールールが修正されることを確認<br>
※テストの設定内容については何でも良いため省略
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/b438180a-ee8d-d38e-5906-173758bfbf96.png)

##### 2つ目(Alarmが沈静(OK))　※リスナールールを"転送"へ修正
1つ目と同様に進めます。

- [Lambda]-[関数]-[関数の作成]の順にクリック
「関数の作成」では以下の流れで設定し、[関数の作成]をクリック

 - 関数名：(任意)TestALBRateLimitOKFunction
 - ランタイム：Python 3.6
 - アクセス権限：[既存のロールを使用する]-[TestALBRateLimitIAMRole]

- [コード]-[コードソース]から[lambda_function.py]をダブルクリックし、中身を以下内容で上書き後、[Deploy]をクリック
"RuleArn"は上記で控えたALBリスナールールのARNを指定
"TargetGroupArn"は上記で控えたALB Target GroupのARNを指定

````lambda_function.py
import boto3

def lambda_handler(event, context):
    client = boto3.client('elbv2')
    response = client.modify_rule(
    RuleArn='arn:aws:elasticloadbalancing:us-east-1:{accont id}:listener-rule/{........}',
    Actions=[
        {
            'Type': 'forward',
            'TargetGroupArn': 'arn:aws:elasticloadbalancing:us-east-1:{accont id}:targetgroup/{........}'
        }
    ]
)
````

- [Test]を実行し、対象のALBリスナールールが修正されることを確認します。<br>
※テストの設定内容については何でも良いため省略
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/5bf12872-ad7b-27a3-49c8-8a3ea073c7a8.png)

#### CloudWatch Alarmの作成
メトリクスALB「NewConnectionCount」の閾値を設定します。テストを容易にするために低い数字を設定します。

- AWSマネジメントコンソールから
[CloudWatch]-[アラーム]-[アラームの作成]の順にクリック
[メトリクスの選択]をクリック
[メトリクス]から[ApplicationELB]-[AppELB別メトリクス]-[{今回作成したLoadBalancer}の"NewConnectionCount"]へチェックを入れ、[メトリクスの選択]をクリック

- [統計]を"合計"、[期間]を"1分"へ変更
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/a166dea1-ee0f-2690-d2b4-c32a9e0186c0.png)

- [条件]から[...よりも]へ"5"を入力

- [その他の設定]-[欠落データの処理]を[欠損データを適性(しきい値をこえていない)として処理]へ選択し、[次へ]をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/86da2252-ddf7-b43d-01ba-05d81f55537b.png)

- [通知]は[削除]をクリックし、[次へ]をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/667c945a-ad70-c138-d3b8-b89b420f39f3.png)

- [アラーム名]へ(任意名)"Test ALB Rate Limit"と入力し、[次へ]をクリック
- [アラームの作成]をクリック
合わせて、今回作成したアラームのARNをコピーし控えておく。※CloudWatch Event作成時に使用するため

#### CloudWatch Eventの作成(2つ)
CloudWatch Alarmの状態に応じてEventを2つ作成します。
##### 1つ目(Alarmが発火(ALARM))
- [CloudWatch]-[イベント]-[ルール]-[ルールの作成]の順にクリック
- イベントソースは[イベントパターンのプレビュー]右の[編集]をクリックし以下内容を入力後、[保存]をクリック<br>
"resources"は上記で控えたアラームのARNを指定<br>
※detail.state.valueは"ALARM"を指定

```
{
  "source": [
    "aws.cloudwatch"
  ],
  "detail-type": [
    "CloudWatch Alarm State Change"
  ],
  "resources": [
    "arn:aws:cloudwatch:us-east-1:{accont id}:alarm:Test ALB Rate Limit"
  ],
  "detail": {
    "state": {
      "value": [
        "ALARM"
      ]
    }
  }
}
```
- [ターゲット]は[ターゲットの追加]から、[Lambda関数]を選択、[関数]は[TestALBRateLimitALARMFunction]を選択し、[設定の詳細]をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/6a656736-d63e-5373-ab61-786f0ea9f6c1.png)

- [名前]は(任意)"TestALBRateLimitALARM"を入力し、[ルールの作成]をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/89ecde80-0de3-cf69-de23-7007232f7e28.png)

##### 2つ目(Alarmが沈静(OK))
- [CloudWatch]-[イベント]-[ルール]-[ルールの作成]の順にクリック
イベントソースは[イベントパターンのプレビュー]右の[編集]をクリックし以下内容を入力後、[保存]をクリック<br>
"resources"は上記で控えたアラームのARNを指定<br>
※detail.state.valueは"OK"を指定

```
{
  "source": [
    "aws.cloudwatch"
  ],
  "detail-type": [
    "CloudWatch Alarm State Change"
  ],
  "resources": [
    "arn:aws:cloudwatch:us-east-1:{accont id}:alarm:Test ALB Rate Limit"
  ],
  "detail": {
    "state": {
      "value": [
        "OK"
      ]
    }
  }
}
```
- [ターゲット]は[ターゲットの追加]から、[Lambda関数]を選択、[関数]は[TestALBRateLimitOKFunction]を選択し、[設定の詳細]をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/db792315-2582-654e-f5d5-3de1e075b37f.png)

- [名前]は(任意)"TestALBRateLimitOK"を入力し、[ルールの作成]をクリック
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/98250c00-cc12-8286-3814-8d3337f23add.png)

# 動作確認
#### 正常時(転送)

環境構築用CloudFormation設定から数分待ってから、以下URLへアクセスし、トップページ及びキャンペーンページ・非キャンペーンページが表示することを確認します。
http://{ALB Public DNS}/index.html

- トップページ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/90e2f9d0-85bb-ae7b-cf74-4631b704e523.png)

- キャンペーンページ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/a5408880-eaaa-58a3-cf99-4b8afc64f271.png)

- 非キャンペーンページ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/40ff9e84-9ef2-0fe1-197d-da5abc2ab770.png)

#### アクセス増(抑止)
各トップページ及びキャンペーンページ・非キャンペーンページへアクセスし、5回程度リロードします。
トップページ: http://{ALB Public DNS}/index.html
キャンペーンページ: http://{ALB Public DNS}/campaign.html
非キャンペーンページ: http://{ALB Public DNS}/not_campaign.html

約1-2分後に各ページへアクセスすると「キャンペーンページ(http://{ALB Public DNS}/campaign.html)」のみ何も表示されない(ステータスコード:503)ことが確認できます。<br>
※キャッシュが残っていると表示するため、その場合、キャッシュを削除するためにスーパーリロード(Ctrl+F5 or Ctrl+Shift+F5)を実施します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/2117f130-a35f-e204-c3a7-48f822c7c497.png)

そのまま何もせず、さらに約1-2分後に「キャンペーンページ(http://{ALB Public DNS}/campaign.html)」へアクセスするとページが表示する(ステータスコード:200)ことが確認できます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1276941/6e7d9875-0ee5-a498-f5d4-48ae976da094.png)

#まとめ
ALB,CloudWatch Alarm/Event,Lambdaを使用して流量制限を行いました。
なお、他の条件で抑止を行いたい場合は、各設定をカスタマイズすることで対応することができます。

「はじめに」に記載しましたが、"一定数以上のアクセスを抑止する"といった方法を見つけることができませんでした。良い方法がありましたら教えて頂ければ幸いです。

#補足
- 環境構築用CloudFormationテンプレート

```001-BaseALBWeb.yml
AWSTemplateFormatVersion: 2010-09-09
Description: EC2 for Web Server

Parameters:
  ParamTag:
    Type: String
    Default: test-site
  ParamAMI:
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
    Default: /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2
  ParamCidrBlock:
    Type: String
    Default: "10.0.0.0/16"

Resources:
  CFnVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref ParamCidrBlock
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Name
          Value: !Ref ParamTag
  CfnVPCIGW:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Ref ParamTag

  CfnAttachGateway:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref CFnVPC
      InternetGatewayId: !Ref CfnVPCIGW

  CfnNatGatewayEIP:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  CfnAttachNatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt CfnNatGatewayEIP.AllocationId
      SubnetId: !Ref CfnPublicSubnet1

  CfnPublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref CFnVPC
      Tags:
        - Key: Name
          Value: !Join [ "-", [ !Ref ParamTag, public ] ]

  CfnPublicRT:
    DependsOn: CfnVPCIGW
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref CfnPublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref CfnVPCIGW

  CfnPublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select [ 0, !GetAZs ]
      VpcId: !Ref CFnVPC
      CidrBlock: !Select [ 0, !Cidr [ !Ref ParamCidrBlock, 4, 8 ]]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Join [ "-", [ !Ref ParamTag, public, a ] ]

  CfnPublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select [ 1, !GetAZs ]
      VpcId: !Ref CFnVPC
      CidrBlock: !Select [ 1, !Cidr [ !Ref ParamCidrBlock, 4, 8 ]]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Join [ "-", [ !Ref ParamTag, public, b ] ]

  CfnPublicSubnet1Association:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref CfnPublicRouteTable
      SubnetId: !Ref CfnPublicSubnet1

  CfnPublicSubnet2Association:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref CfnPublicRouteTable
      SubnetId: !Ref CfnPublicSubnet2

  CfnPrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref CFnVPC
      Tags:
        - Key: Name
          Value: !Join [ "-", [ !Ref ParamTag, private ] ]

  CfnPrivateRT:
    DependsOn: CfnVPCIGW
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref CfnPrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref CfnAttachNatGateway

  CfnPrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: !Select [ 0, !GetAZs ]
      VpcId: !Ref CFnVPC
      CidrBlock: !Select [ 2, !Cidr [ !Ref ParamCidrBlock, 4, 8 ]]
      Tags:
        - Key: Name
          Value: !Join [ "-", [ !Ref ParamTag, private, a ] ]

  CfnPrivateSubnet1Association:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref CfnPrivateRouteTable
      SubnetId: !Ref CfnPrivateSubnet1

  SecurityGroupWeb01:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: fw-ec2-web01
      GroupDescription: fw-ec2-web01
      VpcId: !Ref CFnVPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
        - IpProtocol: icmp
          FromPort: -1
          ToPort: -1
          CidrIp: 0.0.0.0/0
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: !Ref ParamTag

  CfnFrontLB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Ref ParamTag
      Subnets:
        - !Ref CfnPublicSubnet1
        - !Ref CfnPublicSubnet2
      SecurityGroups:
        - !Ref SecurityGroupWeb01
      Tags:
        - Key: Name
          Value: !Ref ParamTag

  CfnFrontLBListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref CfnFrontLB
      Port: 80
      Protocol: HTTP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref CfnFrontLBTargetGroup

  CfnFrontLBTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Ref ParamTag
      VpcId: !Ref CFnVPC
      Port: 80
      Protocol: HTTP
      HealthCheckPath: /index.html
      HealthCheckIntervalSeconds: 5
      HealthCheckTimeoutSeconds: 2
      HealthyThresholdCount: 2
      Targets:
        - Id: !Ref CfnWeb01
      Tags:
        - Key: Name
          Value: !Ref ParamTag

  CfnWeb01:
    DependsOn: CfnPrivateRT
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref ParamAMI
      InstanceType: t2.micro
      SubnetId: !Ref CfnPrivateSubnet1
      SecurityGroupIds:
        - !Ref SecurityGroupWeb01
      UserData:
        Fn::Base64: |
          #!/bin/bash -xe
          yum update -y
          yum install -y httpd
          systemctl start httpd
          systemctl enable httpd
          echo 'This is Top Page<p>' > /var/www/html/index.html
          echo '<a href="campaign.html">Campaign</a><br>' >> /var/www/html/index.html
          echo '<a href="not_campaign.html">Not Campaign</a><br>' >> /var/www/html/index.html
          echo 'This is Campaign Page' > /var/www/html/campaign.html
          echo 'This is not Campaign Page' > /var/www/html/not_campaign.html
      Tags:
        - Key: Name
          Value: !Ref ParamTag

Outputs:
  URL:
    Value: !Join [ "", [ "http://", !GetAtt CfnFrontLB.DNSName,"/index.html" ] ]
```

- 環境構築用CloudFormationテンプレートのデプロイ

```
aws cloudformation validate-template --template-body file://001-BaseALBWeb.yml

aws cloudformation deploy --stack-name handson-alb-rate-limit --template-file 001-BaseALBWeb.yml --region us-east-1
```
- 削除手順<br>
以下の順で削除を行います。
 - CloudWatch Event
 - CloudWatch Alarm
 - Lambda Function
 - ALB 追加したリスナールール
 - CloudFormation Stack