1.この記事を書こうと思った背景

複数の AWS Lambda に CloudWatch Alarms で監視しよう!ということがあったので、 Terraform で以下のように dimensions で監視したい Lambda を列挙するだけだろ!と思いきや、ダメだった。

apply すると、最後に書いた FunctionName のみがCloudWatch Alarms に適用されてしまった。(以下の場合、my_lambda_function2 )

resource "aws_cloudwatch_metric_alarm" "this" {
  metric_name               = "Errors"
  namespace                 = "AWS/Lambda"
  dimensions = {
      "FunctionName" = "my_lambda_function1",
      "FunctionName" = "my_lambda_function2"
  }
#略
}

FunctionName* をつけても「*_lambda_function」などとベタ書きとして認識されてしまってダメ。正規表現っぽく指定することは出来なかった。

  dimensions = {
    "FunctionName" = "*_lambda_function",
  }

さて、じゃあどうしようか?と調べたことを書き留めておく。

なお、上記の dimensions は以下を参考に指定した。

Choose a dimension.

By Function Name (FunctionName) – View aggregate metrics for all versions and aliases of a function.

By Resource (Resource) – View metrics for a version or alias of a function.

By Executed Version (ExecutedVersion) – View metrics for a combination of alias and version. Use the ExecutedVersion dimension to compare error rates for two versions of a function that are both targets of a weighted alias. Across All Functions (none) – View aggregate metrics for all functions in the current AWS Region.

参考:Working with Lambda function metrics - AWS Lambda

2.前提

  • AWS Lambda のエラーを CloudWatch Alarms で検知してから、Slack へ通知が届くまでの大まかな流れは以下のとおり

Image from Gyazo

3.環境情報

$ terraform version
Terraform v1.1.7
on linux_amd64
+ provider registry.terraform.io/hashicorp/aws v4.9.0

この記事では、複数の AWS Lambda のエラーを CloudWatch Alarms で検知できるように Terraform で定義する方法について、2案紹介する。


4.[案1]metric-alarms-by-multiple-dimensions という CloudWatch のサブモジュールを使う

ひとつめのアイデアは、以下の CloudWatch Alarm モジュールの、 metric-alarms-by-multiple-dimensions というサブモジュールを使う方法だ。

terraform-aws-cloudwatch/modules/metric-alarms-by-multiple-dimensions at master · terraform-aws-modules/terraform-aws-cloudwatch

以下がサンプルコードだ。CloudWatch Alarms のメトリクスの意味、使い方について添えている。

module "metric_alarms" {
  source  = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions"
  version = "~> 3.0"

  alarm_description   = "all-lambda-functions-alarm"
  alarm_name          = "all-lambda-functions-alarm"

  # 以下を参考に「メトリクスを集計する AWSサービス」を指定する
  # https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/aws-services-cloudwatch-metrics.html
  namespace           = "AWS/Lambda"

  # 後述する `aws-cli` で指定できる値を確認している。
  metric_name         = "Errors"

  # メトリクスの統計(集計方法) 
  # 以下のいずれかを指定し、集計した値と `threshold` とを `comparison_operator` の比較方法で比較する。
  # Average | Maximum | Minimum | SampleCount | Sum
  statistic           = "Maximum"

  # 閾値
  threshold           = 1
  # 閾値と比較する回数
  evaluation_periods  = 1

  # `evaluation_periods` 1回あたりの統計が適用される期間(秒単位)
  # 有効な値は、10、30、60、および60の任意の倍数。
  period              = 60

  # アラーム状態へ遷移させるか判定する基準
  # `period` * `evaluation_periods` のあいだに `datapoints_to_alarm` 回だけ `threshold` を、
  # `comparison_operator` で指定する比較方法で比較し、TRUE である場合、アラーム状態へ遷移
  # 上記の場合、60秒 * 1 = 60秒のあいだに、閾値である1か、1以上のエラーが検知されたら、アラーム状態へ遷移となる。
  datapoints_to_alarm       = 1

  # `threshold` との比較演算子であり、以下のいずれかを指定。
  # GreaterThanThreshold, GreaterThanOrEqualToThreshold, LessThanThreshold, or LessThanOrEqualToThreshold
  comparison_operator = "GreaterThanOrEqualToThreshold"
  
  # AWS Lambda の場合、以下を参考に入力。
  # https://docs.aws.amazon.com/lambda/latest/dg/monitoring-metrics.html
  # ここで「複数の Lambda」を監視するように指定できる!!
  dimensions = {
    "lambda1" = {
      FunctionName = "my_lambda_function1"
    },
    "lambda2" = {
      FunctionName = ""my_lambda_function2"
    }
  }
  # `ALARM state`(ALARM 状態)に移行したら実行される AWSサービスを指定
  # ここでは SNS Topic をパブリッシュしている
  alarm_actions = [aws_sns_topic.this.arn]

