PagerDutyのオンコールシフトをSlackでリマインドする〜TypeScriptとAWS CDKで実装〜

この記事は[PagerDuty Advent Calendar 2023](https://qiita.com/advent-calendar/2023/pagerduty)の25日目の記事です。

## PagerDutyのオンコールシフトをSlackでリマインドしたい
🎄メリークリスマス🎅 
NewsPicksのSREチームで障害対応をよくやっている[あんどぅ](https://twitter.com/integrated1453)といいます。



先日、[ゆるSRE勉強会 #3](https://yuru-sre.connpass.com/event/299001/)という勉強会で[NewsPicksの開発組織](https://tech.newspicks.com/)のオンコールの体制とポストモーテムの取り組みを発表させていただきまして、その文脈でPagerDutyをどのように活用しているのかを実装とともにご紹介させていただきます。

https://www.docswell.com/s/integrated1453/K1J91E-blameless-postmortems/1

弊社ではモバイル担当1名サーバー担当2名の3名のエンジニアが一週間に2交代制(3.5日シフト)でオンコールを担当します。オンコール担当のことを運用当番と呼んでいるのですが、PagerDutyのオンコール設定は非常〜に便利なので
- 3.5日のうち有給取得するので1日だけOverride Layerで交代してもらう
- チームメンバーが長時間のリリース作業や障害対応を始めたのでシフトを入れ替える

などエンジニア同士の調整で柔軟に変更することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94938/b24eb2bf-97d4-6ed1-09e6-37dd70d720a4.png)

PagerDutyのオンコールシフトに入るメンバーは事前にPagerDutyからリマインドを受け取ることができますが、
- 現在誰がオンコールなのか
- 来週のオンコール予定はどうなっているか

などを開発組織全体で情報共有できるようにしておくといざという時の障害対応がスムーズになります。
全員が(たまにしか設定しない)PagerDutyを確認するより、共用のSlackチャンネルに通知しておく方が、社内の非エンジニアにも問い合わせ先が分かって安心感があると思います。

:::note
PagerDutyのオンコール設定が便利で柔軟に組み替えられるがゆえに、
オンコールシフトが固定的なスケジュールではなく、5分後には変更されることがあり得て、即時とは言わないまでもなる早で全体にSlack通知したいというのが要件のポイントです。
:::

## 実現するための構成

普段AWSを利用しているので、AWSからPagerDutyのAPIにリクエストします。 [^1]
定時タスクで十分に要件を満たすので、EventBridgeルールのcron式/rate式からLambda関数をトリガーします。

[^1]: 実は以前はGASで動いていたのですが、SREチームでこれらのスクリプトや処理をメンテナンスする上で、他と合わせてTypeScriptとCDKで管理したくなり現在はこうなっています


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94938/b37caaed-b8f2-2d6b-a1b2-406b639aec0f.png)




役割の異なるLambda関数を3つ作成していますが、処理としてはいずれも以下となります。
- PagerDutyからオンコールスケジュールを取得する
- SlackのAPIを使ってなにかする（メッセージ通知・トピック/メンション変更）

## PagerDutyのAPIの使い方について

私が説明するまでもなく、同じアドカレの以下の記事でとてもわかりやすく説明いただいていたのでそちらをご覧ください。

https://qiita.com/manji0/items/635792b17861469d9c84

上記の図でわかるように、これからPagerDutyのAPIを使う部分はオンコールスケジュールの取得しかやっていません笑
TypeScriptのLambda関数をCDKでデプロイする具体的な実装例としてご紹介します。

## 実装

PagerDuty関連で実装するLambda関数は以下3つです。[^2]
[^2]:実は他にも動いていますが、あまりPagerDutyに関連しない運用業務のため省略しています

- **来週の当番お知らせ関数**:  
  毎週木曜日に、翌週の当番スケジュールをメンション付きで当番のSlackチャンネルに通知しています。翌週オンコールに入るエンジニアが、予定を調整できているかどうかのリマインドになります。
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94938/689d3742-6f58-33ae-4c26-691d7d5552e6.png)

- **現在の当番お知らせ関数**:  
  PagerDutyのオンコール担当が切り替わったら、エンジニアの全体チャンネルに現在の当番をお知らせします。担当者にメンションし障害対応の心構えや参考ドキュメントのリンクをメッセージで送ります。  
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94938/dcee4269-f115-24fa-847a-2e6baf0f2637.png)
- **当番のメンション変える関数**:  
  社内のエンジニア/非エンジニアが障害連絡や問い合わせの際にエンジニアを呼び出すためのSlackグループを用意しており、バイネームではなくエイリアスでメンションできます。このメンションをPagerDutyの現在のオンコール担当に変更します。
  ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94938/5f275a98-6c3e-8367-2af0-2a07572786de.png)


