統合 CloudWatch Agent のカスタムメトリクスとログ監視の設定を Ansible と CloudFormation で構成する

# 概要

## やりたいこと

1. EC2 のメモリ使用率・ディスク使用率などを CloudWatch で監視したい
2. 各種サービスやアプリケーションが出力するログも CloudWatch に送信したい
3. ただし管理コンソールや AWS Systems Manager を使わないで、Ansible と CloudFormation だけで構成・更新したい

上記 3. で試行錯誤したので、備忘録を記事にしました。

## 環境

- Ansible 2.9 と 2.10 で動作しました。3.x は未使用です
- Amazon Linux 2 を使用します

## 予備知識

まず CloudWatch の全体像について、以下の動画で勉強させていただきました。

- [【AWS】CloudWatch メトリクスを中心に6つの観点から徹底解説！｜くろかわこうへい【渋谷で働いてたクラウドエンジニアTV】](https://www.youtube.com/watch?v=0QoTZG_oYHY&t=653s)
- [【AWS Black Belt Online Seminar】Amazon CloudWatch｜Amazon Web Services Japan 公式](https://www.youtube.com/watch?v=gOaZeJpb0Y4)

# CloudFormation で EC2 に IAM Role をアタッチする

監視対象の EC2 インスタンスが CloudWatch にメトリクスやログを送信するためには、適切な IAM ロールが必要です。

```Yaml:ec2-iam-role-stack.cfn.yaml
AWSTemplateFormatVersion: '2010-09-09'

Parameters:
  MyEc2KeyName:
    Type: "AWS::EC2::KeyPair::KeyName"

Resources:
  # CloudWatchに送信するためのIAMロール定義
  MyIamRole:
    Type: "AWS::IAM::Role"
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Service:
                - "ec2.amazonaws.com"
            Action: 
              - "sts:AssumeRole"
      Path: "/"
      # EC2に適用したい管理ポリシーのARNをここに列挙する
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy

  # 上で定義したIAMロールをEC2インスタンスに適用するために
  # IAMインスタンスプロファイルを定義する
  MyInstanceProfile:
    Type: "AWS::IAM::InstanceProfile"
    Properties:
      Path: "/"
      # 上で定義したIAMロールを関連付ける
      # 注意: 1つのEC2インスタンスに関連付けられるIAMロールは1つだけ
      Roles:
        - !Ref MyIamRole

  # 監視対象のEC2インスタンス
  MyEc2Instance:
    Type: "AWS::EC2::Instance"
    Properties:
      ImageId: ami-00d101850e971728d
      InstanceType: t2.micro
      KeyName: !Ref MyEc2KeyName
      # 上で定義したIAMインスタンスプロファイルをアタッチする
      IamInstanceProfile: !Ref MyInstanceProfile
```

EC2 インスタンス、IAM インスタンスプロファイル、IAM ロール、管理ポリシーという 4 種類のリソースの関係が分かりにくいのでクラス図を作ってみたら、下図のようになりました。

```plantuml
@startuml
class "AWS::EC2::Instance" {
  IamInstanceProfile
}

class "AWS::IAM::InstanceProfile" {
  Roles
}

class "AWS::IAM::Role" {
  ManagedPolicyArns
}

class "AWS::IAM::ManagedPolicy" {
  Path
}

"AWS::EC2::Instance" "0..*" o- "0..1" "AWS::IAM::InstanceProfile" : IamInstanceProfile
"AWS::IAM::InstanceProfile" "0..*" o- "1" "AWS::IAM::Role" : Roles
"AWS::IAM::Role" "0..*" o- "0..*" "AWS::IAM::ManagedPolicy" : ManagedPolicyArns
@enduml
```

InstanceProfile の Roles プロパティはリスト型なので、複数の IAM ロールを指定できるように見えますが、「EC2 インスタンスに同時に関連付けられるロールは 1 つだけ」という成約があるため、結果的に関連付けられる IAM ロールは 1 つだけになります。 → 参考: [AWS::IAM::InstanceProfile - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-iam-instanceprofile.html#aws-resource-iam-instanceprofile-properties)

## IAM ロールと管理ポリシー

適用したい管理ポリシーの ARN を、`AWS::IAM::Role` の `ManagedPolicyArns` プロパティに列挙します。

- [AWS::IAM::ManagedPolicy | AWS User Guide](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-managedpolicy.html)
- [AWS::IAM::Role | AWS User Guide](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-role.html)

上記の `arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy` は、メトリクスやログを CloudWatch に送信するために必要な管理ポリシーです。

これ以外にもアタッチしたい管理ポリシーがあれば、ここに並べて指定します。
例えば S3 へのアクセスが必要なら `arn:aws:iam::aws:policy/AmazonS3FullAccess` などを追加します。

参考： [管理ポリシーとインラインポリシー｜AWS ユーザーガイド](https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/access_policies_managed-vs-inline.html)

管理ポリシーの ARN が分からない場合は、AWS 管理コンソールの「サービス」→ 「IAM」→「ポリシー」に名前の一部を入力すれば簡単に見つけることができます。

下図は「s3」でポリシーを絞り込んで「AmazonS3FullAccess」を選択し、表示された ARN をクリップボードにコピーする例です。

![search-policy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/169872/d8114815-0768-fef1-3c15-833d41c1c47c.png)

![view-policy-arn.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/169872/30bb1fd9-97b3-f102-f186-b75660b56516.png)

## IAM インスタンスプロファイル

IAM ロールを直接 EC2 インスタンスにアタッチすることができないので、IAM ロールを関連付けた IAM インスタンスプロファイルをアタッチすることで、間接的に IAM ロールを EC2 インスタンスに関連付けます。
ただし関連付けられる IAM ロールは １ つだけです。

- [AWS::IAM::InstanceProfile | AWS User Guide](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-iam-instanceprofile.html)

## EC2 インスタンス

上で定義した IAM インスタンスプロファイルを、監視対象の `AWS::EC2::Instance` の `IamInstanceProfile` プロパティにアタッチします。

- [AWS::EC2::Instance | AWS User Guide](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-ec2-instance.html)

# Ansible で統合 CloudWatch Agent を構成する

CloudWatch Agent 用の Ansible ロールを作成します。

```
roles/
├── cloudwatch_agent/
│   ├── handlers/
│   │   └── main.yml
│   ├── tasks/
│   │   └── main.yml
│   └── templates/
│       └── config.json.j2
```

## テンプレート（設定ファイル）

Gunicorn と Nginx のログ、およびディスクとメモリの使用率を収集して CloudWatch に送信するための定義の例です。

```Json:templates/config.json.j2
{
  "agent": {
    "metrics_collection_interval": 60
  },
  "logs": {
    "logs_collected": {
      "files": {
        "collect_list": [
          {
            "file_path": "/var/log/gunicorn/access.log*",
            "log_group_name": "gunicorn.access",
            "log_stream_name": "{local_hostname}"
          },
          {
            "file_path": "/var/log/gunicorn/error.log*",
            "log_group_name": "gunicorn.error",
            "log_stream_name": "{local_hostname}"
          },
          {
            "file_path": "/var/log/nginx/access.log*",
            "log_group_name": "nginx.access",
            "log_stream_name": "{local_hostname}"
          },
          {
            "file_path": "/var/log/nginx/error.log*",
            "log_group_name": "nginx.error",
            "log_stream_name": "{local_hostname}"
          }
        ]
      }
    }
  },
  "metrics": {
    "append_dimensions": {
      "AutoScalingGroupName": "${aws:AutoScalingGroupName}",
      "ImageId": "${aws:ImageId}",
      "InstanceId": "${aws:InstanceId}",
      "InstanceType": "${aws:InstanceType}"
    },
    "metrics_collected": {
      "collectd": {
        "metrics_aggregation_interval": 60
      },
      "disk": {
        "measurement": [
          "used_percent"
        ],
        "metrics_collection_interval": {{ cw_interval_default }},
        "resources": [
          "*"
        ]
      },
      "mem": {
        "measurement": [
          "mem_used_percent"
        ],
        "metrics_collection_interval": {{ cw_interval_default }}
      },
      "statsd": {
        "metrics_aggregation_interval": 60,
        "metrics_collection_interval": 10,
        "service_address": ":8125"
      }
    }
  }
}
```

紛らわしいですが、Ansible (Jinja2) のプレースホルダーは `{{ cw_interval_default }}` だけで、`{local_hostname}` と `${aws: … }` は CloudWatch Agent が使用するプレースホルダーと変数です。

### agent セクション

CloudWatch Agent の全体的な設定です。
`"run_as_user":` オプションを追加して実行ユーザーを指定することができます。省略すると root で実行されます。

### logs セクション

収集するログファイルの設定です。
`"file_path":` オプションでログファイルのパスを指定します。`*`（アスタリスク）と `**`（スーパーアスタリスク）を使用することができます。

agent セクションで root 以外の実行ユーザーを指定した場合は、読み取り権限のないログファイルを収集することができなくなります。

### metrics セクション

カスタムメトリクスの定義と収集の設定です。
内部で使用する collectd と StatsD という 2 つのサービスの設定を含みます。
`"namespace":` オプションでメトリクスの名前空間名を指定することができます。省略すると `CWAgent` になります。

設定ファイルを編集するための詳細情報は、以下のページを参照にさせていただきました。

- [CloudWatch エージェント設定ファイルを手動で作成または編集する｜AWS ユーザーガイド](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/CloudWatch-Agent-Configuration-File-Details.html)
- [CloudWatch エージェントの設定方法｜myn’s diary](https://myn.hatenablog.jp/entry/cloudwatch-agent-configuration)

ウィザードを使用すれば、質問に答えるだけの対話方式で設定ファイルを作成することができます。以下を参考にさせていただきました。

- [ウィザードを使用して CloudWatch エージェント設定ファイルを作成する｜AWS ユーザーガイド](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/create-cloudwatch-agent-configuration-file-wizard.html)
- [CloudWatch で EC2 のメモリ・ディスク使用率を監視する｜Wedding Park ブログ](https://engineers.weddingpark.co.jp/aws-cloudwatch-ec2/)
- [新しいCloudWatch Agentでメトリクスとログの収集が行なえます｜DevelopersIO](https://dev.classmethod.jp/articles/new-cloudwatch-agent-collect-metrics-and-logs/)

## タスク

Amazon Linux 2 に CloudWatch Agent をインストールして、上記テンプレートの設定ファイルを配置する例です。

```Yaml:tasks/main.yml
- name: Install or update Amazon packages
  yum:
    name:
      - amazon-cloudwatch-agent
      - amazon-linux-extras
      - amazon-linux-extras-yum-plugin
    state: latest

- name: Install collectd from Amazon Extras Library
  shell: amazon-linux-extras install collectd
  changed_when: false

- name: Enable and start collectd service
  service:
    name: collectd
    enabled: yes
    state: started

- name: Enable and start amazon-cloudwatch-agent service
  service:
    name: amazon-cloudwatch-agent
    enabled: yes
    state: started

- name: Deploy CloudWatch Agent configuration file from templates
  template:
    dest: "/opt/aws/amazon-cloudwatch-agent/bin/config.json"
    src:  "{{ role_path }}/templates/config.json.j2"
  notify:
    - "cloudwatch agent config changed"
```

2 番めの shell モジュールを使った collectd のインストールは、AWS 公式ドキュメントに合わせて amazon-linux-extras コマンドを使用していますが、yum でインストールしても問題ないと思います。

3 番目と 4 番目の service モジュールで各サービスを起動して自動起動に設定します。

最後に template モジュールで設定ファイルを `/opt/aws/amazon-cloudwatch-agent/bin/` に配置し、ファイルに変更があればハンドラを実行します。

設定ファイルを `/opt/aws/amazon-cloudwatch-agent/etc/` に配置して Playbook を実行するとファイルが削除されてしまうため、設定ファイルに変更がなくても `changed` になってハンドラが実行されてしまいました。
これを避けるために、ウィザードの出力先と同じ `bin/` に設定ファイルを配置するようにしました。

## ハンドラ

設定ファイルをの変更をトリガーとして実行されます。

```Yaml:handlers/main.yml
- name: Fetch configuraton file and restart CloudWatch Agent
  shell: ./amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:config.json -s
  args:
    chdir: "/opt/aws/amazon-cloudwatch-agent/bin/"
  listen: "cloudwatch agent config changed"
```

`amazon-cloudwatch-agent-ctl` コマンドを実行して、設定ファイルの定義を CloudWatch Agent に反映します。

- `-a` オプションで実行するアクション（fetch-config）を指定
- `-c` オプションで設定ファイルのパスを指定
- `-s` オプションでサービスを再起動して、設定ファイルの内容を反映します

`amazon-cloudwatch-agent-ctl` コマンドについては以下を参照してください。

- [コマンドラインを使用して CloudWatch エージェントを起動する｜AWS ユーザーガイド](https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/install-CloudWatch-Agent-commandline-fleet.html#start-CloudWatch-Agent-EC2-commands-fleet)

## Playbook

以上のロールを既存の Playbook に追記するか、以下のように Playbook を作成して `ansible-playbook` コマンドで実行します。

```Yaml:my_playbook.yml
- hosts: all
  become: yes
  roles:
    - role: cloudwatch_agent
```

テンプレートで使用する変数は、どこかで `cw_interval_default = 60` のように定義されているものとします。

# 確認

Playbook が完走すると、EC2 インスタンスがメトリクスとログを CloudWatch に送信し始めるので、AWS 管理コンソールにログインして確認することができます。

## カスタムメトリクス

送信されたカスタムメトリクス名が CloudWatch → メトリクス → エクスプローラー → メトリクス に表示され、選択することができます。

![custom-metrics.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/169872/699fff0d-c9e4-e9ca-7cb0-39082df17b70.png)

## ログ

送信されたロググループ名が CloudWatch → ログ → ロググループ に表示され、選択すると各 EC2 インスタンスの一覧が表示され、さらにインスタンスを選択するとログの内容が表示されます。

![loggroup.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/169872/3f4caeb2-e96d-95e7-2309-bb712305b39d.png)

## トラブルシューティング

意図したとおりに動作しない場合は、SSH でインスタンスにログインして、以下を確認してみてください。

- まず `/opt/aws/amazon-cloudwatch-agent/` に移動する
- Ansible で配置した `bin/config.json` が存在するか、内容が意図したとおりかを確認する
- `etc/amazon-cloudwatch-agent.toml`（実行時に生成される）の内容が `config.json` の内容と一致するを確認する
- `logs/` 内のログを読む
- SSH 端末から手動で `bin/amazon-cloudwatch-agent-ctl -a fetch-config -m ec2 -c file:bin/config.json -s` を実行して、出力されるメッセージを読む
- `bin/amazon-cloudwatch-agent-ctl -a status` を実行して、CloudWatch Agent のサービスの動作状態を確認する