ブログ

Slack botでOpsgenieのオンコール担当者をメンションする

はじめに

こんにちは、フライウィールのソフトウェアエンジニアの新幡です。

フライウィールではサービスの安定した運用のために、オンコールという体制を敷いています。オンコールとは、エンジニアが交代交代で(いわゆるシフト制で)緊急時に対応できるよう待機し、緊急時に迅速に対応できるようにするシステムのことです。業務時間外でも、システムに問題が発生した場合、オンコール担当のエンジニアが迅速に対応することで、サービスの中断を最小限に抑えることができます。

フライウィールでは、このオンコールのスケジュールを Opsgenie というサービスを通じて管理しており、各エンジニアに割り当てられたオンコール時間中は、緊急時に対応する準備が求められます。オンコールのエンジニアは、各自に割り当てられたシフト時間中に重要なトラブルが起きたとき、Opsgenie から電話で連絡を受けることで、迅速に対応することができます。

さらに、フライウィールでは、オンコールという仕組みを、緊急時の担当エンジニアを決めるだけでなく、日常の運用作業をエンジニアに割り振るための仕組みとしても活用しています。例えば、リリース作業や、データベースのレコード操作などの運用作業は、オンコール担当のエンジニアに割り当てられています。この仕組みによって、エンジニアはオンコール担当期間以外は、運用以外の日々の業務に集中することができます。

Opsgenie は Slack と連携する機能があります。詳しくは、Integrate Opsgenie with Slack をみてください。ただ、 Opsgenie 上で割り当てられているオンコール担当者を、 Slackでメンションする簡単な方法はないようでした。そのため、運用作業のリクエストをSlackで行う際には、オンコールを担当する可能性があるエンジニア全員に対して連絡をするのが常態化していました。具体的には、オンコールのシフトに含まれる全員が含まれる Slackユーザグループを作成し、そのグループに対してメンションしていました。例えば、データ活用プラットフォーム Conata(コナタ)™ のプロジェクトを例に使うと、以下のようなメンションでリクエストする形です。

@conata-oncall 週次リリースの作業日です。作業を行ってください

しかしこの方法も問題を孕んでいます。それは、オンコールのシフトに含まれる全員を対象に通知を飛ばす都合、オンコール期間外のエンジニアにも通知が送られてしまう、ということです。単に「Slack上で現在のオンコール担当者一人に対してメンションしたい」というだけなのに、現在オンコール担当でないたくさんの人にも通知が飛んでしまい、集中して作業することの妨げになってしまっていました。

whoisoncall bot

そこで、以下のように、オンコール担当者をメンションしてくれるSlack botである「whoisoncall bot」を作成しました。

このbotを用いることで、オンコール期間中のエンジニアだけをメンションし、通知を送ることができます。そのことによって、オンコール期間外のエンジニアを通知で煩わせることはなくなります。それでは、botの実装について説明していこうと思います。

Slack bot の処理の流れ

Slack bot の処理は以下の図のように、AWS Lambda と Amazon API Gateway を用いて実装されています。

まず前提として、以下のような状況を考えます。

  • Slack に、プロジェクトごとの channel が存在する
  • Opsgenie に、プロジェクトごとのオンコールスケジュール(オンコールの設定)が存在する
  • Slack と Opsgenie では、同一ユーザーは同一のメールアドレスを用いている

それぞれのステップは以下のようにまとめられます。

  1. User が Slack で「@whoisoncall」とメンションします。
  2. メンションによって、app_mention event が起き、Amazon API Gateway に POST リクエストが送られます。このリクエストには、メンションされた channel の ID が含まれます。
  3. Amazon API Gateway が AWS Lambda を起動します。
  4. Slack の channel id を channel name に変換するために、Slack のconversations.info API を利用します。
  5. Slack channel に対応する Opsgenie のオンコールスケジュールに対し、Opsgenieの Who is On Call API を呼び出すことによって、現在のオンコール担当者のメールアドレスを取得します。この際、どのオンコールスケジュールが対応するかについては、Slack の channel name とオンコールスケジュールのマッピングを AWS Lambda に持たせることで解決しています。
  6. オンコール担当者のメールアドレスを、Slack の users.lookupByEmail API を呼び出すことで、Slack の user id に変換します。
  7. 前のステップで取得した Slack の user id を Slack の chat.postMessage API と組み合わせて、オンコール担当者にメンションを送ります。

