CloudWatch Agent on ECS で Prometheus メトリクスを CloudWatch Metrics に集約する

Prometheus(OpenMetrics)フォーマットのメトリクスの公開エンドポイントのメトリクスを収集、 CloudWatch Metrics に収集したメトリクス値を集約し、ダッシュボードでの可視化やアラームを設定できるように試みました。

AWS のドキュメントなどを参照しても、それぞれの設定がどのように作用するのかがイメージしづらく、他に参考になるような資料も少なかったため、私自身がイメージしていた動作になるまでまで四苦八苦してしまいました。

こうすると動くみたいなのはよく書いてあるんですが、その設定がどういう働きをしているのかがわからず、どうデバッグしたらいいのかわからなかったので、「再び苦労する方が少なくなる方が少なくなれば」との思いで、どういう風に動いてそうか、どういう風に設定するのか、設定項目の解説などを書いてみました。

## アーキテクチャ (たぶんこんな感じ)

ここでは WildFly (Java EE Server) にビルトインされているメトリクスのエンドポイントから CloudWatch Agent を用いて収集し、CloudWatch Logs に収集したメトリクス値を書き込みにいく構成を作ってみています。

![architecture.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/57967/78c59d80-46eb-995e-9213-e2d5e734ed67.png)

### CloudWatch Agent の動作シーケンスイメージ

1. サービスディスカバリ: CloudWatch Agent の設定に従い、メトリクスの収集対象の ECS Task の情報を AWSCLI で確認する。
1. メトリクス収集: 1. で確認した ECS Task の情報 (IPアドレスなど) を元にメトリクスのエンドポイントにアクセスしてメトリクスを収集する。
1. メトリクス集約: 収集したメトリクス情報を CloudWatch Metrics にメトリクス値が書き込める「[埋め込みフォーマット](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch_Embedded_Metric_Format_Specification.html)」に変換して、CloudWatchLogsに配置する。


## 設定の流れ

* CloudWatch Agent を ECS Service としてデプロイする
* 収集したいメトリクスのエンドポイントを自動ディスカバリする設定をする
* 収集したメトリクスを CloudWatch Metrics に収集できる形にする

### CloudWatch Agent を ECS Service としてデプロイする

ゴール: CloudWatch Agent コンテナを ECS Service としてデプロイする (メトリクスの収集などはしない)


#### タスクロールの設定

サービスディスカバリ(アーキテクチャのシーケンス①)ができるように、下記のいずれの IAMPolicy もタスクロールに設定しておく必要がある。

1. AWS管理ポリシー: `CloudWatchAgentServerPolicy`
2. 下記のカスタム管理ポリシーで許可するアクション

```json:
ec2:DescribeInstances
ecs:ListTasks
ecs:ListServices
ecs:DescribeContainerInstances
ecs:DescribeServices
ecs:DescribeTasks
ecs:DescribeTaskDefinition
```

#### ECS Task Definition 作成

