Fargateタスクをカスタムメトリクスでターゲット追跡オートスケーリングさせる

マネジメントコンソールからはECSタスク用にApplication Auto Scalingで事前定義された以下のメトリクスでのターゲット追跡スケーリングが設定できる。

  • ECSServiceAverageCPUUtilization (Average)
  • ECSServiceAverageMemoryUtilization (Average)
  • ALBRequestCountPerTarget (Sum)

これ以外の任意のメトリクスの場合はCLIでの設定が必要となる。やり方を確認する。

参考リンク

準備

まずFargateタスクを動かす。

VPCの作成

テンプレートを用意する。

AWSTemplateFormatVersion: "2010-09-09"

Parameters:
  VPCCIDR:
    Type: String
    Default: 10.1.0.0/16
  PublicSubnet1CIDR:
    Type: String
    Default: 10.1.1.0/24
  PublicSubnet2CIDR:
    Type: String
    Default: 10.1.2.0/24
  PrivateSubnet1CIDR:
    Type: String
    Default: 10.1.3.0/24
  PrivateSubnet2CIDR:
    Type: String
    Default: 10.1.4.0/24

Resources:
  ##########
  # VPC
  ##########
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsHostnames: true
      EnableDnsSupport: true
      InstanceTenancy: default
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-VPC

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-IGW

  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  ##########
  # PublicSubnet1
  ##########
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: !Ref PublicSubnet1CIDR
      VpcId: !Ref VPC
      AvailabilityZone: !Select [0, !GetAZs ""]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet1

  PublicSubnet1RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet1

  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicSubnet1RouteTable
      SubnetId: !Ref PublicSubnet1

  PublicSubnet1DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicSubnet1RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
    DependsOn:
      - InternetGateway

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

  NatGateway1:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGateway1EIP.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet1

  ##########
  # PublicSubnet2
  ##########
  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: !Ref PublicSubnet2CIDR
      VpcId: !Ref VPC
      AvailabilityZone: !Select [1, !GetAZs ""]
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet2

  PublicSubnet2RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet2

  PublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicSubnet1RouteTable
      SubnetId: !Ref PublicSubnet2

  PublicSubnet2DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicSubnet2RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
    DependsOn:
      - InternetGateway

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

  NatGateway2:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGateway2EIP.AllocationId
      SubnetId: !Ref PublicSubnet2
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PublicSubnet2

  ##########
  # PrivateSubnet1
  ##########
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: !Ref PrivateSubnet1CIDR
      VpcId: !Ref VPC
      AvailabilityZone: !Select [0, !GetAZs ""]
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet1

  PrivateSubnet1RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet1

  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateSubnet1RouteTable
      SubnetId: !Ref PrivateSubnet1

  PrivateSubnet1DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateSubnet1RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway1

  ##########
  # PrivateSubnet2
  ##########
  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      CidrBlock: !Ref PrivateSubnet2CIDR
      VpcId: !Ref VPC
      AvailabilityZone: !Select [1, !GetAZs ""]
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet2

  PrivateSubnet2RouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: !Sub ${AWS::StackName}-PrivateSubnet2

  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateSubnet2RouteTable
      SubnetId: !Ref PrivateSubnet2

  PrivateSubnet2DefaultRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateSubnet2RouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NatGateway2

Outputs:
  VPC:
    Value: !Ref VPC
    Export:
      Name: !Sub ${AWS::StackName}-VPC
  PublicSubnet1:
    Value: !Ref PublicSubnet1
    Export:
      Name: !Sub ${AWS::StackName}-PublicSubnet1
  PublicSubnet2:
    Value: !Ref PublicSubnet2
    Export:
      Name: !Sub ${AWS::StackName}-PublicSubnet2
  PrivateSubnet1:
    Value: !Ref PrivateSubnet1
    Export:
      Name: !Sub ${AWS::StackName}-PrivateSubnet1
  PrivateSubnet2:
    Value: !Ref PrivateSubnet2
    Export:
      Name: !Sub ${AWS::StackName}-PrivateSubnet2

パラメーターファイルを作成する。

[
  {
    "ParameterKey": "VPCCIDR",
    "ParameterValue": "10.1.0.0/16"
  },
  {
    "ParameterKey": "PublicSubnet1CIDR",
    "ParameterValue": "10.1.1.0/24"
  },
  {
    "ParameterKey": "PublicSubnet2CIDR",
    "ParameterValue": "10.1.2.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet1CIDR",
    "ParameterValue": "10.1.3.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet2CIDR",
    "ParameterValue": "10.1.4.0/24"
  }
]

スタックを作成する。

aws cloudformation create-stack \
  --stack-name MyVPCStack \
  --template-body file://vpc.yaml \
  --parameters file://vpc.parameter.json

