LoginSignup
2
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2020-07-24

はじめに

前回というか第一話の続編。
第一話では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ファイルを入れてている。

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"
  }
}

アプリケーション

アプリケーションは、上記の通り第一話のほぼ流用である。モジュール化するのが面倒だったので、ちょっとまとめたくらいだ。

src/main.go
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
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_buildcdしている。後述のMakefileを使っても良いかと思いつつ、ビルドスクリプト内で別のビルドスクリプトを動かすのには抵抗があったので、一旦は保留。
  • Lambdaで動作させるためにはGOARCH=amd64 GOOS=linuxの変数設定が必要

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

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::FunctionRuntimego1.xを指定(ここは、適切な値に変えてね、ではなくて本当にxを書いておけば良い)
  • AWS::Serverless::FunctionCodeUriはCloudFormationを実行するディレクトリから見た、実行コマンドのある相対パスを指定
  • AWS::Serverless::FunctionHandlerはビルドターゲットのファイル名
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で渡してあげる方が美しいが、今回はそこまでやらなかった。

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パイプラインを作ることができる。簡単!

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2