それぞれの構成要素を詳しく見ていきましょう。

Slack botの設定

Slack API: Applications から Slack App を作成する必要があります。Permission の設定で必要なのは、app_mentions:read, channels:read, chat:write, chat:write.public, groups:read, im:read, mpim:read, users:read.email です。

さらに、Slack でのメンションで bot を起動させるために、Event Subscriptions の設定が必要です。添付した画像のように、app_mention イベントのサブスクリプションを設定することが必要です。また Request URL は後ほど解説する Amazon API Gateway の API endpoint を指定してください。併せてUsing the Slack Events API も参照してください。

AWSの設定

Amazon API Gateway で AWS Lambda を使用する を参考に Amazon API Gateway を設定し、Slack の Event Subscription に API endpoint を設定してください。

AWS Lambdaのコード

それでは、実際に AWS Lambda にデプロイしているコードの解説に移りたいと思います。まず、コード全体を記載したのちに、実装の細かい説明をします。

クリックしてコード全体を開く
"""Mention who is on call in Slack."""

import json
import logging
import os
from typing import Literal, TypeVar

import pydantic
import requests
import slack_sdk
from aws_lambda_typing import context as context_
from aws_lambda_typing import events, responses
from slack_sdk import errors

logger = logging.getLogger()
logger.setLevel(logging.INFO)


SLACK_BOT_TOKEN = os.environ["SLACK_BOT_TOKEN"]
GENIE_KEY = os.environ["GENIE_KEY"]


T = TypeVar("T")


def not_none(obj: T | None) -> T:
    """Not None assertion."""
    if obj is None:
        raise ValueError("Not none assertion fails")
    return obj


def _get_slack_channel_name_from_channel_id(channel_id: str) -> str:
    """Get a Slack channel name from a Slack channel id.

    https://api.slack.com/methods/conversations.info
    Required Bot token scopes: channels:read, group:read, im:read, and mpim:read
    """
    client = slack_sdk.WebClient(token=SLACK_BOT_TOKEN)
    try:
        result = client.conversations_info(channel=channel_id)
        logger.info(result)
    except errors.SlackApiError as e:
        logger.error("Error getting a Slack channel name: %s", e)
        raise e
    channel: dict[str, str] = not_none(result.get("channel"))
    return not_none(channel.get("name"))


class _WhoIsOnCallDataItem(pydantic.BaseModel):
    on_call_recipients: list[str] = pydantic.Field(alias="onCallRecipients")


class _WhoIsOnCallResponse(pydantic.BaseModel):
    """Who is on call response schema."""

    data: _WhoIsOnCallDataItem


def _get_who_is_on_call_from_opsgenie(
    on_call_schedule: str | None,
) -> str | None:
    """Get an email address of who is on call from Opsgenie.

    https://docs.opsgenie.com/docs/who-is-on-call-api
    """
    if on_call_schedule is None:
        return None
    try:
        response = requests.get(
            url="https://api.opsgenie.com/v2/schedules/"
            + on_call_schedule
            + "/on-calls?scheduleIdentifierType=name&flat=true",
            headers={"Authorization": "GenieKey " + GENIE_KEY},
            timeout=10,
        )
        logger.info(response)
    except requests.exceptions.RequestException as e:
        logger.error("Error getting who is on call from Opsgenie: %s", e)
        raise e
    on_call_email_address = _WhoIsOnCallResponse.parse_obj(
        response.json()
    ).data.on_call_recipients[0]
    return on_call_email_address


def _get_user_id_from_email_address(
    email_address: str | None,
) -> str | None:
    """Get a Slack user id from a Slack email address.

    https://api.slack.com/methods/users.lookupByEmail
    Required Bot token scopes: users:read.email
    """
    if email_address is None:
        return None
    client = slack_sdk.WebClient(token=SLACK_BOT_TOKEN)
    try:
        result = client.users_lookupByEmail(email=email_address)
        logger.info(result)
    except errors.SlackApiError as e:
        logger.error("Error getting a Slack user id: %s", e)
        raise e
    user: dict[str, str] = not_none(result.get("user"))
    return not_none(user.get("id"))


