goformationで始めるCloudFormation (2) Cloudwatch Alarm/SNS/Lambda

この記事は [Wanoグループアドベントカレンダー2018](https://qiita.com/advent-calendar/2018/wano-group)の 9日目の記事になります。
[前回](https://qiita.com/NaotoFushimi/items/8debaf49ec3c44c977ef)に続き、[goformation](https://github.com/awslabs/goformation)を使ってCloudFormationのコードを生成していきます。

## SQSの監視
前回はSQSのキューとデッドレターキューを作成するとこまでやりました。
今回は、

* デッドレターキューにメッセージが入ったらSNS経由でLambdaを起動する
* デッドレターキューの放置期間が一定期間を過ぎたらSNS経由でLambdaを起動する

というアラート項目の設定を記述してみます。

## コード

```go

package main

import (
	"flag"
	"fmt"
	cfn "github.com/awslabs/goformation/cloudformation"
	"github.com/labstack/gommon/log"
	"io/ioutil"
	"os"
)

var ENV string

func main() {

	flag.StringVar(&ENV, "env", "dev", "env指定")
	flag.Parse()

	template := cfn.NewTemplate()
	template.Description = fmt.Sprintf(`example1アプリの %s 環境です` , ENV)

	pwd  , _ := os.Getwd()

	resources := NewResourceNames(ENV)

	const MESSEGE_RETENSION_PERIOD_14DAYS = 1209600
	template.Resources[LOGICAL_ID_SQS_DEAD_LETTER_QUEUE.String()] = cfn.AWSSQSQueue{
		FifoQueue: true,
		QueueName: resources.DeadLetterQueueName,
		MessageRetentionPeriod: MESSEGE_RETENSION_PERIOD_14DAYS,
	}

	const TIMEOUT_SEC_5 = 1 * 5
	template.Resources[LOGICAL_ID_SQS_FIFO_QUEUE.String()] = cfn.AWSSQSQueue{
		FifoQueue: true,
		QueueName: resources.FifoQueueName,
		DelaySeconds: 3,
		RedrivePolicy: map[string]interface{}{
			"deadLetterTargetArn": cfn.GetAtt(LOGICAL_ID_SQS_DEAD_LETTER_QUEUE.String(), "Arn"),
			"maxReceiveCount":     1,
		},
		VisibilityTimeout:      TIMEOUT_SEC_5,
		MessageRetentionPeriod: MESSEGE_RETENSION_PERIOD_14DAYS,
	}

	template.Resources[LOGICAL_ID_SNS_ALARM_TOPIC_COMMON.String()] = cfn.AWSSNSTopic{
		TopicName: resources.AlarmSnsTopicCommon,
		Subscription: []cfn.AWSSNSTopic_Subscription{
			cfn.AWSSNSTopic_Subscription{
				Protocol:`lambda`,
				Endpoint: cfn.GetAtt(LOGICAL_ID_LAMBDA_SNS_ALARM_TO_SLACK.String() , `Arn`) ,
			},
		},

	}

	template.Resources[LOGICAL_ID_PERMISSION_LAMBDA_SNS_EVENT.String()] = cfn.AWSLambdaPermission{
		Action: `lambda:InvokeFunction`,
		FunctionName:cfn.GetAtt(LOGICAL_ID_LAMBDA_SNS_ALARM_TO_SLACK.String() , `Arn`),
		Principal:`sns.amazonaws.com`,
		SourceArn:cfn.Ref(LOGICAL_ID_SNS_ALARM_TOPIC_COMMON.String()),
	}

	template.Resources[LOGICAL_ID_LAMBDA_SNS_ALARM_TO_SLACK.String()] = cfn.AWSLambdaFunction{
		Handler:     `index.handler`,
		Runtime:     `nodejs8.10`,
		MemorySize:  256,
		Timeout:     30,
		Code:&cfn.AWSLambdaFunction_Code{
			ZipFile:  func() string{
				lambdaFunctionJs , err := readJs(pwd + `/app/example1/lambda/send_to_slack.js`)
				if err != nil {
					panic(err)
				}
				return lambdaFunctionJs
			}(),
		},
		Role: GetExitedResources().ServerlessRole,
	}


	template.Resources[LOGICAL_ID_CLOUDWATCH_ALARM_DEAD_LETTER_DETECTED.String()] = cfn.AWSCloudWatchAlarm{
		ActionsEnabled: true,
		AlarmName: resources.DeadLetterDetected,
		MetricName:"ApproximateNumberOfMessagesVisible",
		Namespace:`AWS/SQS`,
		Threshold: 0.001,
		Dimensions:[]cfn.AWSCloudWatchAlarm_Dimension{
			cfn.AWSCloudWatchAlarm_Dimension{
				Name : `QueueName`,
				Value: resources.DeadLetterQueueName,
			},
		},
		TreatMissingData: `ignore`,
		EvaluationPeriods :1,
		Period:60,
		ComparisonOperator: "GreaterThanOrEqualToThreshold",
		AlarmActions:[]string{
			cfn.Ref(LOGICAL_ID_SNS_ALARM_TOPIC_COMMON.String()),
		},
		OKActions: []string{
			cfn.Ref(LOGICAL_ID_SNS_ALARM_TOPIC_COMMON.String()),
		},
		Statistic:`Minimum`,
	}

	OLDEST_10_DAYS_SEC := 1 * 60 * 60 * 24 * 10
	template.Resources[LOGICAL_ID_CLOUDWATCH_ALARM_DEAD_LETTER_OLDEST_10DAYS.String()] = cfn.AWSCloudWatchAlarm{
		ActionsEnabled:true,
		AlarmName: resources.DeadLetterOldest10Days,
		MetricName:"ApproximateAgeOfOldestMessage",
		Namespace:`AWS/SQS`,
		Threshold: float64(OLDEST_10_DAYS_SEC),
		Dimensions:[]cfn.AWSCloudWatchAlarm_Dimension{
			cfn.AWSCloudWatchAlarm_Dimension{
				Name : `QueueName`,
				Value: resources.DeadLetterQueueName,
			},
		},
		TreatMissingData: `ignore`,
		EvaluationPeriods:1,
		Period:600,
		ComparisonOperator: "GreaterThanOrEqualToThreshold",
		AlarmActions:[]string{
			cfn.Ref(LOGICAL_ID_SNS_ALARM_TOPIC_COMMON.String()),
		},
		OKActions:[]string{
			cfn.Ref(LOGICAL_ID_SNS_ALARM_TOPIC_COMMON.String()),
		},
		Statistic:`Average`,
	}

	y, err := template.YAML()
	if err != nil {
		fmt.Printf("Failed to generate YAML: %s\n", err)
	} else {
		//fmt.Printf("%s\n", string(y))
	}

	err = ioutil.WriteFile(pwd + `/app/example1/export/template-`+ENV+`.yml`, y, 0777)
	if err != nil {
		panic(err)
	}

}

```

定数系 

```golang
type LOGICAL_ID string

func (self LOGICAL_ID) String() string {
	return (string)(self)
}

const (
	LOGICAL_ID_SQS_DEAD_LETTER_QUEUE LOGICAL_ID = `SqsDeadLetterQueue`
	LOGICAL_ID_SQS_FIFO_QUEUE LOGICAL_ID = `SqsFifoQueue`
	LOGICAL_ID_CLOUDWATCH_ALARM_DEAD_LETTER_DETECTED LOGICAL_ID = `CloudWatchAlarmDeadLetterDetected`
	LOGICAL_ID_CLOUDWATCH_ALARM_DEAD_LETTER_OLDEST_10DAYS LOGICAL_ID = `CloudWatchAlarmDeadLetterOldest10Days`
	LOGICAL_ID_SNS_ALARM_TOPIC_COMMON LOGICAL_ID = `SnsAlarmTopicCommon`
	LOGICAL_ID_PERMISSION_LAMBDA_SNS_EVENT LOGICAL_ID = `PermissionLambdaSnsEvent`
	LOGICAL_ID_LAMBDA_SNS_ALARM_TO_SLACK LOGICAL_ID = `LambdaSnsAlarmToSlack`
)

type ResourceNames struct {
	DeadLetterQueueName string
	FifoQueueName string
	DeadLetterDetected string
	DeadLetterOldest10Days string
	AlarmSnsTopicCommon string
	AlarmSnsSubscriptionCommon string
	SnsAlarmToSlackLambdaTopic string
}

func NewResourceNames(env string) ResourceNames {

	return ResourceNames{
		DeadLetterQueueName: fmt.Sprintf(`%s-example1-dead-letter-queue.fifo` , env),
		FifoQueueName: fmt.Sprintf(`%s-example1-fifo-queue.fifo` , env),
		DeadLetterDetected: fmt.Sprintf(`%s-example1-deadletter-detected` , env),
		DeadLetterOldest10Days: fmt.Sprintf(`%s-example1-deadletter-oldest-10days` , env),
		AlarmSnsTopicCommon : fmt.Sprintf(`%s-example1-alarm-sns-topic-common` , env),
		AlarmSnsSubscriptionCommon : fmt.Sprintf(`%s-example1-sns-subscription-common` , env),
		SnsAlarmToSlackLambdaTopic : fmt.Sprintf(`%s-example1-sns-alarm-totify-slack-topic` , env),
	}
}

func readJs(path string) (string, error) {

	data, err := ioutil.ReadFile(path)
	if err != nil {
		// エラー処理
		log.Error(err)
		return "", err
	}

	return string(data), nil
}

type ExistedResources struct {
	ServerlessRole string
}

func GetExitedResources() ExistedResources {
	return ExistedResources{
		ServerlessRole: `arn:aws:iam::xxxxx:role/xxxxxx-serverless-role`,
	}
}
```

インラインで記載するlambdaのコード

```javascript

exports.handler = (event , context)=> {
  console.log(JSON.stringify(event));
  // Todo
  // slackになんか送るコードを書く。
  // Lambda Layerにaxiosとかいれとくと楽???
  
  return {};
};
```

前回よりリソース定義がグッと増えて、CloudwatchやらSnsやらLambdaの実行Permission設定までも必要となっています。
Role自体は今回はIAMまでは自動生成せず、既存のものを使う前提で書きました。
また、実行するlambda(js)のコードは別ファイルとしてアップロードせず、インラインで記載する手法をやってみました。

## 生成されたtempalte

```yaml

AWSTemplateFormatVersion: "2010-09-09"
Description: example1アプリの dev 環境です
Resources:
  CloudWatchAlarmDeadLetterDetected:
    Properties:
      ActionsEnabled: true
      AlarmActions:
      - Ref: SnsAlarmTopicCommon
      AlarmName: dev-example1-deadletter-detected
      ComparisonOperator: GreaterThanOrEqualToThreshold
      Dimensions:
      - Name: QueueName
        Value: dev-example1-dead-letter-queue.fifo
      EvaluationPeriods: 1
      MetricName: ApproximateNumberOfMessagesVisible
      Namespace: AWS/SQS
      OKActions:
      - Ref: SnsAlarmTopicCommon
      Period: 60
      Statistic: Minimum
      Threshold: 0.001
      TreatMissingData: ignore
    Type: AWS::CloudWatch::Alarm
  CloudWatchAlarmDeadLetterOldest10Days:
    Properties:
      ActionsEnabled: true
      AlarmActions:
      - Ref: SnsAlarmTopicCommon
      AlarmName: dev-example1-deadletter-oldest-10days
      ComparisonOperator: GreaterThanOrEqualToThreshold
      Dimensions:
      - Name: QueueName
        Value: dev-example1-dead-letter-queue.fifo
      EvaluationPeriods: 1
      MetricName: ApproximateAgeOfOldestMessage
      Namespace: AWS/SQS
      OKActions:
      - Ref: SnsAlarmTopicCommon
      Period: 600
      Statistic: Average
      Threshold: 864000
      TreatMissingData: ignore
    Type: AWS::CloudWatch::Alarm
  LambdaSnsAlarmToSlack:
    Properties:
      Code:
        ZipFile: "exports.handler = (event , context)=> {\n  console.log(JSON.stringify(event));\n
          \ // Todo\n  // slackになんか送るコードを書く。\n  // Lambda Layerにaxiosとかいれとくと楽???\n
          \ \n  return {};\n};\n"
      Handler: index.handler
      MemorySize: 256
      Role: arn:aws:iam::xxxxx:role/xxxxxx-serverless-role
      Runtime: nodejs8.10
      Timeout: 30
    Type: AWS::Lambda::Function
  PermissionLambdaSnsEvent:
    Properties:
      Action: lambda:InvokeFunction
      FunctionName:
        Fn::GetAtt:
        - LambdaSnsAlarmToSlack
        - Arn
      Principal: sns.amazonaws.com
      SourceArn:
        Ref: SnsAlarmTopicCommon
    Type: AWS::Lambda::Permission
  SnsAlarmTopicCommon:
    Properties:
      Subscription:
      - Endpoint:
          Fn::GetAtt:
          - LambdaSnsAlarmToSlack
          - Arn
        Protocol: lambda
      TopicName: dev-example1-alarm-sns-topic-common
    Type: AWS::SNS::Topic
  SqsDeadLetterQueue:
    Properties:
      FifoQueue: true
      MessageRetentionPeriod: 1209600
      QueueName: dev-example1-dead-letter-queue.fifo
    Type: AWS::SQS::Queue
  SqsFifoQueue:
    Properties:
      DelaySeconds: 3
      FifoQueue: true
      MessageRetentionPeriod: 1209600
      QueueName: dev-example1-fifo-queue.fifo
      RedrivePolicy:
        deadLetterTargetArn:
          Fn::GetAtt:
          - SqsDeadLetterQueue
          - Arn
        maxReceiveCount: 1
      VisibilityTimeout: 5
    Type: AWS::SQS::Queue

```


## デプロイ && 実行
```
aws cloudformation deploy --template-file <テンプレ名> --stack-name <スタック名> --s3-bucket <アップロード先> --s3-prefix <任意>
```
すればデプロイ完了です。

デッドレターキューにメッセージを登録し、アラートが上がり、Lambdaの実行ログも出てたらとりあえずは完成でいいでしょう。

<img width="1375" alt="1.png" src="https://qiita-image-store.s3.amazonaws.com/0/85375/200ee46c-e915-097d-7889-bcf0e9ba79e2.png">
<img width="520" alt="3.png" src="https://qiita-image-store.s3.amazonaws.com/0/85375/1ab3520a-f1a3-78b7-16c0-612f9d44c23b.png">


## ハマりどころ & 課題

* 論理ID部分の ``` PermissionLambdaSnsEvent ``` がなかなか曲者で、これを設定しないとSNS側の設定だけではLambdaが実行されません。ハマった。
LambdaのイベントソースとしてSNSが設定されてるかとかは確認するといいかと思います。  

<img width="835" alt="2.png" src="https://qiita-image-store.s3.amazonaws.com/0/85375/52efe2c3-1537-c8d0-34e6-67bbe454895c.png">


* CloudwatchAlarmのThresholdを0にするとフィールドがomitemptyされてしまってvalidateでエラーが出るのが曲者。
これどうしたら良いんだ。。 ``` > 0 ``` で設定したいのだが。

```golang

template.Resources[LOGICAL_ID_CLOUDWATCH_ALARM_DEAD_LETTER_DETECTED.String()] = cfn.AWSCloudWatchAlarm{
		ActionsEnabled: true,
		AlarmName: resources.DeadLetterDetected,
		MetricName:"ApproximateNumberOfMessagesVisible",
		Namespace:`AWS/SQS`,
		Threshold: 0.001,
		Dimensions:[]cfn.AWSCloudWatchAlarm_Dimension{
			cfn.AWSCloudWatchAlarm_Dimension{
				Name : `QueueName`,
				Value: resources.DeadLetterQueueName,
			},
		},
...
```

## まとめ
アラート周りはいつもプロジェクトの最初にちゃちゃっと設定したり、GUI側から登録すると勝手にpermissionを作ってくれる面がありましたので、今回定義コードを作ってみて、その部分は改めて何が行われているかを知るいい機会になりました。

また、goformationのCloudwatch周りはまだまだ単純に ``` map[string]interface{} ```で定義されているだけの項目も多かったので、そのへんは拡充を期待したいところです。(コミットしろ)