はじめに

平素は大変お世話になっております。
クイックガードのパー子です。

弊社ではインシデント管理に PagerDuty と Opsgenie を併用 しています。
PagerDuty を主系、Opsgenie を副系として運用しており、PagerDuty の Statusページ をサブスクライブして異常が発生した場合は Opsgenie に通知を飛ばすようにしています。

ところが最近、この Statusページが Atlassian の Statuspage から PagerDuty自身の同等サービス に移行されました。
(公式のお知らせ)

この移行に伴い、以前に使用していた Opsgenie の Statuspageインテグレーション が使えなくなったため、挙動はそのままに新たな仕組みに切り替えました。

この記事では、その新しい仕組みを紹介します。

仕組みの概要

Atlassian版と同じく、PagerDuty の Statusページも Webhook を提供しています。
新たな仕組みでも、引き続き Webhook を利用して通知を受け取ります。

Opsgenie の Alert の操作は、愚直に Alert API を叩いて行うことにします。

実行環境として AWS Lambda を採用し、Function URL で Webhook を受けます。
Webhook のペイロードに応じて、適切な API を叩き分けて Alert を作成、更新、またはクローズします。

実装

具体的な実装手順を解説します。

Opsgenie

まず、Opsgenie のインテグレーションを作成します。

作成するインテグレーションは APIインテグレーション です。

インテグレーションを作成すると APIキーが発行されるので、控えておきましょう。
後ほど AWS Lambda の設定で使用します。

インテグレーションを作成したら、ペイロードの内容に応じて実行される アクション のルールを定義します。

なお、今回の実装では Lambda側でイベントの中身を見て API を叩き分けるので、Filter の設定は不要です。

Create Alert

“Alert Fields” を次のとおり定義します。

項目
Message[PagerDuty] {{extraProperties.status}} - {{message}}
Alias{{extraProperties.incident_id}}
Priority{{priority}}
Entity{{entity}}
Source{{extraProperties.status_page}}
Tagsincident_id: {{extraProperties.incident_id}}
Actions{{actions}}
Description{{description}}
Extra Properties{{extraProperties}}
User{{extraProperties.status_page}}
Note{{description}}

なお、AWS Lambda で Create Alert API を実行する際、リクエスト・フィールドと Webhookペイロード を次のようにマッピングするものとします。

リクエスト・フィールドWebhookペイロード
messagetitle
descriptionmessage
details.ends_atends_at
details.hrefhref
details.incident_idhref の末尾の ID
details.next_update_msnext_update_ms
details.post_typepost_type
details.reported_atreported_at
details.serviceservices[].service_name のリスト
details.severityseverity
details.starts_atstarts_at
details.statusstatus
details.status_pagestatus_page

details.* は任意に追加できるカスタム属性であり、上記 “Alert Fields” の中で extraProperties として参照できます。

例として、以下のイベントが発生したとき、

event.json
{
  "ends_at": null,
  "href": "https://subdomain.trust.pagerduty.com/incident_details/PXXXXXX",
  "message": "We are currently working on the issue.",
  "next_update_ms": 900000,
  "post_type": "incident",
  "reported_at": "2023-05-15T20:47:12Z",
  "services": [
    {
      "service_name": "Checkout",
      "severity": "minor"
    }
  ],
  "severity": "minor",
  "starts_at": null,
  "status": "investigating",
  "status_page": "Acme Corp",
  "title": "Checkout is temporarily unavailable"
}

各フィールドは次のように Alert にマッピングされます。

Close Alert

“Alert Fields” は次のとおりです。

項目
Alias{{alias}}
User{{user}}
Note{{note}}

Close Alert API のリクエスト・フィールドと Webhookペイロードは次のようにマッピングします。

リクエスト・フィールドWebhookペイロード
userstatus_page
sourcestatus_page
notemessage

AWS Lambda

次に、AWS Lambda で Function を作成します。

Function URL

PagerDuty からの Webhook を受け取るために、Function URL を有効化します。
この Function に固有の URL が払い出されるので、控えておきます。

コード

ランタイムは Ruby 3.2 で、ハンドラは source.Handler.process です。

source.rb
require 'json'
require 'net/http'
require 'uri'

class Handler
  class << self
    def process(event:, context:)
      puts(event)

      payload = JSON.parse(event['body'])

      case payload['post_type'].to_sym
      when :maintenance, :incident
        handler = \
          case payload['status'].to_sym
          when :resolved, :completed
            ApiClient::Alert::Closer
          else
            ApiClient::Alert::Creater
          end

        handler.new(payload).call
      end

      :ok
    end
  end
end