def _post_slack_message(
    channel_id: str, thread_ts: str, slack_user_id: str | None
) -> None:
    """Post a Slack message.

    https://api.slack.com/methods/chat.postMessage
    Required Bot token scopes: chat:write
    """
    client = slack_sdk.WebClient(token=SLACK_BOT_TOKEN)
    if slack_user_id is None:
        try:
            result = client.chat_postMessage(
                channel=channel_id,
                thread_ts=thread_ts,
                text="Who is on call bot cannot be used in this channel.",
            )
            logger.info(result)

        except errors.SlackApiError as e:
            logger.error("Error mentioning: %s", e)
            raise e
        return

    try:
        result = client.chat_postMessage(
            channel=channel_id,
            thread_ts=thread_ts,
            text="<" + "@" + slack_user_id + ">" + " is on call.",
        )
        logger.info(result)

    except errors.SlackApiError as e:
        logger.error("Error mentioning: %s", e)
        raise e
    return


def _get_on_call_schedule_from_slack_channel_name(channel_name: str) -> str | None:
    """Get an on call schedule from a Slack channel name."""
    if "conata" in channel_name:
        return "conata_schedule"
    else:
        logger.error("Channel name does not match schedule: %s", channel_name)
        return None


class _AppMentionEvent(pydantic.BaseModel):
    """Slack App Mention Event Schema.

    https://api.slack.com/events/app_mention#app_mention-event__example-event-payloads__standard-app-mention-when-your-app-is-already-in-channel
    """

    type: Literal["app_mention"] = pydantic.Field(description="The type of the event.")
    channel: str = pydantic.Field(description="Channel id.")
    event_ts: str = pydantic.Field(description="Timestamp of the event.")


def _mention_who_is_on_call_in_slack(app_mention_event: _AppMentionEvent) -> None:
    """Mention who is on call in Slack.

    https://api.slack.com/events/app_mention
    """
    channel_name = _get_slack_channel_name_from_channel_id(app_mention_event.channel)
    on_call_schedule = _get_on_call_schedule_from_slack_channel_name(channel_name)
    on_caller_email = _get_who_is_on_call_from_opsgenie(on_call_schedule)
    slack_user_id = _get_user_id_from_email_address(on_caller_email)
    _post_slack_message(
        app_mention_event.channel, app_mention_event.event_ts, slack_user_id
    )


def lambda_handler(
    event: events.APIGatewayProxyEventV2, _context: context_.Context
) -> responses.APIGatewayProxyResponseV2:
    """Entry point of lambda.

    Args:
        event (events.APIGatewayProxyEventV2): Event object (https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-concepts.html#gettingstarted-concepts-event).
            An event is a JSON-formatted document that contains data for a Lambda function to process.
            The Lambda runtime converts the event to an object and passes it to your function code. It is usually of the Python dict type. It can also be list, str, int, float, or the NoneType type.
        _context (context_.Context): Context object (https://docs.aws.amazon.com/lambda/latest/dg/python-context.html).
            A context object is passed to your function by Lambda at runtime. This object provides methods and properties that provide information about the invocation, function, and runtime environment.
    """
    # Suppress Slack's retrying
    # https://api.slack.com/apis/connections/events-api#retries
    request_headers: dict[str, str] = not_none(event.get("headers"))
    slack_retry_num = request_headers.get("X-Slack-Retry-Num")
    if slack_retry_num is not None and int(slack_retry_num) >= 1:
        return {"statusCode": 200}
    body = json.loads(not_none(event.get("body")))
    # Handle URL verification handshake
    # https://api.slack.com/apis/connections/events-api#handshake
    challenge: str | None = body.get("challenge")
    if challenge is not None:
        return {
            "statusCode": 200,
            "headers": {"Content-Type": "text/plain"},
            "body": challenge,
        }
    app_mention_event = _AppMentionEvent.parse_obj(body.get("event"))
    _mention_who_is_on_call_in_slack(app_mention_event)
    return {
        "statusCode": 200,
    }

