🧭

CloudWatch Agent でECSサービスのカスタムメトリクスを収集する

2023/07/28に公開

CloudWatch Agent を用いてECS上のWebアプリケーション (Java + Spring Boot) のカスタムメトリクスを取得するためのメモ。 以下手順

  1. アプリケーションからメトリクスを取得可能にする
  2. CloudWatch Agent をECSサービスとしてデプロイする
  3. CloudWatch Agent に収集するメトリクスの設定を行う

CloudWatch Agent がメトリクスを収集する仕組み

CloudWatch Agent は Prometheus 形式のメトリクスを収集 (スクレイプ) して CloudWatch に送信する機能がある。ざっくりとした仕組みはこんな感じ。

  1. スクレイプ対象の自動検出
    • ECSクラスター内のどのコンテナがスクレイプ対象であるかを調べる
    • 以下の3種類の方法がある
      • Dockerラベル
      • タスク定義のARN
      • ECSサービス名
  2. 対象に対して一定時間毎にスクレイプを実行
  3. CloudWatch に埋め込みメトリクス形式 でログイベントを送信
    • 埋め込みメトリクス形式はCloudWatchがメトリクスをログイベント経由で取り込むための特殊なログフォーマット
    • これによりカスタムメトリクスをCloudWatchに取り込むことが可能

アプリケーションからメトリクスを取得可能にする

Spring Boot ベースの Web アプリケーションからメトリクスを取得できるようにするには Spring Boot Actuator を利用する。

Gradle を利用している場合は build.gradle に依存を追加する。

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-actuator:${バージョン}'
}

設定ファイル (application.yml) に以下を記述して Prometheus Exporter のエンドポイントを追加する。他に有効化したいものがあれば一緒に追加する。

management.endpoints.web.exposure.include: health,prometheus

これでアプリケーションを起動して /actuator/prometheus を HTTP GET すると、以下のようなPrometheusメトリクスの時系列データが取得できる。

# HELP tomcat_sessions_expired_sessions_total  
# TYPE tomcat_sessions_expired_sessions_total counter
tomcat_sessions_expired_sessions_total 0.0
# HELP jdbc_connections_min Minimum number of idle connections in the pool.
# TYPE jdbc_connections_min gauge
jdbc_connections_min{name="dataSource",} 20.0
# HELP tomcat_global_error_total  
# TYPE tomcat_global_error_total counter
tomcat_global_error_total{name="http-nio-8080",} 0.0
# HELP tomcat_cache_access_total  
# TYPE tomcat_cache_access_total counter
tomcat_cache_access_total 0.0
# HELP executor_completed_tasks_total The approximate total number of tasks that have completed execution
# TYPE executor_completed_tasks_total counter
executor_completed_tasks_total{name="applicationTaskExecutor",} 0.0
# HELP tomcat_connections_config_max_connections  

...

Prometheusメトリクスの時系列データは以下のようなフォーマットになっている。
ラベルはCloudWatchメトリクスにおけるDimensionと同様の概念。

<メトリクス名>{<ラベル名>=<ラベル値>, ...} <メトリクス値>

このアプリケーションのコンテナをスクレイプ対象にするため、タスク定義に以下のDockerラベルを追加してECSにデプロイする。

  • ECS_PROMETHEUS_EXPORTER_PORT
    • メトリクスを取得するポート
  • ECS_PROMETHEUS_EXPORTER_PATH
    • メトリクスを取得するパス
  • ECS_PROMETHEUS_JOB_NAME
    • ジョブ名。取得対象につけるIDのようなもので、ECSクラスター内で一意になるようにしておく。タスク定義名とかで良い
{
  "containerDefinitions": {
    "dockerLabels": {
      "ECS_PROMETHEUS_EXPORTER_PORT": "8080",
      "ECS_PROMETHEUS_METRICS_PATH": "/actuator/prometheus"
      "ECS_PROMETHEUS_JOB_NAME": "sample-service-01-app",
    },
    ...
  }
  ...
}

CloudWatch Agent をECSサービスとしてデプロイする

手前味噌だが、下記の cloudwatch-agent Terraform モジュールを用いて CloudWatch Agent を ECS サービスとしてデプロイする。

https://registry.terraform.io/modules/kota65535/cloudwatch-agent/aws/latest

module "cloudwatch_agent" {
  source  = "kota65535/cloudwatch-agent/aws"
  version = "0.1.0"

  ecs_cluster = aws_ecs_cluster.main
  subnet_ids  = [
    "subnet-0abaada26acb8894f"
  ]
  security_group_ids = [
    "sg-00c7b719903e499ea"
  ]
  log_group_name = "/sample/cwagent"

  metric_namespace = "Prometheus"
}

resource "aws_ecs_cluster" "main" {
  name = "sample"
}

いったん terraform apply してECSサービスとしてデプロイされることを確認する。
スクレイプが成功している場合、指定のロググループに ECS_PROMETHEUS_JOB_NAME Dockerラベルで指定したジョブ名のストリームが作成され、以下のようなログが出力されている。

{
    "ClusterName": "sample-service",
    "ECS_PROMETHEUS_EXPORTER_PORT": "8080",
    "ECS_PROMETHEUS_JOB_NAME": "sample-service-01-app",
    "ECS_PROMETHEUS_METRICS_PATH": "/actuator/prometheus",
    "LaunchType": "FARGATE",
    "StartedBy": "ecs-svc/5171949556776798795",
    "TaskClusterName": "sample-service",
    "TaskDefinitionFamily": "sample-service-01",
    "TaskGroup": "service:sample-service-01",
    "TaskId": "d4d46727356d440c950cff309118120b",
    "TaskRevision": "154",
    "Timestamp": "1690508179842",
    "Version": "0",
    "action": "end of major GC",
    "cause": "Allocation Failure",
    "container_name": "app",
    "gc": "MarkSweepCompact",
    "instance": "172.28.20.43:8080",
    "job": "sample-service-01-app",
    "jvm_gc_pause_seconds_count": 0,
    "jvm_gc_pause_seconds_sum": 0,
    "prom_metric_type": "summary"
}

