Cloud Watch AlarmをトリガーにOUでアクセス制御してSNS Topic経由でLambda起動させる

タイトルの通り、Cloud Watch Alarm -> SNS Topic -> Lambdaという構成のAWSリソースを構築しました。

当初の構成ではCloud Watch AlarmからLambdaをOUで絞って呼び出して実行させる予定でしたが、諸々問題があってSNS Topicを追加して対応した流れになります。

自身の振り返りも含めて、類似構成をプライベートのAWSアカウントで作成してみましたので、動作検証の様子も含めて記事にまとめてみたいと思います。

注：私が担当したのは要件や全体的な構成図が出来上がった状態から、必要なAWSリソースの構築や不具合の対応等です。前提となるアプリケーション設計や背景などの詳細は記載しません。

## 要件

### 実現したい事

* Cloud Watchでアプリケーション用ロードバランサー(NLB)の死活監視をする
* ステータス変更をアラーム条件として検知した際に<b>Lambdaを起動して通知</b>をさせる
* アクセス制限方法として<b>OU(組織単位)による権限制御</b>をする

### サービス概要

<登場人物>

* EKS Pod (アプリケーション基盤) ※この記事では具体的に触れません
* AWS NLB (アプリケーション基盤のTCP通信を担う)
* Cloud Watch Alarm (NLBの死活監視・状態変更を検知して通知）
* SNS Topic (Cloud Watch Alarmの通知を検知してLambdaを起動する)
* Lambda (アプリケーションの再起動/停止時の通知処理を起動する)

■どのようなサービス?

　→アプリケーションの通知機能

■何に対する通知機能?

　→アプリケーションの再起動/停止時にLBの稼働ステータスを検知して通知する機能

■稼働ステータスの検知方法

　→Cloud Watch Alarmを利用する

■再起動/停止時の通知方法

　→Lambdaから通知処理を実行する

■アプリケーション用のLBについて

　→AWS NLBを利用
　→対象サービスの起動/停止と共に作成・削除される仕様

■Cloud Watch Alarmの仕様

　　→対象サービスの停止時は削除される為、<b>ARNが動的に変化する(固定の値を保持できない)</b>
　　→起動/停止を検知してアクションアイテムとして指定したSNS Topicに通知する

■SNS Topicについて

　　→Cloud Watch Alarmの通知を受け取りLambdaを起動する


■Lambdaについて

　　→対象アプリケーションの起動/停止をトリガーに通知処理を実行する
　　→作成したSNS Topicからの通知のみで起動する仕様

■OUについて

　　→AWSアカウントを組織単位でグループ化するサービス (Organizational Unitの略)
　　→aws:SourceOrgPathsというGlobal Condition Keyを用いてチェックする

## システム構成

以下の構成で実装できました

![スクリーンショット 2024-05-06 16.03.50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/29cd4786-fede-0710-496b-452dd6f7b696.png)


しかし、最初からこの構成が考案された訳ではなく、当初は以下の構成で実装予定でした

![スクリーンショット 2024-05-06 16.23.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/43bd0cfd-ecb1-edac-fb13-34407bf2aa61.png)


SNS Topicを間に挟まず、

Cloud Watch AlarmにLambda関数のARNを指定
Cloud Watch Alarmからの呼び出しを許可する
↓
LambdaにCloud Watch Logsへの書き込み権限を付与
↓
Cloud Watch Logsでイベントを検知するとLambdaに内容が書き込みされて処理が実行される

という構成を想定していました。が、残念ながら実現できませんでした。
(理由は次の項目で記載します)

## Cloud Watch Alarm -> Lambda構成の問題点

Cloud Watch AlarmにLambda関数のARNを指定
Cloud Watch Alarmからの呼び出しを許可する

上記の構成はごく一般的な設定です。そのまま採用したい所ですが、今回の仕様の中にARNが動的に変化する(固定の値を保持できない)という要件があるので、ARNを直接指定することができません。

そこでOUによるアクセス制限をかける方針となりましたが、ここで予期せぬ問題が発覚しました。

■権限設定が仕様的に不可

以下のような構成でアクセス制限をかけようとしました
Cloud Watch Alarm -> Lambda (`aws:PrincipalOrgID`を許可する)

しかし、class method様の記事の通りサービスプリンシパルでは使えないことがわかりました。
https://dev.classmethod.jp/articles/aws-principalorgid/

代わりに、`aws:SourceOrgPaths`を許可してはどうか試したところ、こちらも残念ながら不可でした。(AWSサポートの方に確認済み)
https://aws.amazon.com/jp/blogs/security/use-scalable-controls-for-aws-services-accessing-your-resources/

Lambdaリソースポリシーはaws:SourceOrgPathsをサポートしていないようです。
代わりの案としてご紹介いただいたのが、SNS Topicを経由してLambda起動を実行する構成です
https://docs.aws.amazon.com/ja_jp/AmazonCloudWatch/latest/monitoring/AlarmThatSendsEmail.html#alarms-and-actions

## サンプルリソースを実装してみる

プライベート環境でTerraformを使って実装してみましたので、実際に利用したサンプルコードを記載します

```
# LB
resource "aws_lb" "alarm_test_lb" {
  name               = "alarm-exec-nlb"
  internal           = true
  load_balancer_type = "network"
  subnets            = ["${var.subnet_id_1}", "${var.subnet_id_2}"]
  enable_deletion_protection = false
}

resource "aws_lb_target_group" "alarm_nlb_tg" {
  name     = "alarm-exec-nlb-tg"
  port     = 80
  protocol = "TCP"
  vpc_id   = var.vpc_id 
}

# cloudwatch alarm
resource "aws_cloudwatch_metric_alarm" "error_rate" {
  alarm_name                = "error-rate"
  comparison_operator       = "GreaterThanThreshold"
  evaluation_periods        = "1"
  metric_name               = "UnHealthyHostCount"
  namespace                 = "AWS/NetworkELB"
  period                    = "300"
  statistic                 = "Sum"
  threshold                 = "0"
  datapoints_to_alarm       = 1
  alarm_description         = "This metric monitors Error rate"
  insufficient_data_actions = []

  dimensions = {
    LoadBalancer = aws_lb.alarm_test_lb.id
  }

  alarm_actions = [aws_sns_topic.alarm_topic.arn]
}

# SNS Topic
resource "aws_sns_topic" "alarm_topic" {
  name = "cloudwatch-alarm-sns-topic"
}

resource "aws_sns_topic_policy" "alarm_topic_policy" {
  arn    = aws_sns_topic.alarm_topic.arn
  policy = data.aws_iam_policy_document.sns_topic_policy.json
}

resource "aws_sns_topic_subscription" "lambda_subscription" {
  topic_arn = aws_sns_topic.alarm_topic.arn
  protocol  = "lambda"
  endpoint  = aws_lambda_function.example_lambda.arn
}

data "aws_iam_policy_document" "sns_topic_policy" {
  statement {
    actions = [
      "SNS:Publish",
    ]

    effect    = "Allow"
    resources = [aws_sns_topic.alarm_topic.arn]

    condition {
      test     = "StringEquals"
      variable = "aws:SourceOrgID"
      values   = [var.ouid]
    }

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

# Lambda
resource "aws_lambda_function" "example_lambda" {
  filename         = "lambda_function_payload.zip"
  function_name    = "example_lambda_function"
  role             = aws_iam_role.lambda_exec.arn
  handler          = "index.handler"
  runtime          = "python3.8"

  source_code_hash = filebase64sha256("lambda_function_payload.zip")

  environment {
    variables = {
      HELLO = "World"
    }
  }
}

resource "aws_lambda_permission" "allow_sns" {
  statement_id  = "AllowExecutionFromSNS"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.example_lambda.arn
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.alarm_topic.arn
}

# IAM Role for Lambda
resource "aws_iam_role" "lambda_exec" {
  name = "lambda_execution_role"

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

resource "aws_iam_policy" "lambda_logs" {
  name        = "lambda_logging_policy"
  description = "IAM policy for logging from a lambda"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = [
          "logs:CreateLogGroup",
          "logs:CreateLogStream",
          "logs:PutLogEvents"
        ],
        Effect   = "Allow",
        Resource = "arn:aws:logs:*:*:*"
      },
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_logs" {
  role       = aws_iam_role.lambda_exec.name
  policy_arn = aws_iam_policy.lambda_logs.arn
}

```

作成したリソースについて解説します
※NLBはあくまで動作確認用の為割愛します

### Cloud Watch Alarm

![スクリーンショット 2024-04-30 18.29.51.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/365eadf0-5c18-02d3-18ee-137292b40f30.png)

* メトリクスでUnHealthyHostCountを指定(ステータス異常となったホスト数を検知する)
* threshold:アラートがトリガーされる閾値。0とすることで、1でも異常を検知したらアラート状態にする
* GreaterThanThreshold：メトリクス値が閾値を超えた場合にアラームを発火させる
* evaluation_periods：アラームの評価期間 (1の場合、1回のデータポイントでアラームが評価される)
* datapoints_to_alarm：アラームを発火させるために必要なデータポイントの数


### SNS Topic

![スクリーンショット 2024-04-30 18.47.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/cf632c08-fa7e-f7e7-bc60-13e22e798ff6.png)

![スクリーンショット 2024-04-30 18.56.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/334ce81a-99a3-67aa-d03d-cc811f02a4bc.png)



* SNS Topicを構築
* サブスクリプションのプロトコル・エンドポイントでLambdaを指定
* IAM PolicyでCloud Wath AlarmにSNS TopicへのPublish(メッセージ公開)する権限を付与
* conditionでaws:SourceOrgIDと一致する場合のみ(指定したAWSアカウントのみ)ポリシーを適用するよう設定

### Lambda

![スクリーンショット 2024-04-30 19.01.13.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/919c1676-2459-f883-f3a3-97d1ad47d1cf.png)

* Lambda実行用のIAM roleをアタッチ
* Lambda関数の実行を許可
* 許可されるLambda関数のARNを指定
* permisson設定でSNS Topicを許可設定
* Lambda関数のログをCloud Watch Logsに書き込める権限(ロググループ、ログストリーム、ログイベント等)を付与

## 動作確認してみる

コマンドから対象のLBにUnHealthyHostCountを擬似的に追加してみて、アラームを発火させてみます

```
[cloudshell-user@ip-**** ~]$ aws cloudwatch put-metric-data --namespace AWS/NetworkELB --metric-name UnHealthyHostCount --dimensions LoadBalancer=arn:aws:elasticloadbalncing:ap-northeast-1:****:loadbalancer/net/alarm-exec-nlb/**** --value 1 --unit Count
```

![スクリーンショット 2024-04-30 19.27.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/45404b96-8458-7e9d-ddcd-0539f956ec1c.png)

アラーム状態となりました

次に、Lambdaが実行されたか確認してみます

![スクリーンショット 2024-04-30 19.29.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/df6ba357-5a5e-0b7c-6b8b-7dccfc7cfa31.png)

メトリクスから実行が確認できました

続けて、Lambdaの実行ログを確認します
ログイベントは以下のようになっていました

![スクリーンショット 2024-04-30 19.31.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2985033/93ca24ed-d060-68ff-cee6-23ebdfa11175.png)

Helle World! を出力するだけの関数ですが、正常に動作していることが確認できました。

---
以上、Cloud Watch AlarmをトリガーにOUでアクセス制御してSNS Topic経由でLambda起動させる事例の紹介でした。

似たようなアーキテクチャを構築する際や、OUでアクセス制御してLambdaを起動したいケース等で、何かの参考になりましたら幸いです。