app mention eventを受け取る

Slack で @whoisoncall とメンションされた際の情報は、app_mention event として AWS Lambda に渡されます。含まれている情報は、Slack 公式ページの app_mention event の例を引用すると

{
    "type": "app_mention",
    "user": "U061F7AUR",
    "text": "<@U0LAN0Z89> is it everything a river should be?",
    "ts": "1515449522.000016",
    "channel": "C123ABC456",
    "event_ts": "1515449522000016"
}

となります。event_ts と channel を利用するための Pydantic のスキーマは以下のようになります。

class _AppMentionEvent(pydantic.BaseModel):
    """Slack App Mention Event Schema.

    https://api.slack.com/events/app_mention#app_mention-event__example-event-payloads__standard-app-mention-when-your-app-is-already-in-channel
  """

    type: Literal["app_mention"] = pydantic.Field(description="The type of the event.")
    channel: str = pydantic.Field(description="Channel id.")
    event_ts: str = pydantic.Field(description="Timestamp of the event.")

AWS Lambda に渡された event は、Pydantic で定義されたスキーマでパースされ、最終的には以下のように _mention_who_is_on_call_in_slack という関数に渡されます。

body = json.loads(not_none(event.get("body")))
app_mention_event = _AppMentionEvent.parse_obj(body.get("event"))
_mention_who_is_on_call_in_slack(app_mention_event)

_mention_who_is_on_call_in_slack は以下のような実装になっており、大まかには5つのステップに分けることができます。

A:Channel id から channel name を取得
B:オンコールスケジュールを channel name から取得
C:オンコール担当者のメールアドレスをオンコールスケジュールから取得
D:Slack の User Id をメールアドレスから取得
E:@whoisoncall がメンションされた投稿への返信として、オンコール担当者をメンション

def _mention_who_is_on_call_in_slack(app_mention_event: _AppMentionEvent) -> None:
    """Mention who is on call in Slack.
  https://api.slack.com/events/app_mention
  """

    channel_name = _get_slack_channel_name_from_channel_id(app_mention_event.channel)
    on_call_schedule = _get_on_call_schedule_from_slack_channel_name(channel_name)
    on_caller_email = _get_who_is_on_call_from_opsgenie(on_call_schedule)
    slack_user_id = _get_user_id_from_email_address(on_caller_email)
    _post_slack_message(
        app_mention_event.channel, app_mention_event.event_ts, slack_user_id
    )

A:Channel id から channel nameを取得

conversations.info APIを利用して、Slack の Channel id から channel name を取得します。ここで channel name を取得しているのは、後述しますが Slack の channel name とオンコールスケジュールを対応させることで処理したいからです。また、Slack API の認証情報は SLACK_BOT_TOKEN として渡しています。

def _get_slack_channel_name_from_channel_id(channel_id: str) -> str:
    """Get a Slack channel name from a Slack channel id.

  https://api.slack.com/methods/conversations.info
  Required Bot token scopes: channels:read, group:read, im:read, and mpim:read
  """
    client = slack_sdk.WebClient(token=SLACK_BOT_TOKEN)
    try:
        result = client.conversations_info(channel=channel_id)
        logger.info(result)
    except errors.SlackApiError as e:
        logger.error("Error getting a Slack channel name: %s", e)
        raise e
    channel: dict[str, str] = not_none(result.get("channel"))
    return not_none(channel.get("name"))

B:channel nameからオンコールスケジュールを取得

実際にオンコールスケジュールを取得するコードは以下の通りです。 前述の通り、Slackの channel name とオンコールスケジュールを対応させています。例えば、  Conata(コナタ)™ のプロジェクトに関連する Slack のチャンネルの場合は、社内の Slack チャンネルの命名規則により、#product-conata や #monitor-conata といった風に “conata” を含む命名をしています。 そのようなチャンネルで @whoisoncall が呼ばれた際には、 Conata 用のオンコールスケジュール名である  “conata_schedule”  を対応させるようになっています。ここでは Conata の例だけを抜き出していますが、実際に運用しているコードでは他のプロジェクトについてもオンコールスケジュールの対応づけを持っています。

