[CDK for Terraform] PagerDuty を使って CloudWatch Logs のエラーを検知したら関係者へ即通知できる環境を構築してみた

はじめに

アノテーション の中野です。

PagerDuty を使って本番環境の24時間365日のアラート監視の効率化を目指すべく調査をしていました。

せっかくだから各リソースを IaC でコード化して多くのプロダクトで使い回せないか、という動機で調査も進めていました。

すると、Terraform の Providers に PagerDuty リソースも実装されていることがわかり、また、CDK for Terraform (以下、cdktf)でも実装が可能であることがわかりました。

そのため、筆者が普段馴染みのある TypeScript にて PagerDuty のリソース環境をコード化できないか試してみました。

PagerDutyとは

まずは、PagerDutyがどういうサービスが軽く触れます。

オペレーショナル・レジリエンスに必要不可欠なプラットフォーム PagerDuty(ページャーデューティー)はシステムのインシデント対応を一元化するプラットフォームです。システム障害対応に費やす時間を軽減し、貴重なエンジニアリソースをビジネス拡大に充てることができます。

公式引用でもあるように、PagerDuty は障害発生時の対応時間を減らして効率的にアラート対応できるためのツールです。 弊社エントリでもやってみたブログがありますので、雰囲気を掴んでみたい方は御覧ください。

それでは、実装したコード例をみていきましょう。

環境情報

  • @cdktf/provider-pagerduty 10.0.2
  • @cdktf/provider-aws 17.0.7
  • cdktf 0.18.0
  • constructs 10.2.70
  • aws-lambda 1.0.7
  • node 18.18.0
  • npm 9.8.1
  • typescript 5.2.2

前提となる準備

大部分はコード化したものの、一部手作業が必要な部分があります。

  • PagerDuty のAPI キー払い出し
  • PagerDuty で通知先として必要なユーザーの作成

まずは、cdktf から Provider を使って PagerDuty の API へアクセスできるようにするために、 API キーを作成する必要があります。

PagerDuty のコンソールの[Integrations] > [API Access Keys] > [Click Create New API Key] から払い出せます。
また、今回はリソース作成をおこなうため、ReadOnlyでは作成しません。
詳しい手順は、ドキュメントを参照ください。

次に、今回のコードでは通知先として必要なユーザーは手動作成してください。
ユーザー作成は、以下の画面から行えます。

作成した構成

リソース構成を図示しました。

PagerDuty の Integration と SNS トピックを連携します。

通知の仕組みとしては、CloudWatch Logs から ERROR という文字列がメトリクスフィルターで補足されると、メッセージが PagerDuty に送信されます。

送信後に、teanOnCall というチーム内に所属するユーザーに対して、escalationPolicy に基づいてエスカレーションがおこなわれます。

コード

今回作成したコードは、こちらです。

ディレクトリ構成としては以下です。

.
├── __tests__
├── cdktf.json
├── help
├── jest.config.js
├── package-lock.json
├── package.json
├── setup.js
└── src
    ├── lambda-alert-sample-stack.ts # アラート発砲用AWS環境スタック
    ├── main.ts # cdktf エントリポイント
    └── pagerduty-cdktf-sample-stack.ts # PagerDuty用スタック
├── tsconfig.json
└── lambda # Lambdaエラー用サンプルコード
    └── lambda-error-alert
        ├── package-lock.json
        ├── package.json
        └── src
                        └── index.ts
        ├── tsconfig.json
        └── dist
            ├── index.js
            └── index.js.map

cdktf のエントリポイントを main.ts にしています。

先に、PagerdutyCdktfSampleStack のスタックを作成後、PagerdutyCdktfSampleStack に依存する Service Integration のパラメーターを AwsAlertSampleStack に引数として渡してあげます。

src/pagerduty-cdktf-sample-stack.ts

import { AwsAlertSampleStack } from './lambda-alert-sample-stack'
import { PagerdutyCdktfSampleStack } from './pagerduty-cdktf-sample-stack'
import { App } from 'cdktf'

const app = new App()
const result = new PagerdutyCdktfSampleStack(app, 'pagerduty-cdktf-sample')
new AwsAlertSampleStack(app, 'aws-alert-sample-stack', {
  path: '../lambda/lambda-error-alert/dist',
  handler: 'index.handler',
  runtime: 'nodejs18.x',
  stageName: 'aws-error-alert',
  version: 'v0.0.1',
  integration: result.integration,
})
app.synth()

