GO + Lambda + DynamoDBでAPIを作ってみる

CRUDAPI作ってlambda関数として登録、DBはDynamoDBを使ったものを作っておくのは便利そうだからやってみたい。フロントはS3でウェブホスティングすれば、料金的にもかなり安く済みそう。

APIはGOで作ってみたいが、作り方がよくわからん。前にGOちょっとやってたけど、忘れてしまった。良い感じのチュートリアルがあるので、これに沿ってやっていきたいと思う。

How to build a Serverless API with Go and AWS Lambda

GETとPOSTのAPIができるみたいです。 いってみよう。

awscliのインストール

まずは、ターミナル上でAWSをいじれるようにするためにcliをインストール。

AWS CLI のインストールと設定 - Amazon Kinesis Data Streams

$ curl "https://bootstrap.pypa.io/get-pip.py" -o "get-pip.py"
$ sudo python get-pip.py
$ sudo pip install awscli
$ aws --version
// これでバージョン表記が出てきたらOK
matplotlib 1.3.1 requires nose, which is not installed.
matplotlib 1.3.1 requires tornado, which is not installed.

こんなかんじのエラーが出てきたら、これらをinstallしましょう。

$ sudo pip install tornado
$ sudo pip install nose

sixがすでに入っているのが原因みたいなので、以下のコマンドを実行。

$ sudo pip install awscli --upgrade --ignore-installed six

ユーザー作成

ポリシー等をいじっていくので、それなりの権限のあるユーザーを作成しよう。試すだけなので、Administrator権限を持ったユーザーを作成。まずは、グループを作る。

f:id:utr066:20180809203655p:plain

f:id:utr066:20180809203700p:plain

グループを作ったらそのグループに新たにユーザーを作って割り当てる。

f:id:utr066:20180809203839p:plain

f:id:utr066:20180809203844p:plain

ユーザーを作成したら、access-keyなどが書かれているcsvをダウンロードしておこう。

$ aws configure
AWS Access Key ID [None]: 取得したaccess-key-ID
AWS Secret Access Key [None]: 取得したsecret-access-key
Default region name [None]: ap-northeast-1
Default output format [None]: json

ここで、ターミナルでawsをいじれるIAMを選択しているよ。指定するのは、さっき作ったユーザー。

cliで何か実行した時に権限がないよーとか言われたらここで設定したaccess-key等を持っているユーザーがその権限を持っているかどうか確認しよう。

コード

$ mkdir books && cd books
$ touch main.go
$ go get github.com/aws/aws-lambda-go/lambda

main.go

package main

import (
    "github.com/aws/aws-lambda-go/lambda"
)

type book struct {
    ISBN   string `json:"isbn"`
    Title  string `json:"title"`
    Author string `json:"author"`
}

func show() (*book, error) {
    bk := &book{
        ISBN:   "978-1420931693",
        Title:  "The Republic",
        Author: "Plato",
    }

    return bk, nil
}

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

ロール作成

ラムダ機能が実行されているときに許可する権限を定義するIAMロールを設定する必要があります。

/tmp/trust-policy.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Service": "lambda.amazonaws.com"
            },
            "Action": "sts:AssumeRole"
        }
    ]
}

これをlambdaに割り当てなければなりません。

aws iam create-role --role-name lambda-books-executor \
--assume-role-policy-document file:///tmp/trust-policy.json

role-nameをlambda-books-executorとして、これにさっきのファイルを適用。aws configureで設定したユーザーに権限があれば、エラーがなく通るはず。これで、lambda-books-executorというroleにさっきのポリシーが適用される。

ここで表示されるARNはあとで必要なのでメモっておきましょう。

lambda-books-executorというroleが作成されたけど、こいつの権限がないので作る。

$ aws iam attach-role-policy --role-name lambda-books-executor \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

ぶっちゃけよくわからん。。

バイナリをzipにする

$ env GOOS=linux GOARCH=amd64 go build -o /tmp/main
$ zip -j /tmp/main.zip /tmp/main
$ aws lambda create-function --function-name books --runtime go1.x \
--role arn:aws:iam::〇〇:role/lambda-books-executor \
--handler main --zip-file fileb:///tmp/main.zip

〇〇の部分はARNの番号を入れる。

実行するとlambda関数ができるので、ブラウザから見てみるといいです。lambda関数の作成には少し時間がかかります。

f:id:utr066:20180808144408p:plain

ブラウザからテストを実行するとこんな感じ。

ターミナルからだとこう。

~/projects/books>aws lambda invoke --function-name books /tmp/output.json
{
    "ExecutedVersion": "$LATEST",
    "StatusCode": 200
}
~/projects/books>cat /tmp/output.json
{"isbn":"978-1420931693","title":"The Republic","author":"Plato"}

lambdaから返された結果が出ていますね。