  # データの欠損がみられたら、閾値内とする(閾値を超えていない)
  treat_missing_data        = "notBreaching"
  # データの欠損がみられたら、閾値外とする(閾値を超えた)
  #treat_missing_data        = "Breaching"
  # データの欠損については、後述する「6.データが欠損していた場合の対応を考えることが難しい」でも扱う
}

参考:metric-alarms-by-multiple-dimensions が公開するサンプルコード | terraform-aws-modules/terraform-aws-cloudwatch: Terraform module which creates Cloudwatch resources on AWS 🇺🇦

※ サブモジュールの実装を眺めると以下のとおり、dimensions で指定した値の数だけ for-each で繰り返し aws_cloudwatch_metric_alarm リソースを作成しているらしく、実際、applyするとリソースが dimensions で指定した値の数だけ作成された。

https://github.com/terraform-aws-modules/terraform-aws-cloudwatch/blob/9a51828bdd2ac4aed3b5c10777a9a94551b16486/modules/metric-alarms-by-multiple-dimensions/main.tf#L1-L2

サンプルコードのエラー検知ロジックについて

サンプルコードのコメントに書いているが再掲する。


# `period` * `evaluation_periods` のあいだに `datapoints_to_alarm` 回だけ `threshold` を、
# `comparison_operator` で指定する比較方法で比較し、TRUE である場合、アラーム状態へ遷移
# 上記の場合、60秒 * 1 = 60秒のあいだに、閾値である1か、1以上のエラーが検知されたら、アラーム状態へ遷移となる。

metric_name         = "Errors"
statistic           = "Maximum"
threshold           = 1
evaluation_periods  = 1
period              = 60
datapoints_to_alarm       = 1
comparison_operator = "GreaterThanOrEqualToThreshold"

metric_name で指定できる値について

metric_name で具体的にどういった値を指定すればいいのか?ドキュメントに記載がないように見受けられたので aws-cli で以下のように取得。

$ aws cloudwatch list-metrics --namespace AWS/Lambda \
> | jq -r '[.Metrics[].MetricName] | unique'
[
  "ConcurrentExecutions",
  "Duration",
  "Errors",
  "Invocations",
  "Throttles",
  "UnreservedConcurrentExecutions"
]

参考:Viewing available metrics - Amazon CloudWatch

output も定義したい場合

output については、同サブモジュールで定義されている output にアクセスして値を引っ張りたいので、module.<MODULE NAME>.<OUTPUT NAME> のように書く。

output "cloudwatch_metric_alarm_arns" {
  description = "List of ARNs of the Cloudwatch metric alarm"
  value       = module.metric_alarms.cloudwatch_metric_alarm_arns
}

output "cloudwatch_metric_alarm_ids" {
  description = "List of IDs of the Cloudwatch metric alarm"
  value       = module.metric_alarms.cloudwatch_metric_alarm_ids
}

参考:Accessing Child Module Outputs | Output Values - Configuration Language | Terraform by HashiCorp

terraform apply を終えた後の様子

以下の画像は、terraform apply を終えた後の CloudWatch Alarms のコンソール画面だ。FunctionNamedummy_slack_notify と Lambda の名前と NamespaceAWS/Lambda と表示されていることが確認できる。

Image from Gyazo

Lambda のエラーメッセージが Slack に通知されていることも確認できた! Image from Gyazo

5.[案2]Function Name を指定せず、Namespace に AWS/Lambda を指定する

ふたつめは、以下の Stack Overflow の記事を参考に Terraform で定義する。

These metrics don't have any dimensions, just the namespace and metric name,

参考:amazon web services - CloudFormation alarm for multiple Lambdas - Stack Overflow

dimensionsFunctionName を定義してしまうと、FunctionName で定義した Lambda のみが CloudWatch Alarms による監視対象となってしまうので注意!!

resource "aws_cloudwatch_metric_alarm" "this" {
  actions_enabled           = true
  
  alarm_description         = "all-lambda-functions-alarm"
  alarm_name                = "all-lambda-functions-alarm"

  # Namespace で Lambda を指定することは必須!
  namespace           = "AWS/Lambda"
  
  metric_name         = "Errors"

  # メトリクスの統計(集計方法) 
  statistic           = "Maximum"

  # 閾値
  threshold                 = 1
  # 閾値と比較する回数
  evaluation_periods        = 1

  # `evaluation_periods` 1回あたりの統計が適用される期間(秒単位)
  period                    = 60

  # アラーム状態へ遷移させるか判定する基準
  datapoints_to_alarm =     1

  # `threshold` との比較演算子。
  comparison_operator       = "GreaterThanOrEqualToThreshold"

  tags                      = {}
  tags_all                  = {}
  
  # データの欠損がみられたら、閾値内とする(閾値を超えていない)
  treat_missing_data        = "notBreaching"
  # データの欠損がみられたら、閾値外とする(閾値を超えた)
  #treat_missing_data        = "Breaching"

  # `ALARM state`(ALARM 状態)に移行したら実行される AWSサービスを指定。
  # ここでは SNS Topic をパブリッシュしている
  alarm_actions = [aws_sns_topic.this.arn]
}