PagerdutyCdktfSampleStackは、以下のコードです。

loadUsersの関数内で、手動作成したユーザーを指定してあげます。

そのため、loadUsersのusers配列内のemailはご自身のアカウントで設定したユーザーのメールアドレスを記載ください。

src/pagerduty-cdktf-sample-stack.ts

import { Construct } from 'constructs'
import { TerraformStack, TerraformVariable } from 'cdktf'
import {
  provider,
  businessService,
  serviceDependency,
  service,
  escalationPolicy,
  schedule,
  user,
  teamMembership,
  dataPagerdutyUser,
  serviceIntegration,
  team,
  dataPagerdutyVendor,
} from '@cdktf/provider-pagerduty'
import { DataPagerdutyUser } from '@cdktf/provider-pagerduty/lib/data-pagerduty-user'
import { ServiceIntegration } from '@cdktf/provider-pagerduty/lib/service-integration'

interface CreateUsersProps {
  construct: Construct
}

/**
 * PagerDutyコンソール作成したユーザー読み込み
 * @param {CreateUsersProps} { construct }
 * @return {*}  {DataPagerdutyUser[]}
 */
const loadUsers = ({ construct }: CreateUsersProps): DataPagerdutyUser[] => {
  const users = [
    new dataPagerdutyUser.DataPagerdutyUser(construct, 'nakanoyoshiyuki', {
      email: 'nakano.yoshiyuki@sample.com',
    }),
    new dataPagerdutyUser.DataPagerdutyUser(construct, 'nakanoyoshiyuki2', {
      email: 'nakano.yoshiyuki2@sample.com',
    }),
  ]
  return users
}

export class PagerdutyCdktfSampleStack extends TerraformStack {
  public integration: ServiceIntegration

  constructor(scope: Construct, id: string) {
    super(scope, id)

    const pagerdutyToken = new TerraformVariable(this, 'PAGERDUTY_TOKEN', {
      type: 'string',
      description: 'Pagerduty Token for cdktf deploy',
      sensitive: true,
    })
    new provider.PagerdutyProvider(this, 'pagerdutyProvider', {
      token: pagerdutyToken.value,
    })

    // ユーザー取得
    const users = loadUsers({ construct: this })

    // チーム取得
    const teamOnCall = new team.Team(this, 'teamOnCall', {
      name: 'teamOnCall',
    })

    // チーム所属
    users.map(
      (user) =>
        new teamMembership.TeamMembership(
          this,
          // @see https://stackoverflow.com/questions/61957767/aws-cdk-cannot-use-tokens-in-construct-id-how-do-i-dynamically-name-construc
          `cltTeamMemberShip${user.node.id}`,
          {
            teamId: teamOnCall.id,
            userId: user.id,
          }
        )
    )

    // オンコール用Business Service作成
    const onCallBusinessService = new businessService.BusinessService(
      this,
      'onCallBusinessService',
      {
        name: 'onCallBusinessService',
        team: teamOnCall.id,
      }
    )

    // サービスのオンコールシフト作成
    const onCallShift = new schedule.Schedule(this, 'onCallShift', {
      timeZone: 'Asia/Tokyo',
      layer: [
        {
          name: 'basicOnCallShift',
          rotationTurnLengthSeconds: 3600,
          rotationVirtualStart: '2023-09-16T00:00:00+09:00',
          start: '2023-09-16T00:00:00+09:00',
          users: users.map((user) => user.id),
        },
      ],
    })

    // オンコールチームのエスカレーションポリシー作成
    const teamOnCallEscalationPolicy = new escalationPolicy.EscalationPolicy(
      this,
      'teamOnCallEscalationPolicy',
      {
        name: 'teamOnCallEscalationPolicy',
        numLoops: 2,
        rule: [
          {
            escalationDelayInMinutes: 30,
            target: [
              {
                id: onCallShift.id,
                type: 'schedule_reference',
              },
            ],
          },
        ],
        teams: [teamOnCall.id],
      }
    )

    // オンコール用Technical Service作成
    const onCallTechnicalService = new service.Service(
      this,
      'onCallTechnicalService',
      {
        name: 'onCallTechnicalService',
        escalationPolicy: teamOnCallEscalationPolicy.id,
      }
    )

    // Business ServiceとTechnical Serviceの依存関係定義
    new serviceDependency.ServiceDependency(this, 'serviceDependencies', {
      dependency: {
        dependentService: [
          {
            type: 'business_service',
            id: onCallBusinessService.id,
          },
        ],
        supportingService: [
          {
            type: 'service',
            id: onCallTechnicalService.id,
          },
        ],
      },
    })

    // CloudWatchのVendor指定
    const cloudwatch = new dataPagerdutyVendor.DataPagerdutyVendor(
      this,
      'vendor',
      {
        name: 'Amazon CloudWatch',
      }
    )

    // PagerDutyのServiceIntegrationにCloudWatchを追加
    this.integration = new serviceIntegration.ServiceIntegration(
      this,
      'serviceIntegration',
      {
        service: onCallTechnicalService.id,
        vendor: cloudwatch.id,
      }
    )
  }
}

