👾

Amazon CloudWatch Alarmでアラーム発生時にSNSからLambdaを起動してみる

2023/10/04に公開

はじめに

最近仕事でよくCloudWatch Alarmでアラーム発生時に〇〇するみたいなことをしていたので手順をまとめようと思います。

概要

今回は適当に作ったEC2インスタンスのメトリクスでアラームを設定してSNS topic経由でLambdaを実行してみようと思います。

まずは実際にコンソール上で作成しその後Terraform化してみたいと思います。

AWS Lambdaの設定

まずは最終的に実行されるLambda関数を作成します。

今回は関数名を「alarm-test」とし、ランタイムは「python3.11」を選択して「関数の作成」をクリックします。

関数が作成されました。今回は呼び出されることを確認したいだけなので最初から入力済みのコードをそのまま使います。

次にLambdaの呼び出し元となるSNSを作成します。

Amazon SNSの設定

「トピックの作成」をクリックします。

タイプ「スタンダード」を選択し、名前を「alarm-test」として「トピックの作成」をクリックします。

トピックが作成できました。次にサブスクリプションを作成します。

プロトコルで「AWS Lambda」を選択します。

エンドポイントには先ほど作成したLambdaのARNを入力します。「サブスクリプションの作成」をクリックします。

サブスクリプションが作成されました。

今回はトピックに対してLambdaのサブスクリプションだけを作成しましたがそのほかにも
プロトコルで「Eメール」を選んでメールで通知したり「HTTPS」を選んで外部サービスのエンドポイントを呼び出すことも可能です。

これで今回用のSNSができたので次にCloudWatch Alarmの設定を行なっていきます。

Amazon CloudWatch Alarmの設定

「アラームの作成」をクリックします。

「メトリクスの選択」をクリックします。

今回はEC2のメトリクスを対象とするので「EC2」をクリックします。

「インスタンス別メトリクス」をクリックします。

インスタンス毎のメトリクスが一覧で表示されるます。

今回はEC2のCPUの使用率を監視するので「CPUUtilization」を選択して「メトリクスの選択」をクリックします。

「メトリクスと条件の指定」画面に切り替わります。

今回はCPUの使用率が1分間の間の最大値が80よりも大きい値があった場合にアラームを実行するように設定します。
「メトリクス名」を「CPUUtilization」にします。
「統計」を「最大」にします。
期間を「1分」にします。
条件は「静的」、「より大きい」、閾値は「80」を設定して「次へ」をクリックします。

次に「アクションの設定」 の画面になります。

「アラーム状態のトリガー」では「アラーム状態」を選びます。
SNSトピックの選択では先ほど作成したSNSを指定したいので「既存のトピックを選択」を選び、
「通知の送信先」で先ほど作成したトピックの「alarm-test」を指定します。
「次へ」をクリックします。

「名前と説明を追加」画面になるので「アラーム名」を入力して「次へ」をクリックします。

「プレビューと作成」画面になるので内容を確認して「アラームの作成」をクリックします。

アラームが作成されました。

これで一通り設定できたので動作を確認してみます。

動作確認

既存のEC2インスタンスに接続してCPUに負荷を加えるコマンドを実行してみます。

しばらく待っているとアラーム状態になりました。

Lambdaのログを見てみると実行されていることがわかりました。

これで正しく動作していることが確認できました。
次は今作った内容をTerraform化していきます。

Terraform化

ざっくりこんな構成で作成してみたいと思います。

├── main.tf
├── modules
│   ├── alarm
│   │   ├── alarm.tf
│   │   ├── function.tf
│   │   ├── functions
│   │   │   └── sample
│   │   │       └── main.py
│   │   ├── logs.tf
│   │   ├── permission.tf
│   │   ├── provider.tf
│   │   ├── role.tf
│   │   ├── topic.tf
│   │   └── variables.tf
│   └── target
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
└── variables.tf

modules/targetはアラームの対象となるEC2を作成します。
それに対してmodules/alarmでアラームのリソースを作成していきます。

リポジトリはこちらです。
https://github.com/Mo3g4u/terraform-alart-sample

targetモジュールの作成

先ほどの手順にはなかったですが、stressコマンドをインストール済みのEC2インスタンスを用意してみます。

modules/target/variables.tf
variable "project" {
  description = "project name"
  type        = string
}
variable "env" {
  description = "environment type"
  type        = string
}
variable "security_group_ids" {
  description = "security group ids"
  type        = list(string)
}
variable "subnet_id" {
  description = "subnet id"
  type        = string
}

ここはSSMで接続できてstressコマンドがあればいいのでざっくり設定します。

