S3にアップしたZIPを別S3バケットに解凍するアプリでAWS SAM+Golang開発の流れを確認

SAM使っての開発の流れを勉強するために簡単なアプリケーション開発しながら、調べたことなど忘れそうなので備忘兼ねてメモしています。順次追記予定です。

例として作ってみたのは、S3バケットにアップロードしたZIPファイルを、他のS3バケットに展開する処理です。

設計

設計というか定義ですが、PPTとかで書いた絵をSAMの定義に落とすのが面倒だったのですが、こんなサービスがあることを、こちらの資料で知り、これを使いました。

Serverless by Design

このようにSAMの定義をお絵かきできるサービスです。プロパティは最低限IDとDescriptionのみ設定です。定義内容は JSON形式でImport/Exportで保存できます。

今回やりたいことも一目瞭然にわかります。

f:id:yomon8:20180309103643p:plain

完了したら build をすると以下のようなポップアップとともにZIPファイルがダウンロードできます。

ZIPを展開した中身です。

$ tree
.
├── template.yaml
└── Unzip.js

SAMのYAML定義のひな形が落ちてきます。これだけでも自分的にはかなり助かります。

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Globals:
  Function:
    AutoPublishAlias: live
    DeploymentPreference:
      Type: AllAtOnce
Resources:
  ZipFiles:
    Type: 'AWS::S3::Bucket'
  Unzip:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: Unzip.handler
      Runtime: nodejs6.10
      CodeUri: .
      Policies:
        - AmazonS3ReadOnlyAccess
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - 's3:GetObject'
                - 's3:PutObject'
              Resource: !Sub ${UnzippedFiles.Arn}/*
      Description: Get and Unzip and Put
      Events:
        BucketZipFiles:
          Type: S3
          Properties:
            Bucket: !Ref ZipFiles
            Events: 's3:ObjectCreated:*'
  UnzippedFiles:
    Type: 'AWS::S3::Bucket'

nodejsやpythonならひな形のスクリプトも一緒にZIPされています。nodejsの場合は以下のようなスクリプトがダウンロードされてきます。今回はGolang使うのでこのファイルは関係無いです。

'use strict';

console.log('Loading function');

exports.handler = (event, context, callback) => {
    console.log('Received event:', JSON.stringify(event, null, 2));
    callback(null, "Hello World");
    //callback('Something went wrong');
};

YAML定義の設定情報

このファイルを確認しながら設定。 https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md

開発

Lambdaならではのところも少しあります、基本的な部分はこちらを確認しながら開発します。

Programming Model for Authoring Lambda Functions in Go - AWS Lambda

実際に簡単ですが、S3からダウンロードしたZIPファイルを展開して、別のS3にアップロードするコード作ってみました。

package main

import (
    "archive/zip"
    "context"
    "crypto/md5"
    "fmt"
    "io/ioutil"
    "log"
    "os"

    "github.com/pkg/errors"

    "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/arn"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/s3"
    "github.com/aws/aws-sdk-go/service/s3/s3manager"
)

type Event map[string]interface{}

type S3Object struct {
    id     string
    region string
    bucket string
    key    string
}

func parseEvent(event Event) *S3Object {
    obj := &S3Object{}
    for _, er := range event["Records"].([]interface{}) {
        if region := er.(map[string]interface{})["awsRegion"]; region != nil {
            obj.region = region.(string)
        }
        if s3 := er.(map[string]interface{})["s3"]; s3 != nil {
            obj.bucket = s3.(map[string]interface{})["bucket"].(map[string]interface{})["name"].(string)
            obj.key = s3.(map[string]interface{})["object"].(map[string]interface{})["key"].(string)
        }
    }
    obj.id = fmt.Sprintf("%x", md5.Sum([]byte(fmt.Sprintf("%s%s%s", obj.region, obj.bucket, obj.key))))
    return obj
}

func wrapError(err error, message string) error {
    return errors.Wrapf(err, "%s:%s", message, err.Error())
}

func logger(id string, step string, message ...string) {
    log.Printf("[%s]%s %s", id, step, message)
}

func Handler(ctx context.Context, event Event) error {
    // SAM定義から設定した環境変数より解凍先のS3情報の取得
    step := "Init"
    s3Endpoint := os.Getenv("S3_ENDPOINT")
    tgtArn, err := arn.Parse(os.Getenv("TARGET_S3_ARN"))
    log.Print("S3_ENDPOINT:", s3Endpoint)
    log.Printf("TARGET_S3_ARN:%#v", tgtArn)

    // S3イベント情報から必要なものを抽出
    step = "ParseEvent"
    src := parseEvent(event)
    logger(src.id, step, fmt.Sprintf("Source Object: %#v", *src))

    // S3からダウンロードしたファイルを保存するTempファイルを作成
    step = "CreateTempfile"
    tmpfile, err := ioutil.TempFile("/tmp", "srctmp_")
    if err != nil {
        return errors.New(err.Error())
    }
    defer os.Remove(tmpfile.Name())
    logger(src.id, step, fmt.Sprintf("tmpfilename: %s", tmpfile.Name()))

    // ダウンロード処理
    step = "SetClient"
    sess := session.Must(session.NewSession(&aws.Config{
        S3ForcePathStyle: aws.Bool(true),
        Region:           aws.String(src.region),
        Endpoint:         aws.String(s3Endpoint),
    }))
    downloader := s3manager.NewDownloader(sess, func(d *s3manager.Downloader) {
        // Option指定
        d.PartSize = 64 * 1024 * 1024
        d.Concurrency = 2
    })

    logger(src.id, step, "Download Start")
    n, err := downloader.Download(
        tmpfile,
        &s3.GetObjectInput{
            Bucket: aws.String(src.bucket),
            Key:    aws.String(src.key),
        })
    if err != nil {
    }
    logger(src.id, step, fmt.Sprintf("%d bytes downloaded", n))

    step = "UnzipTempFile"
    // S3からダウロードしてきたTempファイルを解凍
    r, err := zip.OpenReader(tmpfile.Name())
    if err != nil {
        return wrapError(err, step)
    }
    defer r.Close()
    logger(src.id, step, tmpfile.Name())

    step = "UploadToS3Bucket"
    // 解凍したファイルを宛先のS3にアップロード
    uploader := s3manager.NewUploader(sess)
    for _, f := range r.File {
        if !f.FileInfo().IsDir() {
            rc, err := f.Open()
            if err != nil {
                return wrapError(err, step)
            }
            defer rc.Close()
            _, err = uploader.Upload(&s3manager.UploadInput{
                Bucket: aws.String(tgtArn.Resource),
                Key:    aws.String(f.Name),
                Body:   rc,
            })
            if err != nil {
                return wrapError(err, step)
            }
            logger(src.id, step, fmt.Sprintf("%s uploaded to %s", f.FileInfo().Name(), f.Name))
        }
    }

    return nil
}

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

統合テスト

Unit Test以外にはSAM Localとlocalstackを使ってみました。

SAM Local

GitHub - awslabs/aws-sam-local: AWS SAM Local 🐿 is a CLI tool for local development and testing of Serverless applications

最新版をインストールしたいのでgo getで直接取得します。

$ go get github.com/awslabs/aws-sam-local 

使い方はこちらに書いた通りです。

AWS SAM ローカルを使用したサーバーレスアプリケーション(Golang版) - YOMON8.NET

イベントの形式を確認

形式や使い方を確認するにはこちらのレポジトリを確認するのが一番です。

aws-lambda-go/events at master · aws/aws-lambda-go · GitHub

コーディングするなかで、実際のイベント例を確認したければ以下のように出力できます。

$ aws-sam-local local generate-event s3 \
      --region ap-northeast-1 \
      --bucket zipfiles \
      --key upload > s3event.json

出力したファイルを確認してイベントのスキーマを確認できます。

{
  "Records": [
    {
      "eventVersion": "2.0",
      "eventTime": "1970-01-01T00:00:00.000Z",
      "requestParameters": {
        "sourceIPAddress": "127.0.0.1"
      },
      "s3": {
        "configurationId": "testConfigRule",
        "object": {
          "eTag": "0123456789abcdef0123456789abcdef",
          "sequencer": "0A1B2C3D4E5F678901",
          "key": "upload",
          "size": 1024
        },
        "bucket": {
          "arn": "arn:aws:s3:::zipfiles",
          "name": "zipfiles",
          "ownerIdentity": {
            "principalId": "EXAMPLE"
          }
        },
        "s3SchemaVersion": "1.0"
      },
      "responseElements": {
        "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH",
        "x-amz-request-id": "EXAMPLE123456789"
      },
      "awsRegion": "ap-northeast-1",
      "eventName": "ObjectCreated:Put",
      "userIdentity": {
        "principalId": "EXAMPLE"
      },
      "eventSource": "aws:s3"
    }
  ]
}

あとはこのjsonをSAM Localに渡してGoのハンドラ実行しながらデバックしたりします。UnitTestに組み込んでしまってもよいかもです。

aws-sam-local local invoke unzip -e envet.json  --profile default

localstack

GitHub - localstack/localstack: 💻 A fully functional local AWS cloud stack. Develop and test your cloud apps offline!

localstackですが今回はS3を利用したいので、以下のような docker-compose.yml 用意して実行しています。

version: "3.3"
 
services:
  localstack:
    container_name: localstack
    image: localstack/localstack
    ports:
      - "4572:4572"
    environment:
      - SERVICES=s3
      - DEFAULT_REGION=ap-northeast-1
      - DOCKER_HOST=unix:///var/run/docker.sock

aws cliからも endpoint-url を指定することでアクセスできます。

aws --endpoint-url=http://localhost:4572 s3 mb s3://zipfiles

Golangからアクセスするには上記のコードの以下の部分、特に S3ForcePathStyle を指定してあげることを注意してください。

   sess := session.Must(session.NewSession(&aws.Config{
        S3ForcePathStyle: aws.Bool(true),
        Region:           aws.String(src.region),
        Endpoint:         aws.String(s3Endpoint),
    }))

aws-sam-localとlocalstackも踏まえて以下のようにMakefile作っています。Makefile作っておくと後でCI回すのも便利です。 test-integration の部分が長いですが、実際にS3にアップロードして、Eventを生成トリガーしてテストしています。

PROJECT_NAME      := "lambdagolangsample"
STAGING_S3_BUCKET := "otomo-devel"
STAGING_S3_PREFIX := "package"
STACK_NAME        := "my-stack"

all: build

.PHONY: deps
deps:
    dep ensure

.PHONY: build
build: deps
    for d in $$(ls -d ./functions/*);do GOOS=linux GOARCH=amd64 go build $${d};done

.PHONY: test-unit
test-unit: build
    go test ./functions/...

.PHONY: test-integration
test-integration: test-unit
    docker-compose down
    zip -r testdata.zip ./test/data
    docker-compose -p $(PROJECT_NAME) up -d
    while true;do  if [ $$(docker-compose logs | grep "Ready" | wc -l) -gt 0 ];then break;else echo "waiting...";sleep 1;fi;done
    aws --endpoint-url=http://localhost:4572 s3 mb s3://zipfiles
    aws --endpoint-url=http://localhost:4572 s3 mb s3://unzippedfiles
    aws --endpoint-url=http://localhost:4572 s3 cp testdata.zip s3://zipfiles/testdata.zip
    aws-sam-local local generate-event s3 --region ap-northeast-1 --bucket zipfiles --key testdata.zip > ./test/env/s3event.json
    aws-sam-local local invoke Unzip -e ./test/env/s3event.json --env-vars ./test/env/sam-local-env.json \
        --docker-network $$(docker network ls -q -f name=$(PROJECT_NAME))
    aws --endpoint-url=http://localhost:4572 s3 cp s3://unzippedfiles/test/data/data.txt - | cmp test/data/data.txt -
    docker-compose -p $(PROJECT_NAME) down
    
.PHONY: deploy
deploy: build
    aws-sam-local package --s3-bucket $(STAGING_S3_BUCKET) --s3-prefix $(STAGING_S3_PREFIX) --template-file ./template.yaml --output-template-file  ./packaged.yaml
    aws-sam-local deploy --template-file ./packaged.yaml --stack-name $(STACK_NAME) --capabilities CAPABILITY_IAM

ビルド+デプロイ

ビルド

設計の部分でダウンロードしてきていたSAMのYAML定義ファイルを少し修正します。できたファイルがこちら。

AWSTemplateFormatVersion: '2010-09-09'
Transform: 'AWS::Serverless-2016-10-31'
Globals:
  Function:
    AutoPublishAlias: live
    DeploymentPreference:
      Type: AllAtOnce
Parameters:
  S3Endpoint:
    Type: String
    Default: ""
Resources:
  ZipFiles:
    Type: 'AWS::S3::Bucket'
  Unzip:
    Type: 'AWS::Serverless::Function'
    Properties:
      Handler: unzip
      Runtime: go1.x
      CodeUri: .
      Environment:
        Variables:
          TARGET_S3_ARN: !Sub ${UnzippedFiles.Arn}
          S3_ENDPOINT: !Ref S3Endpoint
      Policies:
        - AmazonS3ReadOnlyAccess
        - Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Action:
                - 's3:GetObject'
                - 's3:PutObject'
              Resource: !Sub ${UnzippedFiles.Arn}/*
      Description: Get and Unzip and Put
      Events:
        BucketZipFiles:
          Type: S3
          Properties:
            Bucket: !Ref ZipFiles
            Events: 's3:ObjectCreated:*'
  UnzippedFiles:
    Type: 'AWS::S3::Bucket'

後は以下のコマンドでMakefileの定義に従いサービスが展開されます。

make deploy

CI

CIにはCodeBuild使ってみます。

CodeBuild用のファイルを準備するのですが、Makefileに諸々書いているので、こちらは比較的すっきりです。

version: 0.2

env:
  variables:
    DOCKER_COMPOSE_VERSION: "1.19.0"
            
phases:
  install:
    commands:
      - sudo apt-get update
      - sudo apt-get install -y zip unzip make
      - sudo curl -L https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-`uname -s`-`uname -m` -o /usr/local/bin/docker-compose
      - go get -u github.com/golang/dep/cmd/dep
      - dep ensure
      - go get -u github.com/awslabs/aws-sam-local 
  build:
    commands:
      - make test-integration
  post_build:
    commands:
      - make deploy

CodeBuildの設定はこのような感じです。

動作確認

動作確認用に以下のようなディレクトリとファイルを準備しました。

$ tree sample/
sample/
├── dir1
│   ├── file1
│   ├── file2
│   └── file3
├── dir2
│   ├── file4
│   ├── file5
│   └── file6
└── dir3
    ├── file7
    ├── file8
    └── file9

ZIP化してアップロードしてみます。

CloudWatch Logで処理のログが出力されているのがわかります。

対象のS3バケットに無事に解凍されたファイルが存在しています。