次に、AwsAlertSampleStack のコードです。

errorMetricFilter で ERROR の文字列が Lambda の CloudWatch Logs Stream で補足できたときに、CloudWatch Alerm がアラーム状態として検知されるようにしています。

CloudWatch Alerm が検知すると、SNS トピックへ通知されて最終的に PagerDuty へ連携されます。

src/lambda-alert-sample-stack.ts

import {
  cloudwatchLogGroup,
  cloudwatchLogMetricFilter,
  cloudwatchMetricAlarm,
  iamRole,
  iamRolePolicyAttachment,
  lambdaFunction,
  provider,
  s3Bucket,
  s3Object,
  snsTopic,
  snsTopicSubscription,
} from '@cdktf/provider-aws'
import { ServiceIntegration } from '@cdktf/provider-pagerduty/lib/service-integration'
import { AssetType, TerraformAsset, TerraformStack } from 'cdktf'
import { Construct } from 'constructs'
import * as path from 'path'

interface LambdaFunctionConfig {
  path: string
  handler: string
  runtime: string
  stageName: string
  version: string
  integration: ServiceIntegration
}

const lambdaRolePolicy = {
  Version: '2012-10-17',
  Statement: [
    {
      Action: 'sts:AssumeRole',
      Principal: {
        Service: 'lambda.amazonaws.com',
      },
      Effect: 'Allow',
      Sid: '',
    },
  ],
}

export class AwsAlertSampleStack extends TerraformStack {
  constructor(scope: Construct, name: string, config: LambdaFunctionConfig) {
    super(scope, name)

    new provider.AwsProvider(this, 'awsProvider', {
      region: 'ap-northeast-1',
    })

    const asset = new TerraformAsset(this, 'lambdaAsset', {
      path: path.resolve(__dirname, config.path),
      type: AssetType.ARCHIVE,
    })

    const bucket = new s3Bucket.S3Bucket(this, 'bucket', {
      bucketPrefix: `cdktf-${name}-bucket`,
    })

    const lambdaArchive = new s3Object.S3Object(this, 'lambdaArchive', {
      bucket: bucket.bucket,
      key: `${config.version}/${asset.fileName}`,
      source: asset.path,
    })

    const role = new iamRole.IamRole(this, 'lambdaExec', {
      name: `cdktf-${name}-role`,
      assumeRolePolicy: JSON.stringify(lambdaRolePolicy),
    })

    const iamPolicy = new iamRolePolicyAttachment.IamRolePolicyAttachment(
      this,
      'lambda-managed-policy',
      {
        policyArn:
          'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole',
        role: role.name,
      }
    )

    const cloudWatchLogGroup = new cloudwatchLogGroup.CloudwatchLogGroup(
      this,
      'cdktf-cloudwatch-log-group',
      {
        name: `/aws/lambda/cdktf-${name}`,
      }
    )

    new lambdaFunction.LambdaFunction(this, 'cdktf-lambda', {
      functionName: `cdktf-${name}`,
      s3Bucket: bucket.bucket,
      s3Key: lambdaArchive.key,
      handler: config.handler,
      runtime: config.runtime,
      role: role.arn,
      dependsOn: [iamPolicy, cloudWatchLogGroup],
    })

    // SNSトピック作成
    const pagerdutySnsTopic = new snsTopic.SnsTopic(
      this,
      'cdktf-lambda-error-to-pagerduty',
      {
        name: 'cdktf-lambda-error-to-pagerduty',
      }
    )
    new snsTopicSubscription.SnsTopicSubscription(
      this,
      'cdktf-lambda-error-to-pagerduty-subscription',
      {
        // PagerDutyの決まったEndpoint形式にする必要がある
        // @see https://registry.terraform.io/providers/PagerDuty/pagerduty/latest/docs/resources/service_integration#attributes-reference
        endpoint: `https://events.pagerduty.com/integration/${config.integration.integrationKey}/enqueue`,
        protocol: 'https',
        topicArn: pagerdutySnsTopic.arn,
      }
    )

    const errorMetricFilter =
      new cloudwatchLogMetricFilter.CloudwatchLogMetricFilter(
        this,
        'cdktf-lambda-error-metric-filter',
        {
          name: `cdktf-${name}-metric-filter`,
          pattern: 'ERROR',
          logGroupName: cloudWatchLogGroup.name,
          metricTransformation: {
            name: `cdktf-${name}-metric-filter`,
            namespace: 'Lambda',
            value: '1',
            defaultValue: '0',
          },
        }
      )

    new cloudwatchMetricAlarm.CloudwatchMetricAlarm(
      this,
      'cdktf-pagerduty-aws-lambda-error-alert',
      {
        alarmName: 'cdktf-pagerduty-aws-lambda-error-alert',
        comparisonOperator: 'GreaterThanOrEqualToThreshold',
        evaluationPeriods: 1,
        threshold: 1,
        metricName: errorMetricFilter.name,
        namespace: 'Lambda',
        period: 30,
        statistic: 'Sum',
        alarmActions: [pagerdutySnsTopic.arn],
      }
    )
  }
}

