Go
Rust
sam
lambda
CustomRuntime

GoとRustで学ぶCustom AWS Lambda Runtimes 〜入門編〜

概要

この記事とServerless2 Advent Calendar 2018の最終日の記事とで次のようなお話を書きます。

  • AWSさんのre:Invent2018で発表されたCustom AWS Lambda Runtimesはどういう仕組みなのか知りたい
  • Custom AWS Lambda Runtimesの実装方法は次のソースを参考にして追ってみるぞ
  • すでにLambdaとして公式サポートされているGoで敢えてCustom AWS Lambda Runtimesを実装してみると理解が深まりそうなのでやってみるぞ

問題意識

COBOL

Custom AWS Lambda Runtimesが発表されたことを受けTwitterなどでは様々なリアクションが見られました。中でも

「LambdaでCOBOLが動くようになった???」

というのは字面のインパクトがありました。

ただ、その実、今回新しくAWSさんがLambdaでサポートする言語として発表されたRubyとは異なりサードパーティがCustom AWS Lambda Runtimesの仕組みでCOBOLで書いたコードをLambdaで動かせるようにした、というもののようです。

LambdaのコンソールでNode.js、PythonやGoと同列にCOBOLが並び始めたのではありません。

また、公式サポートされてない言語をLambda上で動かすのにチャレンジした人はこれまでにもいらっしゃり、COBOLで書かれたコード(ハンドラー)はjsにコンパイルされることでNode.js上で実行された実績が(たぶん)2016年頃からありました。

さんこう
* サーバーレス COBOL on AWS Lambda
* toricls/cobolambda

それでは、Custom AWS Lambda RuntimesとしてAWSさんが提供し始めたものって何なんでしょうか?

AWS Lambda Runtime Interface

AWSさんが提供してくれるのは主に次の2種類の機構です。

  • Lambdaにruntimeとしてprovidedを指定してパッケージをアップロードするとbootstrapというユーザーが実装したファイルをエントリーポイントにしてくれる機構
  • Custom AWS Lambda Runtimesを実装するための4つのAPI

※使えるようになった(使わざるを得ない)環境変数も増えていますが本質的なものではないので記事の必要な箇所で触れます。

runtime: provided

これはチュートリアルを読むとイメージしやすそうです。

Lambdaにデプロイするとき--runtime providedという具合で指定します。

$ aws lambda create-function --function-name [functionの名前] \
--zip-file fileb://[Zipファイル名] --handler [function.handlerなどハンドラー名] --runtime provided \
--role [arn:aws:iam::123456789012:role/lambda-roleなどLambdaがもつ権限]

サポートされている言語ではnodejs8.10python3.7go1.xjava8などを指定していた箇所です。

つまりこの時点ではどの言語でhandlerやその他を実装するかをLambdaに伝えなくてよいということになります。

なぜそれで済むんでしょうか?これまで特定の言語を指定してたにも関わらずそれが不要になるのはどういう仕組みが提供され始めたからなんでしょうか?

API

そこで登場するのがAPIです。Lambdaで実装する際に知りたいのはコンテキスト情報ではないでしょうか。ここでいうコンテキスト情報とはLambdaを起動したイベント情報(S3へのアップロードが起点となった、API Gateway経由でリクエストがきた、パラメタがどんなだったなど)とリクエストID、実行制限時間の残りなどその他情報を指すものとします。

Custom AWS Lambda Runtimesが登場する前、これらのコンテキスト情報はhandlerに渡されてきました。

たとえばaws/aws-lambda-goでは実装されたhandlerはLambda上で起動されるrpcサーバーに登録され、そのrpcサーバーへのリクエストの中にコンテキスト情報が含まれています。

rpcサーバーにhandlerを登録

https://github.com/aws/aws-lambda-go/blob/master/lambda/entry.go#L50

func StartHandler(handler Handler) {
    port := os.Getenv("_LAMBDA_SERVER_PORT")
    lis, err := net.Listen("tcp", "localhost:"+port)
    if err != nil {
        log.Fatal(err)
    }
    function := new(Function)
  function.handler = handler

  // ここでユーザーが実装したhandlerの型などが妥当かチェックされたhandlerが
  // Lambda上のrpcサーバーに登録されています。
    err = rpc.Register(function)
    if err != nil {
        log.Fatal("failed to register handler function")
    }
    rpc.Accept(lis)
    log.Fatal("accept should not have returned")
}

