AWS Lambda(Go) で Lambda レイヤーを活用する

Serverless Framework を使い、AWS の Lambda(Go) で Lambdaレイヤーを活用する方法を調べてみました。

目次

背景

複数のLambda関数群で構成されたサービスを開発していると、各関数共通で使用する処理をライブラリ化したくなってきます。
今回は AWS Lambda レイヤーを使用して、複数のLambdaで使用できるレイヤーを作成してみたので一例を紹介します。

環境

Lambda 関数とレイヤーのデプロイに Serverless Framework を使用しています。
Serverless Framework はNode.jsで作られたCLIツールで AWS LambdaAzure FunctionsGoogle 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関数は記述しません。
ValueXFunctionX をエクスポートしています。

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関数から使用することで、ビルドやデプロイの時間短縮ができそうです。
逆に、小さくなってしまったり頻繁に変更する必要があるモジュールはレイヤー化の恩恵が受けられず、呼び出し側となる関数のソースコードが冗長になってしまうだけなので、使い所と制限を見極めて使用するのが良さそうです。

参考資料