cloudformation/samでsns通知をslackに流すgoのlambdaを作る

# 作成するlambdaの概要

![Blank Diagram - Page 1.png](https://qiita-image-store.s3.amazonaws.com/0/146198/64cd04d8-3703-d43e-d4e1-e72ba50bf6e8.png)

SNSの通知をEventにSlackのIncoming Webhooksを利用してMessageをPOST


# 準備

## コマンド
```bash
$ go version
go version go1.9.2 darwin/amd64

$ dep version                                                                                                
dep:
 version     : devel
 build date  :
 git hash    :
 go version  : go1.9.1
 go compiler : gc
 platform    : darwin/amd64

$ aws --version                                                                                              
aws-cli/1.14.50 Python/2.7.14 Darwin/17.3.0 botocore/1.9.3
```
go, dep, awscliを利用します

## AWS

- Cloudformation,SNS,Lamabda,S3,IAMの権限を付与されたIAM User
- Lambdaのcodeを格納するためのS3 Bucket

## Slack

`https://<your-team>.slack.com/apps/manage/custom-integrations`
からIncoming WebHooksを設定しておきます. Webhook URLを取得できていればOKです、通知先のチャンネルはデフォルトなのでPOST時に上書きできます(Username, Iconemojiも同様)


# 作成

## sampleのfetch
```bash
$ git clone git@github.com:ymgyt/awsctl.git
$ cd awsctl/sam/notify-sample
$ cp .envrc_template .envrc
# .envrcに値を追記

# source ./envrc でもよいです 
$ direnv allow
$ dep ensure
```
sample https://github.com/ymgyt/awsctl/tree/master/sam/notify-sample
git clone後にsampleのdirに移動し、`.envrc_template`を`.envrc`にcopyします
以下の環境変数がSetされている状態なっていればいいので`envrc`を利用しなくても特に問題ないです
その後、depで依存packageを取得します

### 利用する環境変数
- `AWS_DEFAULT_REGION` これから作成するリソースの作成先region
- `FUNCTION_NAME` 作成するLambdaの名前, 既存と重複があるとErrorになります
- `S3_BUCKET` lambdaのcode格納先Bucket
- `STACK` cloudformationのstack名
- `SNS_TOPIC` lambdaのevent sourceにするsnsのtopic名
- `WH_URL` Incoming WebhooksのURL
- `WH_CHANNEL` 通知先のchannel
- `WH_USERNAME` messageの投稿者として利用
- `WH_ICONEMOJI` messageのIcon 


## Cloudromation/samのtemplate作成
```yaml
AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Parameters:
  FunctionName:
    Type: String
  SNSTopic:
    Type: String
  WebHookURL:
    Type: String
  Channel:
    Type: String
  Username:
    Type: String
  IconEmoji:
    Type: String
Resources:
  Function:
    Type: 'AWS::Serverless::Function'
    Properties:
      Runtime: 'go1.x'
      Handler: 'main'
      Tracing: 'PassThrough' # or Active
      Environment:
        Variables:
          WEBHOOK_URL: !Ref WebHookURL
          CHANNEL: !Ref Channel
          USERNAME: !Ref Username
          ICONEMOJI: !Ref IconEmoji
      # main と指定すると fork/exec /var/task/main: permission denied: PathError
      CodeUri: '.'
      FunctionName: !Ref FunctionName
      Description: 'post sns messages to slack incoming webhooks'
      MemorySize: 128
      Timeout: 5
      # Role: 
      # if Role property is set, Policies property has no meaning
      Policies:
        - AWSLambdaBasicExecutionRole
      Events: 
        SNS:
          Type: SNS
          Properties:
            Topic: !Ref Topic
      Tags:
        Key: 'Value'
  Topic:
    Type: 'AWS::SNS::Topic'
    Properties:
      TopicName: !Ref SNSTopic
```
今回使用するtemplateは `template.yml`です.
`Transform: 'AWS::Serverless-2016-10-31'` を加えるとCloudformationからSAMになるという認識なのですが、用語の使い方が間違っているかもしれません.
`Type`に`AWS::Serverless::Function`を指定します, `Runtime`は今のところgoを利用する場合, `go1.x`しか選べないようです.Handlerには`main`を指定します.
`Tracing`はX-Ray関連の設定のようです(X-Rayについてわかっていない)
`Enviroment`でLambdaに渡す環境変数を指定できます,現在のところ`Enviroment`で利用できるのは`Variables`のみのようです.
`CodeUri`にはカレントディレクトリを指定しています.ここでハマってしまう点があったので後述します.
`FunctionName`は作成するLambdaの名前, `MemorySize`,`Timeout`はLambda起動時に割り当てるメモリ量、起動時間の制限です.
`Role`, `Policies` LambdaがassumeするIAMのPolicyを指定します, `Role`と`Policies`はどちらかひとつを選択します.`Policies`を利用すると、指定されたPolicyがattachされたroleが自動的に作成されます.
`Events`でLambdaのTriggerを指定します. 今回はSNSを設定するのでSNSのTopicのARNを指定します.
`Transform`を加えてもあくまでCloudformationであることはかわらないようで、今までできることはできるので、今回利用するSNSも同じtemplate内で作成しています.

## lambdaのsourceを作成
```go:main.go
package main

import (
	"context"
	"log"
	"os"
	"strings"
	"time"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambda"

	"github.com/ymgyt/slack/webhook"
)

var wh *webhook.Client

func init() {
	var err error
	wh, err = webhook.New(webhook.Config{
		URL:       os.Getenv("WEBHOOK_URL"),
		Channel:   os.Getenv("CHANNEL"),
		Username:  os.Getenv("USERNAME"),
		IconEmoji: os.Getenv("ICONEMOJI"),
	})
	if err != nil {
		panic(err)
	}
}

// topic arn format
// arn:aws:sns:<region>:<account-id>:<topic-name>
func topicName(topicARN string) string {
	name := strings.SplitN(topicARN, ":", 6)[5]
	if name == "" {
		return topicARN
	}
	return name
}

func handleRecord(record events.SNSEventRecord) error {
	entity := record.SNS
	payload := &webhook.Payload{
		Text: "SNS Notification",
		Attachments: []*webhook.Attachment{
			{
				Fallback: "SNS Notification",
				Text:     entity.Subject,
				Pretext:  topicName(entity.TopicArn),
				Color:    "good",
				Fields: []*webhook.Field{
					{
						Title: "message",
						Value: entity.Message,
					},
					{
						Title: "timestamp",
						Value: entity.Timestamp.Format(time.RFC3339),
					},
				},
			},
		},
	}
	return wh.SendPayload(payload)
}

func HandleEvent(ctx context.Context, event events.SNSEvent) error {
	log.Println("start")

	for _, record := range event.Records {
		select {
		case <-ctx.Done():
			return ctx.Err()
		default:
			if err := handleRecord(record); err != nil {
				return err
			}
		}
	}

	log.Println("end")
	return nil
}

func main() {
	lambda.Start(HandleEvent)
}
```

### entry point

entry pointの`main`の中でgithub.com/aws/aws-lambda-go/lambdaより提供されている`lambda.Start`を実行します.`lambda.Start`に渡すのはfunctionで、そのfunctionのsignatureにはいくつか制限があります.

>
The handler must be a function.
The handler may take between 0 and 2 arguments. If there are two arguments, the first argument must implement context.Context.
The handler may return between 0 and 2 arguments. If there is a single return value, it must implement error. If there are two return values, the second value must implement error. For more information on implementing error-handling information, see Function Errors (Go) .
The following lists valid handler signatures. TIn and TOut represent types compatible with the encoding/json standard library. For more information, see func Unmarshal to learn how these types are deserialized.
func ()
func () error
func (TIn), error
func () (TOut, error)
func (context.Context) error
func (context.Context, TIn) error
func (context.Context) (TOut, error)
func (context.Context, TIn) (TOut, error)

https://docs.aws.amazon.com/lambda/latest/dg/go-programming-model-handler-types.html

ということで、contextを渡せ、レスポンス用のstructとerrorを返せるようです.
今回は`func(context.Context,TIn) error`型のfuncを渡しています.

### post

各種LambdaのEventSourceのstructはgithub.com/aws/aws-lambda-go/eventsで提供されています.今回はSNSを利用するので `events.SNSEvent`を利用します.slack post用のstructは``init``の中で初期化しておきます.公式docにもそのような記載がありました。

>
Using Global State to Maximize Performance
To maximize performance, you should declare and modify global variables that are independent of your function's handler code. In addition, your handler may declare an init function that is executed when your handler is loaded. This behaves the same in AWS Lambda as it does in standard Go programs. A single instance of your Lambda function will never handle multiple events simultaneously. This means, for example, that you may safely change global state, assured that those changes will require a new Execution Context and will not introduce locking or unstable behavior from function invocations directed at the previous Execution Context. For more information, see Best Practices for Working with AWS Lambda Functions.

https://docs.aws.amazon.com/lambda/latest/dg/go-programming-model-handler-types.html

slackへのpostは自作のslack/webhook packageを利用しています.中では`json.Marshal`してPOST requestを生成しているだけです.
すでに同じ用途のpackageはたくさんあると思います.
https://github.com/ashwanthkumar/slack-go-webhook 等
 
## goのbuild

```bash
$ make build
GOOS=linux go build -o main main.go
```
linux用のbinaryをbuildします

## lambdaのupload
```bash
$ make package
aws cloudformation package \
		--template-file ./template.yml \
		--s3-bucket  <your-bucket> \
		--output-template-file packaged-template.yml
```
`aws cloudfromation package` コマンドでlambdaをs3にuploadします.コマンドが成功するとカレントに`packaged-template.yml`が出力されます.
`packaged-template.yml`はさきほど作成した`template.yml`とほとんど同じですが以下の点が違います

```yaml
Resources:
  Function:
    Properties:
      CodeUri: s3://<bucket>/xxxxxxxxxxxxxxxxxxxxxxxxxx
```
`Function`の`CodeUri`をlocalのpathからupload後のS3のpathに書き換えてくれています.


## cloudformation stack作成
```bash
$ make deploy
aws cloudformation deploy \
		--template-file ./packaged-template.yml \
		--stack-name <your-stackname> \
		--capabilities CAPABILITY_IAM \
		--parameter-overrides \
			"FunctionName=<your-function>" \
			"SNSTopic="<your-topic>" \
			"WebHookURL=<your-webhook-url>" \
			"Channel=<your-channel>" \
			"Username=<your-username>" \
			"IconEmoji=<your-iconemoji>"
```
コマンドが成功すればStack,Lambda,SNS Topicが作成されています.
IAMのroleを生成するので `--capabilities CAPABILITY_IAM`が必要です

## 通知test
```bash
$ make publish-to-topic
aws sns list-topics --query='Topics[].TopicArn' --output='text' | \
	awk -v topic="${SNS_TOPIC}" 'BEGIN{RS="\t"} { \
		sub("\n$",""); \
		split($0,ary,":"); \
		if (ary[6] == topic) {print $0} \
	}' | \
	xargs -I{} aws sns publish \
		--topic-arn "{}" --message "test message"
```
`aws sns publish` コマンドで作成したsnsに通知をだしています. jqに依存したくなかったのでawkを利用しています(awkは GNU Awkです)

<img width="1061" alt="Screen Shot 2018-03-04 at 12.57.16.png" src="https://qiita-image-store.s3.amazonaws.com/0/146198/17bb7439-2645-30e9-2edc-22d459316aab.png">

成功すれば、Slackに通知がきます.

## 繰り返し
```bash
$ make
```
`make`とだけ実行すると, build,package,deploy,publishまで走ります.
packageを実行するたびにS3にfileが作成されます.

# はまったところ

## lambda実行時に`fork/exec /var/task/main: permission denied`
`CodeUri`で最初は`./main`と指定していました。そうしたところ
lambda実行時にうまく起動できないerrorに遭遇しました。
https://github.com/awslabs/aws-sam-local/issues/274
https://github.com/aws/aws-cli/pull/3112

## templateのvalidationができない

```bash
$ aws cloudformation validate-template --template-body=./template.yml

An error occurred (ValidationError) when calling the ValidateTemplate operation: Template format error: unsupported structure.
```

Transformしたtemplateはvalidate対象外のようです.


# まとめ
Lambdaのruntimeにgoが追加されたので、goのlambdaを作成してみました.
Lambdaを運用すると様々なruntimeのlambdaが混在し、codeがconsole上で見えるもの/見えないものあったりしていかに管理していくかが自分の課題です.
Lambdaまわりのflowを管理するFlamework(serverless, apex等...)もあるのですが、他のAWSはcloudformationで管理してlambdaだけ別というのが気になるのでcloudformationとしてlambdaを管理できるsamを利用していこうかなと考えております.


# 参考

SAMのtemplate, package, deployについて
https://docs.aws.amazon.com/lambda/latest/dg/go-programming-model-handler-types.html

Goのlambdaについて
https://docs.aws.amazon.com/lambda/latest/dg/go-programming-model-handler-types.html