EC2 Auto Scaling にあわせて Zabbix でホストの自動登録・削除がしたいんですよ

こんにちは、通信会社で壊れたルーターを取り替えるだけの夜勤作業員、な、のですが、とある縁で今はサーバーの監視・運用代行業務(MSP)をさせてもらっています。

具体的には Zabbix を使ってクラウドにある顧客のサーバーを監視しているのですが、オートスケーリングを使っている案件ではスケールイン、すなわちインスタンスが terminate されると当然ながら通信が途絶えるので、Zabbix agent is unreachable やら unavailable by ICMP やらのポーリング系アラートじゃんじゃん鳴ってしまいます。これでは実務上も精神衛生的にもよろしくないので何とかしたいなあと少し考えてみました。

おそらく Auto Scaling のイベントを SNS で飛ばして Lambda でよしなにやる的なのが "ベストプラクティス" ってやつなのでしょうが、いかんせんクラウドを触り始めてまだ2ヶ月で勝手が分かってないので、とりあえず思いつく古典的手法を試してみます。

忙しい方は最後にあるデモ動画だけ見ていっていただければ幸いです。

スケールアウト時の自動登録は Zabbix におまかせ

Zabbix には標準でエージェント自動登録機能がついています。さらに設定ファイルにホストメタデータを書いておけば、案件やサーバー種別ごとに柔軟な処理が行えます。

f:id:miyahan:20180722180036p:plain

今回の検証では Auto Scaling グループ用に "MySiteAS" という文字列をメタデータとしてマスターAMIに書いておき、Zabbixサーバー側でこれを検知するとホストの登録・ホストグループへの参加・各種テンプレートのリンクを自動で行う自動登録アクションを作りました。

参考:

www.slideshare.net

スケールイン時の自動削除はいろいろたいへん

一方でホストが終了した際の処理は一筋縄ではいきません。監視システムはホストが応答しないのは何らかの障害が起きているのか、意図的にシャットダウンされたのか判断できないからです。しかし仮想サーバーであればそのマネージャーがVMの状態を知ってます。EC2 では AWS CLIAWS SDK を使ってインスタンスの状態を取得できるので、Zabbix server 側でそれを利用できれば終了検知・ホスト削除ができそうです。

ちなみに現状では Zabbix のネットワークディスカバリによるポーリングを行っており、一定期間以上応答しなかったホストを「たぶん stop か terminate したんだろう」と見なして削除するアクションが動いていますが、即時性がないためアラートは鳴ってしまいます。また確信なしに設定を消してしまうのはちょっと気持ち悪い感じがします…。

いろいろ試行錯誤してたどり着いたワークフローが次の通り。

f:id:miyahan:20180722172446p:plain

  1. インスタンスIDをメタデータから取得し Zabbix API でホストマクロに登録
  2. AWS SDK を使って定期的にインスタンスのステータスを確認
  3. インスタンスが terminated になったら Zabbix API を使って Zabbix からホストを削除

インスタンスIDの取得とホストマクロ登録

インスタンスIDの取得方法

インスタンスIDは各インスタンス上でインスタンスメタデータにアクセスすれば簡単に参照することができますし、アクセスによる課金もされません。Zabbix でそれを取得するにはこのようにリモートコマンドcurl を実行するアイテムを作るのが簡単そうです。

f:id:miyahan:20180728121853p:plain

ちなみに -s オプションをつけて進捗表示が結果に含まれてしまうことを防ぎ、-f オプションでHTTPエラーが発生した際に何も出力せずエラー終了させるようにします。残念ながら Zabbix には終了コードを判定する機能はありませんが…。

ちなみにリモートコマンドを利用するには Zabbix agent のコンフィグファイルで EnableRemoteCommands=1 の設定を入れる必要があります。もし諸事情でコマンドの実行ができない場合は、AWS CLIAWS SDK を使って、プライベートIPアドレス等からインスタンスを特定してインスタンスIDを取得する方法が考えられます。

インスタンスID情報をどこに保持するか

さて、インスタンスIDのアイテムは取得できましたが大きな問題があります。アイテムのキーは他のアイテムの値を参照することができないため、インスタンスIDからそのインスタンス状態を取得しにいくアイテムは作れません。ここで考えた回避策は2つ。

案1:インスタンスIDのアイテムにトリガー(インスタンスID変更・一定期間データが取れない等)を作る。アクションで外部スクリプトを実行させ、そこでインスタンス状態のチェックからホストの削除まで一括して行う。つまりZabbixをスクリプトを呼ぶだけのタスクスケジューラとして使う

  • pros: 条件を満たしたときのみ外部スクリプトを実行するので大量のEC2インスタンスを監視する場合に Zabbix server が高負荷になりにくい
  • cons: ほとんどの処理を外部スクリプトで行うので動作・ホスト状態が見えない(どうせ最後は削除するんだから別にいい?)

