【Terraformコード公開(CloudWatch編)】AWSネイティブでECS本番監視を組む実践テンプレート

2026 年 5 月 14 日 | AWS / Observability

【技術相談】本件の内容に関して 30 分間の無料相談承ります →

【Terraformコード公開(CloudWatch編)】AWSネイティブでECS本番監視を組む実践テンプレート

この記事をシェア

こんにちは、練馬区旭丘で個人事業主として AI コンサル兼 AWS 構築をやっている野口です。

基礎編、New Relic 編、Datadog 編に続く、実践編シリーズの 3 本目 CloudWatch 編です。今回が AWS ネイティブで完結させる構成。SaaS にデータを出さずに、CloudWatch Metrics / Logs / Alarms / Dashboards / Application Signals / X-Ray だけで ECS 本番監視を組み上げる Terraform の書き方をまとめます。

最初に大事なお断りです。この記事の Terraform コードは「環境に合わせて調整する雛形」であり、コピー& apply で本番投入できる完成品ではありません。VPC、IAM ポリシー、タグ規約、Terraform ディレクトリ構成は会社ごとに違うので、必ず自社環境に書き直してから使ってください。

とはいえ、「ゼロから書き始めて、どこから手をつけたらいいか分からない」段階の人が、設計判断の足場として読めるレベルまでは書きました。CloudWatch は機能が散在していてドキュメントも分厚いので、最初の構築で何から手をつけるかが本当に分かりにくいです。この記事を起点に、自社で必要な部分を組み立ててください。


この記事で扱うもの

  • CloudWatch Log Group(アプリ / FireLens / 各種運用ログ)の設計、保持期間、KMS 暗号化
  • ECS Container Insights の有効化
  • Application Signals / X-Ray を ECS タスクに組み込む例
  • ALB / ECS / RDS / ElastiCache の代表メトリクスに対する CloudWatch Alarm
  • Metric Filter で ERROR / timeout / OOM / panic / exception を拾う設計
  • ALB / ECS / RDS / ElastiCache を 1 枚に集約した CloudWatch Dashboard
  • SNS Topic までの通知配線(Chatbot / Slack は触れすぎず)
  • 高ボリューム環境でのログコスト制御(retention / filter / subscription / sampling)
  • AWS 内完結のメリデメと「どんな現場に向くか」

想定アーキテクチャ

実践編シリーズ共通で、現実的な高トラフィック ECS 構成を想定します。

  • ALB(複数 AZ、HTTPS 終端、WAF 連携あり)
  • ECS on Fargate(複数 AZ、Service Auto Scaling 有効、タスク数 50〜500 の振れ幅)
  • RDS Aurora MySQL Multi-AZ(Writer + Reader 2 本)
  • ElastiCache for Redis(クラスタモード有効、複数シャード)
  • VPC エンドポイント(S3, ECR, CloudWatch Logs, Secrets Manager, X-Ray 向け)
  • CloudWatch Container Insights / Application Signals / X-Ray(AWS ネイティブで完結)
  • アプリコンテナ(ADOT Collector or X-Ray daemon サイドカー)+ FireLens サイドカー

ポイントは、CloudWatch では「データの吸い口」を AWS サービス側に寄せること。ECS / ALB / RDS / ElastiCache はメトリクスが自動で CloudWatch に上がるので、エージェントを別途入れる必要がありません。Container Insights を有効にすれば、コンテナ内部のメトリクスもタスク定義の追加なしで取れます。

逆に、アプリの APM 相当(分散トレース、レイテンシ分解、依存マップ)を見るには Application Signals + X-Ray を入れる必要があり、ここがこの構成の頑張りどころになります。


実装方針:何を Terraform で管理するか

Terraform で管理するスコープを最初に切ります。

  • AWS provider のみ(CloudWatch は AWS provider の中に全部入っている)
  • KMS Key(Log Group / SNS 暗号化用)
  • CloudWatch Log Group(アプリ / firelens / RDS slow query 等、用途別に複数)
  • ECS Cluster の Container Insights 設定
  • ECS Task Definition(アプリ + FireLens + ADOT Collector の 3 コンテナ)
  • ALB / ECS / RDS / ElastiCache の CloudWatch Alarm 群
  • ログの Metric Filter(ERROR / timeout / OOM / panic / exception)
  • CloudWatch Dashboard(1 サービス 1 枚を基本)
  • SNS Topic(通知の入り口、Chatbot / Slack 連携の手前まで)

Dashboard 内のウィジェット定義は JSON で長くなるので、ファイル分割を強く推奨します。本記事ではコード片として代表的なものだけ載せます。


variables.tf 相当:環境差分を集約する

variable "aws_region" {
  description = "AWSリージョン"
  type        = string
  default     = "ap-northeast-1"
}

variable "environment" {
  description = "環境名(prod/staging/dev)"
  type        = string
}

variable "service_name" {
  description = "サービス名(タグ・ARNの一部に使用)"
  type        = string
}

variable "team" {
  description = "オーナーチーム名(タグに付与)"
  type        = string
}

variable "cluster_name" {
  description = "ECS クラスタ名"
  type        = string
}

variable "alb_arn_suffix" {
  description = "ALB の ARN サフィックス(CloudWatch ディメンション用)"
  type        = string
}

variable "target_group_arn_suffix" {
  description = "ALB Target Group の ARN サフィックス"
  type        = string
}

variable "rds_cluster_id" {
  description = "RDS Aurora クラスタ識別子"
  type        = string
}

variable "elasticache_cluster_id" {
  description = "ElastiCache クラスタ識別子"
  type        = string
}

variable "log_retention_in_days" {
  description = "アプリログの保持期間(環境別で変える前提)"
  type        = number
  default     = 30
}

variable "alarm_notification_emails" {
  description = "SNS Topic に subscribe するメールアドレス一覧(オンコール用)"
  type        = list(string)
  default     = []
}

variable "common_tags" {
  description = "全リソース共通タグ"
  type        = map(string)
  default = {
    ManagedBy = "Terraform"
  }
}

この設計のポイント

  • CloudWatch の Dimension は ARN ではなく ARN サフィックスを使う(app/my-alb/abcdef0123456789 のような形式)。aws_lb.this.arn_suffix から取れる
  • log_retention_in_days を変数化しておくと、prod は 30 日、staging は 7 日のように環境別に振りやすい
  • alarm_notification_emails は最初の段階で用意しておく。後で Chatbot / Slack に乗せる時にも、まず SNS まで届いていることを確認する経路として残す

自社環境で変えるポイント

  • common_tags は社内のタグ規約に合わせる(CostCenter、Owner、Compliance、Version など)
  • 環境別の値は terraform.tfvars または workspace で切り替え

Provider 設定