terraform apply を終えた後の様子

以下の画像も同様に、terraform apply を終えた後の CloudWatch Alarms のコンソール画面だ。こちらは FunctionName は表示されていない一方で、Namespace では AWS/Lambda が確認できる。

Image from Gyazo


6.データが欠損していた場合の対応を考えることが難しい

データが欠損していた場合、安全に倒して通知するか?許容するか?といった判断を閾値に反映させることが難しかった。treat_missing_data を指定する意義について、自分の考えを整理しておく。

仮に、treat_missing_data が未指定であると、データが欠損しているために、閾値を超え、監視対象のステータスを ALARM へ遷移させるかどうかという観点での閾値を超えた回数の判定ができなくなってしまう。

以下の AWS のドキュメントを読むと、たとえば、treat_missing_data = notBreaching とすることで、 データが欠損している分については「閾値内」とみなし判定することができるのではないか?と感じた。

参考:Using Amazon CloudWatch alarms - Amazon CloudWatch

また、以下の検証記事によると欠損データがある場合については以下のように書いてあり、自分でもドキュメントを読んだかぎりでは、明確に「どこまで遡るか」書かれていないように見受けられた。

正確にどこまで遡るかは細かな仕様が書かれていませんので不明

参考:CloudWatch Alarm欠落データの処理を確かめる - Blogaomu

7.サンプルコード

main.tf
module "metric_alarms" {
  source  = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions"
  version = "~> 3.0"

  alarm_description = "all-lambda-functions-alarm"
  alarm_name        = "all-lambda-functions-alarm"

  namespace           = "AWS/Lambda"
  metric_name         = "Errors"
  statistic           = "Maximum"
  threshold           = 1
  evaluation_periods  = 1
  comparison_operator = "GreaterThanOrEqualToThreshold"
  period              = 60
  # https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-cw-alarm.html
  #unit                = "Count"

  datapoints_to_alarm = 1

  dimensions = {
    "lambda1" = {
      FunctionName = "my_lambda_function1"
    },
    "lambda2" = {
      FunctionName = "my_lambda_function2"
    }
  }

  alarm_actions = [aws_sns_topic.this.arn]

  treat_missing_data = "notBreaching"
}

resource "aws_sns_topic" "this" {
  application_success_feedback_sample_rate = 0
  content_based_deduplication              = false
  fifo_topic                               = false
  firehose_success_feedback_sample_rate    = 0
  http_success_feedback_sample_rate        = 0
  lambda_success_feedback_sample_rate      = 0
  name                                     = "cloudwatch-alarm-for-chatbot-topic"
  sqs_success_feedback_sample_rate         = 0
  tags                                     = {}
  tags_all                                 = {}
}

resource "aws_sns_topic_policy" "this" {
  arn = aws_sns_topic.this.arn

  policy = data.aws_iam_policy_document.this.json
}

resource "aws_sns_topic_subscription" "this" {
  endpoint             = "https://global.sns-api.chatbot.amazonaws.com"
  protocol             = "https"
  raw_message_delivery = false
  topic_arn            = aws_sns_topic.this.arn
}

data "aws_iam_policy_document" "this" {
  policy_id = "__default_policy_ID"
  statement {
    actions = [
      "SNS:GetTopicAttributes",
      "SNS:SetTopicAttributes",
      "SNS:AddPermission",
      "SNS:RemovePermission",
      "SNS:DeleteTopic",
      "SNS:Subscribe",
      "SNS:ListSubscriptionsByTopic",
      "SNS:Publish",
    ]

    effect = "Allow"

    resources = [
      aws_sns_topic.this.arn
    ]

    condition {
      test     = "StringEquals"
      values   = [var.aws_account_id]
      variable = "AWS:SourceOwner"
    }
    principals {
      type        = "AWS"
      identifiers = ["*"]
    }
  }
}

output "cloudwatch_metric_alarm_arns" {
  description = "List of ARNs of the Cloudwatch metric alarm"
  value       = module.metric_alarms.cloudwatch_metric_alarm_arns
}

output "cloudwatch_metric_alarm_ids" {
  description = "List of IDs of the Cloudwatch metric alarm"
  value       = module.metric_alarms.cloudwatch_metric_alarm_ids
}

8.参考