Elasticsearchに溜まったログのサマリーをLambdaで毎朝Slackに通知、する仕組みをTerraformで構築する

こちらは[DMM.com Advent Calender 2018](https://qiita.com/advent-calendar/2018/dmm) 10日目の記事です。

入社2年目の@r548です。入社してからはDMMの動画サービス、その中でも特にProductivityに関わるチームでログ収集基盤の構築やUIパーツのコンポーネント化などをやっています。

## はじめに

DMMの動画サービスでは、エラーログ分析にElasticsearch(+kibana)を使っています。皆さんもELKスタックでログ基盤を構築しているところが多いのかなと思います。

しかし、運用していく中でいくつか課題がありました。

- いつも同じ人しかログを見ていない
- kibanaでログを分析するだけで、その他に有効活用できていない
- (エラーログが多すぎる)

そこで、Elasticsearchに溜まったログのサマリーを定期的にSlackへ通知することで

- チャンネルに入ってる人に状況が強制的に目に入る
- もしやばい量のログがあったとしたら、やばいことがみんなに伝わる
- みんなに伝わることで、エラーログ撲滅の流れができる

もし、の話なので実際は分かりませんが、とにかく良い方向に進むはずです！

## どんなことができるようになりますか？

![Screen Shot 2018-12-10 at 10.12.57.png](https://qiita-image-store.s3.amazonaws.com/0/75919/a65e53b4-6708-fd87-63e0-90ba644a53bb.png)

指定した文字列を含むログを集計し、愛嬌たっぷりな報告が毎朝来るようになる(あくまでも例の1つなので、数値は適当です)

## 全体の構成

仕組みの全体図はこんな感じ

![aws_diagram.png](https://qiita-image-store.s3.amazonaws.com/0/75919/658f2406-233b-d441-889a-aa3d7d24c50c.png)

1. CloudWatch Events：Lambdaを定期実行 

2. Lambda：Elascticsearch内の指定したインデックス、特定条件に当てはまる前日分のログを取得

3. Lambda：Elastcsearchから取得した結果をSlackへ通知する

利用したもの

- Elasticsearchのエンドポイント
- AWS Lambda
- AWS CloudWatch Event
- AWS IAM
- Slack Incoming Webhook

今回構築する仕組みはTerraform Moduleにしているので、サクッと試したい方はそちらのリポジトリの方を見てみてください。

[tanets | github](https://github.com/ryota548/tanets)
(うまく動かないかもしれないけど)

## どんな手順で構築するか？

- Elasticsearchのエンドポイントを確認（今回はAWSのESを使用）
- SlackのWebhookURLの取得
- IAMで適切な権限を設定
- Lambdaの作成
  - LambdaからElasticsearchを使えるようにする
  - LambdaからSlack通知できるようにする
- CloudWatch EventsでLambdaを定期実行するように設定

これらをTerraform Moduleとしてソースコード化していきます

## 実際に構築した際のポイントだけ整理

全部書くと長くなっちゃうので、ポイントだけここに書いておきます。詳細は上記に入ったリポジトリを参考にしてください。

### 必要な外部URLをあらかじめ確認しておく

ElasticsearchのVPCエンドポイントは[Amazon Elasticsearch Service が VPC をサポート](https://aws.amazon.com/jp/blogs/news/amazon-elasticsearch-service-now-supports-vpc/)

SlackのIncoming Webhook URLは[SlackのWebhook URL取得手順
](https://qiita.com/vmmhypervisor/items/18c99624a84df8b31008)

が分かりやすく書かれている

### 適切な権限設定をIAMで行う

- Lambdaを実行するための権限
    - AWSLambdaExecute
- LambdaからElasticsearchを実行するための権限
    - AmazonESReadOnlyAccess

```
// AWS lambda用のIAMロール
resource "aws_iam_role" "iam_role" {
  name = "${var.iam_role_name}"

  assume_role_policy = <<EOF
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "sts:AssumeRole",
      "Principal": {
        "Service": "lambda.amazonaws.com"
      },
      "Effect": "Allow"
    }
  ]
}
EOF
}

# LambdaからElasticsearchを実行するためのポリシー作成
resource "aws_iam_role_policy_attachment" "iam_policy_es" {
  role = "${aws_iam_role.iam_role.id}"

  policy_arn = "arn:aws:iam::aws:policy/AmazonESReadOnlyAccess"
}

# Lambdaを実行するためのポリシー作成
resource "aws_iam_role_policy_attachment" "iam_policy_lambda" {
  role = "${aws_iam_role.iam_role.id}"

  policy_arn = "arn:aws:iam::aws:policy/AWSLambdaExecute"
}
```

### Lambdaに諸々実装   

諸々の実装の中から、Elasticsearch, Slackを扱う関数部分だけをピックアップしました。
外部ライブラリを取り入れるのが面倒だったので既存で利用できるライブラリのみを使用しています

Elasticsearchを使えるようにするやつ

```python:lambda/lamda_function.py
from botocore.awsrequest import AWSRequest
from botocore.auth import SigV4Auth
from botocore.endpoint import BotocoreHTTPSession
from botocore.credentials import Credentials
# Elasticsearchのクエリ実行
def execute_query_to_es(url, data):
    request = AWSRequest(method="GET", url=url, data=data)
    if ("AWS_ACCESS_KEY_ID" in os.environ):
        # AWSマネージドのElasticsearchを使うために必要
        credentials = Credentials(os.environ["AWS_ACCESS_KEY_ID"], os.environ["AWS_SECRET_ACCESS_KEY"], os.environ["AWS_SESSION_TOKEN"])
        SigV4Auth(credentials, "es", os.environ["AWS_REGION"]).add_auth(request)
    return BotocoreHTTPSession().send(request.prepare())
```

Slackへ通知するやつ

```python:lambda/lamda_function.py
import urllib.request
# Slackへ通知する
def notifyToSlack(numOfTarget, avarage, unit_string, url):
    level_text = ""
    # 指定した文字列と、それに紐づく件数をセット
    for level, sum in numOfTarget.items():
        level_text += '{}: {}件 \n'.format(level, sum)
    # SlackにPOSTする内容をセット
    slack_message = {
        'channel': SLACK_CHANNEL,
        'icon_emoji': ':female-technologist:',
        'username': USER_NAME,
        'text': 'みんなちゅうもーく！昨日出力された{}を発表しちゃうよー！ \n {} 1{}の平均: {}件 \n 詳細はこちら(kibana)→{}'.format(ERROR_LEVEL, level_text, unit_string, avarage, url)
        
    }
    headers = {
        'Content-Type': 'application/json'
    }

    # SlackにPOST
    req = urllib.request.Request(SLACK_WEBHOOK_URL, json.dumps(slack_message).encode(), headers)
```

### CloudWatch Eventの設定

ほぼ定期実行したい時間や間隔を指定するだけです。

|外から渡すパラメータ|値の例|説明|
|:-:|:-:|:-:|
|event_name|tanets|イベント名。既存のイベント名と被らなければ何でも良い|
|schedule_expression|cron(15 1 * * ? *)|定期実行させたいcron|
|lambda_arn||実行したいLambda関数のARN|

```cloudwatch/main.tf
# 定期的に実行するためのイベントルール作成
resource "aws_cloudwatch_event_rule" "event_rule" {
  name                = "${var.event_name}"
  schedule_expression = "${var.schedule_expression}"
}

# イベントルールに紐づくLambda関数の設定
resource "aws_cloudwatch_event_target" "event_target" {
  rule = "${aws_cloudwatch_event_rule.event_rule.name}"
  arn  = "${var.lambda_arn}"
}
```

以上がざっくりしたまとめです。あとはそれに必要なvariablesやoutputを実装するだけになります。

これらの構築がめんどくさい方向けに、外から使えるようにTerraformをモジュール化しています。

よかったら使ってあげてください（動かなかったら言ってください！）

## Terraform Moduleとしての呼び出し方

Readmeには書きましたが、各々が元々持っているTerraformのプロジェクトから以下を呼び出すことで、上記の構成を一気に取り込むことができます

```
// 前日分のFatal, WarningをSlackへ通知する
module "tanets" {
    source = "git::https://github.com/ryota548/tanets"
    name   = "error_log_daily_notify"　// 他の関数名と被らないように注意してください

    # 定期通知用cron設定
    notify_schedule = "cron(15 1 * * ? *)"

    # Lambda用
    environment   = {
        SLACK_WEBHOOK_URL = "slack.hoge"
        SLACK_CHANNEL     = "hoge"
        KIBANA_URL        = "kibana.hoge"
        ES_INDEXIS        = "hoge-index"
        ES_ENDPOINT       = "es.hoge"
        ERROR_LEVEL       = "Fatal,Warning" // カンマ区切りで
        USER_NAME         = "ログ情報お知らせちゃん"
        UNIT_OF_TIME      = "h"
    }
}
```

## 残った課題

- 全体的にもっと汎用的に作る
- AWS KMSでElasticsearchのエンドポイントやSlackのWebhookURLは暗号化して利用する
- Terraformのベストプラクティスに沿ったプロジェクト構成にする

##　まとめ

- Elasticsearchに溜まったログをLambdaとCloudwatch Eventを利用することで定期的にSlackへ通知することができた
- この仕組みをTerraform moduleにしておくこどで、どんなプロジェクトからでも使い回せるようにできた
- 時間が足りず、いくつか課題が残った