rpcサーバーが受けるリクエストからコンテキスト情報を取得

rpcサーバーにリクエストするのは便宜的に真のランタイムと呼びます。「Custom AWS Lambda Runtimesを実行する」と言ってもAWSさんの外の人が実装できる部分は限られるので…

真のランタイムが各AWSリソースのイベントを受け取り、handlerに引き渡します。

https://github.com/aws/aws-lambda-go/blob/master/lambda/function.go#L24

func (fn *Function) Invoke(req *messages.InvokeRequest, response *messages.InvokeResponse) error {
    defer func() { /* 略 */ }()

    // リクエストから制限時間情報を取得
    deadline := time.Unix(req.Deadline.Seconds, req.Deadline.Nanos).UTC()
    invokeContext, cancel := context.WithDeadline(context.Background(), deadline)
    defer cancel()

    lc := &lambdacontext.LambdaContext{
    // リクエストに含まれるコンテキスト情報を元にhandlerで扱うための構造体を組み立てる
        AwsRequestID:       req.RequestId,
        InvokedFunctionArn: req.InvokedFunctionArn,
        Identity: lambdacontext.CognitoIdentity{
            CognitoIdentityID:     req.CognitoIdentityId,
            CognitoIdentityPoolID: req.CognitoIdentityPoolId,
        },
    }
    if len(req.ClientContext) > 0 {
        if err := json.Unmarshal(req.ClientContext, &lc.ClientContext); err != nil {
            response.Error = lambdaErrorResponse(err)
            return nil
        }
    }
    invokeContext = lambdacontext.NewContext(invokeContext, lc)

    invokeContext = context.WithValue(invokeContext, "x-amzn-trace-id", req.XAmznTraceId)

  // コンテキスト情報を引数にhandlerをキックする。
  // req.Payloadはイベント情報
    payload, err := fn.handler.Invoke(invokeContext, req.Payload)
    if err != nil {
        response.Error = lambdaErrorResponse(err)
        return nil
    }
    response.Payload = payload
    return nil
}

Custom AWS Lambda Runtimes

このコンテキスト情報のやりとり(と真のランタイムとのやりとり)を担うのが今回新たにAWSさんが提供を開始ししたAPIです。

提供されているAPIは現時点で4つで、それぞれの責務はつぎの通りです。

  • Next Invocation
    • 真のランタイムにコンテキスト情報を取得しに行くためのAPI
    • ボディにはイベント情報が入っている
    • ヘッダにはその他のコンテキスト情報が入っている
  • Invocation Response
    • 真のランタイムにレスポンスを返すためのAPI
    • 真のランタイムが受け取ったのが同期的なリクエストであれば、このAPIへのリクエスト情報を元に真のランタイムへのリクエスト元へレスポンスを返す
  • Invocation Error
    • 真のランタイムにhandler実行中のエラーを伝えるためのAPI
  • Initialization Error
    • 真のランタイムにhandler実行前のエラーを伝えるためのAPI

大事っぽいこと

これまで我々開発者はコンテキスト情報が渡されることを期待してhandlerを実装し、イベントをただただ待つことしかできませんでした。
それがAWS Lambda Runtime Interfaceの登場で主体的にコンテキスト情報を取得しに行けるようになったのです。
さらに、その情報取得はAPI経由で行うため、hanlderを実装する言語がなんであれHTTP通信するコードを準備できさえすれば真のランライムの上で踊る(踊らされる)Custom AWS Lambda Runtimesが実装可能になったのです。

Goで実装してみる

まずはチュートリアルの内容を参考にGoで置き換えてみましょう。

チュートリアルのCustom AWS Lambda Runtimesはbashで実装されており、構成と位置付けは次のとおりです。

  • bootstrap
    • 真のランタイムがこのファイルを実行する
    • bootstrapで取得したイベント情報を引数にfunction.shのhandler関数を実行する
  • function.sh
    • bootstrapから受け取ったイベント情報に文字列を追加してbootstrapに返す

それではそれっぽく実装していきます。

フォルダの構成はこんな感じです。

go-custom-runtime-sample/
├artifacts/
├handler/
│ └function.go
├init/
│ └bootstrap.go
├template.yml
└Makefile

実装が後編向けにこの記事から離れつつありますが、原型はとどめているソースコードをGitHubに上げてあります。

https://github.com/toshi0607/custom-runtime-go-sample

bootstrap.go
package main

import (
    "log"
    "os"
    "os/exec"
)