### 共通部分

PagerDutyのオンコールスケジュールの取得は3つ全てのLambda関数で利用するので、importして利用できるように共通化します。

- 来週のオンコールスケジュールを取得する（来週の当番お知らせ関数）
- 現在のオンコール担当を取得する（現在の当番お知らせ関数・当番のメンション変える関数）

の両方の呼び出しパターンに対応します。日付ライブラリとHTTPクライアントを利用します。

:::note
Node.jsランタイムのLambda関数のnpmモジュールで利用実績のあった `dayjs` と `got` を使っています。
Node.js18以上でfetch APIが利用できるならgotは不要かもしれません。開発時点ではNode.js18のLambdaランタイムが出たばかりだったので、慣れたものを使っています。
少なくとも「JST基準で翌週1週間」などを取得する時には日付ライブラリがないと計算やフォーマットで煩雑なコードが増えてしまうのでライブラリを使った方がよいと思います。
:::


```typescript: pagerduty.ts
import dayjs from "dayjs";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import weekday from "dayjs/plugin/weekday";
import got from "got";

import { Constants } from "./constants";

const { SCHEDULE_ID_CLIENT, SCHEDULE_ID_SERVER1, SCHEDULE_ID_SERVER2 } = Constants;
const TIME_ZONE = "Asia/Tokyo";
dayjs.extend(utc);
dayjs.extend(timezone);
dayjs.extend(weekday);
dayjs.locale("ja");

// PagerDuty APIのレスポンスは利用するプロパティのみ最小限の型を定義しています
// 必要に応じて追加してください
// See https://developer.pagerduty.com/api-reference/3a6b910f11050-list-all-of-the-on-calls
export interface Oncalls {
    oncalls: {
        user: {
            id: string;
            summary: string;
        };
        schedule: {
            summary: string;
        };
        start: string;
        end: string;
    }[];
}

/**
 * PagerDutyからoncallsを取得します。
 * 開始日時と終了日時を指定しない場合は現在のoncallsを取得します。
 *
 * @param {string} pdApiKey PagerDutyのAPI Key
 * @param {string} escalationPolicyId エスカレーションポリシーID
 * @param {string} startDateStr JST開始日時（YYYY-MM-DDTHH:mm:ssZ）
 * @param {string} endDateStr JST終了日時（YYYY-MM-DDTHH:mm:ssZ）
 * @returns {Oncalls} Oncalls
 */
export async function getOncalls(
    pdApiKey: string,
    escalationPolicyId: string,
    startDateStr?: string,
    endDateStr?: string
): Promise<Oncalls["oncalls"]> {
    const now = dayjs().tz(TIME_ZONE).format("YYYY-MM-DDTHH:mm:ssZ");
    const url = new URL("https://api.pagerduty.com/oncalls");
    url.searchParams.append("time_zone", TIME_ZONE);
    url.searchParams.append("escalation_policy_ids[]", escalationPolicyId);
    // オンコール担当3人分のスケジュールがあるので、和集合を取得するために3つ条件に入れています。
    url.searchParams.append("schedule_ids[]", SCHEDULE_ID_CLIENT); 
    url.searchParams.append("schedule_ids[]", SCHEDULE_ID_SERVER1);
    url.searchParams.append("schedule_ids[]", SCHEDULE_ID_SERVER2);
    url.searchParams.append("since", startDateStr ?? now);
    url.searchParams.append("until", endDateStr ?? now);

    const response = await got.get(encodeURI(url.toString()), {
        headers: {
            Accept: "application/vnd.pagerduty+json;version=2",
            Authorization: "Token token=" + pdApiKey,
        },
    });
    console.log(response);

    return JSON.parse(response.body).oncalls;
}
```

以下のように利用できます。

```typescript
import * as pagerduty from "./pagerduty";
// dayjs等のimportは省略

//現在のオンコール担当を取得したい時
const oncalls = await pagerduty.getOncalls(PD_API_KEY, ESCALATION_POCLICY_ID);

// JSTで今週日曜0:00-翌週日曜0:00までのオンコール担当を取得したい時
const nextSunday = dayjs().tz("Asia/Tokyo").weekday(6);
const secondNextSunday = nextSunday.add(7, "day");
const startDateStr = dayjs(nextSunday).format("YYYY-MM-DDTHH:mm:ssZ");
const endDateStr = dayjs(secondNextSunday).format("YYYY-MM-DDTHH:mm:ssZ");

const oncalls = await pagerduty.getOncalls(PD_API_KEY, ESCALATION_POCLICY_ID, startDateStr, endDateStr);
```

