API Gateway(HTTP API)のHTTPプロキシ統合とGoを利用したLambda Functionの実装方法

記事タイトルとURLをコピーする

みなさん、こんにちは。サービス開発課でCloud Automatorの開発・運用を担当している尾崎です。
今日はCloud Automatorの内部ツールの開発で採用した、API Gateway(HTTP API)とGoを利用したLambda Functionの実装方法を紹介したいと思います。

この内容は2021/12/01現時点の内容となっており、AWSのアップデートや紹介するライブラリの更新で変わる可能性があります。
=> aws-lambda-go-api-proxyのv0.12.0リリースでServeHTTPを使わなくとも書けるようになりました!

背景

Cloud Automatorは主にRuby on Railsを利用して開発されており、開発チームでの主なプログラミング言語はRubyとなっています。

しかし、今回開発した内部ツールはそれほどアクセスが発生しない、ということが事前にわかっていたので、ランニングコストを最小化するため、API GatewayとLambda Functionを利用したサーバレス構成を採用しました。

また、プログラミング言語も動的言語のRubyではなく、型がネイティブにサポートされた言語で、さらに後方互換が強い言語を採用することになりました。
これは、内部ツールは作ったあとのメンテナンスに時間を割きづらいという特性があるため、それなら言語仕様が変わりづらく、型を利用した静的解析がある言語の方がメンテナンスコストを下げられると考えたためです。
以上の理由でAPI GatewayとLambda Function(Go)を選択しました。

GoでAPI Gateway(REST API)を利用したLambda Functionの実装方法

API GatewayとGoを利用したLambda Functionでは awslabs/aws-lambda-go-api-proxy を利用した開発が便利です。
GinやEchoといったWebフレームワークを利用して実装することが出来ます。

公式のREADME.mdにはGinの例が載っていますが、Echoだと次のようになります。

package main

import (
    "context"
    "log"
    "net/http"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    echoadapter "github.com/awslabs/aws-lambda-go-api-proxy/echo"
    "github.com/labstack/echo/v4"
)

var echoLambda *echoadapter.EchoLambda

func init() {
    // stdout and stderr are sent to AWS CloudWatch Logs
    log.Printf("echo cold start")
    e := echo.New()
    e.GET("/", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
    })

    echoLambda = echoadapter.New(e)
}

func Handler(ctx context.Context, req events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
    // If no name is provided in the HTTP request body, throw an error
    return echoLambda.ProxyWithContext(ctx, req)
}

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

注意すべき点として2021/12/01現在、このEchoを利用したサンプルコードでHTTPリクエストをルーティングさせるにはAPI GatewayのREST APIを選択する必要があります。
API Gatewayの料金ページではREST APIに比べてHTTP APIの方がリクエスト当たりのコストが小さいので、要件が満たせばHTTP APIを利用したいところです。
実際、今回作成した内部ツールでもHTTP APIで要件をカバーできることがわかったので、REST APIではなく、HTTP APIで運用できるようにaws-lambda-go-api-proxyのコードを読んで対応方法を探しました。調べてわかった動くコードは次のセクションです。

GoでAPI Gateway(HTTP API)を利用したLambda Functionの実装方法

Ginを利用する場合はaws-lambda-go-api-proxyがすでにサポートしているので、READMEのコードを元にしてHTTP API用のLambda Functionコードを書くことが出来ます。
他のフレームワークを利用する場合にはaws-lambda-go-api-proxyのhandlerfuncパッケージを利用して書くことが出来ます。
例えば、先ほどのEchoだと次のように書けます。

package main

import (
    "context"
    "log"
    "net/http"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/awslabs/aws-lambda-go-api-proxy/handlerfunc"
    "github.com/labstack/echo/v4"
)

var (
    httpLambda *handlerfunc.HandlerFuncAdapterV2
)

func init() {
    // stdout and stderr are sent to AWS CloudWatch Logs
    log.Printf("echo cold start")
    e := echo.New()
    e.GET("/", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
    })

    httpLambda = handlerfunc.NewV2(e.ServeHTTP)
}