これは CloudWatch Agent がスクレイプにより取得したメトリクスの時系列データである。
Prometheusメトリクス自体のラベルと混同して紛らわしいが、このデータの各フィールドもラベルと呼称される。
ClusterName, TaskDefinitionFamily, container_name などはスクレイプ対象の詳細を示すラベルである。
jvm_gc_pause_seconds_sum, gc, cause などはスクレイプにより取得したPrometheusメトリクス名およびそのラベルとなる。

CloudWatch Agent に収集するメトリクスの設定を行う

まだこの時点ではCloudWatchには何のメトリクスも取り込まれていない。
収集したPrometheusメトリクスをCloudWatchに認識させるには、埋め込みメトリクス形式で送信するための設定をする必要がある。
重要なのは以下の3つのパラメータ。

  • metric_namespace
    • CloudWatchのメトリクスのNamespace。
  • metric_unit
    • メトリクス名とメトリクスの単位のマップ。 Bytes, Seconds, Counts など
    • 利用可能な単位のリストはここを参照
  • metric_declaration
    • 埋め込みメトリクス形式への変換設定のリスト。以下のフィールドを持つ
    • source_labels
      • 変換対象のメトリクスデータを判別するためのラベル名
      • たいてい jobcontainer_name あたりを設定する
    • label_matcher
      • 変換対象のメトリクスデータを判別するためのラベル値の正規表現パターン
      • source_labels で指定されたラベルの値がパターンと一致した場合に変換対象となる
      • source_labels が複数指定された場合は、 ; で区切ってパターンを複数記述する
    • dimensions
      • CloudWatchメトリクスのDimension
      • 基本的にはメトリクスのラベルを指定しつつ、取得対象が判別できるように設定すれば良い
    • metric_selectors
      • 変換対象のメトリクス名の正規表現パターン

つまり、 source_labels で指定したラベルの値が label_matcher の正規表現とマッチしている場合に、 metric_selectors でマッチしたラベルをメトリクス名、 dimensions に指定したラベルをDimensionとしてメトリクスデータが取り込まれる。

以下設定例。

{
  ...
        "emf_processor": {
          "metric_namespace": "Prometheus",
          "metric_unit":{
            "jvm_gc_pause_seconds_count": "Count",
            "jvm_gc_pause_seconds_max": "Seconds",
            "jvm_gc_pause_seconds_sum": "Seconds"
          },
          "metric_declaration": [
            {
              "source_labels": [
                "container_name"
              ],
              "label_matcher": "^app$",
              "dimensions": [
                [
                  "ClusterName",
                  "TaskDefinitionFamily",
                  "action",
                  "cause"
                ]
              ],
              "metric_selectors": [
                "^jvm_gc_pause_seconds_count$",
                "^jvm_gc_pause_seconds_max$",
                "^jvm_gc_pause_seconds_sum$"
              ]
            }
          ]
        }
      }
    }
  }
}

基本的には各メトリクスに対してこの設定を記述することになるが、正直かなりめんどい。
そこでPrometheusのメトリクスデータを入力としてこの設定をいい感じに自動的に生成するスクリプトを書いた。
先程紹介した cloudwatch-agent Terraform モジュールと一緒のレポジトリに置いてある。

https://github.com/kota65535/terraform-aws-cloudwatch-agent/blob/main/scripts/prometheus.py

使い方としてはこんな感じ。
引数にPrometheusメトリクスデータを保存したファイルを指定する。

  • -s, -l オプションで全メトリクスに共通の source_labelslabel_matcher を指定
  • -d オプションは複数回の指定が可能で、全メトリクスに共通のDimensionを追加
  • -o オプションで出力先のファイルを指定
cd scripts
curl http://localhost:8080/actuator/prometheus > out.txt
./prometheus.py out.txt -s 'container_name' -l '^app$' -d 'ClusterName' -d 'TaskDefinitionFamily' -o metrics.json

この出力結果を用いて、先程の cloudwatch-agent Terraform モジュールの metric_declarationmetric_unit 入力変数を設定する。

module "cloudwatch_agent" {
  source  = "kota65535/cloudwatch-agent/aws"
  version = "0.1.0"

  ecs_cluster = aws_ecs_cluster.main
  subnet_ids  = [
    "subnet-0abaada26acb8894f"
  ]
  security_group_ids = [
    "sg-00c7b719903e499ea"
  ]
  log_group_name = "/sample/cwagent"

  metric_namespace   = "Prometheus"
  metric_declaration = file("metrics.json").metric_declaration
  metric_unit        = file("metrics.json").metric_unit
}

terraform apply して新しい CloudWatch Agent のコンテナが起動すると、メトリクスの時系列データのログに CloudWatchMetrics というフィールドが増えている。
これは CloudWatch にこのメトリクスデータが取り込まれたことを示している。

{
    "CloudWatchMetrics": [
        {
            "Metrics": [
                {
                    "Unit": "Count",
                    "Name": "jvm_gc_pause_seconds_count"
                },
                {
                    "Unit": "Seconds",
                    "Name": "jvm_gc_pause_seconds_sum"
                }
            ],
            "Dimensions": [
                [
                    "ClusterName",
                    "TaskDefinitionFamily",
                    "action",
                    "cause",
                    "gc"
                ]
            ],
            "Namespace": "Prometheus"
        }
    ],
    ...
} 

CloudWatchコンソール上でも無事メトリクスが確認できた。満足。

Discussion