PagerDuty APIでオンコール担当を取得する方法だけが知りたいという方は、これにて終了です！お疲れ様でした

活用の実装まで見たい方はもう少しお付き合いください。


### 来週の当番お知らせ関数

上記の `JSTで今週日曜0:00-翌週日曜0:00までのオンコール担当を取得` した後に、Slack用のメッセージを組み立てています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94938/a4578006-80e8-fd21-3c1a-44f6944d5686.png)



```typescript: 来週の当番お知らせ関数の一部
    // スケジュール部分のメッセージ作成
    const scheduleMessage = oncalls
        .map((oncall) => {
            const start = dayjs(oncall.start);
            if (start < nextSunday || start >= secondNextSunday) {
                return; // 「来週にオンコール担当に切り替わる」人だけを通知対象にする
            }
            // Slackで読みやすいように日時をフォーマット
            const startStr = dayjs(start).tz("Asia/Tokyo").format("MM/DD (ddd) HH:mm");
            const endStr = dayjs(oncall.end).tz("Asia/Tokyo").format("MM/DD (ddd) HH:mm");

            const schedule = `
            ${startStr} - ${endStr} [${oncall.schedule.summary}] <@${PD_USER_TO_SLACK[oncall.user.id]}>"
            `;
            return schedule;
        })
        .join("\n");

```

:::note warn
これは残念な点なのですが、オンコール担当者にSlackでメンションするためにPagerDutyユーザーとSlackユーザーの紐づけを持つ必要があり、これは現時点では定数 `PD_USER_TO_SLACK` でマッピングしておりエンジニアの参画・離職でメンテナンスが必要です。SlackのAPIを駆使すればメールアドレスから突合してスクリプトに紐づけ情報を持たないようにすることも可能だと思います。（要Slackアプリのワークスペース権限）
:::

### 現在の当番お知らせ関数

PagerDutyのオンコール担当が切り替わるタイミングは固定ではない（5分後かもしれない）ので、「**PagerDutyのオンコール担当が切り替わったら** 、エンジニアの全体チャンネルに現在の当番をお知らせする」という要件を達成するためには、現在の運用当番の一覧をどこかしらに保存しておき、変更されたということを検知する必要があります。

「DynamoDBやS3に保存する…？」と考えたあなたは、よく訓練されたAWSサーバーレスユーザーです。とはいえこの程度の運用スクリプトでデータストアを持ちたくないですよね。
PagerDutyの状態を確認するというのも一つの手だと思います。「5分間隔で実行して、6分前のPagerDutyのオンコールと現在のオンコールに差分があれば通知する」はどうでしょうか？5分間隔の実行が行われなかった場合に通知されないのでロバストではないですね。

これは考えた人が天才だと思うのですが（私ではありません）、Slackチャンネルのトピック（チャンネルの説明）に現在の運用当番を設定して、ここをデータストアとして使っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/94938/a1074f33-358a-c0e2-c9a0-76610f61ee67.png)
Slackチャンネルのトピックの文字列とPagerDutyの最新のオンコールから生成した文字列の比較を5分ごとに行い、不一致をトリガーにトピックの変更とお知らせ通知を行うことができるようになっています。

```typescript: 5分ごとに実行される現在の当番お知らせ関数の一部
// 現在のオンコール担当を取得
const oncalls = await pagerduty.getOncalls(PD_API_KEY, ESCALATION_POCLICY_ID);

// Slackチャンネルのトピックに設定すべきメッセージを作成
const topicMessage = createTopicMessage(oncalls); // 現在の運用当番: Aさん、Bさん、Cさん

const isTopicChanged = await isTopicChanged(topicMessage);
if (!isTopicChanged) {
    console.log("On-call member has not changed.");
    return; // トピックとの不一致がなければ処理をスキップ
}

// 不一致があればトピックをPagerDutyから取得したオンコール担当に変更
await changeSlackChannelTopic(topicMessage); // 関数の中身の処理は省略


// Slackに運用当番向けメッセージを通知
const toubanWebhook = new IncomingWebhook(SLACK_WEBHOOK_URL);
await toubanWebhook.send(createPreparednessMessage(oncalls)); // 関数の中身の処理は省略


/**
 * トピックに変更があるかを返します。
 */
async function isTopicChanged(newTopic: string): Promise<boolean> {
    const url = "https://slack.com/api/conversations.info";
    const options = {
        headers: {
            authorization: `Bearer ${SLACK_BOT_TOKEN}`,
            "Content-Type": "application/json",
        },
        searchParams: {
            channel: SLACK_CHANNEL_ID,
        },
    };

    const res = await got.get(url, options);
    const currentTopic = JSON.parse(res.body).channel.topic.value;

    const isTopicChanged = newTopic != currentTopic;
    console.log("isTopicChanged:" + isTopicChanged);

    return isTopicChanged;
}
    
```

