Golangはじめて物語(第3話: CodePipeline+SAM+LambdaでCI/CD編)

# はじめに
[前回](https://qiita.com/neruneruo/items/fe292262836ac068e2ea)というか[第一話](https://qiita.com/neruneruo/items/1ef52f908e4497103a59)の続編。
第一話ではServerless Frameworkを使ったが、今回はCodePipelineを使って自動起動するパイプラインを使ってみようと思う。となると、セオリー(だと思ってるの)はSAMをCloudFormationで起動するパターンだろう。

golangのアプリケーションそのものの話は薄めというか、ほぼ入っていないのであしからず。

# 前提条件
・golangのアプリケーションは[第一話](https://qiita.com/neruneruo/items/1ef52f908e4497103a59)のものをほぼ流用するので、読んでいる前提
・[別の記事](https://qiita.com/neruneruo/items/e2e4fd70c90e2dbdc67f)で書いたTerraformのサブモジュールをほぼ流用するので、読んでいる前提
・というかここまでやったら何を書くんだっけ？という感じではあるけど、一応今回は、buildspec.ymlとSAMテンプレートのコツを書いておく

# 全体構成
```
golang-lambdapipeline
├── buildspec.yml
├── .gitignore
├── .gitmodules
├── Makefile
├── src
│   ├── go.mod
│   ├── main.go
│   └── main_test.go
├── template.yml
└── terraform
    ├── 01_prepare
    │   └── main.tf
    └── 02_pipeline
        ├── main.tf
        └── modules
            ├── 01_variables.tf
            ├── 02_iam.tf
            ├── 03_s3.tf
            ├── 04_cloudwatchlogs.tf
            ├── 05_codepipeline.tf
            └── 06_cloudformation_parameter.json
```

アプリケーション中で、```users-table```というDynamoDBを触るので、予め作っておく必要がある。
terraform/01_prepare は、上記DynamoDBテーブルを作成するtfファイルを入れてている。

```HCL:terraform/01_prepare/main.tf
######################################################################
# Provider                                                           #
######################################################################
provider "aws" {
  region = "ap-northeast-1"
}

######################################################################
# DynamoDB                                                           #
######################################################################
resource "aws_dynamodb_table" "users" {
  name           = "users-table"
  billing_mode   = "PROVISIONED"
  read_capacity  = 1
  write_capacity = 1
  hash_key       = "id"
  
  attribute {
    name = "id"
    type = "S"
  }
}
```

# アプリケーション
アプリケーションは、上記の通り[第一話](https://qiita.com/neruneruo/items/1ef52f908e4497103a59)のほぼ流用である。モジュール化するのが面倒だったので、ちょっとまとめたくらいだ。

<details><summary>src/main.go</summary><div>

```golang:src/main.go
package main

import (
	"errors"
	"context"
	"encoding/json"
	"log"

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

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

var ()

const ()

type item struct {
	Id   string `dynamodbav:"id"`
	Name string `dynamodbav:"name"`
}

func init() {
}

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

func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
	var (
		statusCode int

		id          string
		idIsNotNull bool

		record item

		returnbody string

		err error
	)

	if lc, ok := lambdacontext.FromContext(ctx); ok {
		log.Printf("AwsRequestID: %s", lc.AwsRequestID)
	}
	statusCode = 200

	if len(request.QueryStringParameters) == 0 {
		log.Println("QueryStringParameters is not specified.")
		statusCode = 400
	} else {
		if id, idIsNotNull = request.QueryStringParameters["id"]; !idIsNotNull {
			log.Println("[QueryStringParameters]id is not specified")
			statusCode = 400
		}

		if id == "99999" {
			statusCode = 500
		}
	}

	if statusCode == 200 {
		record, err = getUser(id)

		if err != nil {
			if err.Error() == "Not Found" {
				statusCode = 404
			} else {
				statusCode = 500
			}
		} else {
			jsonBytes, _ := json.Marshal(record)
			returnbody = string(jsonBytes)
		}
	}

	response := events.APIGatewayProxyResponse{
		StatusCode:      statusCode,
		IsBase64Encoded: false,
		Body:            returnbody,
		Headers: map[string]string{
			"Content-Type": "application/json",
		},
	}

	return response, nil
}

func getUser(id string) (item, error) {
	var (
		item item
	)

	sess := session.Must(session.NewSessionWithOptions(session.Options{
		SharedConfigState: session.SharedConfigEnable,
	}))

	svc := dynamodb.New(sess)

	result, err := svc.GetItem(&dynamodb.GetItemInput{
		TableName: aws.String("users-table"),
		Key: map[string]*dynamodb.AttributeValue{
			"id": {
				S: aws.String(id),
			},
		},
	})
	if err != nil {
		log.Println("Got error calling GetItem:")
		log.Println(err.Error())
		return item, errors.New("Library Error")
	}

	err = dynamodbattribute.UnmarshalMap(result.Item, &item)
	if err != nil {
		log.Println("Failed to unmarshal Record", err)
		log.Println(err.Error())
		return item, errors.New("Library Error")
	}

	if item.Id == "" {
		log.Printf("Could not find id: %s", id)
		return item, errors.New("Not Found")
	}

	return item, nil
}
```

</div></details>

<details><summary>src/main_test.go</summary><div>

```golang:src/main_test.go
package main

import (
	"context"
	"os"
	"testing"
	"log"

	"github.com/pkg/errors"

	"github.com/aws/aws-lambda-go/events"
	"github.com/aws/aws-lambda-go/lambdacontext"
	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/dynamodb"
	"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
)

var (
	items = []struct {
		Id   string `dynamodbav:"id"`
		Name string `dynamodbav:"name"`
	}{
		{"test11111", "Tanaka Ichiro"},
		{"test22222", "Sato Jiro"},
	}
)

const ()

func setup() error {
	sess := session.Must(session.NewSessionWithOptions(session.Options{
		SharedConfigState: session.SharedConfigEnable,
	}))

	svc := dynamodb.New(sess)

	for _, item := range items {
		av, err := dynamodbattribute.MarshalMap(item)
		if err != nil {
			return errors.Wrap(err, "Got error marshalling new item")
		}

		log.Println(av)

		_, err = svc.PutItem(&dynamodb.PutItemInput{
			Item:      av,
			TableName: aws.String("users-table"),
		})
		if err != nil {
			return errors.Wrap(err, "Got error calling PutItem")
		}
	}

	return nil
}

func teardown() error {
	sess := session.Must(session.NewSessionWithOptions(session.Options{
		SharedConfigState: session.SharedConfigEnable,
	}))

	svc := dynamodb.New(sess)

	for _, item := range items {
		_, err := svc.DeleteItem(&dynamodb.DeleteItemInput{
			TableName: aws.String("users-table"),
			Key: map[string]*dynamodb.AttributeValue{
				"id": {
					S: aws.String(item.Id),
				},
			},
		})
		if err != nil {
			return errors.Wrap(err, "Got error calling DeleteItem")
		}

	}

	return nil
}

func TestHandler(t *testing.T) {
	tests := []struct {
		testid                string
		queryStringParameters map[string]string
		expected              int
	}{
		{testid: "001", queryStringParameters: map[string]string{"id": "test11111"}, expected: 200},
		{testid: "002", queryStringParameters: map[string]string{"id": "test22222"}, expected: 200},
		{testid: "003", queryStringParameters: map[string]string{"id": "test33333"}, expected: 404},
	}

	lc := &lambdacontext.LambdaContext{
		AwsRequestID: "test request",
	}
	ctx := lambdacontext.NewContext(context.Background(), lc)

	for _, te := range tests {
		res, _ := handler(ctx, events.APIGatewayProxyRequest{
			QueryStringParameters: te.queryStringParameters,
		})

		if res.StatusCode != te.expected {
			t.Errorf("Test-ID%s: StatusCode=%d, Expected %d", te.testid, res.StatusCode, te.expected)
		}
	}
}

func TestMain(m *testing.M) {
	err := setup()
	if( err != nil){
		log.Println(err)
	}

	ret := m.Run()
	teardown()
	os.Exit(ret)
}
```

</div></details>

ちゃんと、```go mod init```してgo.modを作ってバージョンを固定化しておこう。
go.sumはリポジトリに入れなくても良いので.gitignoreに書いておく。

# CI/CDパイプライン
## Terraform
これはもう[別の記事](https://qiita.com/neruneruo/items/e2e4fd70c90e2dbdc67f)そのままのTerraformを使っているので、これを見てもらえれば。
ディレクトリ内に.gitmodulesがあるのも、この中でGitのサブモジュール機能を使っているためである。

## Buildspec
BuildSpecについては、以下のポイントを気にしておけば特に難しいところはない。

- ランタイムは```golang: 1.1x```(xは適切な値を選択)
- 今回の構成では、トップディレクトリにsrcがあるもののビルド自体はsrcフォルダ内で実行する必要があるため、```pre_build```で```cd```している。後述のMakefileを使っても良いかと思いつつ、ビルドスクリプト内で別のビルドスクリプトを動かすのには抵抗があったので、一旦は保留。
- Lambdaで動作させるためには```GOARCH=amd64 GOOS=linux```の変数設定が必要

あと、今回はテストコード中で本物のDynamoDBに繋いでしまったため、通常のCodePipelineのロールにDynamoDBアクセスのポリシを追加する必要があって面倒なので割愛した。本当はテストコードをモックにするのが良いのかな……。

```YAML:buildspec.yml
version: 0.2

phases:
  install:
    runtime-versions:
      golang: 1.13
  pre_build:
    commands:
      cd src
  build:
    commands:
      - echo Build started on `date`
      - GOARCH=amd64 GOOS=linux go build -o ../artifact/gopipeline-test
      - echo Build ended on `date`
  post_build:
    commands:
#      - echo Test started on `date`
#      - go test ./...
#      - echo Test ended on `date`
      - cd ../
      - echo CloudFormation Package started on `date`
      - aws cloudformation package --template-file template.yml --output-template-file output-template.yml --s3-bucket ${CF_BUCKET_NAME}
      - echo CloudFormation Package ended on `date`
artifacts:
  type: zip
  files:
    - output-template.yml
cache:
  paths:
    - '/root/.m2/**/*'
```

## SAMテンプレート
SAMテンプレートも以下のポイントを抑えておけば、あとは普通のテンプレートである。

- ```AWS::Serverless::Function```の```Runtime```は```go1.x```を指定(ここは、適切な値に変えてね、ではなくて本当に```x```を書いておけば良い)
- ```AWS::Serverless::Function```の```CodeUri```はCloudFormationを実行するディレクトリから見た、実行コマンドのある相対パスを指定
- ```AWS::Serverless::Function```の```Handler```はビルドターゲットのファイル名

```YAML:template.yml
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: Test for golang Lambda CI/CD Pipeline

Parameters:
  LambdaFunctionName:
    Description: "Lambda Function Name"
    Type: "String"
    Default: "LambdaFunctionName"
  LambdaExecutionRoleName:
    Description: "Lambda Execution Role Name"
    Type: "String"
    Default: "LambdaExecutionRoleName"
  
Globals:
    Function:
        Timeout: 60

Resources:
  # ------------------------------------------------------------#
  #  IAM Role
  # ------------------------------------------------------------#
  LambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties: 
      RoleName: !Sub ${LambdaExecutionRoleName}
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com 
            Action: sts:AssumeRole
      ManagedPolicyArns: 
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole 
  # ------------------------------------------------------------#
  #  Lambda
  # ------------------------------------------------------------#
  LambdaTest:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: !Sub ${LambdaFunctionName}
      Handler: gopipeline-test
      Runtime: go1.x
      MemorySize: 128
      Role: !GetAtt LambdaExecutionRole.Arn
      CodeUri: artifact
      AutoPublishAlias: Prod
      DeploymentPreference:
        Type: AllAtOnce
```

# Makefile
今回のリポジトリは、やりたいことに対していろいろと```cd```しないといけなくて面倒なので、その辺はMakefileに任せる。
Terraformなんかも、いちいちディレクトリに移動なんてしないで、```make terraform-pipeline ARG=apply```とかやって、移動しないようにする。

また、CodePipelineをdestroyする際に、SAMテンプレートで作ったスタックを先に削除しておかないと悲惨なことになるため、destroyの場合のみS3のゴミ掃除とCloudFormationのスタック削除をしている。本当は、MakefileとTerraformで同じ名前を使っているため、Terraform側にしっかりとvarで渡してあげる方が美しいが、今回はそこまでやらなかった。

```Makefile
S3_BUCKET=golang-lambdapipeline-artifact-bucket
STACK_NAME=golang-LambdaPipeline-SAMStack

build:
	cd src; \
	GOARCH=amd64 GOOS=linux go build -o ../artifact/gopipeline-test
.PHONY: build

test:
	cd src; \
	go test ./...
.PHONY: test

terraform-prepare:
	cd terraform/01_prepare; \
	terraform $(ARG)
.PHONY: terraform-prepare

terraform-pipeline:
ifeq ($(ARG),destroy)
	aws s3 rm "s3://$(S3_BUCKET)" --recursive; \
	aws cloudformation delete-stack --stack-name "$(STACK_NAME)";
endif
	cd terraform/02_pipeline; \
	terraform $(ARG)
.PHONY: terraform-pipeline

clean:
	rm -rf artifact
.PHONY: clean
```

これでGolangのCI/CDパイプラインを作ることができる。簡単！