Cloud Watch Alarm + SNS + LambdaでECS・Fargateのリソース状態をSlack通知する（Terraform）

# 本要件の流れ

1. Cloud Watch Alarm発動
2. SNSへメッセージを送信
3. トリガーを検知しLambdaが起動
4. LambdaからSlackへ通知を送信

# 前提

- ECSでなにかしらのアプリケーションを構築済み

# Webhookの取得

まずは実装の前にwebhookが必要なので、事前設定を行います。

[https://slack.com/apps](https://slack.com/apps) へアクセスし、

アプリを検索する > カスタムインテグレーション > Incoming Webhook

で設定の編集を行い、webhookを追加するslackのチャンネルを登録してください。

するとwebhookのurlが発行されるので、それを控えておきます。

「対象のチャンネルにWebhookを追加し、URLを取得する」ということができれば良いです。

色々と記事は出回っているのでググってもらえれば助かります。

# 実装

最初に取得したwebhookのurlは変数で渡せるようにしておきましょう。

develop/variables.tf

```go
variable "slack_webhook" {
  type      = string
  sensitive = true
}

variable "slack_emoji" {
  type    = string
  default = ""
}
```

prod/terraform.tfvars

```go
slack_webhook           = "{取得したURL}"
slack_emoji             = ""
```

一連の流れの引き金になるCloud Watchアラームを構築します。

cloudwatch.tf

```go
resource "aws_cloudwatch_metric_alarm" "ecs_cpu_low" {
  alarm_name          = "ecs-cpu-low"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 10
  metric_name         = "CPUUtilization"
  namespace           = "AWS/ECS"
  period              = 300
  statistic           = "Average"
  threshold           = 15
  treat_missing_data  = "notBreaching"
  datapoints_to_alarm = 10

  dimensions = {
    ClusterName = aws_ecs_cluster.hogehoe.name // このあたりのリソースは解説しませんのであらかじめ構築しておいてください
    ServiceName = aws_ecs_service.hogehoge.name
  }

  alarm_actions = [
    aws_sns_topic.metric_alerm_lambda.arn
  ]
}
```

Cloud Watchアラームについてはこちらの記事で解説していますので割愛します。良かったら見てやってください。今回はecsのcpuが低下した際にアラートを飛ばす設定でいきます。

https://qiita.com/hengineer/items/382c4f45b83cedfaea85

次にSNSのトピックの作成とサブスクリプションの作成を行います。

SNSのトピックとはさまざまなAWSサービスとの連携を図るためのアクセスポイントのようなものです。サブスクリプションは作成したトピックと配信対象のサービスを結びつけるためのリソースです。

sns.tf

```go
resource "aws_sns_topic" "metric_alerm_lambda" {
  name = "metric-alerm-lambda"
}

resource "aws_sns_topic_subscription" "metric_alerm_lambda" {
  topic_arn = aws_sns_topic.metric_alerm_lambda.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.metric_alerm.arn
}
```

続いてLambda周りを実装します。

files/lambda/metric_alerm.py

```python
import json
import requests
import os

def lambda_handler(event, context):
    webhook_url = os.environ['SLACK_WEBHOOK']
    emoji = os.environ['SLACK_EMOJI']

    raw_message = json.loads(event['Records'][0]['Sns']['Message'])

    slack_data = {
        'text': "<!here>\n" + "AWS緊急アラート！！！",
        'icon_emoji': emoji,
        'attachments': [
            {
                'text': raw_message,
                'title': event['Records'][0]['Sns']['Subject'],
                'color': '#ff9a17'
            }
        ]
    }

    response = requests.post(
        webhook_url, data=json.dumps(slack_data),
        headers={'Content-Type': 'application/json'}
    )
    if response.status_code != 200:
        raise ValueError(f'Request to slack returned an error {response.status_code}, the response is:\n{response.text}')
```

Pythonはよくわかっていないので説明は割愛させてください。

とりあえず緊急アラートメッセージとともに、eventから取得したsubjectを送っています。

lambda.tf

```go
resource "aws_lambda_function" "metric_alarm" {
  filename         = data.archive_file.metric_alarm.output_path
  function_name    = "metric-alarm"
  role             = aws_iam_role.lambda_sns.arn
  handler          = "metric_alarm.lambda_handler"
  source_code_hash = data.archive_file.metric_alarm.output_base64sha256
  runtime          = "python3.8"
  timeout          = 30
  layers           = [aws_lambda_layer_version.main_layer.arn]

  environment {
    variables = {
      SLACK_WEBHOOK = var.slack_webhook
      SLACK_EMOJI   = var.slack_emoji
    }
  }
}

data "archive_file" "metric_alarm" {
  type        = "zip"
  source_file = "files/lambda/metric_alarm.py"
  output_path = "files/lambda/metric_alarm.zip"
}

resource "aws_lambda_permission" "lambda_sns" {
  statement_id  = "AllowExecutionFromSNS"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.metric_alarm.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.metric_alarm_lambda.arn
}

resource "null_resource" "pip_install_main" {
  triggers = {
    timestamp = timestamp()
  }
  provisioner "local-exec" {
    command = "pip3.8 install requests -t files/lambda/layer/main/python"
  }
}

data "archive_file" "main_layer" {
  depends_on  = [null_resource.pip_install_main]
  type        = "zip"
  source_dir  = "files/lambda/layer/main"
  output_path = "files/lambda/layer/main/layer.zip"
}

resource "aws_lambda_layer_version" "main_layer" {
  layer_name          = "main_layer"
  filename            = data.archive_file.main_layer.output_path
  source_code_hash    = data.archive_file.main_layer.output_base64sha256
  compatible_runtimes = ["python3.8"]
}
```

iam周りは後ほどつくるので一旦スルーし、それ以外の箇所についてきまぐれに解説していきます。

まずはaws_lambda_functionのリソースから。

`filename` ... デプロイするlambda関数のファイルパスです。

`handler` ... lambda関数のエントリポイント。`{ファイル名（拡張子なし）}.{関数名}` で指定するっぽい。なのでこの場合は `metric_alarm.py` の `lambda_handler`の関数を呼び出すという定義になります。

`source_code_hash` ... 細かい挙動は調べられていませんがファイルの更新時のデプロイの挙動に関わる設定？のようです。（ファイルの更新があればデプロイされる？）

`runtime` ... 使用する言語。（今回は当方のローカル環境にPython3.8が入っていたのでそうなっています。）

続いてarchive_fileのリソースについて。

今回はそもそものlambda関数用のpythonスクリプトと、pythonスクリプト内で呼び出すためのライブラリをzipファイルにする必要があり、そのためにこちらのリソースタイプを使用します。

`source_file` 、`source_dir` ... アーカイブするファイルのパス、ディレクトリ。

`output_path` ... アーカイブファイルの出力先。

続いてnull_resourceについて。

これは特定のリソースに結びつかずにprovisionerが実行できるようにするためのもの。つまり今回みたいにローカルの端末にLambdaで使用するライブラリをインストールしたいみたいなコマンドをterraform実行の過程で行いたい時などに使えます。今回、PythonのLambdaにてrequestsライブラリをあらかじめインストールした状態のzipをアップロードしなければならないためそれに使用しています。

`triggers` ... 設定した値の変更を検知して、それ以下の処理が走るようになっているみたいです。動作確認も兼ねて常に実行されるようにしたかったのでtimestampの値を取っていますが、実際の運用では変えた方が良いと思います。

`provisioner` ... リソースにひも付きローカルやリモート環境で任意のスクリプトを実行できます。今回はlocalなのでローカル環境で指定したcommandが実行されます。

続いてaws_lambda_permissionについて。

外部ソース（EventBridge、SNS、S3など）がlambda関数にアクセスするために必要な許可を与えるリソースです。今回はSNSからの実行を許可する実装になっています。

続いてaws_lambda_layer_versionについて。

今回スクリプト内でimportしているrequestsというライブラリは別途インストールをする必要がありますが、同じzipファイルでまとめてupするよりも、デプロイパッケージのサイズを減らせることもあるなどからあらかじめ必要ライブラリをzip化して、lambda layerというサービスを用いてレイヤーとしてアップロードし、そのアップロードしたレイヤーをlambda関数から呼び出してライブラリを利用するという手法をとります。

特段細かくは解説はしませんが、ハマると厄介なポイントがあるのでそれだけ取り上げておきます。

null_resourceで実行するcommandでライブラリのインストールをしていると思うのですが、

このインストール先を必ず `/python`で終わるディレクトリにしてください。でなければlambda側からlambda layerにデプロイされたパッケージのパスがうまく読み取れずライブラリをimportすることができません。

（公式にも書いてありました。）

https://aws.amazon.com/jp/premiumsupport/knowledge-center/lambda-import-module-error-python/

最後にiam周りのリソースを作っておきます。

Cloud Watchへのログ出力のためです。

iam.tf

```go
resource "aws_iam_role" "lambda_sns" {
  name               = "lambda-sns"
  path               = "/"
  assume_role_policy = data.aws_iam_policy_document.lambda_sns_assume.json
}

data "aws_iam_policy_document" "lambda_sns_assume" {
  statement {
    sid    = ""
    effect = "Allow"
    actions = [
      "sts:AssumeRole"
    ]
    principals {
      type = "Service"
      identifiers = [
        "lambda.amazonaws.com"
      ]
    }
  }
}

data "aws_iam_policy_document" "lambda_sns_cloudwatch" {
  statement {
    sid    = ""
    effect = "Allow"
    actions = [
      "logs:CreateLogGroup",
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = ["arn:aws:logs:*:*:*"]
  }
}

resource "aws_iam_policy" "lambda_sns_cloudwatch" {
  name   = "lambda-sns-cloudwatch"
  policy = data.aws_iam_policy_document.lambda_sns_cloudwatch.json
}

resource "aws_iam_policy_attachment" "lambda_sns_cloudwatch" {
  name       = "lambda-sns-cloudwatch"
  roles      = [aws_iam_role.lambda_sns.name]
  policy_arn = aws_iam_policy.lambda_sns_cloudwatch.arn
}
```

あとは terraform deploy で動く、、、、、はず。

# 参考

https://ianrufus.com/blog/2019/12/terraform-cloudwatch-to-slack/