Amazon Web Services ブログ

CloudWatch と Prometheus のカスタムメトリクスに基づく Amazon ECS サービスのオートスケーリング

この記事は Autoscaling Amazon ECS services based on custom CloudWatch and Prometheus metrics (記事公開日: 2021 年 2 月 26 日) を翻訳したものです。

イントロダクション

クラウドネイティブアプリケーションにとって、水平方向のスケーラビリティは非常に重要な要素です。Amazon ECS にデプロイされたマイクロサービスは、Application Auto Scaling サービスを利用して、観測されたメトリクスデータに基づいて自動的にスケーリングします。Amazon ECS は、サービスに属するタスクが消費する CPU とメモリのリソースに基づいてサービスの使用率を測定し、このデータを ECSServiceAverageCPUUtilizationECSServiceAverageMemoryUtilization という名前の CloudWatch メトリクスとして送信します。Application Auto Scaling は、これらの事前定義されたメトリクスをスケーリングポリシーと組み合わせて使用し、サービスのタスク数をメトリクスに応じてスケールすることができます。しかしながら、サービスの平均的な CPU とメモリの使用量だけでは、いつ、どの程度までスケーリングアクションを実行すべきかの信頼できる指標とはならないユースケースがいくつかあります。受信した HTTP リクエストの数、キュー/トピックから取得したメッセージの数、実行したデータベーストランザクションの数など、アプリケーションの他の側面を追跡するカスタムメトリクスが、スケーリングアクションのトリガーとしてより適している場合があります。

Application Auto Scaling は、事前定義されたメトリクスだけでなく選択した CloudWatch カスタムメトリクスに基づいてサービスをスケーリングすることもサポートしています。お客様は、プログラミング言語やプラットフォームに適した AWS SDK のいずれかを使用して、アプリケーションから CloudWatchに カスタムメトリクスデータを送信することができます。加えて、2020 年 9 月 に発表された Container Insights の Prometheus メトリクスのモニタリングの一般提供開始により、お客様はコンテナアプリケーションからのカスタム Prometheus メトリクスの検出と収集を自動化することができるようになりました。

この記事では、これらのメトリクス収集方法のいずれかを Application Auto Scaling と組み合わせて使用し、カスタムメトリクスデータに基づいて Amazon ECS にデプロイされたサービスをスケールする方法の詳細について説明します。

アーキテクチャ

このオートスケーリングソリューションは、以下の図に示すアプリケーションスタックを使用してデモンストレーションされます。ストリーミングデータは、Amazon ECS クラスターにデプロイされた Kafka Producer Service というサービスによって、Application Load Balancer を介して受信されます。このサービスは、Amazon MSK のトピックにデータをパブリッシュします。別のサービスである Kafka Consumer Service は、Kafka トピックからこれらのメッセージを取得し、後続の処理を行います。これらのサービスは AWS SDK を使用して、カスタムメトリクスデータを CloudWatch 名前空間に送信します。また、これらのサービスは Prometheus クライアントライブラリによってインストルメントされています。Prometheus をサポートする CloudWatch エージェントは、Amazon ECS クラスターにサービスとしてデプロイされ、サービスから Prometheus メトリクスを収集し、CloudWatch に送信するように構成されています。定期的に実行されるようにスケジュールされた AWS Lambda 関数は、アプリケーションから収集されたカスタムメトリクスデータをサービス使用率メトリクスに変換します。このメトリクスは Application Auto Scaling によってターゲットをスケーリングするために使用されます。

目標は、Kafka Consumer Service のタスク数をスケールアウト/インして、以下のようにすることです。

  1. Kafka Consumer Service のすべてのタスクがメッセージを処理する合計レートは、Kafka Producer Service がメッセージを生成する合計レートに追いつきます。
  2. Kafka Consumer Service の各タスクがメッセージを処理する平均レートは、設定されたしきい値以下を維持します。

Deployment architecture for scaling ECS services with Application Auto Scaling

CloudWatch SDK を使用したカスタムメトリクスの送信

アプリケーションは、カスタムメトリクスデータを “AWS/” で始まらない CloudWatch 名前空間に送信することができます。プロデューサーとコンシューマーのアプリケーションは、以下のように AWS SDK for Java でインストルメントされており、生成/消費されたメッセージの数に関するメトリクスデータを取得できます。

package com.octank.kafka.metrics;

import java.util.ArrayList;
import java.util.Collection;