modules/target/main.tf
# -------------------------------------
# Terraform configuration
# -------------------------------------
terraform {
  required_version = ">= 0.14.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.17"
    }
  }
}

# ******************************
# EC2 Instance - テスト用
# ******************************
data "aws_ssm_parameter" "amzn_ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"

}

resource "aws_instance" "main" {
  ami                    = data.aws_ssm_parameter.amzn_ami.value
  instance_type          = "t2.micro"
  vpc_security_group_ids = var.security_group_ids
  subnet_id              = var.subnet_id
  iam_instance_profile   = aws_iam_instance_profile.main.name

  # EBSのルートボリューム設定
  root_block_device {
    # ボリュームサイズ(GiB)
    volume_size = 8
    # ボリュームタイプ
    volume_type = "gp3"
    # EBSのNameタグ
    tags = {
      Name = "${var.project}-${var.env}-ebs-test"
    }
  }

  tags = {
    Name = "${var.project}-${var.env}-ec2-test"
  }
}

resource "aws_ssm_association" "main" {
  association_name = "${var.project}-${var.env}-association-test"
  name             = "AWS-RunShellScript"

  targets {
    key    = "InstanceIds"
    values = [aws_instance.main.id]
  }

  parameters = {
    "commands" = <<EOF
sudo yum install -y stress
EOF
  }
}

# ******************************
# IAM Role
# ******************************
resource "aws_iam_role" "main" {
  name = "${var.project}-${var.env}-role-ec2-test"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Sid    = ""
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      },
    ]
  })
  path = "/"
}

resource "aws_iam_policy_attachment" "main" {
  name       = "${var.project}-${var.env}-policy-attachment-ec2-test"
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  roles      = [aws_iam_role.main.name]

}

# ******************************
# IAM InstanceProfile
# ******************************
resource "aws_iam_instance_profile" "main" {
  name = "${var.project}-${var.env}-instance-profile"

  role = aws_iam_role.main.name
}

alarmモジュール側で使うのでインスタンスのidを出力しておきます。

modules/target/outputs.tf
output "ec2_instance_id" {
  value = aws_instance.main.id
}

ssm接続できてstressコマンドが打てればいいだけなのでこんな感じです。

alarmモジュールの設定

modules/alarm/provider.tf
terraform {
  required_providers {
    aws = {
      source = "hashicorp/aws"
      version = "~> 5.17"
    }
  }
}

インスタンスのIDを受け取るので変数を用意しておきます。

modules/alarm/variables.tf
variable "project" {
  description = "project name"
  type        = string
}
variable "env" {
  description = "environment type"
  type        = string
}

variable "ec2_instance_id" {
  type = string
}

Lambda関連

lambdaのログを出力するCloudWatchのロググループの作成

modules/alarm/logs.tf
resource "aws_cloudwatch_log_group" "sample" {
  name              = "/aws/lambda/${var.project}-${var.env}-sample"
  retention_in_days = 7
}

lambdaのロールの作成

modules/alarm/role.tf
data "aws_caller_identity" "current" {}

resource "aws_iam_role" "lambda_sample" {
  name               = "${var.project}-${var.env}-lambda-sample-role"
  assume_role_policy = data.aws_iam_policy_document.assume_lambda.json

  inline_policy {
    name   = "${var.project}-${var.env}-sample"
    policy = data.aws_iam_policy_document.sample.json
  }
}

data "aws_iam_policy_document" "sample" {
  statement {
    effect = "Allow"
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents"
    ]
    resources = [
      "${aws_cloudwatch_log_group.sample.arn}:*",
    ]
  }
}