DynamoDBのテーブル作成

aws dynamodb create-table --table-name Books \
--attribute-definitions AttributeName=ISBN,AttributeType=S \
--key-schema AttributeName=ISBN,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

ブラウザから見ると、Booksテーブルができてる。

aws dynamodb put-item --table-name Books --item '{"ISBN": {"S": "978-1420931693"}, "Title": {"S": "The Republic"}, "Author":  {"S": "Plato"}}'

上記コマンドでデータを追加すると追加される。

GOでAPI作成

db.go

package main

import (
    "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"
)

// Declare a new DynamoDB instance. Note that this is safe for concurrent
// use.
var db = dynamodb.New(session.New(), aws.NewConfig().WithRegion("ap-northeast-1"))
//regionは自分のawsに沿って設定してください

func getItem(isbn string) (*book, error) {
    // Prepare the input for the query.
    input := &dynamodb.GetItemInput{
        TableName: aws.String("Books"),
        Key: map[string]*dynamodb.AttributeValue{
            "ISBN": {
                S: aws.String(isbn),
            },
        },
    }

    // Retrieve the item from DynamoDB. If no matching item is found
    // return nil.
    result, err := db.GetItem(input)
    if err != nil {
        return nil, err
    }
    if result.Item == nil {
        return nil, nil
    }

    // The result.Item object returned has the underlying type
    // map[string]*AttributeValue. We can use the UnmarshalMap helper
    // to parse this straight into the fields of a struct. Note:
    // UnmarshalListOfMaps also exists if you are working with multiple
    // items.
    bk := new(book)
    err = dynamodbattribute.UnmarshalMap(result.Item, bk)
    if err != nil {
        return nil, err
    }

    return bk, nil
}

main.go

package main

import (
    "github.com/aws/aws-lambda-go/lambda"
)

type book struct {
    ISBN   string `json:"isbn"`
    Title  string `json:"title"`
    Author string `json:"author"`
}

func show() (*book, error) {
    // Fetch a specific book record from the DynamoDB database. We'll
    // make this more dynamic in the next section.
    bk, err := getItem("978-0486298238")
    // DBにあるISBNを引数にしましょう。
    if err != nil {
        return nil, err
    }

    return bk, nil
}

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

コードの変更に伴ってlambda関数もアップデートする必要があります。再度zipにしてupdateしましょう。

$ env GOOS=linux GOARCH=amd64 go build -o /tmp/main
$ zip -j /tmp/main.zip /tmp/main
$ aws lambda update-function-code --function-name books \
--zip-file fileb:///tmp/main.zip
$ aws lambda invoke --function-name books /tmp/output.json
$  cat /tmp/output.json

このまま上記のようにして、lambdaを実行すると AccessDeniedExceptionが出てしまうので、ポリシーを編集する。

/tmp/privilege-policy.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:PutItem",
                "dynamodb:GetItem"
            ],
            "Resource": "*"
        }
    ]
}

アタッチ

$ aws iam put-role-policy --role-name lambda-books-executor \
--policy-name dynamodb-item-crud-role \
--policy-document file:///tmp/privilege-policy.json

もう一度zipにして、lambdaを実行。

$ env GOOS=linux GOARCH=amd64 go build -o /tmp/main
$ zip -j /tmp/main.zip /tmp/main
$ aws lambda update-function-code --function-name books \
--zip-file fileb:///tmp/main.zip
~/projects/books>aws lambda invoke --function-name books /tmp/output.json
{
    "ExecutedVersion": "$LATEST",
    "StatusCode": 200
}
~/projects/books>cat /tmp/output.json
{"isbn":"978-1420931693","title":"The Republic","author":"Plato"}~/projects/books>

今度はうまく表示されますね。

api gatewayを作成する

lambdaからdaynamoDBにアクセスしてデータを取ってくることはできたけど、APIGatewayからはまだできていない。 APIGatewayを使って、HTTPS経由でlamdba関数にアクセスする。

apigatewayを作成します。

$ aws apigateway create-rest-api --name bookstore

ここで出てくるidを書き留めておく。

{
    "apiKeySource": "HEADER",
    "name": "bookstore",
    "createdDate": 909099898,
    "endpointConfiguration": {
        "types": [
            "EDGE"
        ]
    },
    "id": rest-api-i
}
aws apigateway get-resources --rest-api-id 書き留めていたid

ここでもidが出てくるので、メモしておく。

{
    "items": [
        {
            "path": "/",
            "id": root-path-id
        }
    ]
}

/配下にbooksを追加する。

aws apigateway create-resource --rest-api-id rest-api-id \
--parent-id root-path-id --path-part books

今まで書き留めていた2つのidをここで使う。

実行するとこんな感じに出てくる

{
    "path": "/books",
    "pathPart": "books",
    "id": resource-id,
    "parentId": root-path-id
}