import org.apache.log4j.Logger;

import com.amazonaws.auth.DefaultAWSCredentialsProviderChain;
import com.amazonaws.services.cloudwatch.AmazonCloudWatch;
import com.amazonaws.services.cloudwatch.AmazonCloudWatchClientBuilder;
import com.amazonaws.services.cloudwatch.model.Dimension;
import com.amazonaws.services.cloudwatch.model.MetricDatum;
import com.amazonaws.services.cloudwatch.model.PutMetricDataRequest;
import com.amazonaws.services.cloudwatch.model.StandardUnit;
import com.octank.kafka.config.AWSConfig;
import com.octank.kafka.config.ECSConfig;

public class ECSCloudWatchCounter {

    private static final Logger logger = Logger.getLogger(ECSCloudWatchCounter.class);

    private final static String PUBLISH_COUNTER = "messages_produced_total";
    private final static String CONSUME_COUNTER = "messages_consumed_total";

    private final static String CLOUDWATCH_NAMESPACE = "ECS/CloudWatch/Custom";

    private static double messagesPublished = 0.0;
    private static double messagesConsumed = 0.0;

    private static AmazonCloudWatch cloudwatch;
    private static Collection<Dimension> dimensions;
    private static boolean isConsumer;

    public static void init (boolean mode) 
    {
        try {
            isConsumer = mode;
            cloudwatch = AmazonCloudWatchClientBuilder
                    .standard()
                    .withCredentials(DefaultAWSCredentialsProviderChain.getInstance())
                    .withRegion(AWSConfig.getRegion()).build();

            Dimension taskGroupDimension = new Dimension()
                    .withName("TaskGroup")
                    .withValue(ECSConfig.getTaskGroup());

            Dimension clusterDimension = new Dimension()
                    .withName("ClusterName")
                    .withValue(ECSConfig.getClusterName());

            dimensions = new ArrayList<Dimension>();
            dimensions.add(taskGroupDimension);
            dimensions.add(clusterDimension);
        } catch (Exception ex) {
            logger.error(String.format("Exception ocurred while initialiazing CloudWatch client: %s", ex.getMessage()));
        }
    }

    public static void incMessagesPublished() {
        messagesPublished++;
    }

    public static void incMessagesConsumed() {
        messagesConsumed++;
    }

    public static void putMetricData(long timerId) {

        try {            
            if (!isConsumer) {
                MetricDatum publishDatum = new MetricDatum()
                        .withMetricName(ECSCloudWatchCounter.PUBLISH_COUNTER)
                        .withUnit(StandardUnit.None)
                        .withValue(messagesPublished)
                        .withDimensions(dimensions);
        
                PutMetricDataRequest publishRequest = new PutMetricDataRequest()
                        .withNamespace(CLOUDWATCH_NAMESPACE)
                        .withMetricData(publishDatum);
        
                cloudwatch.putMetricData(publishRequest);
            } else {    
                MetricDatum consumeDatum = new MetricDatum()
                        .withMetricName(ECSCloudWatchCounter.CONSUME_COUNTER)
                        .withUnit(StandardUnit.None)
                        .withValue(messagesConsumed)
                        .withDimensions(dimensions);
        
                PutMetricDataRequest consumeRequest = new PutMetricDataRequest()
                        .withNamespace(CLOUDWATCH_NAMESPACE)
                        .withMetricData(consumeDatum);
        
                cloudwatch.putMetricData(consumeRequest);
            }
        }
        catch (Exception ex) {
            logger.error("Exception occurred when calling CloudWatch PutMetricData API", ex);
        }
        
        messagesPublished = 0.0;
        messagesConsumed = 0.0;
    }
}

カウンターは、各タスクによって Kafkaトピックとの間で送受信されたメッセージの数を追跡するために使用されます。incMessagesPublished/incMessagesConsumed メソッドは、アプリケーションコードの他の場所から呼び出され、タスクでメッセージが処理されるたびにカウンターをインクリメントします。putMetricData メソッドは、30 秒ごとに定期的に呼び出され、このメトリクスデータを ECS/CloudWatch/Custom という CloudWatch 名前空間に送信します。カスタムメトリクスに対して発生する料金については、Amazon CloudWatch の料金についてのドキュメントを参照してください。

Prometheus を使用したカスタムメトリクスの送信