案2:インスタンスIDをホストマクロに登録し各アイテム・トリガーで利用する

  • pros: さまざまなオブジェクトからインスタンスIDを参照できるようになるため柔軟性・拡張性に優れる(CloudWatch連携とかしやすそう)
  • cons: 定期的に外部スクリプトインスタンスの状態をチェックするので負荷が高い

いろいろ考えた結果、今後の展望を踏まえ後者のホストマクロにインスタンスIDを登録する方法にしました。

参考:

www.slideshare.net

インスタンスIDをホストマクロへ登録

ではさっそくインスタンスIDをホストマクロに登録しましょう。Zabbix server/web ではそのような処理ができないため、外部スクリプトで Zabbix API を使って設定を行います。(Zabbix server が自分を設定するスクリプトを実行するってのも変な話ですねw)

f:id:miyahan:20180728111905p:plain

先程作ったインスタンスIDアイテムの値が変化したら発火するトリガーを作り、それを実行条件としたアクションを作ります。ここでミソなのがトリガーが "正常" になったときにアクションを実行させることです。初めてインスタンスが立ち上がり Zabbix agent がデータを取り始めると、このトリガーは 不明 → 正常 に状態遷移します。そのためアクション条件を "障害" ではなく "正常" にすることで、値が変わった もしくは 初めて取得できた(トリガーの条件式が評価できた) ときに実行されるようになります。ちなみに厳密に言うとこれだと値が変わった際にアクションの実行が遅れてしまいますがリアルタイム性は必要ないのでよしとしています。

f:id:miyahan:20180728111912p:plain

f:id:miyahan:20180728111921p:plain

そしてアクション実行内容としてリモートコマンドを実行させます。コマンドの引数として ホスト名 と アイテムの値(=インスタンスID) を渡しています。ここで「ホストIDを渡した方が確実なのでは…?」と思われるでしょうが、Zabbix はなぜかリモートコマンドの引数において HOST.ID を参照することができません。。。

#!/bin/env python

import argparse
import sys

from zabbix.api import ZabbixAPI


ZABBIX_SERVER = 'http://localhost/zabbix'
ZABBIX_USER = 'username'
ZABBIX_PASSWORD = 'password'

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Register instance-id to Zabbix host-macro')
    parser.add_argument('-n', '--hostname', required=True, help='Zabbix hostname')
    parser.add_argument('-i', '--instanceid', required=True, help='EC2 instance-id')
    args = parser.parse_args()

    if not args.hostname:
        sys.exit('ERROR: hostname is empty')
    if not args.instanceid:
        sys.exit('ERROR: instanceid is empty')
    if args.instanceid == 'unknown':
        sys.exit('unknown')
    if args.instanceid[0:2] != 'i-':
        sys.exit('ERROR: invalid instanceid')

    zapi = ZabbixAPI(url=ZABBIX_SERVER, user=ZABBIX_USER, password=ZABBIX_PASSWORD)

    """Get hostid by hostname"""
    result = zapi.do_request(
        'host.get',
        {
            'filter': {
                'host': [args.hostname]
            },
            'output': ['name', 'hostid']
        }
    )

    if not result['result']:
        sys.exit('host not found')

    """Update host macro"""
    hostid = result['result'][0]['hostid']
    result = zapi.do_request(
        'host.update',
        {
            'hostid': hostid,
            'macros': [
                {
                    'macro': '{$EC2_INSTANCEID}',
                    'value': args.instanceid,
                }
            ]
        }
    )
    print(result['result'])

そしてこちらが実行される外部スクリプトです。まず host.get APIを使い先程渡されたホスト名からホストIDを検索します。実に滑稽です。。。そしてそのホストIDに対しhost.update APIを使ってマクロを追加・変更します。

f:id:miyahan:20180728120628p:plain

インスタンスを起動してから数分経つと自動でホストマクロが登録されました。成功です。

余談ですが Zabbix ではアクションで実行したリモートコマンドの標準出力・エラー出力・終了コードを参照できません。そのため外部スクリプトが正常に動いているか確認するには、リダイレクト等でファイルに出力しそれを Zabbix server でログ監視するなどの対処が必要です。

インスタンスステータスの取得

つづいてインスタンスのステータスを監視していきます。

f:id:miyahan:20180728121928p:plain

リモートコマンドで AWS SDK を叩いてEC2インスタンスの情報を取得するアイテムをホストに登録します。このとき必要なパラメータは、EC2のリージョン・IAMユーザーのアクセスキー・シークレットキー・監視するインスタンスIDです。キー周りはテンプレートマクロとして予め定義しておき、それをホストに割り当てて継承させるのがよいでしょう。

#!/bin/env python

import argparse
import sys

import boto3