// APIGatewayV2HTTPRequest、APIGatewayV2HTTPResponseと引数の型も変わっている
func Handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) {
    return httpLambda.ProxyWithContext(ctx, req)
}

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

Echoや各種フレームワークが実装しているhttp.Handlerインターフェースをhandlerfunc.NewV2に渡すのがポイントです。

aws-lambda-go-api-proxy v0.12.0以降でAPI Gateway(HTTP API)を利用したLambda Functionの実装方法

package main

import (
    "context"
    "log"
    "net/http"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
    echoadapter "github.com/awslabs/aws-lambda-go-api-proxy/echo"
    "github.com/labstack/echo/v4"
)

var (
    echoLambda *echoadapter.EchoLambdaV2
)

func init() {
    // stdout and stderr are sent to AWS CloudWatch Logs
    log.Printf("echo cold start")
    e := echo.New()
    e.GET("/", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "pong"})
    })

    echoLambda = echoadapter.NewV2(e)
}

// APIGatewayV2HTTPRequest、APIGatewayV2HTTPResponseと引数の型も変わっている
func Handler(ctx context.Context, req events.APIGatewayV2HTTPRequest) (events.APIGatewayV2HTTPResponse, error) {
    return echoLambda.ProxyWithContext(ctx, req)
}

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

おまけ

HTTP APIとLambda Function用のCloudFormationを貼っておきます。

AWSTemplateFormatVersion: 2010-09-09

Parameters:
  LambdaCodeS3BucketName:
    Type: String
  LambdaFunctionName:
    Type: String
  LambdaIAMRoleName:
    Type: String

Outputs:
  ApiGatewayInvokeURL:
    Description: HTTP API Endpoint
    Value: !GetAtt ApiGatewayV2.ApiEndpoint

Resources:
  ApiGatewayV2:
    Type: AWS::ApiGatewayV2::Api
    Properties:
      Name: HTTPAPI
      ProtocolType: HTTP
      Target: !Sub >-
        arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaApiGatewayHandler.Arn}/invocations

  ApiGatewayV2LambdaIntegration:
    Type: AWS::ApiGatewayV2::Integration
    Properties:
      ApiId: !Ref ApiGatewayV2
      Description: Lambda Integration
      IntegrationType: AWS_PROXY
      IntegrationUri: !Sub >-
        arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${LambdaApiGatewayHandler.Arn}/invocations
      IntegrationMethod: POST
      PayloadFormatVersion: '2.0'

  ApiGatewayV2ProxyRoute:
    Type: AWS::ApiGatewayV2::Route
    Properties:
      ApiId: !Ref ApiGatewayV2
      RouteKey: ANY /{proxy+}
      Target: !Sub >-
        integrations/${ApiGatewayV2LambdaIntegration}

  LambdaPermissionProxy:
    Type: 'AWS::Lambda::Permission'
    Properties:
      FunctionName: !Ref LambdaApiGatewayHandler
      Action: 'lambda:InvokeFunction'
      Principal: 'apigateway.amazonaws.com'
      SourceArn: !Sub >-
        arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGatewayV2}/*/*/{proxy+}

  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Ref LambdaIAMRoleName
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - 'sts:AssumeRole'
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  LambdaApiGatewayHandler:
    Type: AWS::Lambda::Function
    Properties:
      Architectures:
        - x86_64
      Code:
        S3Bucket: !Ref LambdaCodeS3BucketName
        S3Key: main.zip
      FunctionName: !Ref LambdaFunctionName
      Handler: handler
      MemorySize: 128
      PackageType: Zip
      Role: !GetAtt LambdaRole.Arn
      Runtime: go1.x
      Timeout: 30

まとめ

これで最低限の運用コストで内部ツールをホストすることが出来るようになりました。
API GatewayへのリクエストをLambda Functionにプロキシする構成は、ローカル環境ではサーバーを起動して開発できるため、いちいちLambdaにデプロイする必要も無く、実装サイクルを高速に回せるというメリットがあります。

今後もこの記事の背景にも書いたとおり、開発時だけではなく、運用もしやすい開発を心がけていきたいと考えています。