Prometheus を使用してカスタムメトリクスを取得する場合、アプリケーションは以下のように Java 用の Prometheus クライアントライブラリでインストルメントされます。

package com.octank.kafka.metrics;

public class PrometheusCounter {
    protected static final String JOB_PRODUCER = "producers";
    protected static final String JOB_CONSUMER = "consumers";
    private final static String PRODUCER_COUNTER = "messages_produced_total";
    private final static String CONSUMER_COUNTER = "messages_consumed_total";
    
    private static io.prometheus.client.Counter messagesPublished;
    private static io.prometheus.client.Counter messagesConsumed;

    public static void init(boolean isConsumer) {
        if (isConsumer) {
            messagesConsumed = io.prometheus.client.Counter
                    .build()
                    .name(CONSUMER_COUNTER)
                    .help("Total number of messages consumed from a kafka topic")
                    .labelNames("job", "topic")
                    .register();
        } else {
            messagesPublished = io.prometheus.client.Counter
                    .build()
                    .name(PRODUCER_COUNTER)
                    .help("Total number of messages published to a kafka topic")
                    .labelNames("job", "topic")
                    .register();
        }
    }

    public static void incMessagesPublished(String topic) {
        messagesPublished.labels(JOB_PRODUCER, topic).inc();
    }

    public static void incMessagesConsumed(String topic) {
        messagesConsumed.labels(JOB_CONSUMER, topic).inc();
    }
}

ここでは、Prometheus Counter を使用して、各タスクによって Kafka トピックとの間で送受信されたメッセージの数を追跡しています。タスクでメッセージが処理されるたびに incMessagesPublished/incMessagesConsumed メソッドを呼び出し、このカウンターがインクリメントされます。この実装では、プロデューサーアプリケーションとコンシューマーアプリケーションの両方が Vert.x フレームワークを使用し、MetricsHandler を使用して /metrics エンドポイントでこれらのカスタムメトリクスをメトリクスコレクターに公開します。

Prometheus モニタリング機能を持つ CloudWatch エージェントを Amazon ECS にデプロイする手順と、ターゲットをスクレイピングするように設定する方法については、ここで説明されています。Prometheus は、ノード、Service、Pod などの Kubernetes クラスター内のスクレイピングターゲットの自動検出をサポートしていますが、Amazon ECS にはそのようなビルトインの検出メカニズムはありません。CloudWatch エージェントが Amazon ECS クラスター内のスクレイピングターゲットを特定するために使用するメカニズムとしては、Prometheus のファイルベースのサービスディスカバリのサポートを利用しており、ここで詳細に説明されています。現在の実装で使用されているエージェント設定を以下に示します。

{
   "logs":{
      "metrics_collected":{
         "prometheus":{
            "prometheus_config_path":"env:PROMETHEUS_CONFIG_CONTENT",
            "ecs_service_discovery":{
               "sd_frequency":"1m",
               "sd_result_file":"/tmp/cwagent_ecs_auto_sd.yaml",
               "task_definition_list":[
                  {
                     "sd_job_name":"producers",
                     "sd_metrics_ports":"8080",
                     "sd_task_definition_arn_pattern":".*:task-definition/KafkaProducerTask:[0-9]+",
                     "sd_metrics_path":"/metrics"
                  },
                  {
                     "sd_job_name":"consumers",
                     "sd_metrics_ports":"8080",
                     "sd_task_definition_arn_pattern":".*:task-definition/KafkaConsumerTask:[0-9]+",
                     "sd_metrics_path":"/metrics"
                  }
               ]
            },
            "emf_processor":{
               "metric_declaration":[
                  {
                     "source_labels": ["job"],
                     "label_matcher":"^producers$",
                     "dimensions": [["ClusterName","TaskGroup"]],
                     "metric_selectors":[
                        "^messages_produced_total$"
                     ]
                  },
                  {
                     "source_labels": ["job"],
                     "label_matcher":"^consumers$",
                     "dimensions": [["ClusterName","TaskGroup"]],
                     "metric_selectors":[
                        "^messages_consumed_total$"
                     ]
                  }
               ]
            }
         }
      },
      "force_flush_interval":5
   }
}

