はじめに
前回というか第一話の続編。
第一話ではServerless Frameworkを使ったが、今回はCodePipelineを使って自動起動するパイプラインを使ってみようと思う。となると、セオリー(だと思ってるの)はSAMをCloudFormationで起動するパターンだろう。
golangのアプリケーションそのものの話は薄めというか、ほぼ入っていないのであしからず。
前提条件
・golangのアプリケーションは第一話のものをほぼ流用するので、読んでいる前提
・別の記事で書いた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ファイルを入れてている。
######################################################################
# 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"
}
}
アプリケーション
アプリケーションは、上記の通り第一話のほぼ流用である。モジュール化するのが面倒だったので、ちょっとまとめたくらいだ。
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
}
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)
}
ちゃんと、go mod init
してgo.modを作ってバージョンを固定化しておこう。
go.sumはリポジトリに入れなくても良いので.gitignoreに書いておく。
CI/CDパイプライン
Terraform
これはもう別の記事そのままのTerraformを使っているので、これを見てもらえれば。
ディレクトリ内に.gitmodulesがあるのも、この中でGitのサブモジュール機能を使っているためである。
Buildspec
BuildSpecについては、以下のポイントを気にしておけば特に難しいところはない。
- ランタイムは
golang: 1.1x
(xは適切な値を選択) - 今回の構成では、トップディレクトリにsrcがあるもののビルド自体はsrcフォルダ内で実行する必要があるため、
pre_build
でcd
している。後述のMakefileを使っても良いかと思いつつ、ビルドスクリプト内で別のビルドスクリプトを動かすのには抵抗があったので、一旦は保留。 - Lambdaで動作させるためには
GOARCH=amd64 GOOS=linux
の変数設定が必要
あと、今回はテストコード中で本物のDynamoDBに繋いでしまったため、通常のCodePipelineのロールにDynamoDBアクセスのポリシを追加する必要があって面倒なので割愛した。本当はテストコードをモックにするのが良いのかな……。
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
はビルドターゲットのファイル名
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で渡してあげる方が美しいが、今回はそこまでやらなかった。
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パイプラインを作ることができる。簡単!