スタック作成の完了を待つ。

aws cloudformation wait stack-create-complete \
  --stack-name MyVPCStack

出力を変数に入れておく。

VpcId=$(aws cloudformation describe-stacks --stack-name MyVPCStack | jq -r '.Stacks[].Outputs[] | select( .OutputKey | test("VPC") ) | .OutputValue')
PrivateSubnet1Id=$(aws cloudformation describe-stacks --stack-name MyVPCStack | jq -r '.Stacks[].Outputs[] | select( .OutputKey | test("PrivateSubnet1") ) | .OutputValue')
PrivateSubnet2Id=$(aws cloudformation describe-stacks --stack-name MyVPCStack | jq -r '.Stacks[].Outputs[] | select( .OutputKey | test("PrivateSubnet2") ) | .OutputValue')
PublicSubnet1Id=$(aws cloudformation describe-stacks --stack-name MyVPCStack | jq -r '.Stacks[].Outputs[] | select( .OutputKey | test("PublicSubnet1") ) | .OutputValue')
PublicSubnet2Id=$(aws cloudformation describe-stacks --stack-name MyVPCStack | jq -r '.Stacks[].Outputs[] | select( .OutputKey | test("PublicSubnet2") ) | .OutputValue')

セキュリティグループの作成

VPCのデフォルトのセキュリティグループを取得する。

DefaultSecurityGroupId=$(aws ec2 describe-security-groups | jq -r '.SecurityGroups[] | select( ( .VpcId | test("'${VpcId}'") ) and ( .GroupName | test("default") ) ) | .GroupId'); echo ${DefaultSecurityGroupId}

ALB用のセキュリティグループを作成する。

aws ec2 create-security-group --group-name MyALBSecurityGroup --description MyALBSecurityGroup --vpc-id ${VpcId}
ALBSecurityGroupId=$(aws ec2 describe-security-groups | jq -r '.SecurityGroups[] | select( .GroupName | test("MyALBSecurityGroup") ) | .GroupId'); echo ${ALBSecurityGroupId}

80番ポートへのアクセスを許可する。

aws ec2 authorize-security-group-ingress --group-id ${ALBSecurityGroupId} --protocol tcp --port 80 --cidr 0.0.0.0/0

ALBの作成

ALBを作成する。

aws elbv2 create-load-balancer --name MyALB \
  --subnets ${PublicSubnet1Id} ${PublicSubnet2Id} --security-groups ${ALBSecurityGroupId} ${DefaultSecurityGroupId}
LoadBalancerArn=$(aws elbv2 describe-load-balancers --name MyALB | jq -r '.LoadBalancers[].LoadBalancerArn'); echo ${LoadBalancerArn}
DNSName=$(aws elbv2 describe-load-balancers --name MyALB | jq -r '.LoadBalancers[].DNSName'); echo ${DNSName}

ターゲットグループを作成する。

aws elbv2 create-target-group --name MyTargetGroup --protocol HTTP --port 80 \
  --target-type ip --vpc-id ${VpcId}
TargetGroupArn=$(aws elbv2 describe-target-groups --names MyTargetGroup | jq -r '.TargetGroups[].TargetGroupArn'); echo ${TargetGroupArn}

ターゲットグループにリクエストを転送するデフォルトルールを持つリスナーを作成する。

aws elbv2 create-listener --load-balancer-arn ${LoadBalancerArn} \
  --protocol HTTP --port 80  \
  --default-actions Type=forward,TargetGroupArn=${TargetGroupArn}

ECSクラスターの作成

Fargateクラスターを作成する。

aws ecs create-cluster --cluster-name fargate-cluster --settings "name=containerInsights,value=enabled"

タスク定義の登録

タスク定義のjsonを作成する。

cat <<EOF > task-definition.json
{
  "family": "echoserver",
  "taskRoleArn": "",
  "executionRoleArn": "ecsTaskExecutionRole",
  "networkMode": "awsvpc",
  "containerDefinitions": [
    {
      "name": "echoserver",
      "image": "k8s.gcr.io/echoserver:1.4",
      "portMappings": [
        {
          "containerPort": 8080,
          "protocol": "tcp"
        }
      ],
      "essential": true,
      "logConfiguration": {
        "logDriver": "awslogs",
        "options": {
          "awslogs-group": "/ecs/echoserver",
          "awslogs-region": "ap-northeast-1",
          "awslogs-stream-prefix": "ecs"
        }
      }
    }
  ],
  "requiresCompatibilities": ["FARGATE"],
  "cpu": "256",
  "memory": "512"
}
EOF

タスク定義を登録する。

aws ecs register-task-definition --cli-input-json file://task-definition.json

タスクが使用するロググループを作成する。

aws logs create-log-group --log-group-name "/ecs/echoserver"