terraform {
  required_version = ">= 1.6.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region

  default_tags {
    tags = merge(var.common_tags, {
      Environment = var.environment
      Service     = var.service_name
      Team        = var.team
    })
  }
}

data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

CloudWatch 構成では追加 provider はありません。「AWS provider 単体で完結」するのが、この構成のシンプルさの源泉です。SaaS 連携用の API Key 管理や、provider 間のバージョン差分に悩まなくてよくなります。


KMS Key:Log Group / SNS の暗号化基盤

CloudWatch Logs の KMS 暗号化はオプションですが、金融・医療・公共・社内データ規約のある組織では実質必須です。最初に Key を 1 本作っておくと後がラクなので、雛形に入れておきます。

resource "aws_kms_key" "observability" {
  description             = "CloudWatch Logs / SNS encryption for ${var.service_name}-${var.environment}"
  deletion_window_in_days = 30
  enable_key_rotation     = true

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "EnableRootAccess"
        Effect = "Allow"
        Principal = {
          AWS = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root"
        }
        Action   = "kms:*"
        Resource = "*"
      },
      {
        Sid    = "AllowCloudWatchLogs"
        Effect = "Allow"
        Principal = {
          Service = "logs.${var.aws_region}.amazonaws.com"
        }
        Action = [
          "kms:Encrypt*",
          "kms:Decrypt*",
          "kms:ReEncrypt*",
          "kms:GenerateDataKey*",
          "kms:Describe*"
        ]
        Resource = "*"
        Condition = {
          ArnLike = {
            "kms:EncryptionContext:aws:logs:arn" = "arn:aws:logs:${var.aws_region}:${data.aws_caller_identity.current.account_id}:log-group:/ecs/${var.service_name}/*"
          }
        }
      }
    ]
  })
}

resource "aws_kms_alias" "observability" {
  name          = "alias/${var.service_name}-${var.environment}-observability"
  target_key_id = aws_kms_key.observability.key_id
}

Log Group 用 KMS Policy の落とし穴

logs.<region>.amazonaws.com の許可を入れ忘れると、Log Group 作成自体は通っても ログ書き込み時に "AccessDenied" で無音失敗します。CloudWatch コンソールで Log Group は表示されるが Log Stream が空、というのが典型症状。実機で必ず 1 回 PutLogEvents が通ることまで確認してください。

KMS Key を共用するか、Log Group 単位で別 Key にするかは社内ポリシー次第。運用負荷的には共用 1 本がラクで、コンプライアンス上問題なければそれで十分です。


CloudWatch Log Group:用途別に分ける

Log Group は「アプリ用」「FireLens 自身用」「RDS slow query 用」のように用途別に分けるのが基本。retention も用途別に変えます。

# 1) アプリ本体のログ(FireLens 経由で書き込む)
resource "aws_cloudwatch_log_group" "app" {
  name              = "/ecs/${var.service_name}/${var.environment}/app"
  retention_in_days = var.log_retention_in_days
  kms_key_id        = aws_kms_key.observability.arn
}

# 2) FireLens / Fluent Bit 自身の動作ログ(短期保持で十分)
resource "aws_cloudwatch_log_group" "firelens" {
  name              = "/ecs/${var.service_name}/${var.environment}/firelens"
  retention_in_days = 7
  kms_key_id        = aws_kms_key.observability.arn
}

# 3) ADOT Collector / X-Ray daemon 用
resource "aws_cloudwatch_log_group" "adot" {
  name              = "/ecs/${var.service_name}/${var.environment}/adot"
  retention_in_days = 7
  kms_key_id        = aws_kms_key.observability.arn
}

# 4) RDS slow query log(取り込みは RDS 側パラメータグループ設定とセット)
resource "aws_cloudwatch_log_group" "rds_slowquery" {
  name              = "/aws/rds/cluster/${var.rds_cluster_id}/slowquery"
  retention_in_days = 30
  kms_key_id        = aws_kms_key.observability.arn
}

この設計の肝

  • アプリ用と FireLens 用は分離。Fluent Bit 側の動作ログ(接続失敗、バッファ溢れなど)はアプリログとは別レーンで保持。混ぜると Logs Insights のクエリが汚れて遅くなる
  • retention_in_days を明示。デフォルトの「Never Expire(無期限)」のまま放置されているプロジェクトは、CloudWatch 課金の地味な温床
  • kms_key_id を全 Log Group で揃える。後から KMS 暗号化を有効にしようとすると Log Group の作り直しが必要になる場合があるので、最初から付けておくのが安全

自社環境で変えるポイント

  • 90 日以上の長期保持が必要なら、Log Group をそのまま延長するのではなく S3 + Athena への Export 運用に切り替える(CloudWatch Logs の長期保持はわりと割高)
  • 環境別の retention:prod 30〜90 日、staging 7 日、dev 3 日が現実的な目安

ECS Cluster の Container Insights を有効化

Container Insights を有効にすると、ECS タスク / サービス / コンテナ単位の詳細メトリクス(CPU、Memory、Network、Storage、タスク数)が自動的に CloudWatch に上がります。「ECS で AWS ネイティブ監視」をやるなら、これは必須です。

resource "aws_ecs_cluster" "this" {
  name = var.cluster_name

  setting {
    name  = "containerInsights"
    value = "enhanced"
  }
}

enhancedenabled の違い

  • enabled(旧): タスク / サービス単位の集約メトリクスのみ
  • enhanced(新): コンテナ単位の詳細メトリクス + Container Insights 経由のログ取り込み(追加課金あり)

enhanced は便利ですがメトリクス本数が増えるぶん課金が乗るので、高タスク数の環境ではまず enabled で開始して、必要に応じて enhanced に昇格するのが現実的。タスク数 500 規模で enhanced に上げると、月次の CloudWatch 請求がそこそこ変動するので、有効化前にコストを試算してください。

provider バージョン注意: enhanced 値を受け付ける属性は AWS provider 5 系の中でも比較的新しめです。古い provider バージョンでは enabled / disabled のみ。terraform plan で必ず差分確認を。


Application Signals と X-Ray:ECS への組み込み

CloudWatch Application Signals は AWS が提供する APM 相当機能で、内部的には X-Ray と CloudWatch メトリクスを統合したものです。OpenTelemetry(OTLP)を喋るアプリなら、ADOT Collector 経由で Application Signals に流せます

ここが CloudWatch 構成の頑張りどころ。タスク定義に ADOT Collector サイドカーを追加します。

