ElastAlertの監視ルールを拡張してすこしだけ幸せになる

# はじめに

[株式会社エイチームライフスタイル](http://life.a-tm.co.jp/) でインフラエンジニアをやっている @ihsiek と申します。

以前、[会社のアドベントカレンダー](https://qiita.com/advent-calendar/2017/ateam-lifestyle)に寄稿した[Elasticsearchを活用したサービス監視ですこしだけ幸せになる](https://qiita.com/ihsiek/items/9ff4f4c56280aa99760b)という記事で、ElastAlertの導入について紹介しましたが、記事内でも述べている通り、ElastAlertをデフォルトで使用していると、いくつか使い勝手がよくないケースがでてきます。

そこで、今回はElastAlertの監視ルールタイプをカスタマイズして、突き当たった問題をいくつか解消した内容について紹介します。
ちなみに私の場合は、**ほかのメンバーがKibanaを見なくても負荷原因を特定できるようになった**ことでアラート対応の差し込みが減り、ずいぶん幸せになりました。

# 背景

top_count_keysでピックアップした上位5件を表示しても**障害が発生しているモジュールを特定できない**し、アクセス元IPアドレスをアラートに載せても**Whoisを引かないとアクセス元を判別できない**し、もう少し楽にできないかなーと思ったのがきっかけです。

# 実現したいこと

- PercentageMatchの挙動に沿って、通知されるアラートの本文に以下２つを追加する
  - **抽出条件にマッチしたイベントのみ**を対象にサマリを出力する。
  - IPアドレスを**WhoisサービスのURL**にセットして表示する。

# カスタムルールの追加方法

細かい説明とチュートリアルは[公式のドキュメント](http://elastalert.readthedocs.io/en/latest/recipes/adding_rules.html)にあるので、この記事では弊社で導入したカスタムルールのサンプルを紹介します。

# 準備
## カスタムルール用のパッケージを作成
まずは、カスタムルール用のパッケージを用意します。

```shell-session
$ sudo mkdir /var/lib/elastalert/elastalert_modules/
$ sudo touch /var/lib/elastalert/elastalert_modules/__init__.py
```

続いて、ElastAlertのモジュール群から参照できるようにシンボリックリンクを貼ります。

```shell-session
$ sudo ln -sf /var/lib/elastalert/elastalert_modules/ /usr/lib/python2.7/dist-packages/elastalert/elastalert_modules
```

これで準備はできたので、次はカスタムルールを作っていきます。

# カスタムルールの作成

## カスタムルールの追加

今回はpercentage_matchの挙動を引き継ぎたいので、PercentageMatchRuleクラスを継承したPercentageMatchWithTopXRuleクラスを作成します。

```py:/var/lib/elastalert/elastalert_modules/custom_rules.py
from elastalert.ruletypes import PercentageMatchRule
import re

class PercentageMatchWithTopXRule(PercentageMatchRule):

    def get_match_str(self, match):
        message = super(PercentageMatchWithTopXRule, self).get_match_str(match)

        top_events = [[key[11:], counts] for key, counts in match.items() if key.startswith('top_events_')]
        def events_to_message(items):
            message = ''
            items.sort(key=lambda x: x[1], reverse=True)
            for term, count in items:
                if re.match("^(\d{1,3}\.){3}\d{1,3}$", term):
                    # If value of term is an IP Address, add the URL of the whois service.
                    message += 'https://wq.apnic.net/static/search.html?query=%s : %s\n' % (term, count)
                else:
                    message += '%s : %s\n' % (term, count)

            return message

        for key, counts in top_events:
            message += '%s:\n' % (key)
            message += '%s\n' % (events_to_message(counts.items()))

        return message

    def generate_aggregation_query(self):
        """
        custom_top_count_keys: A list of fields. ElastAlert will perform a terms query for the top X most common values for each of the fields, where X is 5 by default, or custom_top_count_number if it exists.
        custom_top_count_number: The number of terms to list if custom_top_count_keys is set. (Optional, integer, default 5)
        """

        query = {
            'topx_match_aggs': {
                'filter': {
                    'bool': {
                       'must': self.match_bucket_filter
                    }
                },
                'aggregations': {
                }
            },
            'percentage_match_aggs': {
                'filters': {
                    'other_bucket': True,
                    'filters': {
                        'match_bucket': {
                            'bool': {
                                'must': self.match_bucket_filter
                            }
                        }
                    }
                }
            }
        }
        number = self.top_count_number = self.rules.get('custom_top_count_number', 5)
        keys = self.top_count_keys = self.rules.get('custom_top_count_keys')
        for key in keys:
            child_query = {
                'terms': {
                    'field': key + '.keyword',
                    'order': { '_count' : 'desc' },
                    'size': number
                }
            }
            query['topx_match_aggs']['aggregations'][key] = child_query
        return query

    def check_matches(self, timestamp, query_key, aggregation_data):
        match_bucket_count = aggregation_data['percentage_match_aggs']['buckets']['match_bucket']['doc_count']
        other_bucket_count = aggregation_data['percentage_match_aggs']['buckets']['_other_']['doc_count']

        if match_bucket_count is None or other_bucket_count is None:
            return
        else:
            total_count = other_bucket_count + match_bucket_count
            if total_count == 0:
                return
            else:
                match_percentage = (match_bucket_count * 1.0) / (total_count * 1.0) * 100
                if self.percentage_violation(match_percentage):
                    match = {self.rules['timestamp_field']: timestamp, 'percentage': match_percentage}
                    if query_key is not None:
                        match[self.rules['query_key']] = query_key

                    # Set TopX counts
                    counts = self.get_top_counts(aggregation_data)
                    match.update(counts)

                    self.add_match(match)

    def get_top_counts(self, aggregation_data):
        """
        Counts the number of events for each unique value for each key field.
        Returns a dictionary with top_events_<key> mapped to the top 5 counts for each key. 
        """
        all_counts = {}
        number = self.top_count_number
        keys = self.top_count_keys
        for key in keys:

            hits_terms = aggregation_data.get('topx_match_aggs').get(key, None)
            if hits_terms is None:
                top_events_count = {}
            else:
                buckets = hits_terms.get('buckets')

                terms = {}
                for bucket in buckets:
                    terms[bucket['key']] = bucket['doc_count']
                counts = terms.items()
                counts.sort(key=lambda x: x[1], reverse=True)
                top_events_count = dict(counts[:number])

            # Save a dict with the top 5 events by key
            all_counts['top_events_%s' % (key)] = top_events_count

        return all_counts
```

ファイルを作っただけでは、Supervisorで立ち上げたElastAlertのデーモンに、カスタムルールのクラスがロードされません。
カスタムルールのクラスをロードさせるために、デーモンの再起動をかけます。
これ書いてて気付きましたが、デーモン化しなくてもRundeckやCronから叩けば、デーモンの再起動いらないんですよね・・・。

```shell-session
$ supervisorctl restart elastalert
```

後はpercentage_matchを使用していた設定を書き換えるだけです。

## 監視ルールの設定
### 設定ファイル

```yaml:/var/lib/elastalert/rules/status-5XX/sample.yml
es_host: elasticsearch1.example.com
es_port: 443
use_ssl: True

index: access-log-*
doc_type: access-log

name: HTTP status 5XX on hoge

type: "elastalert_modules.custom_rules.PercentageMatchWithTopXRule"

match_bucket_filter:
- query_string:
    query: "code: [500 TO 599]"

max_percentage: 0.02

custom_top_count_keys:
  - "host"
  - "agent"
  - "path"

timeframe:
  minutes: 5

timestamp_field: "time"

use_kibana4_dashboard: "https://kibana.example.com/#/discover/alert-5XX-hoge"

alert:
- "email"
email:
- "hoge@example.com"
```

### 変更点
- type
    - [変更前] type: percentage_match
    - [変更後] type: "elastalert_modules.custom_rules.PercentageMatchWithTopXRule"
- top_count_keys
    - [変更前] top_count_keys:
    - [変更後] custom_top_count_keys:

## 検証

以下のコマンドを実行して動作検証します。

```shell-session
$ elastalert-test-rule --config /etc/elastalert/config.yml
```

### アラート本文

これでアラートの本文は以下のようになるはずです。

```text
HTTP status 5XX on hoge

Percentage violation, value: 0.0659413122321 (min: None max : 0.02)

path:
/hogehoge : 3
/fugafuga : 2
/hogefuga : 2
/fugahoge : 1
/aaaaaaaa : 1

host:
https://wq.apnic.net/static/search.html?query=xxx.yyy.zzz.aaa : 3
https://wq.apnic.net/static/search.html?query=xxx.yyy.zzz.bbb : 2
https://wq.apnic.net/static/search.html?query=xxx.yyy.zzz.ccc : 2
https://wq.apnic.net/static/search.html?query=xxx.yyy.zzz.ddd : 1
https://wq.apnic.net/static/search.html?query=xxx.yyy.zzz.eee : 1

agent:
Mozilla/5.0 (compatible; SemrushBot/1.2~bl; +http://www.semrush.com/bot.html) : 10

kibana_link: https://kibana.example.com/#/discover/alert-5xx-hoge?_g=%28refreshInterval%3A%28display%3AOff%2Csection%3A0%2Cvalue%3A0%29%2Ctime%3A%28from%3A%272017-12-01T13%3A26%3A33.631170Z%27%2Cmode%3Aabsolute%2Cto%3A%272017-12-01T13%3A36%3A33.631170Z%27%29%29
num_hits: 15165
num_matches: 1
percentage: 0.0659413122321
time: 2017-12-01T13:31:33.631170Z
```

# 最後に

ElastAlertにカスタムルールを導入してから、障害の一次対応でKibanaを見る頻度がめっきり減りました。
そもそもアラートが飛ばないに越したことはありませんが、まずは**何か問題が起こったときにすぐ気付ける仕組みづくりも大事**だと考えています。

それから、弊社では経営陣にもアラート通知が届くようになっているため、副次的な効果として現状起こっている問題を伝えやすくなりました。
経営陣がインフラへの投資に理解を示してくれるのは、もともと弊社の良い部分ではあるのですが、投資の結果どうなったとか、こういう部分に問題があるから投資したいという話をするとき「〇〇な理由でアラートの発生頻度が高い」というコミュニケーションで済ませられるとずいぶん楽ですよね。

ということで、ElasticsearchとElastAlertを導入すると運用に関する些末な問題がずいぶん解消されて幸せになれるので、ぜひ導入を検討してみてください。