var(
    handlerPath string
)

func init() {
    // _HANDLERという環境変数にデプロイ時に--handlerとして指定したhandler名が入っています。
    // ここではfunction.goをhandlerという名前のバイナリにビルドするので「handler」が入る想定です。
    // LAMBDA_TASK_ROOTにはCustom AWS Lambda Runtimesであろうと/var/taskが入ります。
    // Lambdaにデプロイするパッケージが展開される場所は変わりません。
    handlerPath = os.Getenv("LAMBDA_TASK_ROOT")+"/"+os.Getenv("_HANDLER")
}

func main() {
    log.Println("bootstrap started")
    out, err := exec.Command("pwd").Output()
    if err != nil {
        log.Fatalf("failed to exec pwd. error: %v", err)
    }

    log.Printf("pwd: %s\n", string(out))
    // /var/task
    log.Println(handlerPath)

  // この実装ではコンテキスト情報取得や真のランタイムとのやりとりはbootstrapでなくhandler内で行なっています。
    if err := exec.Command(handlerPath).Run(); err != nil {
        log.Fatalf("failed to exec handler. error: %v", err)
    }
}
function.go
package main

import (
    "bytes"
    "fmt"
    "log"
    "net/http"
    "os"
)

var (
    runtimeApiEndpointPrefix string
    nextEndpoint string
)

func init() {
    runtimeApiEndpointPrefix = "http://" + os.Getenv("AWS_LAMBDA_RUNTIME_API") + "/2018-06-01/runtime/invocation/"
    nextEndpoint = runtimeApiEndpointPrefix + "next"
}

func main() {
    log.Println("handler started")

    for {
        func() {
            // コンテキスト情報を取得します。
            resp, _ := http.Get(nextEndpoint)
            defer func() {
                resp.Body.Close()
            }()

            // ヘッダにはリクエストIDが含まれています。
            rId := resp.Header.Get("Lambda-Runtime-Aws-Request-Id")
            log.Printf("実行中のリクエストID" + rId)
            // 最終的に真のランタイムに返すコンテンツはInvocation Response APIのリクエストボディに含めます。
            http.Post(respEndpoint(rId), "application/json", bytes.NewBuffer([]byte(rId)))
        }()
    }
}

// リクエストIDベースで動的にURLが決まるので関数にしています。
func respEndpoint(requestId string) string {
    return runtimeApiEndpointPrefix + requestId + "/response"
}

なんとなくSAM用のファイルも準備しておきます。

template.yml
AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31
Description: AWS Lambda custom runtime implementation by Go
Resources:
  CustomRuntimeGoSample:
    Type: AWS::Serverless::Function
    Properties:
      FunctionName: go-custome-runtime-sample
      # artifacts下の2つのバイナリ(bootstrapとhandler)をデプロイします。
      CodeUri: artifacts
      // ここではfunction.goをhandlerという名前でビルドしたバイナリのことです。
      Handler: handler
      # すでにprovidedが指定できます。
      Runtime: provided
      Tracing: Active

デプロイするにあたってはMakefileを準備しました。

SAMでデプロイする場合は環境変数のSTACK_BUCKETだけSAMで使うS3のバケット名を指定(custom-runtime-go-sample-toshi-20181216 などグローバルにユニークに)してmake create-bucketした後にmake deployを実行すればOKです。

動作確認用にmake invoke-functionも用意しており、response.txtにリクエストIDが出力されるようになっています。

SAMでなくAWS CLIのaws lambdaベースでLambdaを作成、更新、削除するスクリプトも書いてあるのでSAMベースのデプロイが億劫に(遅い)感じる方はそちらをどうぞ。
環境変数のLAMBDA_ROLL_ARNの設定が必要です。arn:aws:iam::123456789012:role/lambda-roleのような形式で、Lambdaが実行できる最低限のロールを指定してください。

Makefile
################
# COMMON
################

build: build-function build-bootstrap
.PHONY: build

build-function:
    GOARCH=amd64 GOOS=linux go build -o artifacts/handler ./handlers
.PHONY: build-function

build-bootstrap:
    GOARCH=amd64 GOOS=linux go build -o artifacts/bootstrap ./init
.PHONY: build-bootstrap



################
# SAM
################

STACK_NAME := custom-runtime-go-sample
TEMPLATE_FILE := template.yml
SAM_FILE := sam.yml