ecs_service_discovery セクションは、CloudWatch エージェントが KafkaConsumerTaskKafkaProducerTask のタスク定義を使ってデプロイされた一連のタスクをスクレイピングターゲットとして特定するのに役立ちます。emf_processor.metric_declaration セクションでは、これらのタスクからスクレイピングされた Prometheus メトリクスを、埋め込みメトリクスフォーマットを使用してパフォーマンスログイベントに変換する方法を設定します。上記の設定を使用すると、エージェントが CloudWatch Logs に送信するパフォーマンスログイベントは以下のようになります。このログイベントは CloudWatch によって使用され、ECS/ContainerInsights/Prometheus という CloudWatch 名前空間の ClusterNameTaskGroup というディメンションに、messages_consumed_total というカスタムメトリクスデータを生成します。これらのディメンションにより、あるサービスに属するすべてのタスクから収集されたメトリクスデータを集約することができます。

{
   "CloudWatchMetrics":[
      {
         "Metrics":[{"Name":"messages_consumed_total"}],
         "Dimensions":[["ClusterName","TaskGroup"]],
         "Namespace":"ECS/ContainerInsights/Prometheus"
      }
   ],
   "ClusterName":"ecs-sarathy-cluster",
   "LaunchType":"EC2",
   "StartedBy":"ecs-svc/2462781964732404868",
   "TaskDefinitionFamily":"KafkaConsumerTask",
   "TaskGroup":"service:ConsumerService",
   "TaskRevision":"24",
   "Timestamp":"1613014769602",
   "Version":"0",
   "container_name":"consumer",
   "exported_job":"consumers",
   "instance":"10.10.101.243:8080",
   "job":"consumers",
   "messages_consumed_total":600,
   "prom_metric_type":"counter",
   "topic":"octank"
}

Application Auto Scaling によるサービスのスケーリング設定

Amazon ECS サービスは、Application Auto Scaling を使用して、CloudWatch メトリクスを選択してターゲット値を設定するターゲット追跡ポリシーでオートスケールされます。選択した CloudWatch メトリクスを示す CustomizedMetricSpecification を使用して、このようなポリシーを設定できます。しかしながら、すべてのメトリクスがターゲット追跡に適しているわけではありませんmessages_consumed_totalmessages_produced_total のような単調に増加する累積メトリクスは、オートスケールには特に適していません。それらをスケール対象のキャパシティ (この場合はサービスのタスク数) に比例して増減する使用率やレートのメトリクスに変換する必要があります。

この作業は、本実装では AWS Lambda 関数によって実行されます。以下に示す Go プログラムを Lambda 関数としてデプロイし、Amazon EventBridge を使って、1 分ごとに実行するようにスケジュールします。この関数は、CloudWatch 名前空間 ECS/CloudWatch/Custommessages_produced_total というメトリクスの末尾 60 秒のデータを基に Metric Math の数式で計算されたデータを取得し、コンシューマーのタスク数で割った rate_messages_produced_average_1m という新しいメトリクスを計算します。そしてこのメトリクスをカスタムメトリクスとして CloudWatch に送信します。このメトリクスが Application Auto Scaling で使用できる有効な使用率メトリクスとして機能します。

package main

import (
    "context"
    "encoding/json"
    "log"
    "os"
    "time"

    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/cloudwatch"
    "github.com/aws/aws-sdk-go/service/ecs"
    "github.com/aws/aws-sdk-go/service/ssm"
)

var sharedSession *session.Session = nil

func main() {
    lambda.Start(HandleRequest)
}