module ApiClient
  module Alert
    class Base
      BASE_URL = 'https://api.opsgenie.com/'; private_constant :BASE_URL
      API_KEY = ENV.fetch('OPSGENIE_API_KEY'); private_constant :API_KEY

      def initialize(payload)
        @payload = payload

        @incident_id = payload['href'].split('/').last

        @service_names = payload['services'].map{|x| x['service_name']}.join(', ')
      end

      def call
        res = Net::HTTP.post(url, data.to_json, {
          'Authorization': "GenieKey #{API_KEY}",
          'Content-Type': 'application/json'
        })

        unless res.code == '202'
          puts(
            fail_log(res)
          )
        end
      end

      private

      def url
        @url ||= URI.join(BASE_URL, path)
      end

      def path
        raise NotImplementedError
      end

      def data
        raise NotImplementedError
      end

      def lookup(key)
        v = @payload[key.to_s]

        v.nil? ? '<null>' : v.to_s
      end

      def fail_log(res)
        JSON.generate({
          url: url.to_s,
          status: res.code,
          message: res.body
        })
      end
    end

    class Creater < Base
      private

      def path
        '/v2/alerts'
      end

      def data
        {
          message: lookup(:title),
          description: lookup(:message),
          details: {
            ends_at: lookup(:ends_at),
            href: lookup(:href),
            incident_id: @incident_id,
            next_update_ms: lookup(:next_update_ms),
            post_type: lookup(:post_type),
            reported_at: lookup(:reported_at),
            service: @service_names,
            severity: lookup(:severity),
            starts_at: lookup(:starts_at),
            status: lookup(:status),
            status_page: lookup(:status_page)
          }
        }
      end
    end

    class Closer < Base
      private

      def path
        "/v2/alerts/#{@incident_id}/close?identifierType=alias"
      end

      def data
        {
          user: lookup(:status_page),
          source: lookup(:status_page),
          note: lookup(:message)
        }
      end
    end
  end
end

Alert の作成とクローズでは APIエンドポイントもリクエスト・ボディも異なるため、“status” の値に基づいて処理を分岐します。

また、公式ドキュメント には記載がありませんが、ポストモーテムの場合は “post_type” に postmortem という値がセットされます。
ポストモーテムは無視したいので、そのようなイベントは処理をスキップします。

Alert の作成において、“Alias"フィールドは重複排除のための識別子として機能します。
Create Alert API を複数回呼び出した場合、Alias が同じならば新しい Alert は作成されず、既存の Alert が更新されるだけとなります。
(この際、メッセージは “Note"欄に追記されていきます。)

APIキー

前段階で控えておいた Opsgenie の APIキーを、環境変数 OPSGENIE_API_KEY にセットします。

PagerDuty Statusページ

最後に、AWS Lambda の Function URL を PagerDuty Statusページ の Webhook に登録します。

注意点として、現状では登録した URL を解除する機能が提供されていません。
そのため、解除したい場合は PagerDuty社に問い合わせる (?) か、あるいは解除せずに当該URL を破棄することになるため、恒久的に使い続けたい URL を登録するのは避けたほうがよいでしょう。

問題点

以上の実装で以前と同じ挙動を実現できたのですが、しかし、PagerDuty側の信頼性や運用に起因する問題がいくつか発生するようになりました。

いずれも PagerDuty側に原因があり、こちら側ですぐに何か対策するのも難しいのでひとまず様子見していますが、改善しないようなら全面的な仕組みの見直しが必要となるかもしれません。

不安定な通知

イベントの通知基盤があまり安定していないようで、通知が飛んでこないことがありました。

Webhook と併せて Slack とメールもサブスクライブしているのですが、そのときはいずれのチャネルにも通知されませんでした。

Webhook に強く依存する仕組みなので、ここが危ういと仕組みそのものが破綻してしまいます。

検知できない障害

Detected や Investigating を飛ばして、いきなり Resolved で公開されるインシデントも存在します。
(例: #P7O0UTE, #P87XMD5)

Detected の公開をサボったのでしょうか?
それとも PagerDuty側でも検知できなかった (= いつの間にか障害が発生していて、気づいたら復旧していた) のでしょうか?

理由はわかりませんが、検知・公開体制の強化を期待したいところです。

まとめ

本記事は「Pagerduty の障害に Opsgenie で備える」の続編です。

先日の PagerDuty Statusページの 移行 に伴い、Statusページに基づく障害検知の仕組みを刷新する必要が生じました。

AWS Lambda + 素の Opsgenie API を叩くことで元々の挙動を再現しましたが、新しい Statusページの信頼性に起因して、いくつかの問題が発生するようになりました。
状況が悪化するようなら根本的な対策を検討せねばなりませんが、しばらくは様子を見ながら運用してみたいと思います。

今後ともよろしくお願い申し上げます。


当記事の図表には AWS Architecture IconsPagerDuty のロゴOpsgenie のロゴ を使用しています。