最初に3行でまとめ
- GoでAWS Lambdaのハンドラを実装した場合に、手元から同じ処理を実行したり開発中の動作確認のため、CLIコマンドとしても実行できると便利です
- そのための、とてもシンプルなwrapperライブラリを書きました lamblocal といいます
- 環境変数を読み込めるCLI flag parserと組み合わせるといいかんじです
Lambdaを実装する言語
最近、自分はAWS Lambdaの関数をGoで書くことがほとんどです。
個別のランタイムがある言語で書くとランタイムのバージョンアップやEoL対応が必要になって面倒だったりしますが、Goで書いてシングルバイナリをbootstrap
という名前でzipに含めてカスタムランタイム(provided.al2
) で動かすとそういう煩わしさがありません。ARM対応もビルド時に環境変数を指定するだけなので極めて簡単です。
GoでLambda関数を書くためには、aws/aws-lambda-goを使って以下のようにします。
package main import ( "github.com/aws/aws-lambda-go/lambda" ) func hello() (string, error) { return "Hello λ!", nil } func main() { // Make the handler available for Remote Procedure Call by AWS Lambda lambda.Start(hello) }
これを go build -o bootstrap main.go
としてLinux向けにビルドしてやると 1、Lambdaのハンドラとして動作します。アーキテクチャはLambdaの設定により、GOARCH=amd64
またはarm64
です。
Lambda関数を手元でも動かしたい
デバッグや開発中の動作確認のため、手元でこのハンドラを実行したいことがあります。また、lambdaとして便利な関数は、手元でも単体のコマンドとして実行できると便利な場面が結構あったりします。
公式には、AWS SAM CLIを使うことでローカル実行ができたりするようですが、ここでは触れません。
Goで書かれたLambda関数ハンドラをLambdaでもそれ以外の環境でも実行できるコマンド(バイナリ)にするためには、以下のようにすればよいのです。
- 実行時に環境がLambda上かどうかを判別する
- Lambda上であれば
lambda.Start()
にハンドラを渡して実行する - そうでない場合、ハンドラの関数に適当な入出力を与えて実行する
実行時の環境がLambda上であることは、環境変数から次のようにして判別できます。2
AWS_EXECUTION_ENV
がAWS_Lambda
で始まっていること (言語別ランタイムの場合)
または
AWS_LAMBDA_RUNTIME_API
が設定されていること (カスタムランタイムの場合)
Lambdaでは入出力はランタイム側で処理されますが、それ以外の環境では標準入出力を使えばよいでしょう。
簡単にやるためのライブラリを書いた
ということで、これを簡単にやるためのlamblocalというライブラリを書きました。実装を見ると分かりますが、本当にシンプルなものです。使用例は次の通りです。lambda.Start()
の代わりにlamblocal.Run()
を呼ぶだけです。
これで作ったバイナリは、Lambda上ではLambda関数として、そうでない環境では単体コマンドとして実行できます。
package main import ( "context" "github.com/aws/aws-lambda-go/events" "github.com/fujiwara/lamblocal" ) func handler(ctx context.Context, payload events.CloudWatchEvent) (string, error) { return payload.ID, nil } func main() { lamblocal.Run(context.TODO(), handler) }
handlerの第2引数(paload)は標準入力から受け付けた内容をJSONとして解釈して任意の型で渡せるようになっているので、型に応じた適当なJSONを喰わせてください。関数の出力は、任意の型をJSONとしてencodeして標準出力に出力されます。
$ echo '{"id":"xxx"}' | go run main.go "xxx"
payloadがない関数(第2引数を _
にする)の場合は実行開始後、標準入力を閉じてください(端末からCtrl-Dを入力)。
// payloadがない関数 func handler(ctx context.Context, _ interface{}) (string, error) { return "OK", nil }
なおlamblocal.Run()
はジェネリクスを使っていて、func Run[T any, U any](ctx context.Context, fn func(context.Context, T) (U, error))
という型になっています。payloadと戻り値には任意の型(any)を扱えますが、jsonとしてUnmarshal/Marshalできる型であることを前提としています。
ハンドラとして渡せる関数のインターフェースは func(context.Context, any) (any, error)
のみです。
lambda.Start()
ではあらゆる型(interface{}
)を引数に取って実行時に型チェックを行う作りになっていて、ビルドはできていても実行できない関数を作ってしまうことが稀にありました。lamblocalでは型を絞ることでその可能性を減らしています。
設定値などの管理方法
Lambdaでは、環境変数で設定値などを渡すのが前提です。CLIでも同様に環境変数を使ってもよいのですが、コマンドライン引数として渡せると便利ですよね。
ここでは応用例として、alecthomas/kong というコマンドラインパーサーと組み合わせる例を紹介します。
kongでは、コマンドライン引数をstructとして定義し、structのタグでデフォルト値やどの環境変数から値を読み取るかを定義できます。
type CLI struct { Foo string `help:"This is Foo." default:"foo" env:"FOO"` }
このように定義すると、CLI.Fooの値は デフォルト値が foo
、環境変数 FOO=bar
が設定されていれば bar
、コマンドライン引数 --foo=baz
が与えられた場合には baz
、となります。つまり適当なデフォルト値を設定した上で、Lambda上で実行される場合は環境変数から、CLIとして実行する場合は引数から上書きできますし、その値が何かという説明も明示的にhelp
として記述できます。
環境変数のみで処理しようとすると得てしてコード内にos.Getenv()
が散在したりしますが、このようにまとめておくことで設定値を適切に管理できますね。
具体的な例はリポジトリのexamples/kongにありますが、次のようになります。
package main import ( "context" "github.com/alecthomas/kong" "github.com/fujiwara/lamblocal" ) type CLI struct { Foo string `help:"Foo." default:"foo" env:"FOO"` } func (c *CLI) Handler(ctx context.Context, _ interface{}) (string, error) { // c.Foo はデフォルト値、環境変数、コマンドライン引数から設定された状態になっている return "OK", nil } func main() { var c CLI kong.Parse(&c) lamblocal.Run(context.TODO(), c.Handler) }
まとめ
GoでAWS Lambdaのハンドラを実装した場合に、手元から同じ処理を実行したり開発中の動作確認のため、CLIコマンドとしても実行できる、とてもシンプルなwrapperライブラリを書きました。
小物ですが便利なので、どうぞご利用ください。
-
確実にシングルバイナリにするために
CGO_ENABLED=0
も指定しましょう。ビルド環境がLinuxの場合、指定しないとlibcへの依存が発生するため、Lambdaに持っていったときにglibcのバージョンの差異で動かないことがあります。↩ - AWS Lambda 環境変数の使用 - AWS Lambda↩