### 当番のメンション変える関数

当番のエイリアスとなるメンションは、常にPagerDutyのオンコール状態と一致させ続けるべきであり、都度メッセージが飛ぶこともない冪等な操作のため、5分ごとに実行しています。

```typescript: 5分ごとに実行される当番のメンション変える関数の一部
/**
 * 現在の運用当番を @XXX-touban ユーザーグループに設定します。
 */
export async function changeUsergroup(): Promise<void> {
    // 現在のオンコール担当を取得
    const oncalls = await pagerduty.getOncalls(PD_API_KEY, ESCALATION_POCLICY_ID);
    // PagerDutyから取得したオンコール担当者の一覧をカンマ区切りのSlackユーザーIDにして
    const users = oncalls.map((oncall) => PD_USER_TO_SLACK[oncall.user.id]).join(",");

    const url = "https://slack.com/api/usergroups.users.update";
    const payload = {
        usergroup: SLACK_GROUP_ID,
        users: users, // メンションのエイリアスとなるユーザーグループの参加者に指定
    };
    const options = {
        body: JSON.stringify(payload),
        headers: {
            authorization: `Bearer ${SLACK_BOT_TOKEN}`,
            "Content-Type": "application/json",
        },
    };

    await got.post(url, options);
}
```

ほとんどSlackのAPIの使い方の説明みたいになってしまいました。

### CDKでLambdaをデプロイするIaC

今回はEventBridge RuleとLambda関数をデプロイするシンプルな作りで、Lambda関数間で定数やPagerDuty APIの共通関数などを共用するため、oncall-notifierというCDKのConstruct一つからセットでデプロイすることにしました。
ディレクトリ構成は以下のようになります。

```
lib/***/
  └ oncall-notifier.ts                         --- CDKでLambdaをデプロイするIaC
  └ oncall-notifier.notifyNextWeekSchedule.ts  --- 来週の当番お知らせ関数
  └ oncall-notifier.changeChannelTopic.ts      --- 現在の当番お知らせ関数
  └ oncall-notifier.changeUsergroup.ts         --- 当番のメンション変える関数
  └ pagerduty.ts                               --- PagerDutyのオンコールスケジュールを取得する共通関数
```