resource "aws_ecs_task_definition" "app" {
  family                   = "${var.service_name}-${var.environment}"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "1024"
  memory                   = "2048"
  execution_role_arn       = aws_iam_role.ecs_task_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn

  container_definitions = jsonencode([
    # ========== 1) アプリコンテナ ==========
    {
      name      = "app"
      image     = "${var.ecr_repo_url}:${var.app_image_tag}"
      essential = true
      portMappings = [{ containerPort = 3000, protocol = "tcp" }]

      environment = [
        { name = "NODE_ENV", value = var.environment },
        # OpenTelemetry: ADOT サイドカーへ送る
        { name = "OTEL_EXPORTER_OTLP_ENDPOINT", value = "http://127.0.0.1:4318" },
        { name = "OTEL_EXPORTER_OTLP_PROTOCOL", value = "http/protobuf" },
        { name = "OTEL_SERVICE_NAME",           value = var.service_name },
        { name = "OTEL_RESOURCE_ATTRIBUTES",
          value = "service.name=${var.service_name},service.version=${var.app_image_tag},deployment.environment=${var.environment},aws.local.service=${var.service_name}" },
        # Application Signals 向け推奨設定(AWS SDK インストルメント自動有効化)
        { name = "OTEL_AWS_APPLICATION_SIGNALS_ENABLED", value = "true" }
      ]

      # アプリログは FireLens 経由で CloudWatch Logs(app)に書く
      logConfiguration = {
        logDriver = "awsfirelens"
        options = {
          Name              = "cloudwatch_logs"
          region            = var.aws_region
          log_group_name    = aws_cloudwatch_log_group.app.name
          log_stream_prefix = "app-"
          auto_create_group = "false"
        }
      }
    },

    # ========== 2) ADOT Collector サイドカー(OTLP → Application Signals / X-Ray) ==========
    {
      name      = "adot-collector"
      image     = "public.ecr.aws/aws-observability/aws-otel-collector:latest"
      essential = true

      command = ["--config=/etc/ecs/ecs-default-config.yaml"]

      portMappings = [
        { containerPort = 4317, protocol = "tcp" }, # OTLP gRPC
        { containerPort = 4318, protocol = "tcp" }  # OTLP HTTP
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-group         = aws_cloudwatch_log_group.adot.name
          awslogs-region        = var.aws_region
          awslogs-stream-prefix = "adot"
        }
      }
    },

    # ========== 3) FireLens (Fluent Bit) サイドカー ==========
    {
      name      = "log_router"
      image     = "public.ecr.aws/aws-observability/aws-for-fluent-bit:stable"
      essential = true

      firelensConfiguration = {
        type = "fluentbit"
        options = {
          enable-ecs-log-metadata = "true"
        }
      }

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          awslogs-group         = aws_cloudwatch_log_group.firelens.name
          awslogs-region        = var.aws_region
          awslogs-stream-prefix = "firelens"
        }
      }

      memoryReservation = 50
    }
  ])
}

この設計の肝

  • アプリは OTLP で ADOT Collector に送るだけ(127.0.0.1:4318)。ベンダーロックインしない構成
  • ADOT Collector が AWS 側に振り分ける:トレースは X-Ray、メトリクスは CloudWatch、Application Signals が両者を統合
  • OTEL_AWS_APPLICATION_SIGNALS_ENABLED = true が Application Signals 向けの分岐スイッチ
  • アプリログは FireLens の cloudwatch_logs 出力プラグインで直接 CloudWatch Logs に書く。awslogs ドライバを直接使うより、FireLens 経由のほうがフィルタや構造化変換を後から差し込める

IAM Role の補足(コードは割愛しますが必要):

  • ecs_task Role に AWSXRayDaemonWriteAccessCloudWatchAgentServerPolicy 相当の権限が必要
  • xray:PutTraceSegments / cloudwatch:PutMetricData / logs:PutLogEvents あたりが要る
  • 最小権限化するなら AWS の公式マネージドポリシーをベースに、リソース ARN で絞る

provider バージョン注意: Application Signals は比較的新しい AWS 機能で、関連リソース(aws_applicationsignals_service_level_objective など)の Terraform サポート状況は AWS provider のリリースごとに変わります。terraform plan 前に provider の CHANGELOG を確認してください。本記事では「ADOT Collector 経由で Application Signals にデータを流す」までを Terraform 化する範囲とし、Application Signals 側の SLO 定義は UI または別 provider バージョンに任せる構成にしています。

自社環境で変えるポイント

  • ADOT Collector のコンフィグは --config=/etc/ecs/ecs-default-config.yaml のデフォルトでだいたい足ります。トレースサンプリング率を下げたいなどの要件があれば、S3 にカスタムコンフィグを置いて AOT_CONFIG_CONTENT 環境変数で読み込ませる方式に切り替え
  • ランタイムが Java / .NET / Python なら、ADOT Auto-Instrumentation Init Container を使う方式もあり(コードに手を入れずに APM 化できる)

ALB の Alarm:5xx / TargetResponseTime / UnHealthyHostCount

ここから Alarm 群です。ALB が一番障害検知の起点になるので、3 つの代表メトリクスを必ず張ります。

# 通知用 SNS Topic(あとで Chatbot / Slack に乗せる土台)
resource "aws_sns_topic" "alarms" {
  name              = "${var.service_name}-${var.environment}-alarms"
  kms_master_key_id = aws_kms_key.observability.arn
}

resource "aws_sns_topic_subscription" "email" {
  for_each  = toset(var.alarm_notification_emails)
  topic_arn = aws_sns_topic.alarms.arn
  protocol  = "email"
  endpoint  = each.value
}

# ALB 5xx の発生件数(ターゲット起因)
resource "aws_cloudwatch_metric_alarm" "alb_target_5xx" {
  alarm_name          = "${var.service_name}-${var.environment}-alb-target-5xx"
  alarm_description   = "ALB Target 5xx が閾値超過。アプリ or 上流依存の劣化"
  namespace           = "AWS/ApplicationELB"
  metric_name         = "HTTPCode_Target_5XX_Count"
  statistic           = "Sum"
  period              = 60
  evaluation_periods  = 3
  threshold           = 10
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    LoadBalancer = var.alb_arn_suffix
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
  ok_actions    = [aws_sns_topic.alarms.arn]
}

