Go言語でAWS Lambdaの開発をサポートするパッケージを作った

11 min読了の目安(約9900字TECH技術記事

仕事でGo言語 + AWS Lambdaを用いる機会が多く、特にセキュリティ監視関連基盤のバックエンド処理を開発しています(これとかこれとかこれ)。

開発をすすめる中で「こうすると便利だな」というちょっとしたtipsはいろいろあったのですが、あまりに細切れな処理すぎるのでプロジェクト間でコピーするなどして開発に利用していました。とはいえ管理しているプロジェクトが多くなってきたことで挙動がまちまちになってしまったり、ある程度tipsの数が溜まってきたのもあって、パッケージとして切り出してみました。

AWSが公式で提供しているPowertools(Python版Java版)を意識してはいますが、完全に再現する目的では作っていません。また、全てのGo + Lambdaの開発者が「この方法に従うべき!」とも思っていません。例えば、API gatewayによって呼び出されるLambdaは各種Web Application Frameworkで同じような機能がサポートされていることもあり、あまりこのパッケージの恩恵は受けられないと思います。なので「こういう処理をまとめておくと便利」ぐらいな話として見ていただければと思います。

基本的にはデータ処理のパイプラインやちょっとしたインテグレーションなどのためのLambdaを想定しており、以下の4つの機能を実装しています。

  • イベントの取り出し
  • 構造化ロギング
  • エラー処理
  • 秘匿値の取得

実装している機能

イベントの取り出し

AWS Lambdaはイベントソースを指定して、そこからの通知をトリガーに起動させることができます。この際、Lambda functionはSQSやSNSといったイベントソースのデータ構造が渡されて起動します。そのため、各種構造データから自分で使うデータを取り出す作業が必要です。golambda.Start() という関数にcallback(以下の例では Handler )を指定すると golambda.Event に必要な情報が格納され、そこから取り出すことができます。

package main

import (
	"strings"

	"github.com/m-mizutani/golambda"
)

type MyEvent struct {
	Message string `json:"message"`
}

// SQSのメッセージをconcatして返すHandler
func Handler(event golambda.Event) (interface{}, error) {
	var response []string

	// SQSのbodyを取り出す
	bodies, err := event.DecapSQSBody()
	if err != nil {
		return nil, err
	}

	// SQSはメッセージがバッチでうけとる場合があるので複数件とみて処理する
	for _, body := range bodies {
		var msg MyEvent
		// bodyの文字列をmsgにbind(中身はjson.Unmarshal)
		if err := body.Bind(&msg); err != nil {
			return nil, err
		}

		// メッセージを格納
		response = append(response, msg.Message)
	}

	// concat
	return strings.Join(response, ":"), nil
}

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

このサンプルコードは ./example/deployable ディレクトリにおいてあり、デプロイして実行を試すことができます。

データを取り出す処理を実装している DecapXxx という関数とは逆に、データを埋め込む処理を EncapXxx として用意しています。これによって上記のLambda Functionに対して、以下のようにテストを書くことができます。

package main_test

import (
	"testing"

	"github.com/m-mizutani/golambda"
	"github.com/stretchr/testify/require"

	main "github.com/m-mizutani/golambda/example/decapEvent"
)

func TestHandler(t *testing.T) {
	var event golambda.Event
	messages := []main.MyEvent{
		{
			Message: "blue",
		},
		{
			Message: "orange",
		},
    }
    // イベントデータの埋め込み
	require.NoError(t, event.EncapSQS(messages))

	resp, err := main.Handler(event)
	require.NoError(t, err)
	require.Equal(t, "blue:orange", resp)
}

現状はSQS、SNS、SNS over SQS(SNSをsubscribeしているSQSキュー) の3つをサポートしていますが、後々DynamoDB stream、Kinesis streamも実装しようと考えています。

構造化ロギング

Lambdaの標準的なログ出力先はCloudWatch Logsになりますが、LogsあるいはLogsのビュワーであるInsightsはJSON形式のログをサポートしています。そのため、Go言語標準の log パッケージを使うのではなく、JSON形式で出力できるロギングツールを用意するのが便利です。

ログの出力形式も含めて、Lambda上でのロギングは概ね要件が共通化されています。多くのロギングツールは出力方法や出力形式について様々なオプションがありますが、Lambda functionごとに細かく設定を変えるということはあまりしません。また出力する内容についてもほとんどの場合はメッセージ+文脈の説明に必要な変数だけで事足りるため、そのような単純化をしたwrapperを golambda では用意しました。実際の出力部分では zerolog を利用しています。本当はzerologで作成したロガーをそのまま露出させるというのでも良かったのですが、できることを絞っておいたほうが自分にとってもわかりやすいなと思い、あえてwrapする形にしました。

Logger というグローバル変数をexportしており、Trace, Debug, Info, Error というログレベルごとのメッセージを出力できるようにしています。任意の変数を永続的に埋め込める Set と、メソッドチェインで値を継ぎ足していける With を用意しています。

// ------------
// 一時的な変数を埋め込む場合は With() を使う
v1 := "say hello"
golambda.Logger.With("var1", v1).Info("Hello, hello, hello")
/* Output:
{
	"level": "info",
	"lambda.requestID": "565389dc-c13f-4fc0-b113-xxxxxxxxxxxx",
	"time": "2020-12-13T02:44:30Z",
	"var1": "say hello",
	"message": "Hello, hello, hello"
}
*/

// ------------
// request ID など、永続的に出力したい変数を埋め込む場合はSet()を使う
golambda.Logger.Set("myRequestID", myRequestID)
// ~~~~~~~ snip ~~~~~~
golambda.Logger.Error("oops")
/* Output:
{
	"level": "error",
	"lambda.requestID": "565389dc-c13f-4fc0-b113-xxxxxxxxxxxx",
	"time": "2020-11-12T02:44:30Z",
	"myRequestID": "xxxxxxxxxxxxxxxxx",
	"message": "oops"
}
*/

また、CloudWatch Logsはログの書き込みに対する料金が比較的高価であり、詳細なログを常に出力しているとコストに大きく影響します。そのため通常は最低限のログだけを出力し、トラブル対応やデバッグの時だけ詳細な出力ができるようにしておくと便利です。 golambda では LOG_LEVEL 環境変数を設定することで、ログ出力レベルを外部からいじることができるようにしています。(環境変数だけならAWSコンソールなどから容易に変更可能なため)

エラー処理

AWS Lambdaはfunctionごとになるべく単機能になるよう実装し、複雑なワークフローを実現する場合にはSNS、SQS、Kinesis Stream、Step Functionsなどを使って複数のLambdaを組み合わせるようにしています。そのため処理の途中でエラーが起きた場合はLambdaのコード内で無理にリカバリしようとせず、なるべく素直にそのままエラーを返すことで外部からの監視で気づきやすくなったり、Lambda自身のリトライ機能の恩恵を受けやすくなったりします。

一方でLambda自身はエラーをあまり丁寧に処理してくれるわけではないので、自前でエラー処理を用意する必要があります。 先述したとおり、Lambda functionは何かあった場合はそのままエラーを返して落ちる、という構成にしておくと便利です。なので、殆どのケースにおいてエラーが発生した場合はメインの関数(後述する例だと Handler() )がエラーを返した場合に一通りまとめてエラーに関する情報を出力してくれると、あちこちのエラー発生箇所でログを出力したりどこかへエラーを飛ばすという処理を書く必要がなくなります。

golambda では、 golambda.Start() で呼び出した主に以下の2つのエラー処理をしています。

  1. golambda.NewError あるいは golambda.WrapError で生成したエラーの詳細なログの出力
  2. エラー監視サービス(Sentry)へエラーを送信

それぞれ詳しく説明します。

エラーの詳細なログ出力

経験上、エラーが起きたときにデバッグのため知りたいのは大きく分けて「どこで起きたのか」「どのような状況で起きたのか」の2つです。

どこでエラーが起きたのかを知る方法としては、 Wrap 関数を使いコンテキストを追記していく、あるいは github.com/pkg/errors パッケージのようにスタックトレースを持つ、などの戦略があります。Lambdaの場合、なるべく単純な処理になるよう実装する方針であれば、ほとんどの場合はスタックトレースでエラー発生箇所とどのように発生したかを知ることができます。

また、エラーの原因となった変数の中身を知ることでエラーの再現条件を把握できます。これはエラーが発生したら関連しそうな変数を都度ログ出力することでも対応できますが、出力行が複数にわたってログの見通しが悪くなってしまいます(特に呼び出しが深い場合)。また、単純にログ出力のコードを繰り返し書かねばならず冗長になり、単純に書くのも大変だしログ出力に関する変更をしたいときに面倒です。

そこで、golambda.NewError() あるいは golambda.WrapError()[1]で生成したエラーは、 With() という関数でエラーに関連する変数を引き回せるようにしました。実体は中に map[string]interface{} の変数にkey/valueの形で格納しているだけです。golambda.NewError() あるいは golambda.WrapError()によって生成されたエラーをメインロジック(以下の例の Handler() )が返すと、 With() によって格納した変数と、エラーが生成された関数のスタックトレースをCloudWatch Logsに出力します。以下、コードの例です。

package main

import (
	"github.com/m-mizutani/golambda"
)

// Handler is exported for test
func Handler(event golambda.Event) (interface{}, error) {
	trigger := "something wrong"
	return nil, golambda.NewError("oops").With("trigger", trigger)
}

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

これを実行すると、以下のように error.values の中に With で格納した変数が、 error.stacktrace にスタックトレースが含まれるログが出力されます。スタックトレースは github.com/pkg/errors の %+v フォーマットでもテキストで出力されますが、構造化ログの出力に合わせてJSON形式に対応しているのもポイントです。

{
    "level": "error",
    "lambda.requestID": "565389dc-c13f-4fc0-b113-f903909dbd45",
    "error.values": {
        "trigger": "something wrong"
    },
    "error.stacktrace": [
        {
            "func": "main.Handler",
            "file": "xxx/your/project/src/main.go",
            "line": 10
        },
        {
            "func": "github.com/m-mizutani/golambda.Start.func1",
            "file": "xxx/github.com/m-mizutani/golambda/lambda.go",
            "line": 127
        }
    ],
    "time": "2020-12-13T02:42:48Z",
    "message": "oops"
}

エラー監視サービス(Sentry)へエラーを送信

Sentryでないといけない理由は特にないのですが、APIに限らずLambda functionもWebアプリケーションなどと同様に何らかのエラー監視サービスを使うのが望ましいです。理由は以下のようなものがあります。

  • CloudWatch Logsにデフォルトで出力されるログからは正常終了したか異常終了したかの判定ができないため、異常終了した実行のログだけ抽出するというのが難しい
  • CloudWatch Logsではエラーをグルーピングするような機能はないため、エラー100件のうち1件だけ種類の違うエラーがある、みたいなやつを見つけ出すのが難しい

両方ともエラーログの出力方法を工夫することである程度解決できなくはないですが、色々気をつけて実装しないとならないため素直にエラー監視サービスを使うのがオススメです。

golambda ではSentryのDSN (Data Source Name) を環境変数 SENTRY_DSN として指定することでメインロジックが返したエラーをSentryに送信します(Sentry + Goの詳細)。送るのはどのエラーでも問題ありませんが、golambda.NewErrorgolambda.WrapError で生成したエラーは github.com/pkg/errors と互換性のある StackTrace() という関数を実装しているため、スタックトレースがSentry側にも表示されます。

これはCloudWatch Logsに出力されるものと同じですが、Sentry側の画面でも確認できるため「通知を見る」→「Sentryの画面を見る」→「CloudWatch Logsでログを検索し詳細を確認する」の2番目のステップでエラーの見当をつけられる場合もあります。あとCloudWatch Logsの検索はまあまあもっさりしているので、検索しないですむならそのほうがよい、というのもあります…。

ちなみにSentryにエラーを送信すると error.sentryEventID としてSentryのevent IDをCloudWatch Logsのログに埋め込むので、Sentryのエラーから検索ができるようになっています。

秘匿値の取得

Lambdaでは実行環境によって変更するようなパラメータは環境変数に格納して利用することが多いです。個人で使っているAWSアカウントであれば全て環境変数に格納するでよいのですが、複数人で共有して使うようなAWSアカウントでは秘匿値と環境変数を分離しておくことで、Lambdaの情報のみを参照できる人(あるいはRole)と秘匿値も参照できる人(あるいはRole)を分離することができます。これは個人で使っていても真にヤバい情報をあつかうのであれば何らかのアクセスキーが漏れても即死しないように権限を分離しておくケースもあるかもしれません。

自分の場合は権限を分離するため、AWS Secrets Manager を利用することが多いです[2]。Secrets Managerからの値の取り出しはAPIを呼び出せば比較的簡単ではあるのですが、それでも同じような処理を100回くらい書いて飽きたのでモジュール化しました。構造体のフィールドに json メタタグをつければそれで値が取得できます。

type mySecret struct {
    Token string `json:"token"`
}
var secret mySecret
if err := golambda.GetSecretValues(os.Getenv("SECRET_ARN"), &secret); err != nil {
    log.Fatal("Failed: ", err)
}

実装しなかった機能

便利そうかなと思いつつ、実装を見送ったものたちです。

  • タイムアウト直前に任意の処理を実行:Lambdaは設定された最大実行時間をすぎると無言で死ぬためパフォーマンス分析の情報を出すためにタイムアウト直前に何らかの処理を呼び出すというテクニックがあります。ただ自分の場合は Lambda function がタイムアウトによって無言で死んで困った経験がほとんどないので、なんか便利そうと思いつつ特に手を付けませんでした。
  • Tracing:Pythonの Powertools では、アノテーションなどを使ってAWS X-Rayでパフォーマンス計測するための機能が提供されています。Goでこれをやろうとすると現状だと普通に公式SDKを使う以上に楽ができる方法があまり浮かばなかったので、特に取り組みませんでした。

まとめ

ということで、Go言語でのLambda実装における自分なりのベストプラクティスのまとめと、それをコード化したものの紹介でした。冒頭に書いたとおりあくまで自分が必要だったものを作っただけなので、万人に使えるものではないかなと思いますが、なにかの参考になれば幸いです。

脚注
  1. こういったエラー生成のメソッドは errors.New()errors.Wrap() などとする習わしがあるかと思いますが、個人的にはどのパッケージを使っているのか直感的にわかりにくくなるので、あえて命名法則を変えました。 ↩︎

  2. 他にも AWS Systems Manager Parameter Store に秘匿値を入れるというやり方もあります。RDSパスワードなどのローテーション機能がある Secrets Manager の方がサービスの思想としては適切かと思い、個人的にはそちらを使っています。ただコストやAPIレートリミットも違ったりするので、本来であれば要件によって使い分けたほうが良さそうです。 ↩︎