🧑‍🚀

Terraform で CloudWatch の監視設定をコード化する

2023/02/19に公開

はじめに

Terraform はコードを書くだけであれば結構簡単です。ただし、コードを適切に管理するためには検討すべきことがたくさんあります。いきおいで IaC を導入するとコードのメンテナンスが疎かになり、管理不全に陥ってしまうかもしれません。
というわけで IaC を導入する場合はシステム全体を一度にコード化するよりも、スモールスタートする方が安全です。まずは、万が一しくじってもサービス影響が出にくい運用機能で PoC してみるのがよいと思います。

今回は CloudWatch のさまざまな監視設定について、パターン毎に Terraform のサンプルコードを紹介します。
これから IaC に取り組むみなさまの参考になれば幸いです👍

Terraform 実装例

こんな感じの構成になります。

メトリクス監視

あるメトリクスが閾値を上回った or 下回った場合にアラートを生成します。CPU やメモリのように上限のあるリソースの使用率を監視する場合などに使用されます。Warning と Error で 2 段階程度の閾値を設けることが一般的です。
CloudWatch Alarm はひとつの閾値しか設定できませんが、今回は Module を使用することで対応しています。また、必要な設定値を Map 型変数のcloudwatch_metric_alarmsにまとめました。main.tfではfor_eachを使用することで要素の数だけ処理を繰り返します。こういう作りにしておくと、監視設定の追加が必要になったときはvariables.tfvarsに要素を追加するだけで対応できるので便利です。

Sample code
variables.tfvars
cloudwatch_metric_alarms = {
  ecs_service_xxxx_cpu_usage = {
    warning_name        = "CPU usage of ECS Service - xxxx has exceeded the warning threshold."
    warning_threshold   = "80"
    error_name          = "CPU usage of ECS Service - xxxx has exceeded the error threshold."
    error_threshold     = "90"
    comparison_operator = "GreaterThanThreshold"
    evaluation_period   = "5"
    period              = "60"
    datapoint           = "5"
    metric = {
      name      = "CPUUtilization"
      namespace = "AWS/ECS"
      statistic = "Average"
    }
    dimensions = {
      ServiceName = "xxxx",
      ClusterName = "xxxx"
    }
    action_isenabled = true
  }
  ecs_service_xxxx_memory_usage = {
    warning_name        = "Memory usage of ECS Service - xxxx has exceeded the warning threshold."
    warning_threshold   = "80"
    error_name          = "Memory usage of ECS Service - xxxx has exceeded the error threshold."
    error_threshold     = "90"
    comparison_operator = "GreaterThanThreshold"
    evaluation_period   = "5"
    period              = "60"
    datapoint           = "5"
    metric = {
      name      = "MemoryUtilization"
      namespace = "AWS/ECS"
      statistic = "Average"
    }
    dimensions = {
      ServiceName = "xxxx",
      ClusterName = "xxxx"
    }
    action_isenabled = true
  }
}
main.tf
module "cloudwatch_metric_alarms" {
  source              = "modules/cloudwatch_metric_alarms"
  for_each            = var.cloudwatch_metric_alarms
  warning_name        = each.value["warning_name"]
  warning_threshold   = each.value["warning_threshold"]
  error_name          = each.value["error_name"]
  error_threshold     = each.value["error_threshold"]
  comparison_operator = each.value["comparison_operator"]
  evaluation_period   = each.value["evaluation_period"]
  period              = each.value["period"]
  datapoint           = each.value["datapoint"]
  metric              = each.value["metric"]
  dimensions          = each.value["dimensions"]
  action_isenabled    = each.value["action_isenabled"]
  sns_topic_arn       = "arn:aws:sns:ap-northeast-1:123456789012:MyTopic"
}
modules/cloudwatch_metric_alarms/main.tf
resource "aws_cloudwatch_metric_alarm" "warning" {
  alarm_name          = var.warning_name
  threshold           = var.warning_threshold
  comparison_operator = var.comparison_operator
  evaluation_periods  = var.evaluation_period
  metric_name         = var.metric["name"]
  namespace           = var.metric["namespace"]
  statistic           = var.metric["statistic"]
  period              = var.period
  dimensions          = var.dimensions
  datapoints_to_alarm = var.datapoint
  actions_enabled     = var.action_isenabled
  alarm_actions       = [var.sns_topic_arn]
  ok_actions          = [var.sns_topic_arn]
}