HTTPメソッドを登録する。

$ aws apigateway put-method --rest-api-id rest-api-id \
--resource-id resource-id --http-method ANY \
--authorization-type NONE

結果は以下のようになる。

{
    "apiKeyRequired": false,
    "httpMethod": "ANY",
    "authorizationType": "NONE"
}

リソースをlambda関数に統合する。

aws apigateway put-integration --rest-api-id rest-api-id \
--resource-id resource-id --http-method ANY --type AWS_PROXY \
--integration-http-method POST \
--uri arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:account-id:function:books/invocations

uriのarnは以下の形式になるので、自分のarnを確認して見ましょう。ブラウザから作成したlambda関数の右上で確認できます。

arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/your-lambda-function-arn/invocations

テストリクエストを送る

$ aws apigateway test-invoke-method --rest-api-id rest-api-id --resource-id resource-id --http-method "GET"
"status": 500,
    "body": "{\"message\": \"Internal server error\"}",

実行するとInternalServerErrorが起きていますね。 これは、作成したbookstoreAPIGateWayにlambda関数を実行する権限がないため起こるようです。なので、権限をこのAPIGatewayに与えます。

エラーが起きないようにするために実行。

$ aws lambda add-permission --function-name books --statement-id a-GUID \
--action lambda:InvokeFunction --principal apigateway.amazonaws.com \
--source-arn arn:aws:execute-api:ap-northeast-1:account-id:rest-api-id/*/*/*

自分のrest-apiとaccount-idを使う。account-idはブラウザから確認できるlambda関数の右上にある数字。

$ aws apigateway test-invoke-method --rest-api-id rest-api-id --resource-id resource-id --http-method "GET"

実行するとまたエラーですね。

"status": 502,
"body": "{\"message\": \"Internal server error\"}",

修正します。

$ go get github.com/aws/aws-lambda-go/events

ファイルを編集する

main.go

package main

import (
    "encoding/json"
    "log"
    "net/http"
    "os"
    "regexp"

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

var isbnRegexp = regexp.MustCompile(`[0-9]{3}\-[0-9]{10}`)
var errorLogger = log.New(os.Stderr, "ERROR ", log.Llongfile)

type book struct {
    ISBN   string `json:"isbn"`
    Title  string `json:"title"`
    Author string `json:"author"`
}

func show(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    // Get the `isbn` query string parameter from the request and
    // validate it.
    isbn := req.QueryStringParameters["isbn"]
    if !isbnRegexp.MatchString(isbn) {
        return clientError(http.StatusBadRequest)
    }

    // Fetch the book record from the database based on the isbn value.
    bk, err := getItem(isbn)
    if err != nil {
        return serverError(err)
    }
    if bk == nil {
        return clientError(http.StatusNotFound)
    }

    // The APIGatewayProxyResponse.Body field needs to be a string, so
    // we marshal the book record into JSON.
    js, err := json.Marshal(bk)
    if err != nil {
        return serverError(err)
    }

    // Return a response with a 200 OK status and the JSON book record
    // as the body.
    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusOK,
        Body:       string(js),
    }, nil
}

// Add a helper for handling errors. This logs any error to os.Stderr
// and returns a 500 Internal Server Error response that the AWS API
// Gateway understands.
func serverError(err error) (events.APIGatewayProxyResponse, error) {
    errorLogger.Println(err.Error())

    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Body:       http.StatusText(http.StatusInternalServerError),
    }, nil
}

// Similarly add a helper for send responses relating to client errors.
func clientError(status int) (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{
        StatusCode: status,
        Body:       http.StatusText(status),
    }, nil
}

func main() {
    lambda.Start(show)
}
$ env GOOS=linux GOARCH=amd64 go build -o /tmp/main
$ zip -j /tmp/main.zip /tmp/main
$ aws lambda update-function-code --function-name books \
--zip-file fileb:///tmp/main.zip

これでデプロイできる。

aws apigateway create-deployment --rest-api-id rest-api-id \
--stage-name staging
{
    "id": "〇〇",
    "createdDate": 〇〇
}

デプロイが完了したので、値が返ってくるか確認。

$ aws apigateway test-invoke-method --rest-api-id rest-api-id \
--resource-id resource-id --http-method "GET" \
--path-with-query-string "/books?isbn=DBに存在するISBN"
"status": 200,
"body": "{\"isbn\":\"978-1420931693\",\"title\":\"The Republic\",\"author\":\"Plato\"}",

statusが200で返ってきました。

デプロイしたら、URLで値が返ってくるのを確認したい。

curl https://rest-api-id.execute-api.ap-northeast-1.amazonaws.com/staging/books?isbn=存在するデータのISBN
{"isbn":"978-1420931693","title":"The Republic","author":"Plato"}

こんな感じで返ってきます。