`oncall-notifier.***.ts` のような関数名にしているのは、CDKのNodejsFunctionでLambda関数をパッケージ・デプロイする際に、[論理IDと一致させて同じフォルダに配置すると対象のファイルパス指定を省略できる](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html#entry)というレールに沿ったものです。`oncall-notifier.ts` にデプロイ処理を集約しているという関係性がわかりやすくて良いかと思いました。

Lambda関数のスクリプトをTypeScriptで書いて、デプロイのIaCもTypeScriptで書いて、同じディレクトリに置いて、TypeScript→JavaScriptへのコンパイルはCDKが一括してesbuildで行ってデプロイしてくれて、開発時はホットリロード・ホットスワップも備えているというのは、この手の運用自動化を行う上で最高の開発者体験だと思っています。

```typescript: oncall-notifier.ts      
import { Duration, Stack } from "aws-cdk-lib";
import * as events from "aws-cdk-lib/aws-events";
import * as targets from "aws-cdk-lib/aws-events-targets";
import * as lambda from "aws-cdk-lib/aws-lambda";
import { LogLevel, NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { RetentionDays } from "aws-cdk-lib/aws-logs";
import * as ssm from "aws-cdk-lib/aws-ssm";
import { Construct } from "constructs";

export class OnCallNotifier extends Construct {
    constructor(scope: Stack, id: string, envName: string) {
        super(scope, id);

        const slackBotToken = ssm.StringParameter.fromStringParameterAttributes(this, "SlackBotToken", {
            parameterName: `/oncall-notifier/slack/bot-token`,
        });

        const pdApiKey = ssm.StringParameter.fromStringParameterAttributes(this, "PdApiKey", {
            parameterName: `/oncall-notifier/pagerduty/api-key`,
        });

        const notifyNextWeekSchedule = new NodejsFunction(this, "notifyNextWeekSchedule", {
            functionName: `${envName}NotifyNextWeekSchedule`,
            handler: "notifyNextWeekSchedule",
            runtime: lambda.Runtime.NODEJS_18_X,
            timeout: Duration.seconds(300),
            memorySize: 512,
            environment: {
                SLACK_BOT_TOKEN: slackBotToken.stringValue,
                PD_API_KEY: pdApiKey.stringValue,
            },
            bundling: { logLevel: LogLevel.SILENT },
            logRetention: RetentionDays.ONE_MONTH,
        });
        new events.Rule(this, "NofifyNextWeekScheduleRule", {
            ruleName: `${envName}NofifyNextWeekScheduleRule`,
            schedule: events.Schedule.cron({
                // 毎週木曜15:00 JST
                weekDay: "THU",
                hour: "6",
                minute: "0",
            }),
            targets: [new targets.LambdaFunction(notifyNextWeekSchedule)],
        });

        const changeChannelTopic = new NodejsFunction(this, "changeChannelTopic", {
            functionName: `${envName}ChangeChannelTopic`,
            handler: "changeChannelTopic",
            runtime: lambda.Runtime.NODEJS_18_X,
            timeout: Duration.seconds(60),
            memorySize: 512,
            environment: {
                SLACK_BOT_TOKEN: slackBotToken.stringValue,
                PD_API_KEY: pdApiKey.stringValue,
            },
            bundling: { logLevel: LogLevel.SILENT },
            logRetention: RetentionDays.ONE_MONTH,
        });
        new events.Rule(this, "ChangeChannelTopicRule", {
            ruleName: `${envName}ChangeChannelTopicRule`,
            schedule: events.Schedule.rate(Duration.minutes(5)),
            targets: [new targets.LambdaFunction(changeChannelTopic)],
        });

        const changeUsergroup = new NodejsFunction(this, "changeUsergroup", {
            functionName: `${envName}ChangeUsergroup`,
            handler: "changeUsergroup",
            runtime: lambda.Runtime.NODEJS_18_X,
            timeout: Duration.seconds(60),
            memorySize: 512,
            environment: {
                SLACK_BOT_TOKEN: slackBotToken.stringValue,
                PD_API_KEY: pdApiKey.stringValue,
            },
            bundling: { logLevel: LogLevel.SILENT },
            logRetention: RetentionDays.ONE_MONTH,
        });
        new events.Rule(this, "ChangeUsergroupRule", {
            ruleName: `${envName}ChangeUsergroupRule`,
            schedule: events.Schedule.rate(Duration.minutes(5)),
            targets: [new targets.LambdaFunction(changeUsergroup)],
        });
    }
}
```

- `SLACK_BOT_TOKEN` と `PD_API_KEY` はSSMパラメータストアに格納された値を環境変数に設定し、スクリプトに渡しています
- それぞれのLambda関数を定時実行するためのEventBridge Ruleを作成し、cron式/rate式でトリガーしています

このConstructをCDKアプリのStackに組み込めば、`cdk deploy` で関数3つがデプロイされて運用当番のリマインド自動化処理が動きます。

## まとめ

「PagerDutyのオンコールシフトをSlackでリマインドする」という運用を成立させるにあたって
- 来週のオンコール予定を通知する
- 現在のオンコール担当が変わったら通知する
- 現在のオンコール担当が変わったらSlackメンションを変更する

という運用自動化をTypeScriptのLambda関数で、IaCもセットで実装しました。

経験上、こういう「THE 運用」みたいな仕組みをコードで全て実装するのはなかなか難しいことも多いと思うのですが、
PagerDutyとSlackにはAPIがありドキュメントも充実していて、AWSにもコードと親和性の高いCDKというIaCがあるおかげでTypeScriptしか書かずに快適に自動化できました。
私はインフラエンジニアですがコードを書くのは好きなので、PagerDutyを使って自動化できることはどんどん自動化していきたいですね！障害対応とかも。

P.S.
障害対応の勉強会を立ち上げたので、もし興味があれば参加のご検討よろしくお願いします〜

https://incident-response.connpass.com/event/304636/