data "aws_iam_policy_document" "assume_lambda" {
  statement {
    actions = [
      "sts:AssumeRole"
    ]

    principals {
      type        = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

lambda function本体を配置

modules/alarm/functions/sample/main.py
import json

def lambda_handler(event, context):
    # TODO implement
    return {
        'statusCode': 200,
        'body': json.dumps('Hello from Lambda!')
    }

lambdaリソースの作成

modules/alarm/function.tf
data "archive_file" "sample" {
  type        = "zip"
  source_dir  = "${path.module}/functions/sample"
  output_path = "${path.module}/outputs/sample.zip"
}

resource "aws_lambda_function" "sample" {
  depends_on = [
    aws_cloudwatch_log_group.sample
  ]

  function_name    = "${var.project}-${var.env}-sample"
  runtime          = "python3.11"
  handler          = "main.lambda_handler"
  filename         = data.archive_file.sample.output_path
  source_code_hash = data.archive_file.sample.output_base64sha256
  role             = aws_iam_role.lambda_sample.arn
}

lambdaのリソースベースポリシー作成

管理画面から実行すると自動で作成されてしまって気づかないのですが、
外部リソース(SNS)からLambda関数へのアクセス権限を与える必要があります。

modules/alarm/permission.tf
resource "aws_lambda_permission" "sample" {
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.sample.function_name
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.sample.arn
}

SNS topicとsubscriptionの作成

modules/alarm/topic.tf
resource "aws_sns_topic" "sample" {
  name = "${var.project}-${var.env}-sample"
}

resource "aws_sns_topic_subscription" "sample" {
  topic_arn = aws_sns_topic.sample.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.sample.arn
}

CloudWatch Alarm alarmの作成

ここのdimensionsの書き方を間違っててしばらく悩みました・・・。

modules/alarm/alarm.tf
resource "aws_cloudwatch_metric_alarm" "sample" {
  alarm_name         = "${var.project}-${var.env}-AlarmSample"
  alarm_description  = "CPUの負荷をみてLambdaを実行します。"
  evaluation_periods = 1
  namespace          = "AWS/EC2"
  metric_name        = "CPUUtilization"
  dimensions = {
    InstanceId = var.ec2_instance_id
  }
  period              = 60
  statistic           = "Maximum"
  threshold           = 80
  comparison_operator = "GreaterThanThreshold"
  alarm_actions       = [aws_sns_topic.sample.arn]
}

main.tfなど

main.tf
# -------------------------------------
# Terraform configuration
# -------------------------------------
terraform {
  required_version = ">= 0.14.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.17"
    }
  }
}

# -------------------------------------
# Provider configuration
# -------------------------------------
provider "aws" {
  region = var.region
  default_tags {
    tags = {
      project    = var.project
      env        = var.env
      managed_by = "terraform"
    }
  }
}


# -------------------------------------
# Sample EC2
# -------------------------------------
module "target" {
  source = "./modules/target"

  project            = var.project
  env                = var.env
  security_group_ids = var.security_group_ids
  subnet_id          = var.subnet_id
}

# -------------------------------------
# Sample Alart
# -------------------------------------
module "alarm" {
  source = "./modules/alarm"

  project         = var.project
  env             = var.env
  ec2_instance_id = module.target.ec2_instance_id
}
variables.tf
variable "region" {
  description = "AWS region"
  default     = "ap-northeast-1"
  type        = string
}

variable "project" {
  description = "project name"
  default     = "terraform-alart-sample"
  type        = string
}

variable "env" {
  description = "environment type"
  default     = "dev"
  type        = string
}

variable "security_group_ids" {
  description = "security group ids"
  type        = list(string)
}

variable "subnet_id" {
  description = "subnet id"
  type        = string
}

ディレクトリ構成や名称などかなり適当ではありますが一旦できたので試してみたいと思います。

実行してみる

セキュリティグループIDとサブネットIDは変数で渡すようにしているので以下のようにplanを実行します。

terraform plan -var 'security_group_ids=[ "xxxxxxxxxxx" ]' -var 'subnet_id=xxxxxxxxxxxxxxxxx' 

内容に問題がなければapplyします

terraform plan -var 'security_group_ids=[ "xxxxxxxxxxx" ]' -var 'subnet_id=xxxxxxxxxxxxxxxxx' 

applyが完了したらEC2インスタンスに接続してstressコマンドを打ってしばらく待ちます。

検知されました!

Lambdaも実行されました!

まとめ

今回は管理画面上でアラームを設定してからTerraform化を行なってみました。
画面上でポチポチやってる時に気にしていなかったリソースについてもTerraformでは気にする必要があるので良い勉強になりました。
監視対象など違うのでこのTerraformはそのまま使えないですが参考にでもなれば幸いです。

おまけ

実はTerraformでpython3.11を使おうとして以下のメッセージがでていました。

% terraform validate
╷
│ Error: expected runtime to be one of [nodejs nodejs4.3 nodejs6.10 nodejs8.10 nodejs10.x nodejs12.x nodejs14.x nodejs16.x java8 java8.al2 java11 python2.7 python3.6 python3.7 python3.8 python3.9 dotnetcore1.0 dotnetcore2.0 dotnetcore2.1 dotnetcore3.1 dotnet6 nodejs4.3-edge go1.x ruby2.5 ruby2.7 provided provided.al2 nodejs18.x python3.10 java17 ruby3.2], got python3.11
│ 
│   with module.alarm.aws_lambda_function.sample,
│   on modules/alarm/function.tf line 13, in resource "aws_lambda_function" "sample":13:   runtime          = "python3.11"
│ 
╵

いつもお世話になっているブログを参考に修正しました 🙇‍♂️
正式リリースになった AWS SAM CLI の Terraform サポート機能を試す

レスキューナウテックブログ

Discussion