func HandleRequest(ctx context.Context) (string, error) {
    initializeAWSSession()
    ssmService := getSSMClient()
    cloudwatchService := getCloudWatchClient()
    ecsService := getECSClient()

    //
    // 対象のサービスで稼働するタスクの数を取得する
    //
    serviceName := aws.String("ConsumerService")
    clusterName := aws.String("ecs-sarathy-cluster")
    describeServiceOutput, err := ecsService.DescribeServices(&ecs.DescribeServicesInput{Cluster: clusterName, Services: []*string{serviceName}})
    if err != nil {
        return err.Error(), err
    }
    serviceList := describeServiceOutput.Services
    var taskCount float64
    for _, svc := range serviceList {
        desired := svc.DesiredCount
        running := svc.RunningCount
        pending := svc.PendingCount
        log.Printf("Task count for %s: desired = %d, running = %d, pending = %d\n", *serviceName, *desired, *running, *pending)
        if *pending > 0 {
            taskCount = float64(*desired)
        } else {
            taskCount = float64(*running)
        }
    }

    //
    // CloudWatch GetMetricData API で使用する MetricDataQuery を定義した SSM パラメータを JSON 形式で取得する
    //
    parameterName := aws.String("CloudWatch-MetricData-Queries")
    getParameterOutput, err := ssmService.GetParameter(&ssm.GetParameterInput{Name: parameterName})
    if err != nil {
        return err.Error(), err
    }

    //
    // JSON データを、'cloudwatch.MetricDataQuery' タイプのインスタンスの配列にアンマーシャルする
    //
    jsonDataBytes := []byte(*getParameterOutput.Parameter.Value)
    var metricDataQueries []*cloudwatch.MetricDataQuery
    err = json.Unmarshal(jsonDataBytes, &metricDataQueries)
    if err != nil {
        return err.Error(), err
    }

    //
    // カスタムメトリクスに関連づけられる名前、名前空間、およびディメンションを取得する
    //
    var metricName, metricNamespace *string
    var metricDimensions []*cloudwatch.Dimension
    for _, query := range metricDataQueries {
        if !*query.ReturnData {
            metricNamespace = query.MetricStat.Metric.Namespace
            metricDimensions = query.MetricStat.Metric.Dimensions
        }
        if *query.ReturnData {
            metricName = query.Label
        }
    }
    if metricName == nil || metricNamespace == nil || metricDimensions == nil {
        return "Unable to find CloudWatch namespace and dimensions for the metric", nil
    }

    //
    // 直近 5 分間のメトリクスデータを取得する
    // MetricDataQuery で定義された Metric Math 数式を使用して計算されたメトリクスデータが取得される
    //
    now := time.Now()
    endTime := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), now.Minute(), 0, 0, now.Location())
    startTime := endTime.Add(-5 * time.Minute)
    maxDataPoints := int64(100)
    getMetricDataOutput, err := cloudwatchService.GetMetricData(&cloudwatch.GetMetricDataInput{
        MetricDataQueries: metricDataQueries,
        StartTime:         &startTime,
        EndTime:           &endTime,
        MaxDatapoints:     &maxDataPoints,
        ScanBy:            aws.String("TimestampDescending"),
    })
    if err != nil {
        return err.Error(), err
    }
    log.Println(getMetricDataOutput.MetricDataResults)

    //
    // 取得した値/タイムスタンプを収集し、CloudWatch カスタムメトリクスとして送信する
    //
    var metricData []*cloudwatch.MetricDatum
    metricDataTimestamps := getMetricDataOutput.MetricDataResults[0].Timestamps
    metricDataValues := getMetricDataOutput.MetricDataResults[0].Values
    for i, value := range metricDataValues {
        timestamp := metricDataTimestamps[i]
        averageValue := *value / taskCount
        metricData = append(metricData, &cloudwatch.MetricDatum{
            MetricName: metricName,
            Timestamp:  timestamp,
            Value:      &averageValue,
            Dimensions: metricDimensions,
        })
    }
    _, err = cloudwatchService.PutMetricData(&cloudwatch.PutMetricDataInput{
        Namespace:  metricNamespace,
        MetricData: metricData,
    })
    log.Println("Completed successully")
    return "", nil
}

func initializeAWSSession() {
    region := os.Getenv("AWS_REGION")
    if region == "" {
        region = "us-east-1"
    }
    sharedSession, _ = session.NewSession(&aws.Config{Region: aws.String(region)})
    if sharedSession == nil {
        log.Fatalf("Unable to create a new AWS client session")
    }
}

func getCloudWatchClient() *cloudwatch.CloudWatch {
    service := cloudwatch.New(sharedSession)
    return service
}

func getSSMClient() *ssm.SSM {
    service := ssm.New(sharedSession)
    return service
}

func getECSClient() *ecs.ECS {
    service := ecs.New(sharedSession)
    return service
}

この Lambda 関数は、AWS Systems Manager パラメータストアから読み込んだ以下の JSON 設定データに基づいてタスクを実行します。この関数の実行ロールには、cloudwatch:GetMetricDatacloudwatch:PutMetricDataecs:DescribeServicesssm:GetParameterlogs: という一連の IAM パーミッションが必要です。