# Target Response Time の p95 が遅い
resource "aws_cloudwatch_metric_alarm" "alb_p95_latency" {
  alarm_name          = "${var.service_name}-${var.environment}-alb-p95-latency"
  alarm_description   = "ALB TargetResponseTime p95 が閾値超過"
  namespace           = "AWS/ApplicationELB"
  metric_name         = "TargetResponseTime"
  extended_statistic  = "p95"
  period              = 60
  evaluation_periods  = 5
  threshold           = 1.0 # 秒
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    LoadBalancer = var.alb_arn_suffix
    TargetGroup  = var.target_group_arn_suffix
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

# UnHealthy Host Count(ヘルスチェック失敗ターゲット)
resource "aws_cloudwatch_metric_alarm" "alb_unhealthy_hosts" {
  alarm_name          = "${var.service_name}-${var.environment}-alb-unhealthy-hosts"
  alarm_description   = "ALB のヘルスチェック失敗ターゲットが存在"
  namespace           = "AWS/ApplicationELB"
  metric_name         = "UnHealthyHostCount"
  statistic           = "Maximum"
  period              = 60
  evaluation_periods  = 3
  threshold           = 0
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    LoadBalancer = var.alb_arn_suffix
    TargetGroup  = var.target_group_arn_suffix
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

Alarm 設計のポイント

  • HTTPCode_ELB_5XX_CountHTTPCode_Target_5XX_Count を区別する。前者は ALB 自体の問題、後者はアプリ・上流の問題。最初に張るべきは Target 5xx
  • extended_statistic = "p95" で p95 が取れる。平均値で見ると遅い 1 割が埋もれるので、レイテンシは必ず p95 で
  • treat_missing_data = "notBreaching" を明示。デフォルトの missing だと、データ未到達でアラートが暴発する
  • evaluation_periods で連続発火回数を制御。瞬間スパイクを拾うなら短く、定常劣化だけ拾うなら長く
  • ok_actions を SNS に出すと、復旧通知も飛ぶ。深夜のオンコール対応で「もう収まったのか?」が分からない事故を防ぐ

自社環境で変えるポイント

  • 5xx 件数の閾値は実トラフィックに対する許容失敗数から決める(10 件は中規模サービスの一例)
  • p95 の閾値はサービスの SLO から逆算。1 秒は緩めの初期値
  • var.alb_arn_suffixaws_lb.this.arn_suffixvar.target_group_arn_suffixaws_lb_target_group.this.arn_suffix を data または直接渡す

ECS の Alarm:CPU / Memory / RunningTask 異常 / デプロイ失敗

ECS Service レベルでの Alarm 群。AWS/ECS namespace と Container Insights 由来の ECS/ContainerInsights namespace を組み合わせます。

# Service レベル CPU 使用率
resource "aws_cloudwatch_metric_alarm" "ecs_cpu" {
  alarm_name          = "${var.service_name}-${var.environment}-ecs-cpu"
  alarm_description   = "ECS Service CPU 使用率が閾値超過"
  namespace           = "AWS/ECS"
  metric_name         = "CPUUtilization"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 10
  threshold           = 80
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    ClusterName = var.cluster_name
    ServiceName = var.service_name
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

# Service レベル Memory 使用率
resource "aws_cloudwatch_metric_alarm" "ecs_memory" {
  alarm_name          = "${var.service_name}-${var.environment}-ecs-memory"
  alarm_description   = "ECS Service Memory 使用率が閾値超過(リーク or サイジング再考)"
  namespace           = "AWS/ECS"
  metric_name         = "MemoryUtilization"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 10
  threshold           = 80
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    ClusterName = var.cluster_name
    ServiceName = var.service_name
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

# RunningTaskCount が Desired を下回る
# Container Insights の RunningTaskCount を使うパターン
resource "aws_cloudwatch_metric_alarm" "ecs_running_below_desired" {
  alarm_name          = "${var.service_name}-${var.environment}-ecs-tasks-below-desired"
  alarm_description   = "実行中タスク数が desired を下回っている(デプロイ失敗 / OOM / Health check 失敗の疑い)"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 5
  threshold           = 1
  treat_missing_data  = "notBreaching"

  metric_query {
    id          = "running"
    return_data = false
    metric {
      namespace   = "ECS/ContainerInsights"
      metric_name = "RunningTaskCount"
      period      = 60
      stat        = "Average"
      dimensions = {
        ClusterName = var.cluster_name
        ServiceName = var.service_name
      }
    }
  }

  metric_query {
    id          = "desired"
    return_data = false
    metric {
      namespace   = "ECS/ContainerInsights"
      metric_name = "DesiredTaskCount"
      period      = 60
      stat        = "Average"
      dimensions = {
        ClusterName = var.cluster_name
        ServiceName = var.service_name
      }
    }
  }

  metric_query {
    id          = "ratio"
    expression  = "running / desired"
    label       = "RunningTask / DesiredTask"
    return_data = true
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

Alarm 設計のポイント

  • CPU / Memory の evaluation_periods は長めに(10 分など)。Auto Scaling と組み合わさるので、瞬間ピークでアラートを出してもノイズになる
  • 「Running < Desired」の検知に Metric Mathを使う。これは CloudWatch の地味な強みで、複数メトリクスを式で組み合わせて 1 つの Alarm にできる
  • Container Insights の RunningTaskCount / DesiredTaskCount を有効活用するには、前述の containerInsights = enhanced or enabled が前提

自社環境で変えるポイント

  • CPU / Memory の閾値は Service Auto Scaling のターゲット値から逆算(例: ターゲット 60% なら Alarm は 80% で)
  • 「Running < Desired」の許容期間は、ローリングデプロイの平均所要時間より長く取る(短いとデプロイ中に毎回アラートが鳴る)

RDS の Alarm:CPU / FreeStorageSpace / Connections / ReplicaLag

resource "aws_cloudwatch_metric_alarm" "rds_cpu" {
  alarm_name          = "${var.service_name}-${var.environment}-rds-cpu"
  alarm_description   = "RDS の CPU 使用率が閾値超過"
  namespace           = "AWS/RDS"
  metric_name         = "CPUUtilization"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 10
  threshold           = 80
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    DBClusterIdentifier = var.rds_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

resource "aws_cloudwatch_metric_alarm" "rds_free_storage" {
  alarm_name          = "${var.service_name}-${var.environment}-rds-free-storage"
  alarm_description   = "RDS の空きストレージが閾値以下"
  namespace           = "AWS/RDS"
  metric_name         = "FreeStorageSpace"
  statistic           = "Minimum"
  period              = 300
  evaluation_periods  = 3
  threshold           = 10737418240 # 10 GiB
  comparison_operator = "LessThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    DBClusterIdentifier = var.rds_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

resource "aws_cloudwatch_metric_alarm" "rds_connections" {
  alarm_name          = "${var.service_name}-${var.environment}-rds-connections"
  alarm_description   = "RDS のコネクション数が閾値超過(max_connections 接近の疑い)"
  namespace           = "AWS/RDS"
  metric_name         = "DatabaseConnections"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 5
  threshold           = 200
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    DBClusterIdentifier = var.rds_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

# Aurora の Reader 遅延(ms)
resource "aws_cloudwatch_metric_alarm" "rds_replica_lag" {
  alarm_name          = "${var.service_name}-${var.environment}-rds-replica-lag"
  alarm_description   = "Aurora Reader のレプリカラグが閾値超過"
  namespace           = "AWS/RDS"
  metric_name         = "AuroraReplicaLag"
  statistic           = "Maximum"
  period              = 60
  evaluation_periods  = 5
  threshold           = 1000 # 1秒
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    DBClusterIdentifier = var.rds_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

RDS Alarm の落とし穴

  • FreeStorageSpace の単位はバイト。GiB と勘違いして閾値を 10 で書くと「ストレージ残量 10 byte 以下」になって永遠に発火しない
  • DatabaseConnections の閾値はインスタンスタイプの max_connections から逆算。Aurora MySQL の max_connections はインスタンスタイプ依存なので、ハードコードせず変数化する手もあり
  • AuroraReplicaLag の単位はミリ秒。秒と勘違いしない
  • 一部のメトリクス(ReadIOPS / WriteIOPS / BufferCacheHitRatio など)は Enhanced Monitoring 有効化が前提のものがある

自社環境で変えるポイント

  • Multi-AZ Cluster でなく Writer 単体構成なら DBInstanceIdentifier ディメンションに切り替え
  • ストレージ閾値は本番のサイジングに合わせる(10GiB はあくまで雛形)

ElastiCache の Alarm:CPU / Connections / Evictions / FreeableMemory

resource "aws_cloudwatch_metric_alarm" "elasticache_cpu" {
  alarm_name          = "${var.service_name}-${var.environment}-elasticache-cpu"
  alarm_description   = "ElastiCache の EngineCPUUtilization が閾値超過"
  namespace           = "AWS/ElastiCache"
  metric_name         = "EngineCPUUtilization"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 5
  threshold           = 80
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    CacheClusterId = var.elasticache_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

resource "aws_cloudwatch_metric_alarm" "elasticache_connections" {
  alarm_name          = "${var.service_name}-${var.environment}-elasticache-connections"
  alarm_description   = "ElastiCache の CurrConnections が閾値超過"
  namespace           = "AWS/ElastiCache"
  metric_name         = "CurrConnections"
  statistic           = "Average"
  period              = 60
  evaluation_periods  = 5
  threshold           = 5000
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    CacheClusterId = var.elasticache_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

resource "aws_cloudwatch_metric_alarm" "elasticache_evictions" {
  alarm_name          = "${var.service_name}-${var.environment}-elasticache-evictions"
  alarm_description   = "ElastiCache でエビクション発生(メモリ不足の兆候)"
  namespace           = "AWS/ElastiCache"
  metric_name         = "Evictions"
  statistic           = "Sum"
  period              = 300
  evaluation_periods  = 1
  threshold           = 0
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    CacheClusterId = var.elasticache_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

resource "aws_cloudwatch_metric_alarm" "elasticache_freeable_memory" {
  alarm_name          = "${var.service_name}-${var.environment}-elasticache-freeable-memory"
  alarm_description   = "ElastiCache の FreeableMemory が閾値以下"
  namespace           = "AWS/ElastiCache"
  metric_name         = "FreeableMemory"
  statistic           = "Minimum"
  period              = 300
  evaluation_periods  = 3
  threshold           = 536870912 # 512 MiB
  comparison_operator = "LessThanThreshold"
  treat_missing_data  = "notBreaching"

  dimensions = {
    CacheClusterId = var.elasticache_cluster_id
  }

  alarm_actions = [aws_sns_topic.alarms.arn]
}

ElastiCache Alarm のポイント

  • EngineCPUUtilization を使うCPUUtilization は OS 全体、EngineCPUUtilization は Redis プロセスのみ)。Redis の負荷を見るなら後者
  • Evictions > 0 即発火で構わない。エビクションは本来「起きてはいけない事象」。発生したらキャッシュサイズか TTL の見直しが必要
  • FreeableMemory の単位はバイト(RDS と同じく注意)
  • クラスタモード有効化の Redis では、CacheClusterId ではなくシャード単位のディメンションになる場合がある(ReplicationGroupId + CacheClusterId 両方使う構成も)。ディメンションは実機の CloudWatch で見えるキーに合わせる

Metric Filter:ログから ERROR / timeout / OOM / panic / exception を抽出

CloudWatch の隠れた強みが Metric Filter。ログから特定パターンをマッチしてカスタムメトリクスに変換できます。これにより「ERROR ログが急増したらアラート」みたいなことが、SaaS なしでできます。

locals {
  app_log_group = aws_cloudwatch_log_group.app.name
  metric_ns     = "${var.service_name}/${var.environment}/AppLogs"
}

# ERROR ログ件数
resource "aws_cloudwatch_log_metric_filter" "app_error" {
  name           = "${var.service_name}-${var.environment}-app-error"
  log_group_name = local.app_log_group
  # JSON ログ({"level":"error", ...})想定。プレーンテキストなら "ERROR" でもよい
  pattern = "{ $.level = \"error\" }"

  metric_transformation {
    name      = "AppErrorCount"
    namespace = local.metric_ns
    value     = "1"
    unit      = "Count"
    default_value = 0
  }
}

# timeout 検出
resource "aws_cloudwatch_log_metric_filter" "app_timeout" {
  name           = "${var.service_name}-${var.environment}-app-timeout"
  log_group_name = local.app_log_group
  pattern        = "?timeout ?\"deadline exceeded\" ?ETIMEDOUT"

  metric_transformation {
    name          = "AppTimeoutCount"
    namespace     = local.metric_ns
    value         = "1"
    unit          = "Count"
    default_value = 0
  }
}

# OOM 検出(コンテナの OOMKilled / dmesg 由来)
resource "aws_cloudwatch_log_metric_filter" "app_oom" {
  name           = "${var.service_name}-${var.environment}-app-oom"
  log_group_name = local.app_log_group
  pattern        = "?OOMKilled ?\"out of memory\" ?\"killed process\""

  metric_transformation {
    name          = "AppOOMCount"
    namespace     = local.metric_ns
    value         = "1"
    unit          = "Count"
    default_value = 0
  }
}

# panic / exception(Go の panic、Java/Python の exception 系を一網打尽)
resource "aws_cloudwatch_log_metric_filter" "app_panic" {
  name           = "${var.service_name}-${var.environment}-app-panic"
  log_group_name = local.app_log_group
  pattern        = "?panic ?Exception ?Traceback ?\"unhandled error\""

  metric_transformation {
    name          = "AppPanicCount"
    namespace     = local.metric_ns
    value         = "1"
    unit          = "Count"
    default_value = 0
  }
}

# ERROR ログ急増アラート(Metric Filter の結果を Alarm 化)
resource "aws_cloudwatch_metric_alarm" "app_error_spike" {
  alarm_name          = "${var.service_name}-${var.environment}-app-error-spike"
  alarm_description   = "アプリログの ERROR が急増"
  namespace           = local.metric_ns
  metric_name         = "AppErrorCount"
  statistic           = "Sum"
  period              = 60
  evaluation_periods  = 3
  threshold           = 50
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  alarm_actions = [aws_sns_topic.alarms.arn]
}

resource "aws_cloudwatch_metric_alarm" "app_oom" {
  alarm_name          = "${var.service_name}-${var.environment}-app-oom"
  alarm_description   = "アプリの OOM 検出(即時対応)"
  namespace           = local.metric_ns
  metric_name         = "AppOOMCount"
  statistic           = "Sum"
  period              = 60
  evaluation_periods  = 1
  threshold           = 0
  comparison_operator = "GreaterThanThreshold"
  treat_missing_data  = "notBreaching"

  alarm_actions = [aws_sns_topic.alarms.arn]
}

Metric Filter のポイント

  • default_value = 0 を必ず指定。これを忘れると、マッチしない期間のデータが「欠損」扱いになり、Alarm の treat_missing_data で挙動がブレる
  • JSON ログ前提なら { $.level = "error" }、プレーンテキストなら "ERROR"ログ形式を最初に決めて、構造化ログに寄せるほうが運用が圧倒的にラク
  • ? でつなぐと OR 条件?timeout ?ETIMEDOUT は「どちらかにマッチ」)。複数パターン拾うときに便利
  • OOM だけは evaluation_periods = 1 で即時発火にする。OOM は発生時点で対応すべき事象なので、サマリ的に見ない

自社環境で変えるポイント

  • ログのレベル名やフィールド名(level / severity / log.level)はアプリのロガー設定に合わせる
  • 閾値はサービスの通常エラー数を観察してから決める(50 件/分は中規模サービスの目安)

CloudWatch Dashboard:ALB / ECS / RDS / ElastiCache を 1 枚に

「ダッシュボードを作って満足する」は基礎編で挙げた失敗 5 パターンの 1 つでしたが、障害時の調査導線として作るぶんには絶対必要です。1 サービス 1 枚を基本に、ALB → ECS → RDS → ElastiCache の縦軸で構成します。

resource "aws_cloudwatch_dashboard" "service" {
  dashboard_name = "${var.service_name}-${var.environment}"

  dashboard_body = jsonencode({
    widgets = [
      # ---- ALB ----
      {
        type   = "metric"
        x      = 0
        y      = 0
        width  = 12
        height = 6
        properties = {
          title  = "ALB Request / 5xx"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["AWS/ApplicationELB", "RequestCount", "LoadBalancer", var.alb_arn_suffix, { stat = "Sum" }],
            [".", "HTTPCode_Target_5XX_Count", ".", ".", { stat = "Sum", yAxis = "right" }]
          ]
          period = 60
        }
      },
      {
        type   = "metric"
        x      = 12
        y      = 0
        width  = 12
        height = 6
        properties = {
          title  = "ALB Target Response Time (p50 / p95 / p99)"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["AWS/ApplicationELB", "TargetResponseTime", "LoadBalancer", var.alb_arn_suffix, "TargetGroup", var.target_group_arn_suffix, { stat = "p50" }],
            ["...", { stat = "p95" }],
            ["...", { stat = "p99" }]
          ]
          period = 60
        }
      },
      # ---- ECS ----
      {
        type   = "metric"
        x      = 0
        y      = 6
        width  = 12
        height = 6
        properties = {
          title  = "ECS CPU / Memory"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["AWS/ECS", "CPUUtilization", "ClusterName", var.cluster_name, "ServiceName", var.service_name, { stat = "Average" }],
            [".", "MemoryUtilization", ".", ".", ".", ".", { stat = "Average" }]
          ]
          period = 60
        }
      },
      {
        type   = "metric"
        x      = 12
        y      = 6
        width  = 12
        height = 6
        properties = {
          title  = "ECS Running / Desired Task Count"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["ECS/ContainerInsights", "RunningTaskCount", "ClusterName", var.cluster_name, "ServiceName", var.service_name],
            [".", "DesiredTaskCount", ".", ".", ".", "."]
          ]
          period = 60
        }
      },
      # ---- RDS ----
      {
        type   = "metric"
        x      = 0
        y      = 12
        width  = 12
        height = 6
        properties = {
          title  = "RDS CPU / Connections"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["AWS/RDS", "CPUUtilization", "DBClusterIdentifier", var.rds_cluster_id, { stat = "Average" }],
            [".", "DatabaseConnections", ".", ".", { stat = "Average", yAxis = "right" }]
          ]
          period = 60
        }
      },
      {
        type   = "metric"
        x      = 12
        y      = 12
        width  = 12
        height = 6
        properties = {
          title  = "RDS ReplicaLag / FreeStorage"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["AWS/RDS", "AuroraReplicaLag", "DBClusterIdentifier", var.rds_cluster_id, { stat = "Maximum" }],
            [".", "FreeStorageSpace", ".", ".", { stat = "Minimum", yAxis = "right" }]
          ]
          period = 60
        }
      },
      # ---- ElastiCache ----
      {
        type   = "metric"
        x      = 0
        y      = 18
        width  = 12
        height = 6
        properties = {
          title  = "ElastiCache CPU / Connections"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["AWS/ElastiCache", "EngineCPUUtilization", "CacheClusterId", var.elasticache_cluster_id],
            [".", "CurrConnections", ".", ".", { yAxis = "right" }]
          ]
          period = 60
        }
      },
      {
        type   = "metric"
        x      = 12
        y      = 18
        width  = 12
        height = 6
        properties = {
          title  = "ElastiCache Evictions / FreeableMemory"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            ["AWS/ElastiCache", "Evictions", "CacheClusterId", var.elasticache_cluster_id, { stat = "Sum" }],
            [".", "FreeableMemory", ".", ".", { stat = "Minimum", yAxis = "right" }]
          ]
          period = 60
        }
      },
      # ---- App Logs Metric Filter ----
      {
        type   = "metric"
        x      = 0
        y      = 24
        width  = 24
        height = 6
        properties = {
          title  = "App Error / Timeout / OOM / Panic"
          region = var.aws_region
          view   = "timeSeries"
          metrics = [
            [local.metric_ns, "AppErrorCount", { stat = "Sum" }],
            [".", "AppTimeoutCount", { stat = "Sum" }],
            [".", "AppOOMCount", { stat = "Sum" }],
            [".", "AppPanicCount", { stat = "Sum" }]
          ]
          period = 60
        }
      }
    ]
  })
}

Dashboard 設計のポイント

  • 1 サービス 1 枚を厳守。「インフラ全体ダッシュボード」みたいなのは作らない(誰も見ないやつ)
  • 縦軸を ALB → ECS → RDS → ElastiCache → App Logs の順に配置。障害時に「上から下に読めば原因に辿り着く」導線を意識
  • view = "timeSeries" が基本。数値 1 枚で見せたいケース(タスク数や 5xx 件数)だけ singleValue を使う
  • Widget の x / y / width / height は 24 グリッドの相対座標。レイアウトを揃えると視認性が上がる

自社環境で変えるポイント

  • Widget の JSON は長くなりがちなので、templatefile でテンプレート化するのが推奨
  • ダッシュボード本体を Terraform で管理しつつ、JSON 部分は外部ファイルに切り出すと PR レビューしやすい

SNS Topic と Chatbot / Slack 連携

SNS Topic はすでに作りました。そこから先(Slack / Microsoft Teams / Chime)の通知ルーティングはこの記事のスコープ外ですが、AWS Chatbot を経由するのが定石です。

# Chatbot 経由で Slack に飛ばす場合の雛形(Slack ワークスペース ID と Channel ID は事前取得が必要)
# resource "awscc_chatbot_slack_channel_configuration" "alarms" {
#   configuration_name = "${var.service_name}-${var.environment}-alarms"
#   iam_role_arn       = aws_iam_role.chatbot.arn
#   slack_channel_id   = var.slack_channel_id
#   slack_workspace_id = var.slack_workspace_id
#   sns_topic_arns     = [aws_sns_topic.alarms.arn]
# }

Chatbot のリソースは Terraform AWS provider の標準にはなく、awscc provider 経由で書く必要があります。この記事では深入りせず、「SNS Topic までは Terraform、その先は Chatbot コンソール or awscc」という分け方を推奨します。

理由は、Chatbot の Slack 連携は OAuth 認可が絡んで完全な IaC 化に手間がかかるためです。初回認可は手動で済ませて、その後 Chatbot Configuration を Terraform 化、というのが現実解。


高ボリューム環境でのログコスト制御

CloudWatch の請求は メトリクス本数 + ダッシュボード数 + ログ取り込み GB + ログ保持 GB + Logs Insights クエリ GB で積み上がります。中でもログコストが支配的なので、ここを真面目に設計します。

1. Retention(保持期間)

  • retention_in_days を必ず明示。デフォルト「Never Expire」は禁忌
  • prod 30〜90 日、staging 7 日、dev 3 日が現実的な目安
  • 90 日以上の長期保持は CloudWatch Logs ではなく S3 Export + Glacier / Athena に切り替えるのがコスト最適。CloudWatch Logs 上での長期保持はわりと割高

2. Filter(取り込み前のフィルタ)

FireLens の Fluent Bit 段階でDEBUG ログとヘルスチェックを破棄します。考え方は New Relic / Datadog 編と同じ。

resource "aws_s3_object" "fluentbit_config" {
  bucket = aws_s3_bucket.fluentbit_config.id
  key    = "fluentbit.conf"
  content = <<-EOT
    [SERVICE]
        Flush               1
        Log_Level           info
        Parsers_File        parsers.conf

    [FILTER]
        Name                grep
        Match               *
        Exclude             level debug

    [FILTER]
        Name                grep
        Match               *
        Exclude             path ^/healthz$

    [OUTPUT]
        Name                cloudwatch_logs
        Match               *
        region              ${var.aws_region}
        log_group_name      ${aws_cloudwatch_log_group.app.name}
        log_stream_prefix   app-
        auto_create_group   false
  EOT
}

CloudWatch Logs は取り込み GB に課金されるので、FireLens 段階で落とすのが一番効きます。Log Group に入れてから消すのは課金的にも運用的にも筋が悪い。

3. Subscription Filter(外部送信 / 二次処理)

特定パターンだけ別の Lambda / Kinesis / Firehose に流したいときに使います。例えば「ERROR ログだけ S3 に永久保管」のような運用。

# 例: ERROR ログだけ Kinesis Firehose 経由で S3 に長期保管
resource "aws_cloudwatch_log_subscription_filter" "error_to_s3" {
  name            = "${var.service_name}-${var.environment}-error-to-s3"
  log_group_name  = aws_cloudwatch_log_group.app.name
  filter_pattern  = "{ $.level = \"error\" }"
  destination_arn = aws_kinesis_firehose_delivery_stream.errors_archive.arn
  role_arn        = aws_iam_role.cwl_to_firehose.arn
}

Subscription Filter のポイント

  • CloudWatch Logs 自体の retention は短く、Subscription Filter 経由で重要ログだけ別保管、という二段構えがコスト最適
  • ただし Subscription Filter は Log Group ごとに最大 2 つの制限あり(過去の制限。最新は要確認)。設計時に「何用に何個使うか」を決めておく

4. Sampling(サンプリング)

アクセスログのように「全件は要らないが傾向は見たい」ものは Fluent Bit でサンプリング。Fluent Bit の sampling フィルタや lua フィルタで実装します。

# Fluent Bit 設定例(100リクエストに1つだけ通す)
[FILTER]
    Name    lua
    Match   *
    script  /fluent-bit/etc/sampler.lua
    call    sample_1_in_100

Lua スクリプト本体は割愛しますが、「ヘルスチェックは 100:1、通常アクセスは 10:1、エラーは全件」のような階層化サンプリングが定石。

5. Metric Filter のコスト副作用

Metric Filter はカスタムメトリクスを生むので、メトリクス本数に課金されます。100 種類のフィルタを乱発すると、メトリクス課金で逆に重くなる。「数を絞り、本当に Alarm 化したいものだけ Metric Filter」にしてください。

6. Logs Insights のクエリコスト

Logs Insights はスキャン GB に課金されます。「とりあえず 7 日分全部 SELECT *」のようなクエリを毎時間打つと、地味に積み上がる。filter を先にかけて絞ってから stats を取るのがコスト面でも実行速度面でも正解。


AWS 内完結のメリデメ

ここまで書いてきた CloudWatch 構成のメリットとデメリットを整理します。

メリット

  • データを外に出さない。コンプライアンス要件が厳しい金融・医療・公共系で稟議が通りやすい
  • AWS 請求にまとめられる。SaaS 別契約や為替リスクなしで、月次予算管理がシンプル
  • IAM Role でアクセス制御が完結。SaaS 連携時の「API Key 漏洩」リスクが構造的に存在しない
  • AWS サービスとの統合がゼロコンフィグ。新しい AWS サービスを足してもメトリクスが自動で上がってくる
  • provider が AWS 1 本。SaaS provider のバージョン互換に振り回されない
  • VPC エンドポイント経由で外向き通信を排除できるので、NAT Gateway 料金が乗らない

デメリット

  • UI / UX が SaaS に比べて素朴。ダッシュボードの作り込みに手間がかかる
  • APM 相当(Application Signals)はまだ成熟度で New Relic / Datadog に追いつき中。深いトランザクション分析は劣る
  • アラートのロジック表現が固い。複雑な条件は Metric Math や Composite Alarm で頑張る必要あり
  • マルチクラウド / オンプレ対象は弱い。CloudWatch Agent を別クラウドに置く構成は可能だが面倒
  • Logs Insights の表現力は SaaS のクエリ言語より弱い部分が残る(最近 SQL 構文対応で改善はしている)
  • 長期保持のログコストが割高になりやすい。S3 Export を併用する設計が必須

落とし穴まとめ — 現場で本当に踏むやつ

落とし穴 1: Log Group の retention を指定し忘れて「Never Expire」のまま

これは CloudWatch あるあるの No.1。Terraform で retention_in_days を書かないと 保持期間が無期限になり、地味に課金が積み上がります。全 Log Group で必ず指定

落とし穴 2: KMS Key の Log Group 用ポリシーで logs.<region>.amazonaws.com を許可し忘れる

Log Group は作れるがログ書き込みが無音で失敗、というやつ。コンソールには Log Group が表示されるので「動いている」と勘違いしがち。実機で 1 回 PutLogEvents が通ることを確認

落とし穴 3: Metric Filter の default_value 未指定

データなしの期間に「欠損」扱いになり、Alarm の treat_missing_data 設定とかみ合わずに誤発火 / 未発火。default_value = 0 を必ず指定

落とし穴 4: ALB / RDS / ElastiCache の Dimension を間違える

  • ALB: LoadBalancer には ARN サフィックス(app/my-alb/abc...)を渡す
  • Target Group: TargetGroup も同様にサフィックス
  • RDS Aurora: クラスタなら DBClusterIdentifier、インスタンスなら DBInstanceIdentifier
  • ElastiCache: クラスタモードか否かでディメンションが変わる

実機の CloudWatch コンソールで実際に見えるディメンション名・値に合わせるのが鉄則。

落とし穴 5: treat_missing_data のデフォルト missing でアラート暴発

treat_missing_data を書かないと、データ未到達期間に「閾値未満」と判定されて深夜にアラートが飛ぶ。notBreaching を明示するのが安全側の設計。

落とし穴 6: Composite Alarm を使わずに Single Alarm を乱発

「ALB 5xx + ECS CPU + RDS CPU が同時に上がったらアラート」のような複合条件は Composite Alarm を使うべき。Single Alarm の乱発はアラート疲れの温床。

落とし穴 7: Application Signals / X-Ray の IAM 権限不足

ADOT Collector を入れたのにデータが上がらない、というケースの大半は IAM 権限不足。xray:PutTraceSegments / cloudwatch:PutMetricData / logs:PutLogEvents の 3 点セットecs_task Role に付けることを忘れない。

落とし穴 8: Dashboard の widget 座標が重なって視認性が壊れる

x / y / width / height を雑に書くと widget が重なる。24 グリッドの座標計算を最初にエクセルで書くくらいの慎重さで設計するのが結局速い。

落とし穴 9: Container Insights を enhanced にして請求が跳ねる

「便利そうだから」で enhanced を全クラスタに広げると、メトリクス本数が増えて課金が乗る。まず enabled で始めて、必要に応じて昇格

落とし穴 10: Subscription Filter の上限に当たる

Log Group あたりの Subscription Filter は数が限られている。「ERROR を S3」「全ログを SIEM」「特定パターンを Lambda」と乱立すると上限に当たる。最初に用途を整理。


まとめ:CloudWatch 編が向いている現場

CloudWatch + Terraform でこの構成を組むのが向いているのは、

  • 100% AWS で構成されていて、SaaS にデータを出さない方針の組織(金融、医療、公共、社内データ規約が厳しい現場)
  • コスト最優先で「まず最低限の監視を立ち上げたい」段階の組織
  • AWS 請求に統合したいので、SaaS 別契約を増やしたくない経営判断
  • 小〜中規模のチームで、専任の監視担当がまだいない
  • OpenTelemetry でベンダーロックインを避けた上で AWS ネイティブに振りたい

逆に 「アプリのコード深部を本気で APM で追いかけたい」 ケースなら New Relic、「マルチクラウド / コンテナ多用 / 横断運用」 なら Datadog のほうが合う場面が多いです。それぞれ実践編で扱った通り。

CloudWatch の強みは AWS 統合のシンプルさとコンプライアンス親和性で、それと裏腹に UI 作り込みの手間と APM の成熟度 が常について回ります。この記事のテンプレートは「AWS ネイティブで最低限のオブザーバビリティを立ち上げる出発点」として書いてあるので、ここから自社の要件に合わせて拡張してください。

「うちはまず CloudWatch で始めて、APM が必要になったら New Relic を足そう」「コンテナ運用が本格化したら Datadog を併用しよう」という基礎編の段階的拡張パスにおいて、この CloudWatch 構成は最初の足場として使えるはずです。

繰り返しになりますが、このコードは雛形です。VPC、IAM、Terraform リポジトリ規約は会社ごとに違うので、必ず自社環境に書き直してから使ってください。とはいえ、設計判断のたたき台としては十分なところまでは書いたつもりです。これを起点に、本気の本番監視を組んでください。

これで実践編シリーズ(基礎編 + New Relic 編 + Datadog 編 + CloudWatch 編)は一通り完結です。3 つのツールで同じ ECS 高ボリューム構成を Terraform 化したので、横並びで読み比べると 「同じ要件をどう実装するか」の違いと共通点が見えてくると思います。自社の選定に役立てていただければ幸いです。


関連キーワード

  • CloudWatch / CloudWatch Logs / CloudWatch Alarm / CloudWatch Dashboard
  • Terraform / AWS provider
  • ECS / Fargate / Container Insights
  • Application Signals / X-Ray / ADOT Collector
  • OpenTelemetry / OTLP
  • FireLens / Fluent Bit
  • SNS / Chatbot / Slack
  • Metric Filter / Subscription Filter
  • ログコスト最適化 / Retention 設計
  • オブザーバビリティ / SRE

この記事が役に立ったらシェアしてください

CloudWatch で本番監視を組もうとしている仲間に共有してみてください。

カテゴリ

AWS / Observability

公開日

2026 年 5 月 14 日

💬 無料技術相談のご案内

この記事でご紹介した技術について、導入や活用のご相談を30 分間無料承っております。

  • 「自社でも導入できる?」といった技術的な疑問
  • 既存システムとの連携・移行に関するご相談
  • コスト感や導入スケジュールの目安

30 年以上の IT 経験をもとに、率直にお答えします。強引なセールスや勧誘は一切ありません。

野口真一 野口真一

お気軽にご相談ください

記事に関するご質問や、AI・IT 技術導入のご相談など、お気軽にお問い合わせください。