createもできるようにする

db.go

package main

import (
    "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 db = dynamodb.New(session.New(), aws.NewConfig().WithRegion("ap-northeast-1"))

func getItem(isbn string) (*book, error) {
    input := &dynamodb.GetItemInput{
        TableName: aws.String("Books"),
        Key: map[string]*dynamodb.AttributeValue{
            "ISBN": {
                S: aws.String(isbn),
            },
        },
    }

    result, err := db.GetItem(input)
    if err != nil {
        return nil, err
    }
    if result.Item == nil {
        return nil, nil
    }

    bk := new(book)
    err = dynamodbattribute.UnmarshalMap(result.Item, bk)
    if err != nil {
        return nil, err
    }

    return bk, nil
}

// Add a book record to DynamoDB.
func putItem(bk *book) error {
    input := &dynamodb.PutItemInput{
        TableName: aws.String("Books"),
        Item: map[string]*dynamodb.AttributeValue{
            "ISBN": {
                S: aws.String(bk.ISBN),
            },
            "Title": {
                S: aws.String(bk.Title),
            },
            "Author": {
                S: aws.String(bk.Author),
            },
        },
    }

    _, err := db.PutItem(input)
    return err
}

main.go

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "regexp"

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

var isbnRegexp = regexp.MustCompile(`[0-9]{3}\-[0-9]{10}`)
var errorLogger = log.New(os.Stderr, "ERROR ", log.Llongfile)

type book struct {
    ISBN   string `json:"isbn"`
    Title  string `json:"title"`
    Author string `json:"author"`
}

func router(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    switch req.HTTPMethod {
    case "GET":
        return show(req)
    case "POST":
        return create(req)
    default:
        return clientError(http.StatusMethodNotAllowed)
    }
}

func show(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    isbn := req.QueryStringParameters["isbn"]
    if !isbnRegexp.MatchString(isbn) {
        return clientError(http.StatusBadRequest)
    }

    bk, err := getItem(isbn)
    if err != nil {
        return serverError(err)
    }
    if bk == nil {
        return clientError(http.StatusNotFound)
    }

    js, err := json.Marshal(bk)
    if err != nil {
        return serverError(err)
    }

    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusOK,
        Body:       string(js),
    }, nil
}

func create(req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    if req.Headers["Content-Type"] != "application/json" {
        return clientError(http.StatusNotAcceptable)
    }

    bk := new(book)
    err := json.Unmarshal([]byte(req.Body), bk)
    if err != nil {
        return clientError(http.StatusUnprocessableEntity)
    }

    if !isbnRegexp.MatchString(bk.ISBN) {
        return clientError(http.StatusBadRequest)
    }
    if bk.Title == "" || bk.Author == "" {
        return clientError(http.StatusBadRequest)
    }

    err = putItem(bk)
    if err != nil {
        return serverError(err)
    }

    return events.APIGatewayProxyResponse{
        StatusCode: 201,
        Headers:    map[string]string{"Location": fmt.Sprintf("/books?isbn=%s", bk.ISBN)},
    }, nil
}

func serverError(err error) (events.APIGatewayProxyResponse, error) {
    errorLogger.Println(err.Error())

    return events.APIGatewayProxyResponse{
        StatusCode: http.StatusInternalServerError,
        Body:       http.StatusText(http.StatusInternalServerError),
    }, nil
}

func clientError(status int) (events.APIGatewayProxyResponse, error) {
    return events.APIGatewayProxyResponse{
        StatusCode: status,
        Body:       http.StatusText(status),
    }, nil
}

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

コードを更新したのでlambdaを更新します。

$ env GOOS=linux GOARCH=amd64 go build -o /tmp/main
$ zip -j /tmp/main.zip /tmp/main
$ aws lambda update-function-code --function-name books \
--zip-file fileb:///tmp/main.zip

create関数を動かして、dynamoDB更新

curl -i -H "Content-Type: application/json" -X POST \
-d '{"isbn":"978-0141439587", "title":"Emma", "author": "Jane Austen"}' \
https://rest-api-id.execute-api.ap-northeast-1.amazonaws.com/staging/books

show関数を動かす

curl https://rest-api-id.execute-api.ap-northeast-1.amazonaws.com/staging/books?isbn=978-0141439587

POSTの場合、BadRequestが出てしまう。

create関数の中のこれがあるとなんだかうまくいかないのでコメントアウトし、再度zipにしてlambda更新。

if req.Headers["Content-Type"] != "application/json" {
    return clientError(http.StatusNotAcceptable)
}

これでDynamoDBをみてみるとcurlでPOSTしたデータが入っているはず。

まとめ

むずすぎいいいいいいいいいいいい。ぶっちゃけわからない部分あるなあ。今度はCLIじゃなくてAWSのコンソールからやってみるのもありだ。