AWS Lambdaで効率的にgoバイナリを実行する

最近LambdaとAPI Gatewayを結構使ってて、これはいいな、と思っていたところ、 AWS LambdaでJavaとNode.jsとGoの簡易ベンチマークをしてみた という記事を見かけたので、関連した記事を書いてみます。

前提条件

AWS Lambdaではnodejsなどが使えますが、golangは(今のところ)使えません。従って、golangで書きたい場合は、上記記事にある通り、

nodejsを起動 -> nodejsがgolangのバイナリをchild_process.spawn で起動

というやりかたとなります。

そのため、一回一回のリクエストのたびにプロセスを立ち上げるコストが掛かり、毎回500msecほどかかってしまう、という事になってしまいます。

この問題点を解消するライブラリが lambda_proc です。

lambda_proc

Lambdaは独自のコンテナで起動されています。起動したコンテナは一定時間存続し続け、リクエストのたびにそのコンテナが使いまわされます。 では、一回一回goのプロセスを起動し直すのではなく、起動しっぱなしにしておけばgoの起動コストはなくなるのではないか、というやり方が lambda_proc です。

nodeとgoのやりとりはstdin/stdoutを使います。具体的にはこのような感じです。

リクエスト
client -> node --(標準入力)--> go

リプライ
go --(標準出力)--> node -> client

lambda_procでは、node側でJSONを整形し、goに対して一行のJSONとして渡します (Line Delimited JSON)。 go側のヘルパーライブラリがJSONを整形し、goのメイン関数に渡します。

返答は、適当なstructを返すとlambda_procのヘルパーライブラリがJSONに整形してnodeに返してくれます。

実際にベンチマークにgoのソースコードは以下となります。標準出力に書いてしまうとnode側に渡ってしまうので、ログは標準エラー出力に書く必要があります。

package main

import (
     "encoding/json"
     "log"
     "os"

     "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/bitly/go-simplejson"
     "github.com/jasonmoo/lambda_proc"
)

// 標準エラー出力に書き出す
var logObj = log.New(os.Stderr, "", 0)

// 元記事のメイン部分の関数
func parse(jsonStr string) {
     js, _ := simplejson.NewJson([]byte(jsonStr))
     records := js.Get("Records")
     size := len(records.MustArray())

     for i := 0; i < size; i++ {
             record := records.GetIndex(i)

             logLn(record.Get("eventName").MustString())  // fmt.Printlnは使えない
             logLn(record.Get("eventId").MustString())
             logLn(record.Get("dynamodb").MustMap())
     }

     ddb := dynamodb.New(session.New(), aws.NewConfig().WithRegion("ap-northeast-1"))
     tableName := "mytest"
     keyValue := "test"
     attribute := dynamodb.AttributeValue{S: &keyValue}
     query := map[string]*dynamodb.AttributeValue{"id": &attribute}

     getItemInput := dynamodb.GetItemInput{
             TableName: &tableName,
             Key:       query,
     }

     obj, _ := ddb.GetItem(&getItemInput)
     logLn(obj)
}

// fmt.Printlnをして標準出力に書き込むと、js側でparseしてしまうので、標準エラー出力に書き出す
func logLn(a ...interface{}) {
     logObj.Println(a...)
}

// なにかstructを返さなければいけないのでダミーの構造体を作成。普通に書くと、むしろstructを返せたほうがいいでしょう
type Return struct {
     Id    string
     Value string
}

// メインとなる関数
func handlerFunc(context *lambda_proc.Context, eventJSON json.RawMessage) (interface{}, error) {
     parse(string(eventJSON))
     return Return{Id: "test", Value: "somevalue"}, nil
}

// mainではlambda_procに登録する
func main() {
     lambda_proc.Run(handlerFunc)
}

ベンチマーク

Lambdaには標準でテスト用のJSONが用意されています。今回は DynamoDB Update のテスト用JSONを手元に保存しました。 curlから叩ける用にLambdaのAPI Endpointを用意し、以下のスクリプトで0.5秒おきにcurlを10回起動しました。

for I in `seq 1 10`
do
curl -X POST -H "Content-type: application/json" --data @body.json https://hoge.execute-api.ap-northeast-1.amazonaws.com/prod/benchmark
sleep 0.5
done

これを実行すると、

Duration

Billed Duration

Used Memory

367.42 ms

400 ms

14 MB

36.92 ms

100 ms

14 MB

44.00 ms

100 ms

14 MB

46.05 ms

100 ms

14 MB

61.44 ms

100 ms

15 MB

50.48 ms

100 ms

15 MB

Duration

Billed Duration

Used Memory

393.30 ms

400 ms

14 MB

44.13 ms

100 ms

14 MB

47.99 ms

100 ms

14 MB

52.30 ms

100 ms

14 MB

という二つのログストリームがCloudWatchに出てきました。

このログから2つのコンテナが使われていることが分かります。また、初回は400msecと時間がかかりますが、以降のリクエストに対しては40msec程度しかかかっていません。もちろんメモリも最小限です。今回は10回しか呼び出しをしていませんが、もっと大量でも構いません。また、一回の実行制限時間を長くすればもっと長生きになり、起動コストはかなり無視できる範囲に収まると思います。

ログ出力には注意

上記コード中にも記載していますが、ログ出力に fmt.Println を使ってしまうと標準出力に書きだされてしまい、node側に伝わってしまいます。そのため、標準エラー出力に書き出すようにしています。これで解決はできますが、loggingライブラリを使う時は気をつけたほうがいいでしょう。

goのプログラムがシンプルになった

なお、今回lambda_procを使った副次的な効果として、goのプログラムがシンプルになりました。

普通のアプリケーションサーバーだと、HTTPを意識してcontextやらいろいろなことを扱う必要がありました。しかし、この方式では、 標準入力/出力しか 関係ありません。HTTPに関連することはすべてAWS Lambda (およびAPI Gateway)がまかないます。goは標準入出力だけを見ればよく、しかもその形式は標準的となったJSONです。

この制限により、実装する範囲を狭めることになり、非常に処理が書きやすくなりました。

また、テストもしやすくなりました。従来であれば、 "net/http/httptest" を立てて、とか考える必要がありますが、標準入出力だけですみます。

まとめ

lambda_proc を使うことで、AWS Lambda上でgoのプログラムを効率的に呼び出せることを示しました。これにより、たまに行う処理だけでなく、がんがんくるリクエストをさばく用途にもgoが使えるのではないか、と思います。

lambdaは無料でかなりのコンピュータ資源を使えるので、うまく使って費用を節約していきたいですね。

Comments

comments powered by Disqus