最近Mackerelの通知チャンネルをいろいろ見ることがあったのだけど、SlackやTeamsといった対応済みのもの以外への通知はWebhookを使う、というのが基本スタンスとなっている。
MackerelのWebhook通知として提供されるのは、サンプル(Mackerelの「テスト」で送出されるもの)、アラート通知、アラートグループ通知、ホストステータス変更、ホスト登録、ホスト退役、監視ルールの操作(追加・変更・削除)。
困ったことに、ドキュメントのWebhookにアラートを通知するでは「通知されるJSONは以下のような内容を含んでいます。」というかなり曖昧なものしかなく、アラート通知のことしか書かれていない。
この機会にWebhookまわりをひととおり洗い直すか〜と思い、以下をやってみた。
- 各通知のJSONスキーマを作る
- それぞれのWebhookに応じてメソッドを呼び出し、ターゲットに投稿するまでの一連をライブラリ化する
- 実装例としてGoogle Chatに投稿するサーバーとLambdaコードを用意する
こうしてできたのがSaba Webhook Gatewayってわけよ。
ロゴはStable Diffusionで生成した。
各通知のJSONスキーマを作る
まずはMackerelのWebhook JSONを収集する必要がある。Sinatraで適当に記録するのを立てた。
require 'sinatra' set :bind, '0.0.0.0' post '/' do status 204 request.body.rewind json = request.body.read puts "[raw json]" puts json end
ポート4567で立ち上がるので、インターネット経由でアクセスできるようにする。public IPのあるVMやVPSに直接立ててもいいし、ngrokなどでプロキシしてもいい。MackerelのWebhookはHTTPSでなくても送信できる。
通知チャンネルにWebhook登録してテストをクリックすると、JSONが飛んでくる。
[raw json] {"event":"sample","message":"Sample Notification from Mackerel","imageUrl":null}
あとはひたすらパターンを収集する。
- テストで送出されるサンプル(先に提示のもの): event=sample
- アラート通知: event=alert
- オープン / 自動クローズ / APIクローズ / 手動クローズ
- ホストメトリック / サービスメトリック / チェック監視 / 式 / Connectivity / 外形監視 / 異常検知
- アラートグループ通知: event=alertGroup
- オープン / 自動クローズ / APIクローズ / 手動クローズ
- ホストステータス変更: event=hostStatus
- ホスト登録: event=hostRegister
- ホスト退役: event=hostRetire
- 監視ルールの操作
- 追加: event=monitorCreate / 変更: event=monitorUpdate / 削除: event=monitorDelete
- ホストメトリック / サービスメトリック / チェック監視 / 式 / Connectivity / 外形監視 / 異常検知
いっぱいパターンがある。そのうちこれにクエリ監視ルールも加わると思うが、現時点ではまだない。
設定がオフならばJSONに一切出ないもの(通知の再送間隔、証明書の有効期限の監視など)もあれば、設定でオフなのときに常にnullを返すもの(関連するグラフを表示など)もあるのでややこしい、というかスキーマにするのがだいぶ難しい。今もしMackerelを再設計するとスキーマファーストになったりするのかね。
異常検知が出なくて苦労したけど、もうだめだ出ないな……と諦めて、忙しすぎて起動していなかったゲームPCを起動したところ、即アラートが出てちょっと複雑な気分になった。
JSON Schema Toolに現物JSONを投入してスキーマを書き出していく。書き出されたスキーマはあくまで投入したものから愚直に生成されているので、生IDを置き換えたり、defaultを消したり、requiredを削ったり、固定値をenumにしたり。そのままだと新しいキーが来ても受け入れてしまうので、"additionalProperties": false
も追加する。
検証にはJSON Schema Validatorを使っていた。
成果物のJSONスキーマはwebhooksフォルダにまとめている。
進めていくについれ、1つのJSONスキーマを作るのはほぼ無理だなと判断して、それぞれの通知ごとのスキーマにしている。
監視ルールの操作はそれぞれの監視の全項目がJSONで送られており、複雑化している。requiredを最大公約数にしたけれども、oneOfでがんばってもいいのかもしれない。
ホスト登録/ステータス/退役のほうはほぼ同じなのでまとめられそうだが、それぞれに分けるポリシーにしたので3つに分かれている。
外形監視エラーの理由がSlack通知にはあるんだけど、Webhookにはそもそもその出力が存在しないことにも気付いた。今後これは改善対象にしてもよさそう。
それぞれのWebhookに応じてメソッドを呼び出し、ターゲットに投稿するまでの一連をライブラリ化する
やるべきこととしては、Webhook JSONの受け付けとRubyオブジェクト化、eventに基づく大まかな分岐、各event処理、ターゲットへの投稿というフローになる。いろいろなターゲットで利用しやすいよう、基底クラスで分岐後のメソッドまで用意した。
module SabaWebhookGateway class Base def initialize @debug = ENV['DEBUG'] || nil end ... def parse(json) begin JSON.parse(json, symbolize_names: true) rescue JSON::ParserError => e if @debug puts "[json error]\n#{e}" end nil end end def sample(h) puts "[sample]\n#{h}" '{ "event": "sample" }' end def alert(h) puts "[alert]\n#{h}" '{ "event": "alert" }' end ... def monitor_update(h) case h[:monitor][:type] when 'host' monitor_update_host(h) when 'external' monitor_update_external(h) when 'expression' monitor_update_expression(h) when 'anomalyDetection' monitor_update_anomaly_detection(h) when 'service' monitor_update_service(h) when 'connectivity' monitor_update_connectivity(h) end end ...
JSONのままだといろいろ扱いづらい気がしたので、早々にRuby Hashオブジェクト化している。JSON.parse
にsymbolize_names: true
を付けておけば、Hashオブジェクトのキーが全部シンボルになってくれる。
実装例としてGoogle Chatに投稿するサーバーとLambdaコードを用意する
Mackerelがまだ対応していない通知先で、あると嬉しいユーザーが存在しそうなもの、という観点で、Google Chatを選んで実装してみた。有償版のGoogle Workspaceのみとはなるが、Google ChatスペースはWebhookで外部からのメッセージを受け付けるようになっている。
実装としては、ヘッダとセクションからなる「カード」をJSONで作り、これをGoogle ChatスペースのWebhook URLに送るだけ。とっても簡単だ。送るのはfaradayを使ってみた。
googlechat.rbにそれぞれのイベントハンドラを実装していく。
require_relative 'base' require 'faraday' module SabaWebhookGateway class GoogleChat < Base def googlechat_card(header, sections) j = { cards: { header: header, sections: sections } } JSON.generate(j) end def sample(h) header = { title: 'notification test' } widget1 = [{ textParagraph: { text: h[:message] } }] sections = [{ widgets: widget1 }] googlechat_card(header, sections) end ... def post(json) resp = Faraday.post(ENV['GOOGLECHAT_WEBHOOK']) do |req| req.headers['Content-Type'] = 'application/json' req.body = json end if @debug p("[post]\n#{resp.body}") end end
WebhookのURLは環境変数で引き渡すようにしている。dotenvを使っているので.env
ファイルを設定ファイルのように使うこともできるし、Lambdaの環境変数渡しも想定した。
alertイベントは状況によって保有している情報や書き出すべき情報にバリエーションがあるので、複雑め。
セクションの領域には普通にハイパーリンクや画像配置ができる。逆にヘッダは長いと切り詰められてしまうので、Slack互換の通知表現はできなかった。
def alert(h) header = { title: %Q([#{h[:orgName]}] #{h[:alert][:status].upcase}: #{h[:alert][:monitorName]}) } start_time = to_date(h[:alert][:openedAt]) end_time = h[:alert][:closedAt] ? to_date(h[:alert][:closedAt]) : nil header[:subtitle] = if end_time %Q(from #{start_time} to #{end_time}) else %Q(from #{start_time}) end sa = [] sa << %Q(<a href="#{h[:alert][:url]}">View Alert</a>) if h[:host] && h[:host][:roles] # Host sa << %Q(Host: <a href="#{h[:host][:url]}">#{h[:host][:name]}</a>) h[:host][:roles].each do |a| sa << %Q(Role: [<a href="#{a[:serviceUrl]}">#{a[:serviceName]}</a>] <a href="#{a[:roleUrl]}">#{a[:roleName]}</a>) end end if h[:service] && h[:service][:roles] ... if h[:alert][:metricLabel] ... end widget1 = [{ textParagraph: { text: sa.join("\n") } }] sections = [{ widgets: widget1 }] if h[:imageUrl] ... sections.push({ widgets: widget2 }) end if h[:memo] ... end googlechat_card(header, sections) end
require 'dotenv' require 'sinatra' require 'pathname' bindir = Pathname.new(__FILE__).realpath.dirname $LOAD_PATH.unshift((bindir + '../lib').realpath) require 'saba-webhook-gateway' Dotenv.load ... set :bind, @bind set :port, @port post @path do status 204 request.body.rewind request_json = request.body.read m = SabaWebhookGateway::GoogleChat.new h = m.parse(request_json) m.run(h) if h end
よしよし。
次にAWS Lambda版を作ってみる。実はこれまでLambdaは耳で聞くことはあっても、実際に書いて動かしてみたことがなかった(!)。API Gatewayと絡めないといけないのが面倒かなと思っていたのだけど、今は関数URLで普通にHTTPSの口ができる。え、それ最高じゃん。
GOOGLECHAT_WEBHOOK環境変数についてはLambdaの環境変数を使いつつ、せっかくなのでKMSでの暗号化を前提にするようにしてみた。不要ならGOOGLECHAT_WEBHOOKの解読をしているところを一式削除してしまえばいい。
require_relative '../lib/saba-webhook-gateway' require 'aws-sdk-kms' require 'base64' ENCRYPTED = ENV['GOOGLECHAT_WEBHOOK'] # Decrypt code should run once and variables stored outside of the function # handler so that these are decrypted once per container DECRYPTED = Aws::KMS::Client.new.decrypt({ ciphertext_blob: Base64.decode64(ENCRYPTED), encryption_context: { 'LambdaFunctionName' => ENV['AWS_LAMBDA_FUNCTION_NAME'] } }).plaintext ENV['GOOGLECHAT_WEBHOOK'] = DECRYPTED # rubocop:disable Lint/UnusedMethodArgument def lambda_handler(event:, context:) m = SabaWebhookGateway::GoogleChat.new h = m.parse(JSON.generate(event)) if h[:body] m.run(m.parse(h[:body])) elsif h[:event] m.run(h) end { statusCode: 200, body: JSON.generate('received on Lambda') } end
コード自体は短いけど、Lambdaの実行ではいろいろハマった。
- gem類も全部セットのアーカイブにする必要がある。また、Ruby 3.2なので、Debian bookwormの3.1だとバージョンが合わずに読み込まれない。zip archiverで無理矢理調整した。
- 実行するメソッドの設定を探すのにしばらく苦戦した。サンプルのコードソースでテストをした状態で開いていると、「ランタイム設定」が出ないのな。
- event、contextの引数に入ってくるものでしばらく混乱していた。eventはすでにRuby Hashなのね。そしてメタデータがほかにもいろいろ付いていて、本来ほしいのはbodyで、これをさらにJSON解釈する必要がある、に気付くまで無駄に時間がかかった。
いったん動くようになれば、あとはSinatra同様普通に動く。実運用ではCORSなどを設定するのが安全。
間違った。送信元制限と間違えたんだけど、パブリック関数URLはけっこう大胆な設計で、制限はほとんどできないことがわかった。難読URLではあるけど知られてしまえば叩き放題になってしまうので、アプリケーションより前の叩かれる前に接続制限したいときには結局API Gatewayを併用しないといけない模様。最高じゃなかった……。
感想
コア部分の実装自体はさほど難しくはなくて、MackerelのWebhookパターンを集めたり、Lambdaでどう動かすのかを調査したり、ローカルStable Diffusionの環境構築をやり直したりのほうで時間を食っていた。
Lambdaの使い方やKMSで暗号化する方法をこの機会に覚えられたのはよかった。パラメータストアも使ってみればよかったかな。
あと、もう1つ実装例にMeta(Facebook) Messengerに出せないかを考えていたのだが、どうも一方的な送信は公開ページに対してしか行えない仕様だった。SPAMに使えちゃうからね。アプリケーション申請を出さないといけないし、アラートを世界に情報発信するのもいかがなものかという気持ちになってやめた。
何か面白そうな送出ターゲットを募集中(Mastodon?)