def _get_on_call_schedule_from_slack_channel_name(channel_name: str) -> str | None:
    """Get an on call schedule from a Slack channel name."""
    if "conata" in channel_name:
        return "conata_schedule"
     else:
        logger.error("Channel name does not match schedule: %s", channel_name)
        return None

C:オンコール担当者のメールアドレスをオンコールスケジュールから取得

Who is On Call API の Get On Calls を呼び出すことで、オンコール担当者のメールアドレスを取得しています。API のレスポンスは、Who is On Call API の例を引用すると以下の形になります。

{
    "data": {
        "_parent": {
            "id": "d875alp4-9b4e-4219-a803-0c26936d18de",
            "name": "ScheduleName",
            "enabled": true
        },
        "onCallRecipients": [
            "user4@opsgenie.com"
        ]
    },
    "took": 0.101,
    "requestId": "7f0alpde-3c67-455f-97ec-24754432d413"
}

data フィールドのなかの onCallRecipients に、オンコール担当者のメールアドレスが存在していることがわかります。取得するのに必要な情報に対応する Pydantic のスキーマは

class _WhoIsOnCallDataItem(pydantic.BaseModel):
    on_call_recipients: list[str] = pydantic.Field(alias="onCallRecipients")
class _WhoIsOnCallResponse(pydantic.BaseModel):
    """Who is on call response schema."""
    data: _WhoIsOnCallDataItem

となり、 Opsgenie の API Endpoint からオンコール担当者のメールアドレスを取得するのは以下のようなコードになります。ここで GENIE _KEY として Opsgenie の認証情報を渡しています。

def _get_who_is_on_call_from_opsgenie(
    on_call_schedule: str | None,
) -> str | None:
    """Get an email address of who is on call from Opsgenie.
  https://docs.opsgenie.com/docs/who-is-on-call-api
  """
    if on_call_schedule is None:
        return None
    try:
        response = requests.get(
            url="https://api.opsgenie.com/v2/schedules/"
            + on_call_schedule
            + "/on-calls?scheduleIdentifierType=name&flat=true",
            headers={"Authorization": "GenieKey " + GENIE_KEY},
            timeout=10,
        )
        logger.info(response)
    except requests.exceptions.RequestException as e:
        logger.error("Error getting who is on call from Opsgenie: %s", e)
        raise e
    on_call_email_address = _WhoIsOnCallResponse.parse_obj(
        response.json()
    ).data.on_call_recipients[0]
    return on_call_email_address

D:SlackのUser Idをメールアドレスから取得

Slack の User Id をオンコール担当者のメールアドレスから取得します。Slack の User Id はオンコール担当者にメンションをして通知を送るために用います。実装は以下の通りです。

def _get_user_id_from_email_address(
    email_address: str | None,
) -> str | None:
    """Get a Slack user id from a Slack email address.

    https://api.slack.com/methods/users.lookupByEmail
    Required Bot token scopes: users:read.email
  """
    if email_address is None:
        return None
    client = slack_sdk.WebClient(token=SLACK_BOT_TOKEN)
    try:
        result = client.users_lookupByEmail(email=email_address)
        logger.info(result)
    except errors.SlackApiError as e:
        logger.error("Error getting a Slack user id: %s", e)
        raise e
    user: dict[str, str] = not_none(result.get("user"))
    return not_none(user.get("id"))

E:@whoisoncall がメンションされた投稿への返信として、オンコール担当者をメンション

Formatting text for app surfaces: Mentioning users | Slack にあるように、Slack でユーザをメンションするためには、<@{SLACK_USER_ID}> という形で、メッセージを投稿する必要があります。以下のコードは、slack_user_id が正しく取得できた場合には <@{SLACK_USER_ID}> is on call. というメッセージを投稿し、できなかった場合には、Who is on call bot cannot be used in this channel.というメッセージを投稿します(エラーになるのは、不測のエラーよりも、対応するオンコールスケジュールが存在しないようなチャンネルで誤ってこの bot が使われたという場合が大抵だからです)