PagerDutyインシデント確認

リソースを作成するには、以下の手順で実行します。

なお、cdktf では AWS 環境へデプロイする際に、デプロイしたい AWS 環境の認証情報を利用する必要がありますので、事前にセットアップしておいてください。

$ npm install
$ export TF_VAR_PAGERDUTY_TOKEN='PagerDutyコンソールから取得したAPI Key'
$ aws sts get-caller-identity # AWS認証情報が設定されているか確認
$ cdktf deploy pagerduty-cdktf-sample aws-alert-sample-stack

# 途中デプロイの許可の確認があるので、Approve を選択して Enter

デプロイが環境すると、onCallTechnicalService の Integration に SNS との連携に必要な情報が追加されています。

この Integration URL が SNS トピックの Subscription の Endpoint として追加さています。

また、PagerDuty のオンコールシフトに時間ごとにシフトが組まれています。

もしも AWS 環境でエラーを検知して PagerDuty に連携された場合は、このシフトに基づいてアラート確認の担当者がアサインされます。

それでは実際のエラーを発砲して、PagerDuty にインシデントとして連携されるか確認します。

今回は Lambda のテストコンソールを任意実行してエラーを意図的に発生させます。

すると、PagerDuty のコンソール上にインシデントとして通知されます。

SMS で通知がきました。

また、アサイン担当者に電話番号が登録されている場合、PagerDuty から電話がかかってきます。

ちなみに、通知の設定についてはユーザーごとに複数設定することができます。

ハマった箇所

SNSとPagerDutyの連携

SNS トピックと PagerDuty の Service Integration 連携の際に、endpoint の設定を integration.name のような形にしていましたが、しばらく時間が経過しても SNS の Status が Confirmed にならずに連携されませんでした。

原因についてドキュメントで調査したところ、endpoint に指定する値の仕様が Terraorm 側の PagerDuty のドキュメントに記載がありました。 endpoint には integration_key をつかって、指定の URL 形式にしてあげる必要がありました。

To configure an event, please use the integration_key in the following interpolation:
https://events.pagerduty.com/integration/${pagerduty_service_integration.slack.integration_key}/enqueue

そのため、以下のような形式でendpointを指定してあげました。

src/lambda-alert-sample-stack.ts

new snsTopicSubscription.SnsTopicSubscription(
      this,
      'cdktf-lambda-error-to-pagerduty-subscription',
      {
        // PagerDutyの決まったEndpoint形式にする必要がある
        // @see https://registry.terraform.io/providers/PagerDuty/pagerduty/latest/docs/resources/service_integration#attributes-reference
        endpoint: `https://events.pagerduty.com/integration/${config.integration.integrationKey}/enqueue`,
        protocol: 'https',
        topicArn: pagerdutySnsTopic.arn,
      }
    )

さいごに

Lambda のアラート以外でも、本サンプルが CloudWatch Alerm と PagerDuty の連携自動化するための足がかりになれば幸いです。

参考情報

アノテーション株式会社について

アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社 WEB サイトをご覧ください。