Lambda から別の Lambda 関数を非同期で呼び出す

AWS Lambda 関数から別の Lambda 関数を呼び出す処理を実装します。 事情があって、非同期で呼び出す方法を調べてみました。

背景

  • Slack の Bot を Lambda で実装していたのですが、少し処理に時間がかかるものだとタイムアウトしてしまい、処理が実行されませんでした。
  • Lambda を用いてサーバーレスな運用をしたい Slack Bot の場合、大きな壁となります。
  • そこで、以下のように、Slack からのリクエストには 3 秒以内にレスポンスを返却し、処理自体は別の Lambda 関数が行うという構成にすれば、少々複雑な処理であってもタイムアウトせずに実行可能です。

f:id:linkode-okazaki:20200206161555p:plain

実際の実装方法

  • 今回は、Go で実装する方法を紹介します。

呼ばれる関数の実装

  • 単純に Slack に投稿するだけのものとします。
  • 呼ばれてから投稿するまでに 5 秒スリープさせます。
package main

import (
    "log"
    "os"
    "time"

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

// HandleRequest : リクエストハンドラー
func HandleRequest() {
    time.Sleep(5 * time.Second)
    // 環境変数 SLACK_ACCESS_TOKEN には、Slack App の「Bot User OAuth Access Token」を設定する
    api := slack.New(os.Getenv("SLACK_ACCESS_TOKEN"))
    _, _, err := api.PostMessage("random", slack.MsgOptionText("メッセージが投稿されました", false))
    if err != nil {
        log.Printf("failed to post message: %s", err)
    }
}

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

呼び出す側の関数

  • Lambda の処理内で別の関数を呼び出すには、lambda.Invoke を用います。
    • github.com/aws/aws-sdk-go/service/lambdalambda であることに注意です(github.com/aws/aws-lambda-go/lambdaの方ではありません!)。
    • 引数である InvokeInputInvocationType によって、同期呼び出しか非同期呼び出しかが決まります。
      • "Event" にすると、非同期呼び出しになります。
      • "RequestResponse" にすると、同期呼び出しになります。

非同期呼び出しの場合

svc := lambda.New(session.New())

input := &lambda.InvokeInput{
    FunctionName:   aws.String(invokedFunction),
    Payload:        []byte(body),
    InvocationType: aws.String("Event"),
}

resp, _ := svc.Invoke(input)
fmt.Println(resp.Payload)

return events.APIGatewayProxyResponse{
        Body: "OK",
        Headers: map[string]string{
            "Content-Type": "text"},
        StatusCode: 200,
}, nil

同期呼び出しの場合

svc := lambda.New(session.New())

input := &lambda.InvokeInput{
    FunctionName:   aws.String(invokedFunction),
    Payload:        []byte(body),
    InvocationType: aws.String("RequestResponse"),
}

resp, _ := svc.Invoke(input)
fmt.Println(resp.Payload)

return events.APIGatewayProxyResponse{
        Body: "OK",
        Headers: map[string]string{
            "Content-Type": "text"},
        StatusCode: 200,
}, nil

main.go の仕上げ

  • 上記以外のところは、Slack の Event API を受け取って検証などを行うだけです。
  • 最終的な main.go はこうなります。

呼び出し側の main.go の実装▼

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"

    "github.com/aws/aws-lambda-go/events"
    l "github.com/aws/aws-lambda-go/lambda"
    "github.com/aws/aws-sdk-go/aws"
    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/lambda"
    "github.com/nlopes/slack"
    "github.com/nlopes/slack/slackevents"
)

// HandleRequest Lambda のリクエストをハンドルする
func HandleRequest(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    eventsAPIEvent, err := VerifyEvent(request)

    if err != nil {
        log.Printf("ERROR: %s", err)
        return events.APIGatewayProxyResponse{
            Body:       request.Body,
            StatusCode: http.StatusInternalServerError,
        }, nil
    }

    log.Printf("EventType: %s", eventsAPIEvent.Type)
    if eventsAPIEvent.Type == slackevents.URLVerification {
        response, err := returnChallengeValue(request)

        if err != nil {
            log.Printf("ERROR: %s", err)
            return events.APIGatewayProxyResponse{
                Body: request.Body, StatusCode: http.StatusInternalServerError,
            }, nil
        }
        return response, nil
    }

    invokedFunction := os.Getenv("INVOKED_FUNCTION_NAME")
    input := &lambda.InvokeInput{
        FunctionName:   aws.String(invokedFunction),
        Payload:        []byte(request.Body),
        InvocationType: aws.String("Event"),
    }

    svc := lambda.New(session.New())
    resp, _ := svc.Invoke(input)
    fmt.Println(resp.Payload)

    response := events.APIGatewayProxyResponse{
        Body: "OK",
        Headers: map[string]string{
            "Content-Type": "text"},
        StatusCode: 200,
    }
    log.Print(response)

    return response, nil
}

// VerifyEvent : Slack からのリクエストイベントを検証する
func VerifyEvent(request events.APIGatewayProxyRequest) (slackevents.EventsAPIEvent, error) {
    // リクエストヘッダの検証
    if err := verifyRequest(request); err != nil {
        return slackevents.EventsAPIEvent{}, err
    }
    // Token の検証
    eventsAPIEvent, err := slackevents.ParseEvent(
        json.RawMessage(request.Body),
        slackevents.OptionVerifyToken(
            &slackevents.TokenComparator{
                VerificationToken: os.Getenv("SLACK_VERIFICATION_TOKEN"),
            },
        ),
    )
    if err != nil {
        return slackevents.EventsAPIEvent{}, err
    }
    return eventsAPIEvent, nil
}

func verifyRequest(request events.APIGatewayProxyRequest) error {
    body := request.Body
    headers := convertHeaders(request.Headers)
    sv, err := slack.NewSecretsVerifier(headers, os.Getenv("SLACK_SIGNING_SECRET"))
    if err != nil {
        return err
    }
    sv.Write([]byte(body))
    return sv.Ensure()
}

func convertHeaders(headers map[string]string) http.Header {
    header := http.Header{}
    for key, value := range headers {
        header.Set(key, value)
    }
    return header
}

func returnChallengeValue(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    var r *slackevents.ChallengeResponse
    err := json.Unmarshal([]byte(request.Body), &r)

    if err != nil {
        return events.APIGatewayProxyResponse{}, err
    }

    return events.APIGatewayProxyResponse{
        Body: r.Challenge,
        Headers: map[string]string{
            "Content-Type": "text"},
        StatusCode: 200,
    }, nil
}

func main() {
    l.Start(HandleRequest)
}

  • 呼び出す側の関数には、他の Lambda 関数を呼び出すためのアクセス権限を設定する必要があります。
{
    "Effect": "Allow",
    "Action": [
        "lambda:InvokeFunction"
    ],
    "Resource": "*"
}
  • Serverless Framework を用いて設定する場合、serverless.ymlprovider に以下のように記載します。
provider:
  iamRoleStatements:
    - Effect: 'Allow'
      Action:
        - 'lambda:InvokeFunction'
      Resource:
        - '*'
  • AWS Lambda にデプロイして動作を確認します。上記の実装の場合、Bot にメンションすると、5秒以上経ってからメッセージが返ってきます。

f:id:linkode-okazaki:20200206161601p:plain

参考資料