はじめに
アノテーション の中野です。
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 の連携自動化するための足がかりになれば幸いです。
参考情報
- 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 サイトをご覧ください。