[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 に引数として渡してあげます。
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はご自身のアカウントで設定したユーザーのメールアドレスを記載ください。
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: '[email protected]', }), new dataPagerdutyUser.DataPagerdutyUser(construct, 'nakanoyoshiyuki2', { email: '[email protected]', }), ] 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 へ連携されます。
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を指定してあげました。
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 の連携自動化するための足がかりになれば幸いです。
参考情報
- cdktf-provider-pagerduty/docs/API.typescript.md at main · cdktf/cdktf-provider-pagerduty
- hashicorp/aws | Terraform Registry
- PagerDuty/pagerduty | Terraform Registry
- Deploy Lambda functions with TypeScript and CDK for Terraform | Terraform | HashiCorp Developer
- Variables and Outputs - CDK for Terraform | Terraform | HashiCorp Developer
- Amazon CloudWatch インテグレーションガイド|PagerDuty
アノテーション株式会社について
アノテーション株式会社は、クラスメソッド社のグループ企業として「オペレーション・エクセレンス」を担える企業を目指してチャレンジを続けています。「らしく働く、らしく生きる」のスローガンを掲げ、様々な背景をもつ多様なメンバーが自由度の高い働き方を通してお客様へサービスを提供し続けてきました。現在当社では一緒に会社を盛り上げていただけるメンバーを募集中です。少しでもご興味あれば、アノテーション株式会社 WEB サイトをご覧ください。