deploy: build
    sam package \
        --template-file $(TEMPLATE_FILE) \
        --s3-bucket $(STACK_BUCKET) \
        --output-template-file $(SAM_FILE)
    sam deploy \
        --template-file $(SAM_FILE) \
        --stack-name $(STACK_NAME) \
        --capabilities CAPABILITY_IAM
.PHONY: deploy

create-bucket:
    aws s3 mb "s3://$(STACK_BUCKET)"
.PHONY: create-bucket

delete:
    aws cloudformation delete-stack --stack-name $(STACK_NAME)
    aws s3 rm "s3://$(STACK_BUCKET)" --recursive
    aws s3 rb "s3://$(STACK_BUCKET)"
.PHONY: delete



################
# AWS CLI
################

FUNCTION_NAME := go-custome-runtime-sample
ARTIFACT_ZIP := artifacts.zip

create-zip:
    cp artifacts/handler handler
    cp artifacts/bootstrap bootstrap
    chmod +x handler bootstrap
    zip $(ARTIFACT_ZIP) handler bootstrap
    rm handler
    rm bootstrap
.PHONY: create-zip

deploy-sub: build create-zip update-function
.PHONY: deploy-sub

recreate-function: delete-function create-function
.PHONY: recreate-function

create-function: build create-zip
    aws lambda create-function \
      --function-name $(FUNCTION_NAME) \
      --zip-file "fileb://$(ARTIFACT_ZIP)" \
      --handler "handler" \
      --runtime provided \
      --role $(LAMBDA_ROLL_ARN)
.PHONY: create-function

delete-function:
    aws lambda delete-function \
      --function-name $(FUNCTION_NAME)
.PHONY: delete-function

update-function:
    aws lambda update-function-code \
      --function-name $(FUNCTION_NAME) \
      --zip-file "fileb://$(ARTIFACT_ZIP)"
.PHONY: update-function

invoke-function:
    aws lambda invoke \
      --function-name $(FUNCTION_NAME) \
      --payload '{"text":"Hello"}' \
      response.txt
.PHONY: invoke-function

update-and-check-function: update-function invoke-function
.PHONY: update-and-check-function

これじゃない感

チュートリアル通りに実装して動作が確認できました。でもこれは僕らが知ってるLambdaじゃない感がありませんか?

こんな風に書きたいくないですが?

package main

import (
  "fmt"

  "github.com/aws/aws-lambda-go/lambda"
)

type MyEvent struct {
  Name string `json:"What is your name?"`
}

type MyResponse struct {
  Message string `json:"Answer:"`
}

func hello(event MyEvent) (MyResponse, error) {
  return MyResponse{Message: fmt.Sprintf("Hello %s!!", event.Name)}, nil
}

func main() {
  lambda.Start(hello)
}

ハンドラーには自分が関心ある処理だけ書かれていている状態にしたいですね。
カスタムイベントでないならすでに定義された型を使ったりしたいですよね。本来書くべきロジックに集中したいものです。

こういう思いはCustom AWS Lambda Runtimesを使っている限りは実現できないのでしょうか?

ここで、AWSさんが直々に実装したというRust版のCustom AWS Lambda Runtimesを覗いてみましょう。

使い方

README.mdにこんな風に使うと書いてあります。

extern crate lambda_runtime as lambda;
extern crate serde_derive;
extern crate log;
extern crate simple_logger;

use serde_derive::{Serialize, Deserialize};
use lambda::{lambda, Context, error::HandlerError};
use log::error;
use std::error::Error;

#[derive(Serialize, Deserialize)]
struct GreetingEvent {
    greeting: String,
    name: String,
}

#[derive(Serialize, Deserialize)]
struct GreetingResponse {
    message: String,
}

fn main() -> Result<(), Box<dyn Error>> {
    simple_logger::init_with_level(log::Level::Debug).unwrap();
    lambda!(my_handler);

    Ok(())
}

fn my_handler(event: GreetingEvent, ctx: Context) -> Result<GreetingResponse, HandlerError> {
    if event.name == "" {
        error!("Empty name in request {}", ctx.aws_request_id);
        return Err(ctx.new_error("Empty name"));
    }

    Ok(GreetingResponse {
        message: format!("{}, {}!", event.greeting, event.name),
    })
}

これがまさに「いつもの」Lambdaの使い方です。このファイル自体はbootstrapという名前でビルドしなければいけないとはいえ、書いているのはハンドラーと独自イベント型定義とハンドラーをどこかに渡す処理だけです。