1. コンテナイメージ
DockerHub にもあるが、 無償利用の場合は Pull 回数に制限があったり、帯域が狭かったりするので、 ECR Public Repositories に公開されているイメージを利用すると考えます。
   * ECR Public の CloudWatch Agent コンテナイメージ: [`public.ecr.aws/cloudwatch-agent/cloudwatch-agent:1.247350.0b251780`](https://gallery.ecr.aws/cloudwatch-agent/cloudwatch-agent)

1. CloudWatch Agent の空の設定
   環境変数 `CWAGENT_CONFIG` として、下記の設定(JSON)が読み込まれるようにする。
   ```js:CWAGENT_CONFIG
   {
     "agent": {
       "debug": true // CloudWatchAgentのデバッグレベルのログが出力(標準出力)されるようになる。
     }
   }
   ```

#### ECS Service 作成

DesiredCount (起動するタスク数) は １つ になるように設定する。
これでただ、 CloudWatch Agent の Task が起動する(しつづける)だけの状態が作れるか試す。


### 収集したいメトリクスのエンドポイントをディスカバリする設定をする

Task Definition のところで用意した `CWAGENT_CONFIG` の JSON に Prometheus のエンドポイントをディスカバリする設定を継ぎ足します。

```js:CWAGENT_CONFIG
{
  "agent": {
    "debug": true
  },
  "logs": {
    "metrics_collected": {
      "prometheus": {
        // Prometheus の設定情報をファイル、または環境変数から読み出す設定
        // ここでは、PROMETHEUS_CONFIG_CONTENT という環境変数から読み出している。設定の内容は後述。
        "prometheus_config_path": "env:PROMETHEUS_CONFIG_CONTENT",
          // ECS 上にデプロイされているメトリクス
        "ecs_service_discovery": {
          // CWAgent がディスカバリを行う間隔です (1m となっていれば、 1分毎に収集対象を更新してくれます)
          "sd_frequency": "1m",
          // ディスカバリした結果が設定ファイルとしてこのパスに出力される。
          // 特に編集したりすることはないですが、どんなメトリクスエンドポイントがディスカバリされたかなどを確認するときはこのファイルを参照すると良いでしょう。
          "sd_result_file": "/tmp/cwagent_ecs_auto_sd.yaml",
          // ECSサービス名が正規表現パターンに一致したものに紐づくタスクを列挙してくれる設定です。他にも タスク定義のARNベースや、Dockerラベルベースで検出してくれるものもある。
          "service_name_list_for_tasks": [
            {
              // メトリクスエンドポイントが公開されているポート番号
              "sd_metrics_ports": "9990",
              // ECSサービス名の正規表現パターン。このパターンにマッチしたものがすべて列挙される。(この場合は、全ての ECS Service がディスカバリ対象となります)
              // ※ディスカバリ対象のECSクラスタ名を ecs_service_discovery の sd_target_cluster に書いていない場合は、デプロイされたクラスタが対象となる。
              "sd_service_name_pattern": ".*",
              // メトリクスのエンドポイントを公開しているパス
              // /metrics の場合に限りこの設定を省略することもできるようです。
              "sd_metrics_path": "/metrics"
            }
          ]
        }
      }
    }
  }
}
```

`CWAGENT_CONFIG` 内に記述した、 `PROMETHEUS_CONFIG_CONTENT` の環境変数についても設定を用意する。
これは、 [Prometheus の設定](https://prometheus.io/docs/prometheus/latest/configuration/configuration/) そのもので、この設定を CloudWatch Agent が読んで使ってくれるようです。

```yaml:PROMETHEUS_CONFIG_CONTENT
global:
  # メトリクスエンドポイントをスクレイピングする間隔
  # 1m となっている場合は、 1分毎に メトリクスエンドポイント、今回の場合は /metrics をスクレイピングしてくれる
  scrape_interval: 1m
  # スクレイピングするときのタイムアウト設定
  # 10s となっている場合は、１つのメトリクスエンドポイントに対してのスクレイピングの開始から10秒以内に応答しない場合はタイムアウトしてメトリクスの収集を中断する
  scrape_timeout: 10s
scrape_configs:
  # スクレイピングする設定をジョブ名といったキーで管理する
  - job_name: wildfly-metrics
    # １つのメトリクスエンドポイントから収集するメトリクスの最大数
    sample_limit: 10000
```

上記の環境変数を Task Definition に設定して、CloudWatch Agent を動作させると、サービスディスカバリが行われ、
ディスカバリされたメトリクスのエンドポイントから収集したメトリクスが CloudWatch Logs ロググループ `/aws/ecs/containerinsights/{CLUSTER_NAME}/prometheus` に出力されるようになる。
※これはCWAgentタスク自体のログドライバ(awslogs)等によって転送されるロググループとは異なるロググループで、収集したメトリクス情報のみを書き込むロググループ。デフォルトでは上記ロググループ名になるが、 `CWAGENT_CONFIG` にロググループを指定することも可能らしい。

このロググループに出力がない場合は、メトリクスの収集ができていないので、以下の順で問題がないか確認する。

1. CloudWatch Agent のタスクのログを `AWSCLI` で検索して以下のように、検出に成功している場合は、横に1以上の数値が記録されているはずです。
   ```
   2022-03-29T07:22:31Z D! ECS_SD_Stats: AWSCLI_DescribeTasks: 1
   2022-03-29T07:22:31Z D! ECS_SD_Stats: AWSCLI_ListServices: 1
   2022-03-29T07:22:31Z D! ECS_SD_Stats: AWSCLI_ListTasks: 1
   ```
1. 1 以上になっていない場合は、 `sd_metrics_ports` に設定したポートがパブリッシュされているか確認する。
1. 1 以上になっている場合は、ディスカバリされたタスクのセキュリティグループなどでメトリクスのエンドポイントのポートへのアクセスができない状態になっていないか確認する。


### 収集したメトリクスを CloudWatch Metrics に集約できる形にする

上記まででは、メトリクスを収集してそれをそのままロググループに流しているだけで、 CloudWatch Metrics に記録されるようにはならないため、`CWAGENT_CONFIG` に設定を追加し、どういったネームスペースに、どういったディメンションで記録するかを `emf_processor` という項目に書いていく。

ここでは以下のようなメトリクスがメトリクスエンドポイントにて公開されていたとする。

```prometheus:/metrics
# HELP base_gc_total Displays the total number of collections that have occurred. This attribute lists -1 if the collection count is undefined for this collector.
# TYPE base_gc_total counter
base_gc_total{name="G1 Old Generation"} 0.0
base_gc_total{name="G1 Young Generation"} 34.0
```

この時点では上記に関するメトリクス情報が出力されているロググループに出力されている値は以下のようになっている。

```js:/aws/ecs/containerinsights/{CLUSTER_NAME}/prometheus
{
    "ClusterName": "demo",
    "LaunchType": "FARGATE",
    "ServiceName": "wildfly-demo",
    "StartedBy": "ecs-svc/123456789",
    "TaskClusterName": "demo",
    "TaskDefinitionFamily": "wildfly-demo",
    "TaskGroup": "service:wildfly-demo",
    "TaskId": "xxxxxx",
    "TaskRevision": "2",
    "Timestamp": "1648540305048",
    "Version": "0",
    "base_gc_total": 4,
    "container_name": "wildfly",
    "instance": "10.123.123.123:9990",
    "job": "demo",
    "name": "G1 Old Generation",
    "prom_metric_type": "counter"
}
```

上記メトリクスの情報を CloudWatch Metrics に書き込めるようにする `emf_processor` 設定する。

```js:CWAGENT_CONFIG
{
  "agent": {
    "debug": true
  },
  "logs": {
    "metrics_collected": {
      "prometheus": {
        "prometheus_config_path": "env:PROMETHEUS_CONFIG_CONTENT",
        "ecs_service_discovery": { /** 省略 */ }
      },
      "emf_processor": {
        "metric_declaration_dedup": true,
        "metric_declaration": [
            {
              "source_labels": [
                "^base_gc"  // base_gc から始まるメトリクス名があるものについて下記の設定に従い、
              ],
              // 下記のディメンションで記録させる
              "dimensions": [
                [
                  "ClusterName", // 上記メトリクスログにおける ClusterName の値をディメンションに設定
                  "ServiceName", // 上記メトリクスログにおける ServiceName の値をディメンションに設定
                  "GC"           // メトリクスログに GC という項目 がないが、name の値を GC として記録したい。(設定方法は後述)
                ]
              ],
              // メトリクス値として記録する値のメトリクス名
              "metric_selectors": [
                "^base_gc"
              ]
            },
        ]
      }
    }
  }
}
```

ログに出力されている `name` の値を GC として読み替えさせる設定を `PROMETHEUS_CONFIG_CONTENT` の `metric_relabel_configs` に記述する。

```yaml:PROMETHEUS_CONFIG_CONTENT
global:
  scrape_interval: 1m
  scrape_timeout: 10s
scrape_configs:
  - job_name: demo
    sample_limit: 10000
    file_sd_configs:
      - files: ["/tmp/cwagent_ecs_auto_sd.yaml"]
    metric_relabel_configs:
      - source_labels: ["name"] # name の項目を
        target_label: GC        # GC に置き変える (source_labels は ["name", base_gc_total"] としたほうがいい可能性がある。replacement: $1 という設定が省略されているが、 source_labels の第一引数の値を置換えてくれる模様。
```

この設定により、下記のように出力され、CloudWatch Metrics にも上記で設定したディメンションで記録される。

```js:/aws/ecs/containerinsights/{CLUSTER_NAME}/prometheus
{
    "CloudWatchMetrics": [
        {
            "Metrics": [
                {
                    "Name": "base_gc_total"
                }
            ],
            "Dimensions": [
                [
                    "ClusterName",
                    "GC",
                    "ServiceName"
                ]
            ],
            "Namespace": "ECS/ContainerInsights/Prometheus" // Namespace は特別設定しないとこの名前になる
        }
    ],
    "ClusterName": "demo",
    "GC": "G1 Old Generation",  // この項目が name と同じ内容で追加されている
    "LaunchType": "FARGATE",
    "ServiceName": "wildfly-demo",
    "StartedBy": "ecs-svc/123456789",
    "TaskClusterName": "demo",
    "TaskDefinitionFamily": "wildfly-demo",
    "TaskGroup": "service:wildfly-demo",
    "TaskId": "xxxxxx",
    "TaskRevision": "2",
    "Timestamp": "1648540305048",
    "Version": "0",
    "base_gc_total": 4,
    "container_name": "wildfly",
    "instance": "10.123.123.123:9990",
    "job": "demo",
    "name": "G1 Old Generation",
    "prom_metric_type": "counter"
}
```