[
   {
      "Id":"m1",
      "Label":"sum_messages_produced_total_1m",
      "ReturnData":false,
      "MetricStat":{
         "Metric":{
            "Namespace":"ECS/CloudWatch/Custom",
            "MetricName":"messages_produced_total",
            "Dimensions":[
               {
                  "Name":"ClusterName",
                  "Value":"ecs-sarathy-cluster"
               },
               {
                  "Name":"TaskGroup",
                  "Value":"service:PublisherService"
               }
            ]
         },
         "Period":60,
         "Stat":"Sum"
      }
   },
   {
      "Id":"m2",
      "Expression":"m1/60",
      "Label":"rate_messages_produced_average_1m",
      "ReturnData":true,
      "Period":60
   }
]

次に、コンシューマーサービスの DesiredCount ディメンションを Application Auto Scaling のスケーラブルターゲットとして登録します。これにより、サービスは最大 10 個のタスクまでスケールアウトできるようになります。この実装では、10 は Kafka トピックのパーティション数でもあります。

CLUSTER_NAME=ecs-sarathy-cluster
SERVICE_NAME=ConsumerService
aws application-autoscaling register-scalable-target \
--service-namespace ecs \
--scalable-dimension ecs:service:DesiredCount \
--resource-id service/$CLUSTER_NAME/$SERVICE_NAME \
--min-capacity 1 \
--max-capacity 10

次に、config.json で定義された設定パラメータを使って、ターゲット追跡ポリシーを作成します。

CLUSTER_NAME=ecs-sarathy-cluster
SERVICE_NAME=ConsumerService
POLICY_NAME=Message-Consumption-Rate-Policy
aws application-autoscaling put-scaling-policy \
--policy-name $POLICY_NAME \
--service-namespace ecs \
--resource-id service/$CLUSTER_NAME/$SERVICE_NAME \
--scalable-dimension ecs:service:DesiredCount \
--policy-type TargetTrackingScaling \
--target-tracking-scaling-policy-configuration file://config.json

config.json ファイルの内容を以下に示します。このポリシーでは、ターゲット値を 30 としてカスタムメトリクス rate_messages_produced_average_1m を追跡します。

{
    "TargetValue":30.0,
    "ScaleOutCooldown":120,
    "ScaleInCooldown":120,
    "CustomizedMetricSpecification":{
       "MetricName":"rate_messages_produced_average_1m",
       "Namespace":"ECS/CloudWatch/Custom",
       "Dimensions":[
          {
             "Name":"ClusterName",
             "Value":"ecs-sarathy-cluster"
          },
          {
             "Name":"TaskGroup",
             "Value":"service:PublisherService"
            }
       ],
       "Statistic":"Average"
    }
 }

上記のポリシーを作成すると、Application Auto Scaling はスケーリングアクションのトリガーとなる CloudWatch アラームを作成および管理します。Prometheus Counter に基づいて作成された CloudWatch カスタムメトリクスを、使用率またはレートメトリクスに変換する手順は基本的に同じです。関連するメトリクスデータは、名前空間 ECS/ContainerInsights/Prometheus から取得されます。

オートスケーリングの動作

まず、Kafka Publisher Service の 2 つのタスク、Kafka Consumer Service の 1 つのタスク、そして Prometheus をサポートする CloudWatch エージェントをデプロイした構成から始めます。Kafka トピックは 10 個のパーティションで設定されています。約 65 ~ 70 メッセージ/秒のレートで安定したメッセージのストリームが Kafka トピックに発行されています。すべてのパーティションからのメッセージは、コンシューマーサービスのシングルタスクによって約 20 メッセージ/秒のレートで処理されます。1 分あたりに生成/消費されたメッセージのレートは、それぞれ SUM(rate_messages_produced_total)/60SUM(rate_messages_consumed_total)/60 という Metric Math 数式を使用して計算され、図 2 中に表示されています。図 3 は、Lambda によって計算され、Application Auto Scaling のターゲット追跡に使用される、カスタム使用率メトリクス rate_messages_produced_average_1m を示しています。これは、生成されたメッセージのレートをコンシューマータスクの数あたりに計算したもので、コンシューマータスクが 1 つしかないため、最初は約 65 ~ 70 メッセージ/秒となっています。

図 1. ECS にデプロイされているサービスの一覧

図 2. Metric Math の数式を使用して表示された生成/消費されたメッセージの初期のレート

図 3. Lambda によって計算され、Application Auto Scaling によるターゲット追跡に使用されるカスタム使用率メトリクス