resource "aws_cloudwatch_metric_alarm" "error" {
  alarm_name          = var.error_name
  threshold           = var.error_threshold
  comparison_operator = var.comparison_operator
  evaluation_periods  = var.evaluation_period
  metric_name         = var.metric["name"]
  namespace           = var.metric["namespace"]
  statistic           = var.metric["statistic"]
  period              = var.period
  datapoints_to_alarm = var.datapoint
  dimensions          = var.dimensions
  actions_enabled     = var.action_isenabled
  alarm_actions       = [var.sns_topic_arn]
  ok_actions          = [var.sns_topic_arn]
}

計算式を使用したメトリクス監視

基本的にはメトリクス監視と同じですが、メトリクスをそのまま監視対象とするのではなく、数式を使用して計算した値を評価します。ユースケースは RDS のメモリ使用率の監視です。RDS は標準メトリクスとしてメモリ使用率(%)を報告をしてくれないので、実装するには工夫が必要です。具体的には以下の手順を踏みます。

  • RDS の拡張モニタリングを有効化し、メトリクスフィルターを使用してログから総メモリ容量を取得する。
  • 総メモリ容量と標準メトリクスとして報告される空きメモリ容量(FreeableMemory)からメモリ使用率(%)を算出する。

詳細は以下のブログがとても丁寧に解説されてますのでリンクさせていただきます🙇‍♂️

https://www.seeds-std.co.jp/blog/creators/2022-05-25-184457/

メトリクス監視と同様に 2 段階閾値の対応で Module を使っているため、この Module にメトリクスフィルター設定を追加すると使い回しが難しくなってしまいます。Module の汎用性を考慮して、今回はメトリクスフィルターを Module の外側で設定しました。

Sample code
variables.tfvars
cloudwatch_metric_math_alarms = {
  rds_xxxx_disk_usage = {
    warning_name        = "Memory usage of RDS - xxxx has exceeded the warning threshold."
    warning_threshold   = "80"
    error_name          = "Memory usage of RDS - xxxx has exceeded the error threshold."
    error_threshold     = "90"
    comparison_operator = "GreaterThanThreshold"
    evaluation_period   = "5"
    period              = "60"
    datapoint           = "5"
    metrics = {
      m1 = {
        id         = "m1"
        name       = "TotalMemory"
        namespace  = "Custom/RDS"
        statistic  = "Average"
        dimensions = {
          DBInstanceIdentifier = "xxxx"
        }
      },
      m2 = {
        id         = "m2"
        name       = "FreeableMemory"
        namespace  = "AWS/RDS"
        statistic  = "Average"
        dimensions = {
          DBInstanceIdentifier = "xxxx"
        }
      }
    }
    expression       = "(m1 - m2 / 1024) / m1 * 100"
    expression_label = "Memory Usage"
    action_isenabled = true
  }
}
main.tf
resource "aws_cloudwatch_log_metric_filter" "rds_xxxx_total_memory" {
  name           = "rds_xxxx_total_memory"
  log_group_name = "RDSOSMetrics"
  pattern        = <<-EOT
  { ($.instanceID="xxxx") && ($.memory.total = *) }
  EOT

  metric_transformation {
    name       = "TotalMemory"
    namespace  = "Custom/RDS"
    value      = "$.memory.total"
    unit       = "Kilobytes"
    dimensions = {
      DBInstanceIdentifier = "$.instanceID"
    }
  }
}