def _post_slack_message(
    channel_id: str, thread_ts: str, slack_user_id: str | None
) -> None:
    """Post a Slack message.
    https://api.slack.com/methods/chat.postMessage
    Required Bot token scopes: chat:write

  """
    client = slack_sdk.WebClient(token=SLACK_BOT_TOKEN)
    if slack_user_id is None:
        try:
            result = client.chat_postMessage(
                channel=channel_id,
                thread_ts=thread_ts,
                text="Who is on call bot cannot be used in this channel.",
            )
            logger.info(result)
        except errors.SlackApiError as e:
            logger.error("Error mentioning: %s", e)
            raise e
        return
    try:
        result = client.chat_postMessage(
            channel=channel_id,
            thread_ts=thread_ts,
            text="<" + "@" + slack_user_id + ">" + " is on call.",
        )
        logger.info(result)
    except errors.SlackApiError as e:
        logger.error("Error mentioning: %s", e)
        raise e
    return

以上が大まかな処理です。他に、特別な処理として、Slack による URL verification handshake と Slack によるリトライの対応を実装しているので、それらについて解説します。

SlackによるURL verification handshake

Using the Slack Events API | URL verification handshake

Slack の Event API での URL verification handshake は、Slack がイベントを送信する URL を確認するプロセスです。このプロセスの一環として、Slack から Challenge Token を含むリクエストが送られます。このトークンをサーバーがレスポンスとして返すことで、Slack は URL の正当性を確認します。以下の実装では、その処理を行っています。

   # Handle URL verification handshake
   # https://api.slack.com/apis/connections/events-api#handshake
   challenge: str | None = body.get("challenge")
   if challenge is not None:
       return {
           "statusCode": 200,
           "headers": {"Content-Type": "text/plain"},
           "body": challenge,
       }

Slackによるリトライの対応

Using the Slack Events API | Retries にあるように、Slack の Event API はレスポンスを返すのに3秒以上かかる場合に Retry を行います。この記事の bot は合計4回も外部の API を呼び出す必要があるため、3秒以上の時間がかかり Retry がかかってしまうことがあります。Retry が起きると、オンコール担当者は複数回メンションされることになり、通知がとてもうるさくなってしまいます。

時間がかかる処理について、理想的には Using the Slack Events API | Overview で紹介されている、以下のような流れで行うのが正攻法です。

  1. A user creates a circumstance that triggers an event subscription to your application.
  2. Your server receives a payload of JSON describing that event.
  3. Your server acknowledges receipt of the event.
  4. Your business logic decides what to do about that event.
  5. Your server carries out that decision.

ここでは、レスポンスを一度返し(3. の部分)、後に時間のかかる処理を行う(4. の部分)という形になっています。本来であればこの形であるべきでしょう。

しかしながら、この記事で紹介しているコードでは、実装をできるだけシンプルにするため、 hacky ではありますが Retry を握りつぶすことで対処しています。以下の箇所がその実装です。

   request_headers: dict[str, str] = not_none(event.get("headers"))
   slack_retry_num = request_headers.get("X-Slack-Retry-Num")
   if slack_retry_num is not None and int(slack_retry_num) >= 1:
       return {"statusCode": 200}

X-Slack-Retry-Num という HTTP ヘッダーに Retry の回数の情報が含まれているので、Retry が2回目以降である場合には、200 のレスポンスを返すようにしています。

実際に使ってみて

実際に @whoisoncall bot を運用してみたところ、自分が作業する必要のない運用作業の通知に巻き込まれることがなくなり、集中して作業できるようになりました。また、Slackのリマインド機能と組み合わせが便利です。Slackのリマインドでは動的にメンションすることが難しいですが、@whoisoncall botと組み合わせることで、そのタイミングのオンコール担当者をメンションできるようになりました。

最後に

フライウィールでは、このように、エンジニアの生産性を上げる取り組みを行っています。現在、ソフトウェアエンジニアを積極採用中ですので、少しでもフライウィールのエンジニアリング文化にご興味をお持ち頂けましたら、まずはお気軽にカジュアル面談・ご応募頂ければと思います!