つまりCustom AWS Lambda Runtimesを利用していても実装の仕方次第では「いつもの」Lambdaを書けるということです。

パッケージ(今回追う範囲ではRustでいうcrate)のパッケージ構成を見てみましょう。

パッケージ構成

aws-lambda-rust-runtime/
lambda-http
lambda-runtime-client
lambda-runtime

構成に関する説明もREADMEの冒頭に書いてありますね。

lambda-http

API Gateway向けのユースケースです。API Gateway向けのイベントやリクエストの型が定義されていたりますが、本題とは関係ないのでこれはいったんスルー。

lambda-runtime-client is a client SDK for the Lambda Runtime APIs. You probably don't need to use this crate directly!

lambda-runtime-client

真のランタイムの4つのAPIのクライアントです。愚直な実装になっています。僕も実装してみましたが後編ネタなので予告的扱いで場所のみ示します。

https://github.com/toshi0607/custom-runtime-go-sample/blob/master/runtime/client.go

lambda-runtime-client is a client SDK for the Lambda Runtime APIs. You probably don't need to use this crate directly!

lambda-runtime

これが僕らが実装べきCustom Runtimeです。lambda-runtime-clientを利用していますが、Rustでhandlerを実装する人はlambda-runtime-clientを意識しなくてよい作りになっています。

lambda-runtime is a library that makes it easy to write Lambda functions in Rust.

このライブラリで実装されている最重要関数を貼ります。

    fn start(&mut self) {
        debug!("Beginning main event loop");
        loop {
            let (event, ctx) = self.get_next_event(0, None);
            let request_id = ctx.aws_request_id.clone();
            info!("Received new event with AWS request id: {}", request_id);
            let function_outcome = self.invoke(event, ctx);
            match function_outcome {
                Ok(response) => {
                    debug!(
                        "Function executed succesfully for {}, pushing response to Runtime API",
                        request_id
                    );
                    match serde_json::to_vec(&response) {
                        Ok(response_bytes) => {
                            match self.runtime_client.event_response(&request_id, response_bytes) {
                                Ok(_) => info!("Response for {} accepted by Runtime API", request_id),
                                // unrecoverable error while trying to communicate with the endpoint.
                                // we let the Lambda Runtime API know that we have died
                                Err(e) => {
                                    error!("Could not send response for {} to Runtime API: {}", request_id, e);
                                    if !e.recoverable {
                                        error!(
                                            "Error for {} is not recoverable, sending fail_init signal and panicking.",
                                            request_id
                                        );
                                        self.runtime_client.fail_init(&e);
                                        panic!("Could not send response");
                                    }
                                }
                            }
                        }
                        Err(e) => {
                            error!(
                                "Could not marshal output object to Vec<u8> JSON represnetation for request {}: {}",
                                request_id, e
                            );
                            self.runtime_client
                                .fail_init(&RuntimeError::unrecoverable(e.description()));
                            panic!("Failed to marshal handler output, panic");
                        }
                    }
                }
                Err(e) => {
                    debug!("Handler returned an error for {}: {}", request_id, e);
                    debug!("Attempting to send error response to Runtime API for {}", request_id);
                    match self.runtime_client.event_error(&request_id, &e) {
                        Ok(_) => info!("Error response for {} accepted by Runtime API", request_id),
                        Err(e) => {
                            error!("Unable to send error response for {} to Runtime API: {}", request_id, e);
                            if !e.recoverable {
                                error!(
                                    "Error for {} is not recoverable, sending fail_init signal and panicking",
                                    request_id
                                );
                                self.runtime_client.fail_init(&e);
                                panic!("Could not send error response");
                            }
                        }
                    }
                }
            }
        }
    }

ハンドラーの呼ばれているlambda!(my_handler);のlambda!はこのstart関数のマクロです。

ハンドラーを受け取り、真のランタイムAPIのクライアント内部実装としてイベントのためのループを回すこと1層を挟むことによって「いつもの」Lambdaを実装できそうな気配がしてきました。

(Goでサンプル実装したものもループやイベント取得をfunction.goでなくbootstrap.goで行えば多少それっぽくなりそうな感じもしますね)

実装記事はServerless2 Advent Calendar 2018の最終日に載せるのでお楽しみに!(まだ書けてない)

空いてる日や投稿されていない日があるので、書きたいことがあるけれどまだ書いてない方、ぜひぜひ書いてみてください!!