サービスの作成

サービスのjsonを作成する。

cat <<EOF > service.json
{
  "cluster": "fargate-cluster",
  "serviceName": "echoserver",
  "taskDefinition": "echoserver",
  "loadBalancers": [
    {
      "targetGroupArn": "${TargetGroupArn}",
      "containerName": "echoserver",
      "containerPort": 8080
    }
  ],
  "desiredCount": 1,
  "launchType": "FARGATE",
  "networkConfiguration": {
    "awsvpcConfiguration": {
      "subnets": ["${PrivateSubnet1Id}", "${PrivateSubnet2Id}"],
      "securityGroups": ["${DefaultSecurityGroupId}"],
      "assignPublicIp": "DISABLED"
    }
  }
}
EOF

サービスを作成する。

aws ecs create-service --cli-input-json file://service.json

稼働確認

アクセスしてみる。

$ curl -s http://${DNSName}/
CLIENT VALUES:
client_address=10.1.1.95
command=GET
real path=/
query=nil
request_version=1.1
request_uri=http://myalb-211320250.ap-northeast-1.elb.amazonaws.com:8080/

SERVER VALUES:
server_version=nginx: 1.10.0 - lua: 10001

HEADERS RECEIVED:
accept=*/*
host=myalb-211320250.ap-northeast-1.elb.amazonaws.com
user-agent=curl/7.54.0
x-amzn-trace-id=Root=1-5f9c2630-1ad009bd47fca22b24e2d8a3
x-forwarded-for=27.0.3.145
x-forwarded-port=80
x-forwarded-proto=http
BODY:
-no body in request-

オートスケーリングの設定

スケーラブルターゲットの作成

CLIの入力のjsonを作成する。

ACCOUNT_ID=$(aws sts get-caller-identity --output text --query Account)
cat <<EOF > scalable-target.json
{
    "ServiceNamespace": "ecs",
    "ResourceId": "service/fargate-cluster/echoserver",
    "ScalableDimension": "ecs:service:DesiredCount",
    "MinCapacity": 1,
    "MaxCapacity": 10,
    "RoleARN": "arn:aws-cn:iam::${ACCOUNT_ID}:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService"
}
EOF

スケーラブルターゲットを登録する。

aws application-autoscaling register-scalable-target --cli-input-json file://scalable-target.json

スケーリングポリシーの作成

CLIの入力のjsonを作成する。

cat <<EOF > scaling-policy.json
{
  "TargetValue": 10,
  "CustomizedMetricSpecification": {
    "MetricName": "MyMetricName",
    "Namespace": "MyNamespace",
    "Dimensions": [
      {
        "Name": "MyMetricDimensionName",
        "Value": "MyMetricDimensionValue"
      }
    ],
    "Statistic": "Average"
  },
  "ScaleOutCooldown": 60,
  "ScaleInCooldown": 60,
  "DisableScaleIn": false
}
EOF

スケーリングポリシーを作成する。

aws application-autoscaling put-scaling-policy \
  --policy-name MyMetricTargetTrackingPolicy \
  --service-namespace ecs \
  --resource-id service/fargate-cluster/echoserver \
  --scalable-dimension ecs:service:DesiredCount \
  --policy-type TargetTrackingScaling \
  --target-tracking-scaling-policy-configuration file://scaling-policy.json

マネジメントコンソールで設定を確認する。詳細は表示されないようだ。

f:id:sotoiwa:20201031000439p:plain

以下のようなアラームが自動的に設定される。

f:id:sotoiwa:20201031000521p:plain

動作確認

カスタムメトリクスの送信

今回は適当なカスタムメトリクスを固定で送信する。

以下のスクリプトを作成。

#!/bin/bash

while true
do
  echo "Put custom metrics..."
  aws cloudwatch put-metric-data --metric-name MyMetricName --namespace MyNamespace \
    --unit None --value 20 --dimensions MyMetricDimensionName=MyMetricDimensionValue
  echo "Sleep..."
  sleep 10
done

これを実行しておく。

f:id:sotoiwa:20201031000540p:plain

メトリクスは少し遅れて(最大2分?)CloudWatchで見れるようになる。メトリクスは20を固定で出力しており、ターゲットは10なので、1分毎のデータ3つ見れるようになると、アラームが発報してスケールアウトが行われる。タスクを追加してもメトリクスは20のまま変わらないので、タスクの最大数までスケールアウトが行われる。

スケールアウトのクールダウン時間を60秒に設定したので、60秒後に次のスケールがアウトが行われるのかと思ったが、次のタスク追加まではもう少し時間がかかっているようだ。また追加されるのは1つづつとは限らないようで、ロジックは不明だがいい感じにやろうとしてくれるっぽい。

f:id:sotoiwa:20201031000554p:plain