EC2 Auto Scaling にあわせて Zabbix でホストの自動登録・削除がしたいんですよ
こんにちは、通信会社で壊れたルーターを取り替えるだけの夜勤作業員、な、のですが、とある縁で今はサーバーの監視・運用代行業務(MSP)をさせてもらっています。
具体的には Zabbix を使ってクラウドにある顧客のサーバーを監視しているのですが、オートスケーリングを使っている案件ではスケールイン、すなわちインスタンスが terminate されると当然ながら通信が途絶えるので、Zabbix agent is unreachable やら unavailable by ICMP やらのポーリング系アラートじゃんじゃん鳴ってしまいます。これでは実務上も精神衛生的にもよろしくないので何とかしたいなあと少し考えてみました。
おそらく Auto Scaling のイベントを SNS で飛ばして Lambda でよしなにやる的なのが "ベストプラクティス" ってやつなのでしょうが、いかんせんクラウドを触り始めてまだ2ヶ月で勝手が分かってないので、とりあえず思いつく古典的手法を試してみます。
忙しい方は最後にあるデモ動画だけ見ていっていただければ幸いです。
スケールアウト時の自動登録は Zabbix におまかせ
Zabbix には標準でエージェント自動登録機能がついています。さらに設定ファイルにホストメタデータを書いておけば、案件やサーバー種別ごとに柔軟な処理が行えます。
今回の検証では Auto Scaling グループ用に "MySiteAS" という文字列をメタデータとしてマスターAMIに書いておき、Zabbixサーバー側でこれを検知するとホストの登録・ホストグループへの参加・各種テンプレートのリンクを自動で行う自動登録アクションを作りました。
参考:
スケールイン時の自動削除はいろいろたいへん
一方でホストが終了した際の処理は一筋縄ではいきません。監視システムはホストが応答しないのは何らかの障害が起きているのか、意図的にシャットダウンされたのか判断できないからです。しかし仮想サーバーであればそのマネージャーがVMの状態を知ってます。EC2 では AWS CLI や AWS SDK を使ってインスタンスの状態を取得できるので、Zabbix server 側でそれを利用できれば終了検知・ホスト削除ができそうです。
いろいろ試行錯誤してたどり着いたワークフローが次の通り。
- インスタンスIDをメタデータから取得し Zabbix API でホストマクロに登録
- AWS SDK を使って定期的にインスタンスのステータスを確認
- インスタンスが terminated になったら Zabbix API を使って Zabbix からホストを削除
インスタンスIDの取得とホストマクロ登録
インスタンスIDの取得方法
インスタンスIDは各インスタンス上でインスタンスメタデータにアクセスすれば簡単に参照することができますし、アクセスによる課金もされません。Zabbix でそれを取得するにはこのようにリモートコマンドで curl を実行するアイテムを作るのが簡単そうです。
ちなみに -s
オプションをつけて進捗表示が結果に含まれてしまうことを防ぎ、-f
オプションでHTTPエラーが発生した際に何も出力せずエラー終了させるようにします。残念ながら Zabbix には終了コードを判定する機能はありませんが…。
ちなみにリモートコマンドを利用するには Zabbix agent のコンフィグファイルで EnableRemoteCommands=1
の設定を入れる必要があります。もし諸事情でコマンドの実行ができない場合は、AWS CLI や AWS SDK を使って、プライベートIPアドレス等からインスタンスを特定してインスタンスIDを取得する方法が考えられます。
インスタンスID情報をどこに保持するか問題
さて、インスタンスIDのアイテムは取得できましたが大きな問題があります。アイテムのキーは他のアイテムの値を参照することができないため、インスタンスIDを取得して保持しているアイテムとそれを引数にしてインスタンスの状態を取得するアイテムを作ることができません。そこで代替案を2つ考えました。
案1:インスタンスID取得のアイテムにトリガー(インスタンスID変更・一定期間データが取れない等)を作り、アクションで外部スクリプトを実行させ、そこでインスタンス状態のチェックからホストの削除まで一括して行う。つまりZabbixをスクリプトを呼ぶだけのタスクスケジューラとして使う
- pros: 条件を満たしたときのみ外部スクリプトを実行するので Zabbix server/proxy が高負荷になりにくい
- cons: 状態遷移時にしかチェックが走らないので処理が漏れる可能性がある。またZabbixからEC2ステータス等の情報や状況を確認できない
案2:インスタンスIDをユーザーマクロに登録し各アイテム・トリガーで利用する(=ユーザーマクロをグローバル定数として使う)
- pros: さまざまなオブジェクトからインスタンスIDを参照できるようになるため柔軟性・拡張性に優れる(CloudWatch連携とかしやすそう)
- cons: 定期的に外部スクリプトでインスタンスの状態をチェックするので負荷が高め
いろいろ考えた結果、今後の展望を踏まえ後者のホストマクロにインスタンスIDを登録する方法にしました。
参考:
www.slideshare.net
インスタンスIDをホストマクロへ登録
ではさっそくインスタンスIDをホストマクロに登録しましょう。Zabbix server/web ではそのような処理ができないため、外部スクリプトで Zabbix API を使って設定を行います。(Zabbix server が自分を設定するスクリプトを実行するってのも変な話ですねw)
先程作ったインスタンスIDアイテムの値が変化したら発火するトリガーを作り、それを実行条件としたアクションを作ります。ここでミソなのがトリガーが "正常" になったときにアクションを実行させることです。初めてインスタンスが立ち上がり Zabbix agent がデータを取り始めると、このトリガーは 不明 → 正常 に状態遷移します。そのためアクション条件を "障害" ではなく "正常" にすることで、値が変わった もしくは 初めて取得できた(トリガーの条件式が評価できた) ときに実行されるようになります。ちなみに厳密に言うとこれだと値が変わった際にアクションの実行が遅れてしまいますがリアルタイム性は必要ないのでよしとしています。
そしてアクション実行内容としてリモートコマンドを実行させます。コマンドの引数として ホスト名 と アイテムの値(=インスタンス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を使ってマクロを追加・変更します。
インスタンスを起動してから数分経つと自動でホストマクロが登録されました。成功です。
余談ですが Zabbix ではアクションで実行したリモートコマンドの標準出力・エラー出力・終了コードを参照できません。そのため外部スクリプトが正常に動いているか確認するには、リダイレクト等でファイルに出力しそれを Zabbix server でログ監視するなどの対処が必要です。
インスタンスステータスの取得
つづいてインスタンスのステータスを監視していきます。
リモートコマンドで 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公式のPython用AWS SDK "Boto 3" を使ってEC2インスタンスの情報を取りに行っています。
しばらくしてアイテム値にインスタンスのステータスが入ってきました。取れてますね。
ホストの自動削除
さてラストスパートです。取得したインスタンスのステータスが "terminated" になったらホストを自動削除する仕組みを作ります。多分に漏れず、トリガーアクションではホストを削除するといった処理を行わせることはできないため、これもリモートコマンドで外部スクリプトから Zabbix API を叩いて実現させます。このできそうでできないのが Zabbix。
アイテムの値が "terminated" になったら発火するトリガーを作り、それを条件にしたアクションを作成。
そしてホスト削除用のリモートコマンドを実行します。渡す引数はホスト名のみ。
#!/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 テンプレート や 外部スクリプトはこちらで公開しています。よろしければ使ってみて下さい。
おわりに
というわけでレガシーなシステムにレガシーな場当たり対処をしてモダンなサービスにどうにか対応したいと奮闘したお話でした。おそらくもう少しスマートな方法はあると思いますが、やはり Zabbix でイマドキなサービスを監視するのは限界なのかなーと思うところです。特にコンテナやサーバーレスなんかは Zabbix でどう監視するのか見当もつきません。まあ Zabbix を触ってまだ2ヶ月の初心者が言えることではありませんが…。
ただ今回の試行錯誤を通して Zabbix は「痒いところに手が届かない。でもやりようはある」という絶妙な拡張性・柔軟性をもったプロダクトでもあることがわかりました。外部スクリプトで全部やるとかそれは柔軟性なのか!?というツッコミはありますが、それは Zabbix API が強力だからこそできる力技なんですから。
これからも上手くつきあっていきたいと思います。