監視アラートをエヴァンゲリオンっぽく表示をしてテンションを上げる

[千株式会社 Advent Calendar 2018 8日目](https://qiita.com/advent-calendar/2018/sen)の記事は今日が投稿日だと思ったら実は昨日が投稿予定日だったピチピチの新卒よしけん( @yoshiken )がお送りします。日々スケジューリングが甘いと上司に怒られてるのにこの体たらく、次の出社日が怖いです。

AWSのマネージドサービスについてズラズラ書いているのでアプリケーションだけ見たい方は `5. フロントエンド説明` を流し目程度に読んでください。

<h3>なお、ご注意していただきたいのはこのシステムは弊社では運用してません。ただの個人的な趣味のシステムです。</h3>

# お品書き

1. 前説
2. システムフロー
3. AWS設定
4. zabbix設定
5. フロントエンド説明


# 前説

今年の新卒で最初はプロダクトチームでPHPをゴリゴリ書いていましたが、9月?ぐらいにSREへ転属となり日々アラートに怯える日常を過ごしております。

弊社SREチームではもちろんのことながらあらゆるサーバーのリソース監視を行っております。

主として使用しているのが[zabbix](https://www.zabbix.com/jp/)というオープンソースの集中監視ツールです。なぜzabbixが採用された経緯までは知りませんが、OSSなのでmackerelといったクラウド監視ツールでは手の届かない細かい部分までカスタマイズができるのでSREチームの余力がある場合は検討すべきものだと思います。ただ、スタートアップやインラフの知見が乏しいチームなどで、インフラにリソースを裂けない場合はおとなしくクラウド監視ツールを使ったほうがランニングコストとして良いかもしれません。適材適所ってやつですね。

後々ご説明しますが、zabbixではトリガー(pingが通らない、メモリがn%以上など)に対してアクションが任意で決められることができます。
現在弊社では基本的にトリガーのレベルが重症(これも後々説明します)以上だとSlackでSREチームに対してメンションが送られる設定にしています。

<img width="669" alt="スクリーンショット 2018-12-09 12.53.25.png" src="https://qiita-image-store.s3.amazonaws.com/0/45617/cf647d8b-43b3-3925-227e-7ecfe4dc19e8.png">

これは僕がzabbixのメジャーアップデートの検証の際にAMIをそのまんまコピペしたときにslack連携切るの忘れてアラートチャンネルが爆発したときのSSです。agentの設定とか色々ありIPアドレス変わるだけでこれだけ監視失敗するんだなぁ〜と感心しつつチームのメンバーとリーダーに土下座をしたのは言わずもがな…

さてさて前フリが長くなってしまったのですがここから技術的な話です。

先程ふれたようにzabbixでは様々なトリガーが選択・作成が可能ですが、僕はふとこういうアラートを見て思うところがありました。

<img width="680" alt="スクリーンショット 2018-12-09 13.04.30.png" src="https://qiita-image-store.s3.amazonaws.com/0/45617/a068c68c-9566-3dbc-355a-e6576bc2d2d1.png">

ECSが発表されるはるか昔、Dockerに注目していた先人たちがログ保存関連をEC2内にdockerを立てて管理していました。(今は徐々にマネージドサービスに寄せています)
はるか昔に構築されたものなので、仕様としてマネージドに頼ることなく負荷に合わせてDockerがオートスケールする形を取っています。今ではマネージドに投げれば大抵のことはマウスでカチカチするれば終わりますが、昔はそんなものはなかったので自作するしかなかったという点を垣間見ると尊敬すべき構成だと思います。

また道がそれかけましたね。このようにDockerのコンテナ数をトリガーとして検知してアラートを飛ばしてくるまではいいんですが、問題はその後のアクションで

```
zabbbix「Dockerコンテナに変化あったで」
SRE「おかのした」
SRE「サーバにログイン」
SRE「docker ps」
SRE「うーん？問題ないように見えるな。」
SRE「負荷高かったんかな？んーアクセス数が急増したわけではないな…」
SRE「元のコンテナのログ見るか」
SRE「おっ？一瞬timeoutになってる」
SRE「なるほど。それで一瞬死んだと思われたんやな。でもすぐに復帰したっぽいし問題ないやろ」

```

<h3>長い！</h3>

毎度毎度こんなことをやってられるか！

というお気持ちになります。

今回の例の場合は、zabbix上のアクション設定で`リモートコマンド`で`docker ps` and `docker logs -t "conter name" ` の情報が流れるようにすればいいかもしれませんが、(個人的に)**zabbixのアクションとトリガーはすごく可読性が悪い**ので別の形でアクションを実行して必要な情報だけを抜くようにしたいと思います。


# システムフロー

前説が長くなりましたが、要は簡潔でかつ柔軟なアラート対応をしたいというわけです。

zabbixを例に上げて話をしていましたが、別段zabbixだけに限らず任意のエンドポイントにPOSTを実行できる監視システムなら全て対応できるフローにして汎用性を高めます。なんだったらcron回してshellでPOSTしても動くシステムです。

せっかくAWS上で動かしているのでAWSのマネージドサービスをもりもり使っていこうと思います。

まずはざっくりとしたフロー図を

![無題のプレゼンテーション.jpg](https://qiita-image-store.s3.amazonaws.com/0/45617/e06fd3a2-2833-83f9-a2dc-286b1d2709cb.jpeg)


何かしらの監視失敗アクションをトリガーにAWS API GatewayにPOST、それを発火にlambdaを呼び出し+メタ情報を付与、lambdaで任意の処理を行います。

先にこのフローにした理由を述べておきます

1. API gatewayで入り口を統一することであらゆる箇所から送る情報をシンプルにまとめることができる
2. API gatewayの呼び出しパスやmeta情報で呼び出すlambdaを任意に選択できる。
3. lambdaを用いることでシステム内だけではなくAWS全体のメタ的情報を取得して比較することができる

特に3に関しては、 `pingが通らなかった` というアラートに対して

1. firewallなどセキュリティミドルウェアがブロッキングしている
2. DNSの名前解決が失敗している
3. route53の設定ミス
4. AWSのSG設定ミス
5. subnet設定ミス

などなど考えられると思いますがそれらを総じて検査し、結果のみを抽出して任意の場所(AWS外でも可)に送信できるのは大きい利点となります。

# AWS設定
## lambda

早速作っていきましょう。

まずはベースとなるlambdaの作成です。

![無題のプレゼンテーション (7).jpg](https://qiita-image-store.s3.amazonaws.com/0/45617/88a21b25-9f34-5a24-008e-88e0f4c012e1.jpeg)

![Screenshot from 2018-12-09 14-29-17.png](https://qiita-image-store.s3.amazonaws.com/0/45617/0f73551a-865f-5b8e-4d0b-7047ccee8bfa.png)

lambdaは `python` `ruby` `node.js`など対応していますが好きな言語を選びましょう。今回の例はPythonを選んでいきます。

作成時の注意ですが、ロールの権限は作成予定のリソース以外には余計な権限は付与しないようにしましょう。

さて、これだけでベースとなるlambdaは作成完了です。
「え？これだけ？」と思っていると思いますが、大丈夫です。あとから色々追加していくので今は大枠だけを完成させましょう。


## API Gateway

次に出入り口となるAPI Gateway を作成します

POSTじゃなくてもGETでもPUTでもそこはお好きに選択してください。自由はあなたのものです。

![無題のプレゼンテーション (1).jpg](https://qiita-image-store.s3.amazonaws.com/0/45617/9e334bfc-95dc-65de-6d52-99234232781d.jpeg)

![無題のプレゼンテーション (3).jpg](https://qiita-image-store.s3.amazonaws.com/0/45617/58598792-0d75-1402-7698-32f3959e7123.jpeg)

![無題のプレゼンテーション (4).jpg](https://qiita-image-store.s3.amazonaws.com/0/45617/e0d88ef3-dfc7-c7bf-2824-621584507afa.jpeg)


![無題のプレゼンテーション (5).jpg](https://qiita-image-store.s3.amazonaws.com/0/45617/34e8e2cc-250f-8dc3-cf86-e9486612d90d.jpeg)

![無題のプレゼンテーション (8).jpg](https://qiita-image-store.s3.amazonaws.com/0/45617/74fe3ab4-240e-ebd4-e6b3-b9df68a31e24.jpeg)

保存を押すと権限を求められますが、先程作成したLambdaかあってるか確認してOKを押します。

![Screenshot from 2018-12-09 14-41-20.png](https://qiita-image-store.s3.amazonaws.com/0/45617/4c98103e-8def-5ee2-f0a8-c8a28ae61cf2.png)

おぉ…これだけでだいぶそれっぽいものができたと思いませんか？

実際にクライアントの上にあるテストを押してみましょう。

ポチポチしていくと↓のように無事通信できてることが確認できると思います。

```
{
  "statusCode": 200,
  "body": "\"Hello from Lambda!\""
}
```

もしここでコケてしまった場合どこかしらで設定ミスをしてるのでlambda名など間違ってないか確認しましょう。

## Lambda + API Gateway

さてLambdaとAPI Gatewayの連携はできましたね？

ここからはあなたやあなたのチームの要件に沿ってカスタマイズしていきましょう。

例えば特定のパスに以下のJSONがPOSTされたときに状態遷移したいという要件が出たとします

```json
{
  "servername": "hogehoge"
  "status": 2
}
```

Lambda上では以下のようなコードを記述します。

```python:lambda_function
import json
import urllib.request


def lambda_handler(event, context):
    post_status(event)
    return {
        'statusCode': 200,
        'body': json.dumps(event)
    }

def post_status(event):
    url = "http://example.com"
    headers = {
        'Content-Type': 'application/json',
    }
    req = urllib.request.Request(url, json.dumps(event).encode(), headers)
    with urllib.request.urlopen(req) as res:
        body = res.read()
```

pythonで外部ライブラリを使用したい場合は下記の記事が参考になると思います。
https://qiita.com/SHASE03/items/16fd31d3698f207b42c9

今回はシンプルな要件なので外部ライブラリを使わずさくっと進めます


## フロントエンド実装

実際にサーバーでコマンド実行させて…とやろうとしたんですが、時間がなかったので、今回はwebアプリケーションにPOSTして状態変化が発生した場合websocketで各ブラウザにかっこよくブラウザでアラート表示をしたいと思います

pythonのFlaskでwebsocket自体は割とかんたんで[gevent](http://www.gevent.org/index.html)を使うと秒で実装できるので相当な理由がないならgeventをつかっておけば楽ができると思います。

細かいところは端折って重要そうな箇所だけサクッと紹介します。

```python:main.py
from flask import Flask, request, render_template
from gevent import pywsgi
from geventwebsocket.handler import WebSocketHandler
```

importはFlaskとgeventだけで問題ないです。



```python:main.py
@app.route('/post', methods=['POST'])
def post():
    try:
        if request.json['serverno'] and request.json['status']:
            sql = "UPDATE serverstatus SET statusnum = %s where serverno = %s"
            dbconnect(sql, (int(request.json['status']), int(request.json['serverno'])))
            return jsonify({
                'statusCode': 200,
                'body': "UPDATE OK!"
            })
    except Exception as e:
        print(e)
    return jsonify({"statusCode": 200,'body': "please serverno:int statuts:int"})
```

POST受ける部分

時間がないのでだいぶ雑に書いてますが要は `methods=['POST']` でPOSTを受けて `request` から取り出します。


```python:main.py
@app.route('/pipe')
def pipe():
    sql = "select * from serverstatus"
    if request.environ.get('wsgi.websocket'):
        ws = request.environ['wsgi.websocket']

        while True:
            result = dbconnect(sql)
            for res in result:
                print(res)
                if res['statusnum'] != 1:
                    ws.send('true')
                else:
                    ws.send('false')
```

後ほど説明しますが `http://example.com/pipe` をwebsocketと接続する場所としていますsqlでmysqlに保存した状態を常時取得して通常ステータスになった場合`true`を送信。発火を教えてあげます。


ついではjs部分ですが、時間の都合上HTMLに押し込めたのでずらーっと書いておきます

```html:index.html
 <div id="view"></div>
    <div id="msg">
        <div class="msg-box">
            <p class="title">
                警告
            </p>
            <p class="body">
                サーバに異常発生
            </p>
            <p class="time-title">
                経過時間
            </p>
            <p id="time" class="time">
                00:00:00
            </p>
        </div>
    </div>
    <script type="text/javascript">
        var sec = 0;
        var ws = new WebSocket("ws://localhost:8080/pipe");

        ws.onmessage = function(e) {
            if (e.data == "true") {
                trigger()
            }
        }

        trigger()
        function trigger() {
            var view = document.getElementById("view");
            for (var i = 0; i < 99; i++) {
                var wrap = document.createElement("div");
                wrap.setAttribute("class", "wrap");
                if (i % 2 == 0) {
                    wrap.classList.add("s0");
                }
                if (i % 2 == 1) {
                    wrap.classList.add("s1");
                }
                var img = document.createElement("img");
                img.setAttribute("src", "/static/img/emergency.png");
                wrap.appendChild(img);
                view.appendChild(wrap);
            }
            setInterval('showTime()', 1000);
        }
        var SecondsTohhmmss = function (totalSeconds) {
            var hours = Math.floor(totalSeconds / 3600);
            var minutes = Math.floor((totalSeconds - (hours * 3600)) / 60);
            var seconds = totalSeconds - (hours * 3600) - (minutes * 60);
            seconds = Math.round(seconds * 100) / 100
            var result = (hours < 10 ? "0" + hours : hours);
            result += ":" + (minutes < 10 ? "0" + minutes : minutes);
            result += ":" + (seconds < 10 ? "0" + seconds : seconds);
            return result;
        }
        function showTime() {
            sec++;
            document.getElementById("time").innerHTML = SecondsTohhmmss(sec);
        }
        trigger();
    </script>
```

見てのとおりなんで不要かもしれませんが、

```javascript
var ws = new WebSocket("ws://localhost:8080/pipe");
```

ここでwebsocketとのコネクションを貼って待ち状態にします。

```javascript
 ws.onmessage = function(e) {
            if (e.data == "true") {
                trigger()
            }
        }
```

ここでメッセージを受信するとtrigger関数を発火させます。

具体的にこういった形になります(都合上webページを開くと表示させるようにしています。あとフレームレートがおかしくなっているので秒数が飛ばし気味になってます)

![ezgif-2-00810219a92c.gif](https://qiita-image-store.s3.amazonaws.com/0/45617/d26e90c2-88fb-9128-6735-5c6691bab852.gif)


# おわり
時間が足らずに駆け足になってしまいましたが、みなさんもつまらないアラート生活から抜け出して少し刺激があるアラート表示をしてみてはどうでしょうか？

やはりアラートが慢性的に出てしまって慣れてしまうと「またか・・・どうせ大丈夫だろ…」となりがちだとは思いますが、1行のログには1ユーザーがいるという意識を忘れずに真摯にアラートと向き合いましょう。