module "cloudwatch_metric_math_alarms" {
  source              = "modules/cloudwatch_metric_math_alarms"
  for_each            = var.cloudwatch_metric_math_alarms
  warning_name        = each.value["warning_name"]
  warning_threshold   = each.value["warning_threshold"]
  error_name          = each.value["error_name"]
  error_threshold     = each.value["error_threshold"]
  comparison_operator = each.value["comparison_operator"]
  evaluation_period   = each.value["evaluation_period"]
  period              = each.value["period"]
  datapoint           = each.value["datapoint"]
  metrics             = each.value["metrics"]
  expression          = each.value["expression"]
  expression_label    = each.value["expression_label"]
  action_isenabled    = each.value["action_isenabled"]
  sns_topic_arn       = "arn:aws:sns:ap-northeast-1:123456789012:MyTopic"
}
modules/cloudwatch_metric_math_alarms/main.tf
resource "aws_cloudwatch_metric_alarm" "warning" {
  alarm_name          = var.warning_name
  threshold           = var.warning_threshold
  comparison_operator = var.comparison_operator
  evaluation_periods  = var.evaluation_period
  datapoints_to_alarm = var.datapoint
  actions_enabled     = var.action_isenabled
  alarm_actions       = [var.sns_topic_arn]
  ok_actions          = [var.sns_topic_arn]

  metric_query {
    id          = "e1"
    expression  = var.expression
    label       = var.expression_label
    return_data = "true"
  }

  dynamic "metric_query" {
    for_each = var.metrics
    content {
      id = metric_query.value["id"]

      metric {
        metric_name = metric_query.value["name"]
        namespace   = metric_query.value["namespace"]
        period      = var.period
        stat        = metric_query.value["statistic"]
        dimensions  = metric_query.value["dimensions"]
      }
    }
  }
}

resource "aws_cloudwatch_metric_alarm" "error" {
  alarm_name          = var.error_name
  threshold           = var.error_threshold
  comparison_operator = var.comparison_operator
  evaluation_periods  = var.evaluation_period
  datapoints_to_alarm = var.datapoint
  actions_enabled     = var.action_isenabled
  alarm_actions       = [var.sns_topic_arn]
  ok_actions          = [var.sns_topic_arn]

  metric_query {
    id          = "e1"
    expression  = var.expression
    label       = var.expression_label
    return_data = "true"
  }

  dynamic "metric_query" {
    for_each = var.metrics
    content {
      id = metric_query.value["id"]

      metric {
        metric_name = metric_query.value["name"]
        namespace   = metric_query.value["namespace"]
        period      = var.period
        stat        = metric_query.value["statistic"]
        dimensions  = metric_query.value["dimensions"]
      }
    }
  }
}

ログ監視

ログに含まれる特定の文字列を検知してアラートを生成します。
CloudWatch Alarm でログ監視を行うにはメトリクスフィルターを使用するため、aws_cloudwatch_log_metric_filteraws_cloudwatch_metric_alarmの 2 つの Resource をひとつの Module にまとめました。
メトリクスフィルターのパターンは書き方が結構特殊です。いつも忘れてしまうのでマニュアルのリンクを貼っておきます。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html

ログ監視も最終的にはメトリクスを評価することになりますが、リソースの使用量を監視するのとは少し性質が違うのでその辺りを調整します。

Sample code
variables.tfvars
cloudwatch_log_alarms = {
  ec2_xxxx_var_log_messages = {
    name                = "Error detected in /var/log/messages on EC2 - xxxx."
    threshold           = "0"
    comparison_operator = "GreaterThanThreshold"
    evaluation_period   = "1"
    period              = "60"
    datapoint           = "1"
    metric = {
      name      = "error"
      namespace = "Custom/Logs"
      statistic = "Sum"
    }
    filter_name      = "ec2_xxxx_var_log_messages"
    filter_pattern   = "error"
    log_group_name   = "ec2_xxxx_var_log_messages"
    action_isenabled = true
  }
}
main.tf
module "cloudwatch_log_alarms" {
  source              = "modules/cloudwatch_log_alarms"
  for_each            = var.cloudwatch_log_alarms
  name                = each.value["name"]
  threshold           = each.value["threshold"]
  comparison_operator = each.value["comparison_operator"]
  evaluation_period   = each.value["evaluation_period"]
  period              = each.value["period"]
  datapoint           = each.value["datapoint"]
  metric              = each.value["metric"]
  filter_name         = each.value["filter_name"]
  filter_pattern      = each.value["filter_pattern"]
  log_group_name      = each.value["log_group_name"]
  action_isenabled    = each.value["action_isenabled"]
  sns_topic_arn       = "arn:aws:sns:ap-northeast-1:123456789012:MyTopic"
}
modules/cloudwatch_log_alarms/main.tf
resource "aws_cloudwatch_log_metric_filter" "this" {
  name           = var.filter_name
  log_group_name = var.log_group_name
  pattern        = var.filter_pattern

  metric_transformation {
    name       = var.metric["name"]
    namespace  = var.metric["namespace"]
    value      = "1"
    unit       = "Count"
  }
}

