こんにちは、ソーシャル経済メディア「NewsPicks」でSREをしている飯野です。 今回はSREで行ったNew RelicをCDK for TerraformでIaC管理する話を紹介したいと思います。
- SLOモニタリングをSREチームだけで行うのは難しい
- CDK for Terraformとcdktf-newrelic-provider
- IaCで作成する内容
- CDK for Terraformで実装していく
- 完成したものはこちら
- 現在はどのような運用をしているのか
- 感想
- 参考
SLOモニタリングをSREチームだけで行うのは難しい
SREチームでは定期的にSLOモニタリングを行い、可用性やレスポンスに問題ががあれば各チームにエスカレーションを行っています。
モニタリング対象の変化が少なけれSREチームだけ監視する運用でも問題ありません。しかし、NewsPicksのバックエンドはAPIが1000以上ある巨大なWebアプリケーションです。日々活発に開発が進められており、監視するべきAPIも変化していきます。SREチームだけでモニタリングを運用していくには難しい状態が続いていました。
この状態を改善し、各チームにオーナーシップを持ってもらうことを目的に、チームが担当するAPIはチームでSLOモニタリングしていく方向を検討したのですが、SLOの項目を手動で作成していくのは負担が大きすぎます。
というわけで、SREチームでNew RelicのSLOモニタリングをIaCで管理する方法を模索することになったのでした。
CDK for Terraformとcdktf-newrelic-provider
New RelicのIaC化にはterraform-newrelicが利用できます。しかし当然ですがTerraformでしか使えません*1。全面的にCDKを採用しているNewsPicksとしては、開発者によるメンテナンスやコントリビュートが盛んに行われているCDKの開発者体験を手放したくないと考えていました。また、後述のバーンレートアラートの閾値などは、複雑な計算式を理解しなくてもメンテナンスできるようにしたいという要件もありました。
今回はCDKのようにTerraformを扱えるCDK for Terraform(cdktf)とcdktf-newrelic-providerを組み合わせてIaC管理を行うことにしました。
- CDK for Terraform
- cdktf-newrelic-provider
- terraform-newrelic
追記
この記事を公開した2023/09/28にCloudFormationでもNew Relicのリソースが管理できるようになったという発表がありました。
Exciting News! You can now provision and manage New Relic resources using #AWS #CloudFormation, #awscdk, and more! Don't miss it! Read the blog here: https://t.co/LgcPSelNfD
— AWS CloudFormation (@AWSCloudFormer) 2023年9月27日
発表時点ではServiceLevelに対応していないためCDK for Terraformを採用する理由がありますが、今後対応が進めばCDKを直接利用することもできそうです。
IaCで作成する内容
SLOモニタリングをチームに展開し、皆で育てていける状態にするのを目的とします。次のことができることを目標としました。
- APIごとに担当チームを設定したい
- APIごとにServiceLevelを作成したい
- ServiceLevelに問題があった場合にAlertを上げたい
- Alertにはバーンレートアラートを採用して、一瞬の変化は無視したい
- Alertが上がったらチームのSlackチャンネルに通知したい
- チームごとにServiceLevelの状態が一覧できるダッシュボードがあるとなお良い
- このブログでは範囲外とします
CDK for Terraformで実装していく
作成する内容が決まったので、実際に実装していきます。
newrelic-terraformのリソースをER図ふうに書くと次のようになります。この図の水色の番号の順番に実装を進めていきます。
(完成品が見たいんだ!という人は目次から「完成したものはこちら」に飛んでください)
-1. cdktf init
プロジェクトテンプレートを使ってプロジェクトを作成します。 --providers=newrelic/newrelic
を指定して作成するとproviderもインストールしてくれるので便利です。
$ mkdir techblog-2023-09 $ cd techblog-2023-09 $ cdktf init --template=typescript --providers=newrelic/newrelic --local
ブログ執筆時点ではそれぞれ次のバージョンでプロジェクトが作成されました
- cdktf-0.18.0
- cdktf-newrelic-provider-10.0.2
0. @cdktf/newrelic-provicer
の初期化
cdktfでnewrelic-terraformを使うための最初のおまじないとして、NewrelicProviderを初期化します。
diffを見る
diff --git a/main.ts b/main.ts index 6214796..6770b97 100644 --- a/main.ts +++ b/main.ts @@ -1,14 +1,42 @@ -import { App, TerraformStack } from "cdktf"; +import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; +import { App, Fn, TerraformStack, TerraformVariable } from "cdktf"; import { Construct } from "constructs"; +interface MyStackProps { + readonly newrelicAccountId: number; + readonly newrelicRegion: "US" | "EU"; +} + class MyStack extends TerraformStack { - constructor(scope: Construct, id: string) { + constructor(scope: Construct, id: string, props: MyStackProps) { super(scope, id); - // define resources here + const apiKey = this.createNewrelicApiKeyVariables(); + new NewrelicProvider(this, "newrelic", { + accountId: props.newrelicAccountId, + apiKey: apiKey.value, + region: props.newrelicRegion, + }); + } + + protected createNewrelicApiKeyVariables(): TerraformVariable { + const apiKey = new TerraformVariable(this, "NEW_RELIC_API_KEY", { + type: "string", + description: "New Relic API Key", + nullable: false, + sensitive: true, + }); + apiKey.addValidation({ + condition: Fn.can(Fn.regex("^NRAK", apiKey.fqn)), + errorMessage: "NEW_RELIC_API_KEY value must be valid API Key, starting with `NRAK`", + }); + return apiKey; } } const app = new App(); -new MyStack(app, "techblog-2023-09"); +new MyStack(app, "techblog-2023-09", { + newrelicAccountId: Number(process.env.NEW_RELIC_ACCOUNT_ID), + newrelicRegion: "US", +}); app.synth();
APIキーをTerraformVariableから取得し、そのAPIキーを使ってNewrelicProviderを初期化します。
+ const apiKey = this.createNewrelicApiKeyVariables(); + new NewrelicProvider(this, "newrelic", { + accountId: props.newrelicAccountId, + apiKey: apiKey.value, + region: props.newrelicRegion, + });
TerraformVariableを使用するとcdktf実行時に引数や設定ファイルから変数の値を取得できます。
+ protected createNewrelicApiKeyVariables(): TerraformVariable { + const apiKey = new TerraformVariable(this, "NEW_RELIC_API_KEY", { + type: "string", + description: "New Relic API Key", + nullable: false, + sensitive: true, + }); ...omit + return apiKey; }
現在のディレクトリの *.auto.tfvars
や terraform.tfvars
というファイルを自動で読み込むので次の内容で保存しておくと良いです。
NEW_RELIC_API_KEY = "NRAK-xxxxxxxxx"
Terraform Variableのvalidationも試したくなったので、APIキーがNRAKから始まっていることを確認しています。
+ apiKey.addValidation({ + condition: Fn.can(Fn.regex("^NRAK", apiKey.fqn)), + errorMessage: "NEW_RELIC_API_KEY value must be valid API Key, starting with `NRAK`", + });
- CDK for TerraformでのTerraform Variableの使い方
1.DataNewrelicEntityの作成
次のDataNewrelicEntityを作成します。
diffを見る
--- a/main.ts +++ b/main.ts @@ -1,3 +1,4 @@ +import { DataNewrelicEntity } from "@cdktf/provider-newrelic/lib/data-newrelic-entity"; import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; import { App, Fn, TerraformStack, TerraformVariable } from "cdktf"; import { Construct } from "constructs"; @@ -5,6 +6,7 @@ import { Construct } from "constructs"; interface MyStackProps { readonly newrelicAccountId: number; readonly newrelicRegion: "US" | "EU"; + readonly entityName: string; } class MyStack extends TerraformStack { @@ -17,6 +19,8 @@ class MyStack extends TerraformStack { apiKey: apiKey.value, region: props.newrelicRegion, }); + + new DataNewrelicEntity(this, "techblog-app-entity", { name: props.entityName }); } protected createNewrelicApiKeyVariables(): TerraformVariable { @@ -38,5 +42,6 @@ const app = new App(); new MyStack(app, "techblog-2023-09", { newrelicAccountId: Number(process.env.NEW_RELIC_ACCOUNT_ID), newrelicRegion: "US", + entityName: process.env.ENTITY_NAME!, }); app.synth();
DataNewrelicEntityはNew RelicのAPMやモバイルアプリなどのEnttiyを扱うための読み取り専用のクラスです*2。ServiceLevelはEntityに対して作成するので準備しておきます。
+ new DataNewrelicEntity(this, "techblog-app-entity", { name: props.entityName });
2.ServiceLevelの作成
モニタリングの対象としてServiceLevelを作成します。
diffを見る
--- a/main.ts +++ b/main.ts @@ -1,7 +1,14 @@ import { DataNewrelicEntity } from "@cdktf/provider-newrelic/lib/data-newrelic-entity"; import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; +import { ServiceLevel } from "@cdktf/provider-newrelic/lib/service-level"; import { App, Fn, TerraformStack, TerraformVariable } from "cdktf"; import { Construct } from "constructs"; +import * as SqlString from "sqlstring"; + +interface EndpointConfig { + readonly id: string; + readonly transactionName: string; +} interface MyStackProps { readonly newrelicAccountId: number; @@ -20,7 +27,24 @@ class MyStack extends TerraformStack { region: props.newrelicRegion, }); - new DataNewrelicEntity(this, "techblog-app-entity", { name: props.entityName }); + const entity = new DataNewrelicEntity(this, "techblog-app-entity", { name: props.entityName }); + const configs: EndpointConfig[] = [ + { + id: "news-news-id-get", + transactionName: "WebTransaction/SpringController/news/{news} (GET)", + }, + { + id: "live-movie-news-movie-id-get", + transactionName: "WebTransaction/SpringController/live-movie/{liveMovieId} (GET)", + }, + ]; + configs.map((config) => { + return { + id: config.id, + availability: this.createEndpointAvailabilityServiceLevel(entity, config), + latency: this.createEndpointLatencyServiceLevel(entity, config), + }; + }); } protected createNewrelicApiKeyVariables(): TerraformVariable { @@ -36,6 +60,85 @@ class MyStack extends TerraformStack { }); return apiKey; } + + createEndpointAvailabilityServiceLevel(entity: DataNewrelicEntity, config: EndpointConfig): ServiceLevel { + const accountId = entity.accountId; + const name = ["Availability:", config.id].join(" "); + const description = ""; + + const objective = { + target: 99.9, + timeWindow: { + rolling: { + count: 7, + unit: "DAY", + }, + }, + }; + + const baseCondition = SqlString.format("entityGuid = ?", [entity.guid]); + const validWhere = SqlString.format(`${baseCondition} AND name = ?`, [config.transactionName]); + const badWhere = SqlString.format(`${baseCondition} AND transactionName = ? AND error.expected IS FALSE`, [ + config.transactionName, + ]); + + return new ServiceLevel(this, `${config.id}-availability-service-level`, { + guid: entity.guid, + name: [entity.name, name].join(" - "), + description, + events: { + accountId, + validEvents: { + from: "Transaction", + where: validWhere, + }, + badEvents: { + from: "TransactionError", + where: badWhere, + }, + }, + objective, + }); + } + + createEndpointLatencyServiceLevel(entity: DataNewrelicEntity, config: EndpointConfig): ServiceLevel { + const accountId = entity.accountId; + const name = ["Latency:", config.id].join(" "); + const description = ""; + + const objective = { + target: 80.0, + timeWindow: { + rolling: { + count: 7, + unit: "DAY", + }, + }, + }; + + const baseCondition = SqlString.format("entityGuid = ? AND name = ?", [entity.guid, config.transactionName]); + + const validWhere = baseCondition; + const goodWhere = SqlString.format(`${baseCondition} AND duration < ?`, [0.5]); + + return new ServiceLevel(this, `${config.id}-latency-service-level`, { + guid: entity.guid, + name: [entity.name, name].join(" - "), + description, + events: { + accountId, + validEvents: { + from: "Transaction", + where: validWhere, + }, + goodEvents: { + from: "Transaction", + where: goodWhere, + }, + }, + objective, + }); + } } const app = new App();
一つ目のServiceLevelはAvailabilityに対するものです。 objective
でSLOを設定しており、99.9%以上の可用性があることを目標にしています。
badWhereの条件はアプリケーションによってカスタマイズの余地があり、NewsPicksサーバーでは外部APIの呼び出し失敗やコンテンツのAccept Header由来のエラーは除外しています。
+ createEndpointAvailabilityServiceLevel(entity: DataNewrelicEntity, config: EndpointConfig): ServiceLevel { + const accountId = entity.accountId; + const name = ["Availability:", config.id].join(" "); + const description = ""; + + const objective = { + target: 99.9, + timeWindow: { + rolling: { + count: 7, + unit: "DAY", + }, + }, + }; + + const baseCondition = SqlString.format("entityGuid = ?", [entity.guid]); + const validWhere = SqlString.format(`${baseCondition} AND name = ?`, [config.transactionName]); + const badWhere = SqlString.format(`${baseCondition} AND transactionName = ? AND error.expected IS FALSE`, [ + config.transactionName, + ]); + + return new ServiceLevel(this, `${config.id}-availability-service-level`, { + guid: entity.guid, + name: [entity.name, name].join(" - "), + description, + events: { + accountId, + validEvents: { + from: "Transaction", + where: validWhere, + }, + badEvents: { + from: "TransactionError", + where: badWhere, + }, + }, + objective, + }); + }
二つ目はLatencyに対するServiceLevelです。 こちらはレイテンシーが0.5秒以内のリクエストが80%以上あることを目標にしています。説明用ということで0.5秒をハードコードしていますが、実際はエンドポイントごとに適切な目標を設定しています。
+ createEndpointLatencyServiceLevel(entity: DataNewrelicEntity, config: EndpointConfig): ServiceLevel { + const accountId = entity.accountId; + const name = ["Latency:", config.id].join(" "); + const description = ""; + + const objective = { + target: 80.0, + timeWindow: { + rolling: { + count: 7, + unit: "DAY", + }, + }, + }; + + const baseCondition = SqlString.format("entityGuid = ? AND name = ?", [entity.guid, config.transactionName]); + + const validWhere = baseCondition; + const goodWhere = SqlString.format(`${baseCondition} AND duration < ?`, [0.5]); + + return new ServiceLevel(this, `${config.id}-latency-service-level`, { + guid: entity.guid, + name: [entity.name, name].join(" - "), + description, + events: { + accountId, + validEvents: { + from: "Transaction", + where: validWhere, + }, + goodEvents: { + from: "Transaction", + where: goodWhere, + }, + }, + objective, + }); + }
モニタリングしたい項目を配列に入れてループで回して作成します。*3
- new DataNewrelicEntity(this, "techblog-app-entity", { name: props.entityName }); + const entity = new DataNewrelicEntity(this, "techblog-app-entity", { name: props.entityName }); + const configs: EndpointConfig[] = [ + { + id: "news-news-id-get", + transactionName: "WebTransaction/SpringController/news/{news} (GET)", + }, + { + id: "live-movie-news-movie-id-get", + transactionName: "WebTransaction/SpringController/live-movie/{liveMovieId} (GET)", + }, + ]; + configs.map((config) => { + return { + id: config.id, + availability: this.createEndpointAvailabilityServiceLevel(entity, config), + latency: this.createEndpointLatencyServiceLevel(entity, config), + }; + });
ServiceLevelでモニタリングができるようになりました。
3.AlertPolicyの作成
次はIssueを作成するルールとなるAlertPolicyとAlertを検知するAlertConditionを作成していきます。
diffを見る
--- a/main.ts +++ b/main.ts @@ -1,3 +1,4 @@ +import { AlertPolicy } from "@cdktf/provider-newrelic/lib/alert-policy"; import { DataNewrelicEntity } from "@cdktf/provider-newrelic/lib/data-newrelic-entity"; import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; import { ServiceLevel } from "@cdktf/provider-newrelic/lib/service-level"; @@ -45,6 +46,12 @@ class MyStack extends TerraformStack { latency: this.createEndpointLatencyServiceLevel(entity, config), }; }); + + // https://docs.newrelic.com/docs/alerts-applied-intelligence/new-relic-alerts/alert-policies/specify-when-alerts-create-incidents/ + new AlertPolicy(this, "alert-policy", { + name: [entity.name, "SLO violation"].join(" - "), + incidentPreference: "PER_CONDITION", + }); } protected createNewrelicApiKeyVariables(): TerraformVariable {
これから作成するAlertConditionは監視対象のServiceLevelが異なるため独立性が高いので、AlertPolicyにはAlertConditionごとにIssueを作成する PER_CONDITION
を設定します。
+ new AlertPolicy(this, "alert-policy", { + name: [entity.name, "SLO violation"].join(" - "), + incidentPreference: "PER_CONDITION", + });
4.AlertCondition(バーンレートアラート)の作成
つぎにServiceLevelを監視してAlertを上げる条件AlertConditionを作成します。
diffを見る
--- a/main.ts +++ b/main.ts @@ -1,5 +1,7 @@ import { AlertPolicy } from "@cdktf/provider-newrelic/lib/alert-policy"; import { DataNewrelicEntity } from "@cdktf/provider-newrelic/lib/data-newrelic-entity"; +import { EntityTags } from "@cdktf/provider-newrelic/lib/entity-tags"; +import { NrqlAlertCondition } from "@cdktf/provider-newrelic/lib/nrql-alert-condition"; import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; import { ServiceLevel } from "@cdktf/provider-newrelic/lib/service-level"; import { App, Fn, TerraformStack, TerraformVariable } from "cdktf"; @@ -9,6 +11,13 @@ import * as SqlString from "sqlstring"; interface EndpointConfig { readonly id: string; readonly transactionName: string; + readonly teams: string[]; +} + +interface EndpointAvailabilityAlertConfig { + readonly id: string; + readonly serviceLevel: ServiceLevel; + readonly teams: string[]; } interface MyStackProps { @@ -33,25 +42,36 @@ class MyStack extends TerraformStack { { id: "news-news-id-get", transactionName: "WebTransaction/SpringController/news/{news} (GET)", + teams: ["SRE", "DEV"], }, { id: "live-movie-news-movie-id-get", transactionName: "WebTransaction/SpringController/live-movie/{liveMovieId} (GET)", + teams: ["DEV"], }, ]; - configs.map((config) => { + const serviceLevels = configs.map((config) => { return { id: config.id, availability: this.createEndpointAvailabilityServiceLevel(entity, config), latency: this.createEndpointLatencyServiceLevel(entity, config), + teams: config.teams, }; }); // https://docs.newrelic.com/docs/alerts-applied-intelligence/new-relic-alerts/alert-policies/specify-when-alerts-create-incidents/ - new AlertPolicy(this, "alert-policy", { + const alertPolicy = new AlertPolicy(this, "alert-policy", { name: [entity.name, "SLO violation"].join(" - "), incidentPreference: "PER_CONDITION", }); + + for (const serviceLevel of serviceLevels) { + this.createEndpointAvailabilityNrqlAlertCondition(alertPolicy, { + id: serviceLevel.id, + serviceLevel: serviceLevel.availability, + teams: serviceLevel.teams, + }); + } } protected createNewrelicApiKeyVariables(): TerraformVariable { @@ -146,6 +166,78 @@ class MyStack extends TerraformStack { objective, }); } + + createEndpointAvailabilityNrqlAlertCondition( + alertPolicy: AlertPolicy, + endpointAlertConfig: EndpointAvailabilityAlertConfig, + ): NrqlAlertCondition { + const serviceLevel = endpointAlertConfig.serviceLevel; + const name = ["SLO violation", serviceLevel.name].join(" "); + const query = SqlString.format( + [ + "FROM Metric", + "SELECT", + " 100 - clamp_max((sum(newrelic.sli.valid) - sum(newrelic.sli.bad)) / sum(newrelic.sli.valid) * 100, 100) as 'Error Rate'", + "WHERE sli.guid = ?", + ].join(" "), + [serviceLevel.sliGuid], + ); + + const alertCondition = new NrqlAlertCondition(this, `${endpointAlertConfig.id}-alert-condition`, { + // Workaround: https://github.com/cdktf/cdktf-provider-newrelic/issues/972 + policyId: Fn.tonumber(alertPolicy.id), + name, + type: "static", + nrql: { + query, + }, + critical: { + operator: "ABOVE", + threshold: this.calculateThreshold(endpointAlertConfig.serviceLevel), + thresholdDuration: 60, + thresholdOccurrences: "AT_LEAST_ONCE", + }, + aggregationWindow: 3600, + slideBy: 60, + // null可だがnullの場合、常に差分が出るためデフォルト値を指定する + violationTimeLimitSeconds: 259200, + }); + new EntityTags(this, `${endpointAlertConfig.id}-alert-condition-tags`, { + guid: alertCondition.entityGuid, + tag: [ + { + key: "team", + values: endpointAlertConfig.teams, + }, + ], + }); + + return alertCondition; + } + + calculateThreshold( + serviceLevel: ServiceLevel, + errorBudgetConsumptionRate = this.googleRecommendedErrorBudgetConsumptionRate, + ): number { + const sloTarget = serviceLevel.objectiveInput?.target ?? 99.9; + const errorRate = 1.0 - sloTarget / 100.0; + const burnRate = this.calculateBurnRate(serviceLevel, errorBudgetConsumptionRate); + // アラート閾値はパーセンテージ。かける100をする + return errorRate * burnRate * 100; + } + + calculateBurnRate(serviceLevel: ServiceLevel, errorBudgetConsumptionRate: number): number { + const sloTimeWindowDays = serviceLevel.objectiveInput?.timeWindow.rolling.count ?? 7; + const sloTimeWindowHours = sloTimeWindowDays * 24; + const alertWindowHours = 1.0; + return (errorBudgetConsumptionRate * sloTimeWindowHours) / alertWindowHours; + } + + // Googleは1時間で2%のSLOエラーバジェット消費についてアラートを出すことを推奨している + // https://sre.google/workbook/alerting-on-slos/ + get googleRecommendedErrorBudgetConsumptionRate(): number { + return 0.02; + } } const app = new App();
AlertConditionのクエリで sli.guid
を条件に絞り込むことでServiceLeveごとに状態を監視する条件が作成できます。
+ createEndpointAvailabilityNrqlAlertCondition( + alertPolicy: AlertPolicy, + endpointAlertConfig: EndpointAvailabilityAlertConfig, + ): NrqlAlertCondition { + const serviceLevel = endpointAlertConfig.serviceLevel; + const name = ["SLO violation", serviceLevel.name].join(" "); + const query = SqlString.format( + [ + "FROM Metric", + "SELECT", + " 100 - clamp_max((sum(newrelic.sli.valid) - sum(newrelic.sli.bad)) / sum(newrelic.sli.valid) * 100, 100) as 'Error Rate'", + "WHERE sli.guid = ?", + ].join(" "), + [serviceLevel.sliGuid], + ); + + const alertCondition = new NrqlAlertCondition(this, `${endpointAlertConfig.id}-alert-condition`, { + // Workaround: https://github.com/cdktf/cdktf-provider-newrelic/issues/972 + policyId: Fn.tonumber(alertPolicy.id), + name, + type: "static", + nrql: { + query, + }, + critical: { + operator: "ABOVE", + threshold: this.calculateThreshold(endpointAlertConfig.serviceLevel), + thresholdDuration: 60, + thresholdOccurrences: "AT_LEAST_ONCE", + }, + aggregationWindow: 3600, + slideBy: 60, + // null可だがnullの場合、常に差分が出るためデフォルト値を指定する + violationTimeLimitSeconds: 259200, + }); ... omit + return alertCondition; + }
AlertConditionの作成のキモとなる部分はcritical.thresholdを計算している calculateThreshold
です。
calcuateThreshold
は引数にServiceLevelをとり、ServiceLevelのobjectiveに応じた閾値を計算します。デフォルトのバーンレートは一時間に2%ずつ、100時間でエラーバジェットを完全に消費するペースにしています。目標を下回った場合にアラートを出します。
+ calculateThreshold( + serviceLevel: ServiceLevel, + errorBudgetConsumptionRate = this.googleRecommendedErrorBudgetConsumptionRate, + ): number { + const sloTarget = serviceLevel.objectiveInput?.target ?? 99.9; + const errorRate = 1.0 - sloTarget / 100.0; + const burnRate = this.calculateBurnRate(serviceLevel, errorBudgetConsumptionRate); + // アラート閾値はパーセンテージ。100をする + return errorRate * burnRate * 100; + } + + calculateBurnRate(serviceLevel: ServiceLevel, errorBudgetConsumptionRate: number): number { + const sloTimeWindowDays = serviceLevel.objectiveInput?.timeWindow.rolling.count ?? 7; + const sloTimeWindowHours = sloTimeWindowDays * 24; + const alertWindowHours = 1.0; + return (errorBudgetConsumptionRate * sloTimeWindowHours) / alertWindowHours; + } + + // Googleは1時間で2%のSLOエラーバジェット消費についてアラートを出すことを推奨している + // https://sre.google/workbook/alerting-on-slos/ + get googleRecommendedErrorBudgetConsumptionRate(): number { + return 0.02; + }
つぎの通知の布石として、AlertConditionにteamという名前のタグを設定しています。このタグはAlertが作成するIssueにも反映されます。
+ new EntityTags(this, `${endpointAlertConfig.id}-alert-condition-tags`, { + guid: alertCondition.entityGuid, + tag: [ + { + key: "team", + values: endpointAlertConfig.teams, + }, + ], + });
実際にAlertCondtionを作成する部分は次の通りです。APIエンドポイントごとに担当チームを設定しておき、その情報も含めてAlertConditionを作成しています。
@@ -33,25 +42,36 @@ class MyStack extends TerraformStack { { id: "news-news-id-get", transactionName: "WebTransaction/SpringController/news/{news} (GET)", + teams: ["SRE", "DEV"], }, { id: "live-movie-news-movie-id-get", transactionName: "WebTransaction/SpringController/live-movie/{liveMovieId} (GET)", + teams: ["DEV"], }, ]; - configs.map((config) => { + const serviceLevels = configs.map((config) => { return { id: config.id, availability: this.createEndpointAvailabilityServiceLevel(entity, config), latency: this.createEndpointLatencyServiceLevel(entity, config), + teams: config.teams, }; }); // https://docs.newrelic.com/docs/alerts-applied-intelligence/new-relic-alerts/alert-policies/specify-when-alerts-create-incidents/ - new AlertPolicy(this, "alert-policy", { + const alertPolicy = new AlertPolicy(this, "alert-policy", { name: [entity.name, "SLO violation"].join(" - "), incidentPreference: "PER_CONDITION", }); + + for (const serviceLevel of serviceLevels) { + this.createEndpointAvailabilityNrqlAlertCondition(alertPolicy, { + id: serviceLevel.id, + serviceLevel: serviceLevel.availability, + teams: serviceLevel.teams, + }); + } }
この記事ではAvailbilityの方のみ作成していますが、Latencyも同じような構成で作成できます。*4
5. NotificationDestinationの作成
NotificationDestinationはData Sourcesなのでcdktfでは作成できません。 公式ドキュメントを参考に作成し、IDをメモしておいてください。
6. NotificationChannelの作成
Alertを上げる仕組みが完成したので、通知を作っていきます。
diffを見る
--- a/main.ts +++ b/main.ts @@ -1,6 +1,7 @@ import { AlertPolicy } from "@cdktf/provider-newrelic/lib/alert-policy"; import { DataNewrelicEntity } from "@cdktf/provider-newrelic/lib/data-newrelic-entity"; import { EntityTags } from "@cdktf/provider-newrelic/lib/entity-tags"; +import { NotificationChannel } from "@cdktf/provider-newrelic/lib/notification-channel"; import { NrqlAlertCondition } from "@cdktf/provider-newrelic/lib/nrql-alert-condition"; import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; import { ServiceLevel } from "@cdktf/provider-newrelic/lib/service-level"; @@ -20,10 +21,16 @@ interface EndpointAvailabilityAlertConfig { readonly teams: string[]; } +interface Team { + readonly name: string; + readonly slackChannelId: string; +} + interface MyStackProps { readonly newrelicAccountId: number; readonly newrelicRegion: "US" | "EU"; readonly entityName: string; + readonly slackDestinationId: string; } class MyStack extends TerraformStack { @@ -72,6 +79,21 @@ class MyStack extends TerraformStack { teams: serviceLevel.teams, }); } + + const teams: Team[] = [ + { + name: "SRE", + slackChannelId: "xxxxx", + }, + { + name: "DEV", + slackChannelId: "yyyyy", + }, + ]; + + for (const team of teams) { + this.createNotificationChannel(props.slackDestinationId, team); + } } protected createNewrelicApiKeyVariables(): TerraformVariable { @@ -238,6 +260,21 @@ class MyStack extends TerraformStack { // アラート閾値はパーセンテージを表すかける100をする return errorRate * burnRate * 100; } + + createNotificationChannel(destinationId: string, team: Team): NotificationChannel { + return new NotificationChannel(this, `${team.name}-notification-channel`, { + destinationId, + name: `Team notification channel (${team.name})`, + type: "SLACK", + product: "IINT", + property: [ + { + key: "channelId", + value: team.slackChannelId, + }, + ], + }); + } } const app = new App(); @@ -245,5 +282,6 @@ new MyStack(app, "techblog-2023-09", { newrelicAccountId: Number(process.env.NEW_RELIC_ACCOUNT_ID), newrelicRegion: "US", entityName: process.env.ENTITY_NAME!, + slackDestinationId: process.env.SLACK_DESTINATION_ID!, }); app.synth();
NotificationChannelはNotificationDestinationのどこに通知するかを扱います。type
やproperty
を設定して特定のSlack channelに通知する設定を作成します。
+ createNotificationChannel(destinationId: string, team: Team): NotificationChannel { + return new NotificationChannel(this, `${team.name}-notification-channel`, { + destinationId, + name: `Team notification channel (${team.name})`, + type: "SLACK", + product: "IINT", + property: [ + { + key: "channelId", + value: team.slackChannelId, + }, + ], + }); + }
チームごとにNotificationChannelを作成し、アラートをそれぞれのSlack channelに通知できるようにします。
+ const teams: Team[] = [ + { + name: "SRE", + slackChannelId: "xxxxx", + }, + { + name: "DEV", + slackChannelId: "yyyyy", + }, + ]; + + for (const team of teams) { + this.createNotificationChannel(props.slackDestinationId, team); + }
7. Workflowの作成
最後はAlertとNotificationChannelの連携です。
diffを見る
--- a/main.ts +++ b/main.ts @@ -5,6 +5,7 @@ import { NotificationChannel } from "@cdktf/provider-newrelic/lib/notification-c import { NrqlAlertCondition } from "@cdktf/provider-newrelic/lib/nrql-alert-condition"; import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; import { ServiceLevel } from "@cdktf/provider-newrelic/lib/service-level"; +import { Workflow } from "@cdktf/provider-newrelic/lib/workflow"; import { App, Fn, TerraformStack, TerraformVariable } from "cdktf"; import { Construct } from "constructs"; import * as SqlString from "sqlstring"; @@ -92,7 +93,8 @@ class MyStack extends TerraformStack { ]; for (const team of teams) { - this.createNotificationChannel(props.slackDestinationId, team); + const notificationChannel = this.createNotificationChannel(props.slackDestinationId, team); + this.createWorkflow([alertPolicy], notificationChannel, team, false); } } @@ -275,6 +277,43 @@ class MyStack extends TerraformStack { ], }); } + + // https://docs.newrelic.com/docs/alerts-applied-intelligence/applied-intelligence/incident-workflows/incident-workflows/ + createWorkflow( + alertPolicies: AlertPolicy[], + notificationChannel: NotificationChannel, + team: Team, + workflowEnabled: boolean, + ): Workflow { + return new Workflow(this, `${team.name}-workflow`, { + name: `Team Alert Notification (${team.name})`, + issuesFilter: { + name: "filter by tag team", + type: "FILTER", + predicate: [ + { + attribute: "accumulations.tag.policyId", + operator: "EXACTLY_MATCHES", + values: alertPolicies.map((alertPolicy) => { + return alertPolicy.id; + }), + }, + { + attribute: "accumulations.tag.team", + operator: "CONTAINS", + values: [team.name], + }, + ], + }, + mutingRulesHandling: "NOTIFY_ALL_ISSUES", + destination: [ + { + channelId: notificationChannel.id, + }, + ], + enabled: workflowEnabled, + }); + } } const app = new App();
Workflowの作成で重要なのは issuesFilter.predicate
と destination
です。
+ // https://docs.newrelic.com/docs/alerts-applied-intelligence/applied-intelligence/incident-workflows/incident-workflows/ + createWorkflow( + alertPolicies: AlertPolicy[], + notificationChannel: NotificationChannel, + team: Team, + workflowEnabled: boolean, + ): Workflow { + return new Workflow(this, `${team.name}-workflow`, { + name: `Team Alert Notification (${team.name})`, + issuesFilter: { + name: "filter by tag team", + type: "FILTER", + predicate: [ + { + attribute: "accumulations.tag.policyId", + operator: "EXACTLY_MATCHES", + values: alertPolicies.map((alertPolicy) => { + return alertPolicy.id; + }), + }, + { + attribute: "accumulations.tag.team", + operator: "CONTAINS", + values: [team.name], + }, + ], + }, + mutingRulesHandling: "NOTIFY_ALL_ISSUES", + destination: [ + { + channelId: notificationChannel.id, + }, + ], + enabled: workflowEnabled, + }); + }
issuesFilter.predicate
で反応するIssueの条件を指定します。特定のteamタグがついたIssueにのみ反応するようにします。
+ issuesFilter: { + name: "filter by tag team", + type: "FILTER", + predicate: [ + { + attribute: "accumulations.tag.policyId", + operator: "EXACTLY_MATCHES", + values: alertPolicies.map((alertPolicy) => { + return alertPolicy.id; + }), + }, + { + attribute: "accumulations.tag.team", + operator: "CONTAINS", + values: [team.name], + }, + ], + },
destination
で通知先を指定します。
+ destination: [ + { + channelId: notificationChannel.id, + }, + ],
NotificationChannelと同様に、チームごとにWorkflowを作成します。
for (const team of teams) { - this.createNotificationChannel(props.slackDestinationId, team); + const notificationChannel = this.createNotificationChannel(props.slackDestinationId, team); + this.createWorkflow([alertPolicy], notificationChannel, team, false); }
完成したものはこちら
できました!これでAPIの追加や閾値変更、チームの再編も自由自在です!
完成したソースコードを見る
import { AlertPolicy } from "@cdktf/provider-newrelic/lib/alert-policy"; import { DataNewrelicEntity } from "@cdktf/provider-newrelic/lib/data-newrelic-entity"; import { EntityTags } from "@cdktf/provider-newrelic/lib/entity-tags"; import { NotificationChannel } from "@cdktf/provider-newrelic/lib/notification-channel"; import { NrqlAlertCondition } from "@cdktf/provider-newrelic/lib/nrql-alert-condition"; import { NewrelicProvider } from "@cdktf/provider-newrelic/lib/provider"; import { ServiceLevel } from "@cdktf/provider-newrelic/lib/service-level"; import { Workflow } from "@cdktf/provider-newrelic/lib/workflow"; import { App, Fn, TerraformStack, TerraformVariable } from "cdktf"; import { Construct } from "constructs"; import * as SqlString from "sqlstring"; interface EndpointConfig { readonly id: string; readonly transactionName: string; readonly teams: string[]; } interface EndpointAvailabilityAlertConfig { readonly id: string; readonly serviceLevel: ServiceLevel; readonly teams: string[]; } interface Team { readonly name: string; readonly slackChannelId: string; } interface MyStackProps { readonly newrelicAccountId: number; readonly newrelicRegion: "US" | "EU"; readonly entityName: string; readonly slackDestinationId: string; } class MyStack extends TerraformStack { constructor(scope: Construct, id: string, props: MyStackProps) { super(scope, id); const apiKey = this.createNewrelicApiKeyVariables(); new NewrelicProvider(this, "newrelic", { accountId: props.newrelicAccountId, apiKey: apiKey.value, region: props.newrelicRegion, }); const entity = new DataNewrelicEntity(this, "techblog-app-entity", { name: props.entityName }); const configs: EndpointConfig[] = [ { id: "news-news-id-get", transactionName: "WebTransaction/SpringController/news/{news} (GET)", teams: ["SRE", "DEV"], }, { id: "live-movie-news-movie-id-get", transactionName: "WebTransaction/SpringController/live-movie/{liveMovieId} (GET)", teams: ["DEV"], }, ]; const serviceLevels = configs.map((config) => { return { id: config.id, availability: this.createEndpointAvailabilityServiceLevel(entity, config), latency: this.createEndpointLatencyServiceLevel(entity, config), teams: config.teams, }; }); // https://docs.newrelic.com/docs/alerts-applied-intelligence/new-relic-alerts/alert-policies/specify-when-alerts-create-incidents/ const alertPolicy = new AlertPolicy(this, "alert-policy", { name: [entity.name, "SLO violation"].join(" - "), incidentPreference: "PER_CONDITION", }); for (const serviceLevel of serviceLevels) { this.createEndpointAvailabilityNrqlAlertCondition(alertPolicy, { id: serviceLevel.id, serviceLevel: serviceLevel.availability, teams: serviceLevel.teams, }); } const teams: Team[] = [ { name: "SRE", slackChannelId: "xxxxx", }, { name: "DEV", slackChannelId: "yyyyy", }, ]; for (const team of teams) { const notificationChannel = this.createNotificationChannel(props.slackDestinationId, team); this.createWorkflow([alertPolicy], notificationChannel, team, false); } } protected createNewrelicApiKeyVariables(): TerraformVariable { const apiKey = new TerraformVariable(this, "NEW_RELIC_API_KEY", { type: "string", description: "New Relic API Key", nullable: false, sensitive: true, }); apiKey.addValidation({ condition: Fn.can(Fn.regex("^NRAK", apiKey.fqn)), errorMessage: "NEW_RELIC_API_KEY value must be valid API Key, starting with `NRAK`", }); return apiKey; } createEndpointAvailabilityServiceLevel(entity: DataNewrelicEntity, config: EndpointConfig): ServiceLevel { const accountId = entity.accountId; const name = ["Availability:", config.id].join(" "); const description = ""; const objective = { target: 99.9, timeWindow: { rolling: { count: 7, unit: "DAY", }, }, }; const baseCondition = SqlString.format("entityGuid = ?", [entity.guid]); const validWhere = SqlString.format(`${baseCondition} AND name = ?`, [config.transactionName]); const badWhere = SqlString.format(`${baseCondition} AND transactionName = ? AND error.expected IS FALSE`, [ config.transactionName, ]); return new ServiceLevel(this, `${config.id}-availability-service-level`, { guid: entity.guid, name: [entity.name, name].join(" - "), description, events: { accountId, validEvents: { from: "Transaction", where: validWhere, }, badEvents: { from: "TransactionError", where: badWhere, }, }, objective, }); } createEndpointLatencyServiceLevel(entity: DataNewrelicEntity, config: EndpointConfig): ServiceLevel { const accountId = entity.accountId; const name = ["Latency:", config.id].join(" "); const description = ""; const objective = { target: 80.0, timeWindow: { rolling: { count: 7, unit: "DAY", }, }, }; const baseCondition = SqlString.format("entityGuid = ? AND name = ?", [entity.guid, config.transactionName]); const validWhere = baseCondition; const goodWhere = SqlString.format(`${baseCondition} AND duration < ?`, [0.5]); return new ServiceLevel(this, `${config.id}-latency-service-level`, { guid: entity.guid, name: [entity.name, name].join(" - "), description, events: { accountId, validEvents: { from: "Transaction", where: validWhere, }, goodEvents: { from: "Transaction", where: goodWhere, }, }, objective, }); } createEndpointAvailabilityNrqlAlertCondition( alertPolicy: AlertPolicy, endpointAlertConfig: EndpointAvailabilityAlertConfig, ): NrqlAlertCondition { const serviceLevel = endpointAlertConfig.serviceLevel; const name = ["SLO violation", serviceLevel.name].join(" "); const query = SqlString.format( [ "FROM Metric", "SELECT", " 100 - clamp_max((sum(newrelic.sli.valid) - sum(newrelic.sli.bad)) / sum(newrelic.sli.valid) * 100, 100) as 'Error Rate'", "WHERE sli.guid = ?", ].join(" "), [serviceLevel.sliGuid], ); const alertCondition = new NrqlAlertCondition(this, `${endpointAlertConfig.id}-alert-condition`, { // Workaround: https://github.com/cdktf/cdktf-provider-newrelic/issues/972 policyId: Fn.tonumber(alertPolicy.id), name, type: "static", nrql: { query, }, critical: { operator: "ABOVE", threshold: this.calculateThreshold(endpointAlertConfig.serviceLevel), thresholdDuration: 60, thresholdOccurrences: "AT_LEAST_ONCE", }, aggregationWindow: 3600, slideBy: 60, // null可だがnullの場合、常に差分が出るためデフォルト値を指定する violationTimeLimitSeconds: 259200, }); new EntityTags(this, `${endpointAlertConfig.id}-alert-condition-tags`, { guid: alertCondition.entityGuid, tag: [ { key: "team", values: endpointAlertConfig.teams, }, ], }); return alertCondition; } calculateThreshold( serviceLevel: ServiceLevel, errorBudgetConsumptionRate = this.googleRecommendedErrorBudgetConsumptionRate, ): number { const sloTarget = serviceLevel.objectiveInput?.target ?? 99.9; const errorRate = 1.0 - sloTarget / 100.0; const burnRate = this.calculateBurnRate(serviceLevel, errorBudgetConsumptionRate); // アラート閾値はパーセンテージ。かける100をする return errorRate * burnRate * 100; } calculateBurnRate(serviceLevel: ServiceLevel, errorBudgetConsumptionRate: number): number { const sloTimeWindowDays = serviceLevel.objectiveInput?.timeWindow.rolling.count ?? 7; const sloTimeWindowHours = sloTimeWindowDays * 24; const alertWindowHours = 1.0; return (errorBudgetConsumptionRate * sloTimeWindowHours) / alertWindowHours; } // Googleは1時間で2%のSLOエラーバジェット消費についてアラートを出すことを推奨している // https://sre.google/workbook/alerting-on-slos/ get googleRecommendedErrorBudgetConsumptionRate(): number { return 0.02; } createNotificationChannel(destinationId: string, team: Team): NotificationChannel { return new NotificationChannel(this, `${team.name}-notification-channel`, { destinationId, name: `Team notification channel (${team.name})`, type: "SLACK", product: "IINT", property: [ { key: "channelId", value: team.slackChannelId, }, ], }); } // https://docs.newrelic.com/docs/alerts-applied-intelligence/applied-intelligence/incident-workflows/incident-workflows/ createWorkflow( alertPolicies: AlertPolicy[], notificationChannel: NotificationChannel, team: Team, workflowEnabled: boolean, ): Workflow { return new Workflow(this, `${team.name}-workflow`, { name: `Team Alert Notification (${team.name})`, issuesFilter: { name: "filter by tag team", type: "FILTER", predicate: [ { attribute: "accumulations.tag.policyId", operator: "EXACTLY_MATCHES", values: alertPolicies.map((alertPolicy) => { return alertPolicy.id; }), }, { attribute: "accumulations.tag.team", operator: "CONTAINS", values: [team.name], }, ], }, mutingRulesHandling: "NOTIFY_ALL_ISSUES", destination: [ { channelId: notificationChannel.id, }, ], enabled: workflowEnabled, }); } } const app = new App(); new MyStack(app, "techblog-2023-09", { newrelicAccountId: Number(process.env.NEW_RELIC_ACCOUNT_ID), newrelicRegion: "US", entityName: process.env.ENTITY_NAME!, slackDestinationId: process.env.SLACK_DESTINATION_ID!, }); app.synth();
現在はどのような運用をしているのか
ここまでの実装は2023年2月時点の状況*5で、現在はこの実装をベースに大幅に改良したものを運用しています。
- 全体的にリファクタリング
- 役割ごとにConstructを分割したり
- 設定項目を別ファイルに切り出したり
- アプリケーション全体の可用性、レイテンシーを扱うServiceLevelを追加
- チームごとにServiceLevelが一覧できるダッシュボードを作成
- S3Backendを利用してtfstateをS3に保存
- NewsPicksのサブシステムにも対応
感想
監視項目の追加、変更、削除が一瞬でNew Relicに反映されるのがめちゃくちゃ気持ちいいです。
CDK for Terraformの存在を知らなかったので、AWS, GCP, Azureのようなクラウドサービス以外をTypescriptで型の支援がある中でIaC化できるのは衝撃的でした。ほとんどCDKと同じ感覚で使えるのがよいです。
自分で定義した関数が使えるというのがTerraformを直接使う場合との大きな違いで、とくにAlertConditionのServiceLevelに応じた閾値設定などはCDK for Terraformの強みが出た部分だと思いました。
参考
- https://newrelic.com/jp/blog/how-to-relic/relic-solution-patterns-for-implementing-alerts-workflows
- この記事で紹介されている「パターン 2:担当チームごとに1つのワークフロー」をそのまま実装しています。
*1:技術選定をした2023/02時点
*2:terraformにはData Sources(data)とResoruces(resource)という考えがあります。Data Sourcesはterraformの外部で作成された情報を参照する時に利用し、読み取り専用です。Resorucesはterraformでリソースを作成できます。
*3:mapを使っているのは次で利用するためです。
*4:このとき、idが重複しないように気をつけてください。xxx-availability-alert-condition、xxx-latency-alert-conditionのようにServiceLevelで監視する項目を入れると良いと思います。
*5:実装をした飯野は育休に入ってしまったのでした。