if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Get EC2 instance status from AWS')
    parser.add_argument('-r', '--region', required=True, help='AWS region name')
    parser.add_argument('-a', '--accesskey', required=True, help='AWS access key')
    parser.add_argument('-s', '--secretkey', required=True, help='AWS secret key')
    parser.add_argument('-i', '--instanceid', required=True, help='EC2 instance-id')
    args = parser.parse_args()

    if not args.region:
        sys.exit('ERROR: region is empty')
    if not args.accesskey:
        sys.exit('ERROR: accesskey is empty')
    if not args.secretkey:
        sys.exit('ERROR: secretkey is empty')
    if not args.instanceid:
        sys.exit('ERROR: instanceid is empty')
    if args.instanceid == 'unknown':
        sys.exit('unknown')
    if args.instanceid[0:2] != 'i-':
        sys.exit('ERROR: invalid instanceid')

    ec2 = boto3.session.Session(
        aws_access_key_id=args.accesskey,
        aws_secret_access_key=args.secretkey).client('ec2', args.region)

    instances = ec2.describe_instances(
        InstanceIds=[args.instanceid]
    )

    state = None
    for reservations in instances['Reservations']:
        for instance in reservations['Instances']:
            state = instance['State']['Name']

    if not state:
        sys.exit('instance not found')

    print(state)

そしてこちらが呼ばれるスクリプトAmazon公式のPythonAWS SDK "Boto 3" を使ってEC2インスタンスの情報を取りに行っています。

f:id:miyahan:20180728122457p:plain

しばらくしてアイテム値にインスタンスのステータスが入ってきました。取れてますね。

ホストの自動削除

さてラストスパートです。取得したインスタンスのステータスが "terminated" になったらホストを自動削除する仕組みを作ります。多分に漏れず、トリガーアクションではホストを削除するといった処理を行わせることはできないため、これもリモートコマンドで外部スクリプトから Zabbix API を叩いて実現させます。このできそうでできないのが Zabbix。

f:id:miyahan:20180728122713p:plain

アイテムの値が "terminated" になったら発火するトリガーを作り、それを条件にしたアクションを作成。

f:id:miyahan:20180728122958p:plain

そしてホスト削除用のリモートコマンドを実行します。渡す引数はホスト名のみ。

#!/bin/env python

import argparse
import sys

from zabbix.api import ZabbixAPI


ZABBIX_SERVER = 'http://localhost/zabbix'
ZABBIX_USER = 'username'
ZABBIX_PASSWORD = 'password'

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Delete host from Zabbix server')
    parser.add_argument('-n', '--hostname', required=True, help='Zabbix hostname')
    args = parser.parse_args()

    zapi = ZabbixAPI(url=ZABBIX_SERVER, user=ZABBIX_USER, password=ZABBIX_PASSWORD)

    """Get hostid by hostname"""
    result = zapi.do_request(
        'host.get',
        {
            'filter': {'host': [args.hostname]},
            'output': ['name', 'hostid']
        }
    )

    if not result['result']:
        sys.exit('hostname not found')

    """Delete host from Zabbix server"""
    hostid = result['result'][0]['hostid']
    result = zapi.do_request('host.delete', [hostid])
    print(result)

で、これがスクリプト。さきほどと同じくホスト名からホストIDをルックアップし、host.delete APIでホスト削除を行います。これで完成です!!

デモ動画


Demo: Zabbix auto registration when EC2 scale-out

まずスケールアウト(ホスト増加)から。まず Zabbix agent が起動したら Zabbix server にコンフィグを取りに行き、そのときにホストメタデータを使ってサーバーにホストが自動登録され監視がスタートします。そして第2段階としてエージェント側でホストメタデータからインスタンスIDを取得しサーバーに送信します。サーバーはそれを受けリモートコマンドを実行しホストマクロに登録します。そして定期的にインスタンスIDからインスタンスのステータスを取りに行きます。


Demo: Zabbix auto host deleting when EC2 scale-in

続いてスケールイン(ホスト減少)。定期的に取りに行っているインスタンスステータスが "terminated" になったらトリガーが発火しホストを削除するリモートコマンドが実行されます。サーチ&デストロイ。この早さであればポーリング系アラートは鳴りません。これで枕を高くして眠ることが出来ます。

ソースコード

今回の検証で使った Zabbix テンプレート や 外部スクリプトはこちらで公開しています。よろしければ使ってみて下さい。

github.com

おわりに

というわけでレガシーなシステムにレガシーな場当たり対処をしてモダンなサービスにどうにか対応したいと奮闘したお話でした。おそらくもう少しスマートな方法はあると思いますが、やはり Zabbix でイマドキなサービスを監視するのは限界なのかなーと思うところです。特にコンテナやサーバーレスなんかは Zabbix でどう監視するのか見当もつきません。まあ Zabbix を触ってまだ2ヶ月の初心者が言えることではありませんが…。

ただ今回の試行錯誤を通して Zabbix は「痒いところに手が届かない。でもやりようはある」という絶妙な拡張性・柔軟性をもったプロダクトでもあることがわかりました。外部スクリプトで全部やるとかそれは柔軟性なのか!?というツッコミはありますが、それは Zabbix API が強力だからこそできる力技なんですから。

これからも上手くつきあっていきたいと思います。