TerraformでMonitoring As Code   ~メトリクスアラーム設定編(slack・電話通知)~

# はじめに
こんにちわ。Wano株式会社エンジニアのnariと申します。

最近、**TerraformやLambda(Go)などを使ったMonitoring As Code**の話を記事にすることが多くなってきました。

今までは、リソースのステータスチェックシステム周りの話が多かったのですが、今回は**メトリクスアラーム設定周りのIaC**に関して記事にしていこうかなと思います。

### 過去のMonitoring As Code系記事
- [ApexとTerraformとGoでAWS上に構築したCD Pipelineのステータスをslackに通知 - Qiita](https://qiita.com/fukubaka0825/items/c50ba6cac3510fa5ea9a)
- [AWS AutoScalingで増減したEC2インスタンスに、動的にCloudWatchのAlarmをLambda(Go)で設定する - Qiita](https://qiita.com/fukubaka0825/items/c3b84320aade3e991d6d)
- [AWS CloudWatch Agent導入+LoadAverage送信cronをTerraformとuserDataで設定して、デフォルトのEC2メトリクスを補填する - Qiita](https://qiita.com/fukubaka0825/items/3f7a9ea103fcbb9e8872)

# システム全体像
<img width="797" alt="スクリーンショット 2019-08-12 22.21.19.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/321850/d238c14c-df38-dad7-341b-df56bf2db6d8.png">

# 何故Monitoring As Codeしたいか
- **同じようなアラーム設定を手動で繰り返しやりたくない**
- **異なる環境で設定を使いまわしたい**

# Terraformのソース
## module(metrics alarm)
- **一つのSNS Topicが複数アラートと、複数通知先(slack,電話etc)を持てる**ように設計した
- 今回の場合は、通知先の前に必ずlambdaが挟まる想定で作っている

```main.tf
//登録通知先が、全てlambdaの場合のみ使用可能なmodule
/* CloudWatch */
resource "aws_cloudwatch_metric_alarm" "this" {
  count = length(var.cwa_objs)
  /* required */
  alarm_actions       = [aws_sns_topic.this.arn]
  ok_actions          = [aws_sns_topic.this.arn]
  alarm_name          = var.cwa_objs[count.index]["alarm_name"]
  comparison_operator = var.cwa_objs[count.index]["comparison_operator"]
  evaluation_periods  = var.cwa_objs[count.index]["evaluation_periods"]
  threshold           = var.cwa_objs[count.index]["threshold"]
  metric_name         = var.cwa_objs[count.index]["metric_name"]
  namespace           = var.cwa_objs[count.index]["namespace"]
  period              = var.cwa_objs[count.index]["period"]
  statistic           = var.cwa_objs[count.index]["statistic"]
  alarm_description   = var.cwa_objs[count.index]["alarm_description"]
  dimensions          = var.cwa_objs[count.index]["dimensions"]

  /* optional */
  tags = var.cwa_objs[count.index]["cw_tags"]
}


/* SNS */
resource "aws_sns_topic" "this" {
  /* required */
  name = var.sns_name
  /* optional */
  display_name = var.display_name
  policy       = var.policy
  tags         = var.sns_tags
}

resource "aws_sns_topic_subscription" "notify_to_slack" {
  count = length(var.sns_topic_subscription_objs)
  /* required */
  topic_arn = aws_sns_topic.this.arn
  endpoint  = var.sns_topic_subscription_objs[count.index]["endpoint"]

  /* optional */
  raw_message_delivery = var.sns_topic_subscription_objs[count.index]["raw_message_delivery"]
  protocol             = "lambda"
}

/* Lambda permision */
resource "aws_lambda_permission" "default" {
  count         = length(var.sns_topic_subscription_objs)
  statement_id  = var.sns_topic_subscription_objs[count.index]["statement_id"]
  action        = "lambda:InvokeFunction"
  function_name = var.sns_topic_subscription_objs[count.index]["endpoint"]
  principal     = "sns.amazonaws.com"
  source_arn    = aws_sns_topic.this.arn
}


```

```variables.tf
/* CloudWatch */
/* required */
variable "cwa_objs" {
  description = "通知したいアラームをここに詰める"
  type = list(object({
    alarm_name          = string
    comparison_operator = string
    evaluation_periods  = number
    threshold           = number
    alarm_description   = string
    metric_name         = string
    namespace           = string
    period              = string
    statistic           = string
    dimensions          = map(string)
    cw_tags             = map(string)
  }))
}


/* SNS */
/* required */
variable "sns_name" {
  description = "The name of the SNS topic to create"
  type        = string
}

variable "sns_topic_subscription_objs" {
  description = "登録したい通知先内容をここに詰める(lambda限定)"
  type = list(object({
    endpoint             = string
    raw_message_delivery = bool
    statement_id         = string
    })
  )
}

/* optional */
variable "create_sns_topic" {
  description = "Whether to create the SNS topic"
  type        = bool
  default     = true
}

variable "display_name" {
  description = "The display name for the SNS topic"
  type        = string
  default     = null
}

variable "policy" {
  description = "The fully-formed AWS policy as JSON"
  type        = string
  default     = null
}

variable "sns_tags" {
  description = "A mapping of tags to assign to all resources"
  type        = map(string)
  default     = {}
}

```

## moduleを使う側
- moduleを利用して、アラートと通知先の配列にひたすら設定したものを詰める

```main.tf
/* metrics_alarm_to_slack 　*/
module "laboon_metrics_alarm_to_slack" {
  source   = "../../../../modules/common/metric_alarm"
  cwa_objs = local.metrics_alarm_to_slack_objs

  sns_name = "laboon_stage_metrics_alert_to_slack"
  sns_topic_subscription_objs = [
    //この配列に通知先lambdaを追加する
    {
      endpoint             = var.apex_function_laboon_stage_metrics_alarm_to_slack
      raw_message_delivery = false
      statement_id         = "laboon_stage_metrics_alert_to_slack"
    }
  ]
}

/* metrics_alarm_to_call 　*/
module "laboon_metrics_alarm_to_call" {
  source   = "../../../../modules/common/metric_alarm"
  cwa_objs = local.metrics_alarm_to_call_objs
  sns_name = "hoge_metrics_alert_to_call"
  sns_topic_subscription_objs = [
    //この配列に通知先lambdaを追加する
    {
      endpoint             = var.apex_function_hoge_metrics_alarm_to_call
      raw_message_delivery = false
      statement_id         = "hoge_metrics_alert_to_call"
    },
    {
      endpoint             = var.apex_function_hoge_metrics_alarm_to_slack
      raw_message_delivery = false
      statement_id         = "hoge_metrics_alert_to_slack_with_call"
    }
  ]
}

  /* metrics_alarm_to_slack required*/
  metrics_alarm_to_slack_objs = [
    //この配列にslackに通知したいメトリクス(not critical)を追加する
    //LAMBDA
    {
      alarm_name          = "start_execution(stepfunctions)lambdaの死"
      comparison_operator = "GreaterThanThreshold"
      evaluation_periods  = "1"
      metric_name         = "Errors"
      namespace           = "AWS/Lambda"
      period              = "300"
      statistic           = "Average"
      threshold           = "0.0"
      alarm_description   = "start_execution(stepfunctions)lambdaの死"
      dimensions = {
        FunctionName = "hoge_start_execution"
        Resource     = "hogee_start_execution:current"
      }
      cw_tags = {}
    },
    //ECS/ContainerInsights
    {
      alarm_name          = "CpuUtilized-for-hoge-ecs-task"
      comparison_operator = "GreaterThanThreshold"
      evaluation_periods  = "2"
      metric_name         = "CpuUtilized"
      namespace           = "ECS/ContainerInsights"
      period              = "60"
      statistic           = "Average"
      threshold           = "60"
      alarm_description   = "CpuUtilized-for-hoge-ecs-task"
      dimensions = {
        TaskDefinitionFamily = "hoge-ad-fluentd-ecs"
        ClusterName          = "hoge-ad-cluster"
      }
      cw_tags = {}
    },
     ...

  /* metrics_alarm_to_call required*/
  metrics_alarm_to_call_objs = [
    //この配列に電話に通知したいメトリクス(critical)を追加する
    //ELB
    {
      alarm_name          = "hoge-ad500Error>0"
      comparison_operator = "GreaterThanThreshold"
      evaluation_periods  = "1"
      metric_name         = "HTTPCode_Target_5XX_Count"
      namespace           = "AWS/ApplicationELB"
      period              = "300"
      statistic           = "Average"
      threshold           = "0.0"
      alarm_description   = "hoge-ad500Error>0"
      dimensions = {
        TargetGroup  = "targetgroup/hoge-lb-target-group/xxxxxxxxxxxxxxxxxxx"
        LoadBalancer = "app/hoge-aws-lb/xxxxxxxxxxxxxxxxxxx"
      }
      cw_tags = {}
    },
    ...
```

# Lambda(Go)のソース
## slackに通知

- アラートの場合とOKの場合で内容を変える

```main.go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"os"
    . . .
)

type SNSMessage struct {
	AlarmName      string
	NewStateValue  string
	NewStateReason string
}

func main() {
	lambda.Start(metricsAlarmToSlackHandler)
}

func metricsAlarmToSlackHandler(context context.Context, event events.SNSEvent) (e error) {
	webhookURL := os.Getenv("webHookUrl")
	channel := os.Getenv("slackChannel")

	snsMessage := &SNSMessage{}
	err := json.Unmarshal([]byte(event.Records[0].SNS.Message), snsMessage)
	if err != nil {
		log.Error(err)
		return err
	}
	if snsMessage.NewStateValue == "ALARM" {
		title := snsMessage.AlarmName
		slackIcon := ":sweat:"
		slackName := "hogeのモニタリング:warning"
		cwURL := fmt.Sprintf("https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#alarmsV2:alarm/%v", title)
		text := fmt.Sprintf(":cloudwatch: *WARNING* :cloudwatch: \n アラーム:%v\n 理由:%v \n url:%v", title, snsMessage.NewStateReason, cwURL)
		messageLevel := slack_reporter.SLACK_MESSAGE_LEVEL_ALART
		err = slack_reporter.ReportToSlack(webhookURL, channel, slackName, slackIcon, title, text, messageLevel)
		if err != nil {
			log.Error(err)
			return err
		}
		return nil
	}

	title := snsMessage.AlarmName
	slackIcon := ":nick:"
	slackName := "hogeのモニタリング:ok"
	cwURL := fmt.Sprintf("https://ap-northeast-1.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-1#alarmsV2:alarm/%v", title)
	text := fmt.Sprintf(":cloudwatch: *OK* :cloudwatch: \n アラーム:%v\n 理由:%v \n url:%v", title, snsMessage.NewStateReason, cwURL)
	messageLevel := slack_reporter.SLACK_MESSAGE_LEVEL_OK
	err = slack_reporter.ReportToSlack(webhookURL, channel, slackName, slackIcon, title, text, messageLevel)
	if err != nil {
		log.Error(err)
		return err
	}
	return nil
}

```

## 電話で通知(amazon connect)
- Amazon Connectの設定は[こちら](https://dev.classmethod.jp/cloud/aws/inbound-call-using-amazon-connect/)がわかりやすい こちらの設定はTerraform対応がないので、手動でやる
- アラートの場合のみ電話をかける

```main.go
package main

import (
	"context"
	"encoding/json"
	"fmt"
	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/connect"
    ...
)

type SNSMessage struct {
	AlarmName      string
	NewStateValue  string
	NewStateReason string
}

func main() {
	lambda.Start(metricsAlarmToCallHandler)
}

func metricsAlarmToCallHandler(context context.Context, event events.SNSEvent) (e error) {
	snsMessage := &SNSMessage{}
	err := json.Unmarshal([]byte(event.Records[0].SNS.Message), snsMessage)
	if err != nil {
		log.Error(err)
		return err
	}

	if snsMessage.NewStateValue != "ALARM" {
		return nil
	}

	cnt := connect.New(session.New(), &aws.Config{Region: aws.String("ap-northeast-1")})
	destinationPhoneNumber := "+81xxxxxxxxxxxx"
	contactFlowID := "2877axxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
	instanceID := "29f67xxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
	sourcePhoneNumber := "+81xxxxxxxxxx"
	attributes := map[string]*string{}
	message := fmt.Sprintf("hoge 本番環境にて障害が発生しました。直ちに対応してください。エラー内容は、%v", snsMessage.AlarmName)
	attributes["message"] = &message

	input := &connect.StartOutboundVoiceContactInput{
		DestinationPhoneNumber: &destinationPhoneNumber,
		ContactFlowId:          &contactFlowID,
		InstanceId:             &instanceID,
		SourcePhoneNumber:      &sourcePhoneNumber,
		Attributes:             attributes,
	}
	_, err = cnt.StartOutboundVoiceContact(input)
	if err != nil {
		log.Error(err)
		return err
	}
	return nil
}
```

# 終わりに
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">まだ入門監視は全部吸収できていないので、まず要件を満たすものをざっと作ってしまい、後でしっかり読んでその観点からリファクタをproposalする(リソースメトリクスでガンガンアラートながしてしまったりしているので)</p>&mdash; nari@技術書典7📖 GoとAWSで作る本格slackbot (@fukubaka0825) <a href="https://twitter.com/fukubaka0825/status/1159765834606104576?ref_src=twsrc%5Etfw">August 9, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

今回はメトリクスアラーム周りのIaCの話を記事にしました。  

最近ここら辺のタスクを業務で扱うことが出てきたので、**そもそものクリティカルとそうでないものの区別や、リソースメトリクスのアラーム設定是非**などに関してまだまだ考えがまとまっていないため、今回はとりあえず要件を満たす形で実装しました。 (ちょっとチェックボックス監視みがある。。)

これから運用していく中で、[入門監視](https://www.amazon.co.jp/%E5%85%A5%E9%96%80-%E7%9B%A3%E8%A6%96-%E2%80%95%E3%83%A2%E3%83%80%E3%83%B3%E3%81%AA%E3%83%A2%E3%83%8B%E3%82%BF%E3%83%AA%E3%83%B3%E3%82%B0%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E3%83%87%E3%82%B6%E3%82%A4%E3%83%B3%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3-Mike-Julian/dp/4873118646/ref=sr_1_1?adgrpid=51573772977&gclid=EAIaIQobChMIxrD9qbj94wIVknZgCh3igAqmEAAYASAAEgIxSPD_BwE&hvadid=338526654369&hvdev=c&hvlocphy=1009308&hvnetw=g&hvpos=1t1&hvqmt=e&hvrand=3602768270125716480&hvtargid=kwd-596525862201&hydadcr=15814_11177315&jp-ad-ap=0&keywords=%E5%85%A5%E9%96%80%E7%9B%A3%E8%A6%96&qid=1565616603&s=gateway&sr=8-1)などから得た観点を整理して、リファクタしていこうと思っています(ここら辺もまた記事にします)

また、電話通知に関しては、**エスカレーションや電話に出れなかった場合のリトライの実装**などができていないため、対応ができ次第そこらへんも記事にしていこうかなと思います。

それにしても**監視は面白い**。これからもここら辺のタスクはガンガンやっていこうと思います(**ビジネスKPI,SLOの設定絡みのとこもどんどんやりたい**)