kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

MackerelからのWebhookを任意に処理するためのSaba Webhook Gatewayを作り、試しにGoogle Chat通知を実装した

最近Mackerelの通知チャンネルをいろいろ見ることがあったのだけど、SlackやTeamsといった対応済みのもの以外への通知はWebhookを使う、というのが基本スタンスとなっている。

MackerelのWebhook通知として提供されるのは、サンプル(Mackerelの「テスト」で送出されるもの)、アラート通知、アラートグループ通知、ホストステータス変更、ホスト登録、ホスト退役、監視ルールの操作(追加・変更・削除)。

困ったことに、ドキュメントのWebhookにアラートを通知するでは「通知されるJSONは以下のような内容を含んでいます。」というかなり曖昧なものしかなく、アラート通知のことしか書かれていない。

この機会にWebhookまわりをひととおり洗い直すか〜と思い、以下をやってみた。

  • 各通知のJSONスキーマを作る
  • それぞれのWebhookに応じてメソッドを呼び出し、ターゲットに投稿するまでの一連をライブラリ化する
  • 実装例としてGoogle Chatに投稿するサーバーとLambdaコードを用意する

こうしてできたのがSaba Webhook Gatewayってわけよ。

github.com

ロゴは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のあるVMVPSに直接立ててもいいし、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 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.parsesymbolize_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

Sinatraを使ったサーバーを作り、テストする。

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

Google Chatへの通知
グラフ付きの通知

よしよし。

次に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?)