resource "aws_cloudwatch_metric_alarm" "this" {
  alarm_name          = var.name
  threshold           = var.threshold
  comparison_operator = var.comparison_operator
  evaluation_periods  = var.evaluation_period
  metric_name         = var.metric["name"]
  namespace           = var.metric["namespace"]
  statistic           = var.metric["statistic"]
  period              = var.period
  datapoints_to_alarm = var.datapoint
  treat_missing_data  = "notBreaching"
  actions_enabled     = var.action_isenabled
  alarm_actions       = [var.sns_topic_arn]
}

イベント監視

システムから報告される特定のイベントを検知してアラートを生成します。
aws_cloudwatch_event_ruleaws_cloudwatch_event_targetの 2 つの Resource を使用するのでこちらも Module でまとめました。
patterninput_pathsinput_templateの書き方は以下を参考にしてください。

https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/events/EventTypes.html

通知されるイベントの形式がさまざまなので、variables.tfvarsがかなり見づらくなってしまいました😥
正直この辺りは Management Console から設定する方が圧倒的にわかりやすいです。Management Console ではサンプルイベントと照らし合わせて動作確認しながら設定できますが、Terramform にはそういった機能はありません。目当てのイベントが条件にマッチするか確認するために、結局 Management Console から確認したりすることが多いです。思い切ってイベント監視はコード化しないというのもアリかもしれません。

Sample code
variables.tfvars
cloudwatch_event_alarms = {
  aws_health_event = {
    name = "aws_health_event"
    pattern = <<-EOT
    {
      "detail-type": ["AWS Health Event"],
      "source": ["aws.health"]
    }
    EOT
    input_paths = {
      "detail":"$.detail-type",
      "eventTypeCode": "$.detail.eventTypeCode",
      "time":"$.time",
      "region":"$.region"
    }
    input_template = <<-EOT
    "A monitored event has occurred."
    "---"
    "Event Type: <detail>"
    "Event Type Code: <eventTypeCode>"
    "Event Time: <time>"
    "Event Region: <region>"
    EOT
    action_isenabled = true
  }
  rds_instance_event = {
    name = "rds_instance_event"
    pattern = <<-EOT
    {
      "detail-type": ["RDS DB Instance Event"],
      "source": ["aws.rds"],
      "detail": {
        "EventCategories": [
          "availability",
          "failover",
          "deletion",
          "failure",
          "notification",
          "recovery"
        ],
        "SourceType": ["DB_INSTANCE"]
      }
    }
    EOT
    input_paths = {
      detail   = "$.detail-type",
      resource = "$.resources",
      message  = "$.detail.Message"
    }
    input_template = <<-EOT
    "A monitored event has occurred."
    "---"
    "Event Type: <detail>"
    "Event Resource: <resource>"
    "Event Message: <message>"
    EOT
    action_isenabled = true
  }
}
main.tf
module "cloudwatch_event_alarms" {
  source           = "../../modules/cloudwatch_event_alarms"
  for_each         = var.cloudwatch_event_alarms
  name             = each.value["name"]
  pattern          = each.value["pattern"]
  input_paths      = each.value["input_paths"]
  input_template   = each.value["input_template"]
  action_isenabled = each.value["action_isenabled"]
  sns_topic_arn    = "arn:aws:sns:ap-northeast-1:123456789012:MyTopic"
}
modules/cloudwatch_event_alarms/main.tf
resource "aws_cloudwatch_event_rule" "this" {
  name          = var.name
  event_pattern = var.pattern
  is_enabled    = var.action_isenabled
}

resource "aws_cloudwatch_event_target" "this" {
  rule = aws_cloudwatch_event_rule.this.name
  arn  = var.sns_topic_arn

  input_transformer {
    input_paths    = var.input_paths
    input_template = var.input_template
  }
}

おわりに

何年か前に IaC が流行し出した頃から Terraform は触っていますが、当時よりベストプラクティスもある程度整理されてきて取っ付きやすくなってきたように思います。
特に以下のドキュメントは GCP を使用していなくても一読する価値があります。オススメです。

https://cloud.google.com/docs/terraform?hl=ja

もちろんプロジェクトによっては IaC を採用しないケースもありますが、選択肢として持っておくためにも情報のキャッチアップは続けていきたいと思っています。自分の中で整理できた内容をこんな形で少しずつアウトプットしていきたいです👍

Discussion