ターゲット追跡スケーリングポリシーを作成すると、Application Auto Scaling は、下図に示す 2 つのメトリクスアラームを作成し、コンシューマータスクのオートスケーリングを管理します。この 2 つのメトリクスアラームは、1 つはスケールアウトを処理し、もう 1 つはスケールインを処理するもので、これらを併用することで、使用率メトリクス rate_messages_produced_average_1m の平均値が 27 ~ 30 の範囲に収まるようにします。

図 4. Application Auto Scaling によって管理される CloudWatch メトリクスアラーム

メトリクス rate_messages_produced_average_1m の初期値は約 65 ~ 70 であるため、このメトリクスの上限しきい値を追跡するアラームは、最初のスケールアウトアクションを実行します。これによって 2 つの追加のコンシューマータスクが起動され、 この使用率メトリクスの値が目標の 30 を下回ります。最初のスケールアウトアクションが上手くいったので、生成されるメッセージのレートを約 130 ~ 140 メッセージ/秒に倍増させてみます。これによって、2 回目のスケーリングアクションが実行されます。メトリクスが設定されたしきい値を超えたため、目標値の 30 以下に維持するために、さらに 2 つのタスクが起動されます。より多くのコンシューマータスクが起動されると、rate_messages_consumed_total_1m で示されるすべてのコンシューマータスクが処理するメッセージのレートが上昇し、プロデューサーのレートに近づいていくことがわかります。以下の図は、2 つのスケールアウトアクションに対するシステムの動作を示しています。

図 5. 2 回のスケールアウトアクション中のターゲットメトリクス rate_messages_produced_average_1m の挙動

図 6. スケールアウトアクション中の 1 分あたりに生成/消費 (青/赤) されたメッセージのレートの挙動

今度は、生成されるメッセージのレートを約 50 メッセージ/秒に減少させてみます。下図に示すようにスケールインアクションが発生し、コンシューマータスクの数は 2 つに減少します。Application Auto Scaling の主な目的は、システムの負荷の増加に応じて可用性を優先することです。これらの図からわかるように、スケールアウトは、比較的早くメトリクスが 1 分間隔で 3 回閾値を超えたときに実行されるのに対し、スケールインは、1 分間隔で 15 回閾値を超えたときにのみ、はるかに保守的な方法で実行されます。

図 7. スケールインアクション中のターゲットメトリクス rate_messages_produced_average_1m の挙動

図 8. スケールインアクション中の 1 分あたりに生成/消費 (青/赤) されたメッセージのレートの挙動

以下の図は、オートスケーリングイベント中の Kafka Consumer Service のタスク数の変化と、その平均的な CPU とメモリのプロファイルを示しています。スケールアウト前は、10 個のパーティションすべてからメッセージを消費するタスクが 1 つあり、スケールアウト後は、5 つのタスクのそれぞれが 2 つのパーティションからメッセージを処理しています。しかし、このユースケースでは、アプリケーションの性質上、各タスクの平均 CPU およびメモリ使用量は、メッセージを消費しているパーティションの数とは相関関係がないため、オートスケーリングいつ実行するかを示す信頼できる指標とはなりません。

図 9. コンシューマータスクの CPU とメモリの平均使用量

Container Insights によって Amazon ECS サービスから収集された Prometheus のカスタムメトリクスを使ってオートスケーリングを実装する際の手順は、上記と全く同じです。これらのメトリクスは、CloudWatch SDK を使って送信されるものと同様に、最終的には CloudWatch カスタムメトリクスとしても公開されます。

まとめ

アプリケーションから収集されたカスタムメトリクスが、アプリケーションによる CPU やメモリの使用量を提供するメトリクスよりも、オートスケーリングアクションを実行するためのより優れた指標となるユースケースが多くあります。Amazon ECS にデプロイされたマイクロサービスには、CloudWatch SDK または CloudWatch Container Insights の Prometheus メトリクスのモニタリングのいずれかを使用して、そのようなカスタムアプリケーションメトリクスを収集するオプションがあります。単調に増加する値であるカウンターは、処理された HTTP リクエストの数、キューから処理されたメッセージの数、実行されたデータベーストランザクションの数などの累積メトリクスをキャプチャするためによく使用されます。この記事では、Amazon ECS クラスターにデプロイされたマイクロサービスを効果的にオートスケールするために、このようなカスタム累積メトリクスと組み合わせて Application Auto Scaling を使用するアプローチを紹介しました。

翻訳はプロフェッショナルサービスの杉田が担当しました。原文はこちらです。