Serverless Framework を使い、AWS の Lambda(Go) で Lambdaレイヤーを活用する方法を調べてみました。
目次
背景
複数のLambda関数群で構成されたサービスを開発していると、各関数共通で使用する処理をライブラリ化したくなってきます。
今回は AWS Lambda レイヤーを使用して、複数のLambdaで使用できるレイヤーを作成してみたので一例を紹介します。
環境
Lambda 関数とレイヤーのデプロイに Serverless Framework を使用しています。
Serverless Framework はNode.jsで作られたCLIツールで AWS Lambda、Azure Functions、Google Cloud Functions等の Serverless Applicationを構成管理、デプロイするためのツールです。
また、Lambda関数のランタイムとしてGo言語を、goのビルドにmakeを使用しました。
各ツールのインストールや環境構築については割愛します。
AWS Lambda レイヤーとは
AWS Lambda レイヤー
https://docs.aws.amazon.com/ja_jp/lambda/latest/dg/configuration-layers.html
追加のコードとコンテンツをレイヤーの形式で取り込むように Lambda 関数を設定することができます。
ドキュメントのとおりですが、Lambda 関数で使用するコードやコンテンツをレイヤーにすることで使い回すことができるようになります。
注意したいのはレイヤーの数とサイズに関しての Lambda 関数の制限です。
- 一度に使用できるレイヤーは5つまで
- zip展開後のデプロイパッケージサイズはレイヤーを含めて250MB
またレイヤーは、関数実行環境の /opt
ディレクトリに抽出される、とあります。
Go 言語の場合
Go言語のコードは依存するパッケージを含め単一のバイナリにコンパイルされます。
ただ、Go1.8でリリースされた標準パッケージの plugin
パッケージを使用することによって、Goで作成された共通ライブラリ(*.so)を動的にロードできます。
この仕組を使って、共通ライブラリをLambdaのレイヤーにしてみたいと思います。
Go の plugin パッケージ
plugin
パッケージにはいくつか制限があります。
main
パッケージからエクスポートされた(=大文字で始まる)関数と変数のみをロードできます。- 静的型付けのため、ロードされたすべてのシンボルを正しい型にキャストする必要があります。
- Linux, FreeBSD, macOS でのみ機能します(記事執筆時点)。
実際に小さなサンプルを作ってみます。
まず、プラグインとなる myplugin.go
を作成します。
package main import "fmt" var ValueX int func FunctionX() { fmt.Printf("Hello, number %d\n", ValueX) }
main
パッケージですが、main関数は記述しません。
ValueX
と FunctionX
をエクスポートしています。
myplugin.go
をプラグインとしてビルドするには、-buildmode=plugin
を指定します。
$ go build -buildmode=plugin myplugin.go
ソースコードと同じ階層に myplugin.so
が出力されますが、出力先を指定する場合は -o
オプションを使用します。
$ go build -buildmode=plugin -o myplugin.so myplugin.go
これで、プラグイン(共通ライブラリ)が作成できました。
プラグインを使用するコードは次のとおりです。
package main import "plugin" func main() { myPlugin, err := plugin.Open("myplugin.so") if err != nil { panic(err) } valueXSymbol, err := myPlugin.Lookup("ValueX") if err != nil { panic(err) } funcXSymbol, err := myPlugin.Lookup("FunctionX") if err != nil { panic(err) } // シンボルをキャスト valX := valueXSymbol.(*int) funcX := funcXSymbol.(func()) *valX = 123 funcX() // prints "Hello, number 123" }
プラグインを読み込み、シンボルを取得、キャストしてから使用します。
プロジェクトの作成
Goのplugin
パッケージの使い方がわかったので、AWS Lambda レイヤーの動作が確認できるプロジェクトを作成します。
プロジェクトのテンプレートを作成します。
$ sls create --template aws-go
モジュール名は linkode.co.jp/TestLayer
としました。
$ go mod init linkode.co.jp/TestLayer
フォルダー構成は次のようにしました。
./ │ .gitignore │ go.mod │ Makefile │ serverless.yml ├─handler │ └─TestLayerA │ main.go └─usecase └─nlp kagome.go
/handler/TestLayerA/main.go
がLambda関数のソースコードで、/usecase/nlp/kagome.go
がレイヤーにするプラグインのソースコードです。
ビルドの準備
ソースコードの実装
kagome.go(プラグイン)
kagome.go
は日本語の形態素解析が行えるKagome (github.com/ikawaha/kagome)を使って、入力された日本語の文章をわかち書きする、Wakati
という関数をエクスポートしたプラグインにしました。
package main import ( "log" "github.com/ikawaha/kagome-dict/ipa" "github.com/ikawaha/kagome/v2/tokenizer" ) func Wakati(input string) []string { t, err := tokenizer.New(ipa.Dict()) if err != nil { log.Fatalf("Failed to tokenizer.New: %s", err) } return t.Wakati(input) }
main.go(Lambda関数)
Lambda関数となる main.go
では、プラグインを呼び出して、"すもももももももものうち"
をわかち書きし、結果をログに出力してます。
プラグインは、/opt
に配置されていることを前提にしています。
実運用の際は、パス /opt
は直接コードに記述せず環境変数等にしておくことでローカルでのテストにも対応できます。
package main import ( "log" "plugin" "strings" "github.com/aws/aws-lambda-go/lambda" ) // Handler is lambda handler func Handler() { nlpPlugin, err := plugin.Open("/opt/kagome.so") if err != nil { log.Fatalf("Failed to plugin.Open: %s", err) } wakatiSymbol, err := nlpPlugin.Lookup("Wakati") if err != nil { log.Fatalf("Failed to nlpPlugin.Lookup: %s", err) } // シンボルをキャスト wakatiFunc := wakatiSymbol.(func(string) []string) seg := wakatiFunc("すもももももももものうち") log.Println(seg) } func main() { lambda.Start(Handler) }
Makefile の編集
Makefileは次のように編集します。
.PHONY: build clean deploy build: buildUsecase env GOOS=linux go build -ldflags="-s -w" -o bin/handler/TestLayerA/app.lambda_handler handler/TestLayerA/main.go buildUsecase: env GOOS=linux go build -ldflags="-s -w" -buildmode=plugin -o bin/usecase/nlp/kagome.so usecase/nlp/kagome.go clean: rm -rf ./bin deploy: clean build sls deploy --verbose
main.go
は通常のビルドですが、kagome.go
はプラグインとしてビルドするように指定しています。
これで、ビルドの準備が整いました。
デプロイの設定
Lambda関数で使用するレイヤーの定義や指定は serverless.yml
で行います。
レイヤーの定義は layersプロパティで行います。
レイヤーの名称はNLP
としました。
layers: NLP: path: bin/usecase/nlp compatibleRuntimes: - go1.x
定義したレイヤーをLambda関数から使うには functions
プロパティで次のように記述します。
functions: TestLayerA: package: exclude: - ./** include: - ./bin/handler/TestLayerA/** handler: bin/handler/TestLayerA/app.lambda_handler layers: - { Ref: NLPLambdaLayer }
レイヤーの指定には、AWS CloudFormation の組み込み関数 Ref が使用できます。
レイヤー名は、TitleCaseで末尾に LambdaLayer
を付与する必要があります。
serverless.yml
全体は次のとおりです。
service: testlayer frameworkVersion: '>=1.28.0 <2.0.0' provider: name: aws runtime: go1.x stage: dev region: us-east-2 iamRoleStatements: - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents Resource: - "*" layers: NLP: path: bin/usecase/nlp compatibleRuntimes: - go1.x package: individually: true # 関数を個別にパッケージします functions: TestLayerA: package: exclude: - ./** include: - ./bin/handler/TestLayerA/** handler: bin/handler/TestLayerA/app.lambda_handler layers: - { Ref: NLPLambdaLayer }
動作確認
make
コマンドでビルドを行い、Serverless Framework のコマンド sls deploy
でデプロイして動作確認をしてみます。
Lambda関数を実行して、ログを確認します。
$ sls invoke -f TestLayerA --log
2020/10/02 09:26:17 [すもも も もも も もも の うち]
無事に、意図したログが確認できました。
まとめ
手元の環境で確認したところ、
Lambda関数本体の TestLayerA.zip
が約 3.7MB
レイヤーにしている NLP.zip
が約 12.5MB
両方を展開して合計したサイズは 約 23.4MB
でした。
Kagome (github.com/ikawaha/kagome) が、形態素解析のための辞書を同梱しているため、比較的大きなサイズになっていると考えられます。
今回のような比較的大きく変更頻度が少ないモジュールをレイヤーにして各Lambda関数から使用することで、ビルドやデプロイの時間短縮ができそうです。
逆に、小さくなってしまったり頻繁に変更する必要があるモジュールはレイヤー化の恩恵が受けられず、呼び出し側となる関数のソースコードが冗長になってしまうだけなので、使い所と制限を見極めて使用するのが良さそうです。