🙌

最近やってるLambdaとAPI Gatewayの開発方法の話

2021/06/30に公開

先日ちょっとしたAPIをLambdaとAPI Gatewayを使って作ったので、これを機会に最近の弊社のLambda構築状況について書きたいと思います。

TL;DR あるいは まとめ

  • LambdaはECRによるイメージで起動。こうすることで、開発はイメージを作るところまで、インフラはイメージから後、というように分業できる
  • aws-lambda-powertoolsはいいぞ
  • API GatewayはOpenAPIをterraformに読み込ませることで簡単に設定できる

Lambdaのruntime

Lambdaは2020年12月からコンテナイメージをサポートしました。これは、ECRに置かれたコンテナイメージをLambdaが読み込んで起動する、というものです。
弊社ではLambdaは基本的にこのコンテナイメージからの起動で構築しています。

コンテナイメージを使う利点は以下の2つです。

  1. コンテナイメージを責任分界点として、開発とインフラの担当範囲を明確に分割できる
  2. ライブラリの依存関係に悩むことがない

順に述べていきます。

担当範囲の分割

1番目の利点の前提として、開発とインフラはGitHub レポジトリからして分けられており、基本的には違う人間が扱うようになっています。

  • 開発用GitHub レポジトリ
  • インフラ用GitHub レポジトリ

開発用GitHubレポジトリではPRがマージされたり、リリースタグが打たれると、CIによってコンテナイメージがbuildされ、ECRにpushされます。
その後Lambdaのsource imageだけを変更するデプロイを自動で行います。

インフラ側ではLambdaを動かすための環境を整えることだけをしています。

このように、コンテナイメージを責任分界点として、

  • 開発はECRにコンテナイメージをpushするまで (+ Lambaのsource更新)
  • インフラはECRから後

というように担当範囲を明確に分けられます。
また、同じコンテナイメージをインフラ側でも使うことで、開発環境の整備なども必要なくなり、手元での検証や問題追求も比較的容易になります。

ライブラリの依存関係に悩まない

pythonの標準ライブラリは充実しているとはいえ、やはりライブラリを使いたくなります。LambdaにはLayerがあったりと、そういうライブラリを入れやすくなっているとはいえちょっと手間がかかります。
コンテナイメージにすることで、この問題はほぼ解消されます。

弊社のPythonプロジェクトは全部poetryで管理されているので、poetry exportrequirements.txtに依存を書き出しておいて、以下のような感じでpip install しています。

FROM public.ecr.aws/lambda/python:3.8

COPY dockerfiles/foo/requirements.txt ./requirements.txt
RUN pip install --no-cache-dir -r ./requirements.txt \
    && rm ./requirements.txt

本当はdistrolessを使うといいのですが、Lambdaが提供しているコンテナイメージをBaseに使ったほうが楽なのでこうしています。

pythonでのLambda実装

Lambdaをpythonで書くときは必ずaws-lambda-powertoolsを使っています。もはや欠かせないライブラリとなっています。

aws-lambda-powertools

aws-lambda-powertools(以下powertools)にはかなり多くの機能があるのですが、特によく使う機能である以下の3つを少し紹介します。

logger

logはDataDogに投げるときにはJSONになっているといろいろな情報を同時に送れて便利です。しかし、PythonのJSONロガーのパッケージは複数あったり設定がめんどくさかったりします。powertoolsでは以下のように書くだけでよしなにやってくれます。

from aws_lambda_powertools import Logger

logger = Logger()

def foo(event) -> None:
 logger.info({
    "operation": "foo",
    "charge_id": event['charge_id']
 })

POWERTOOLS_SERVICE_NAME という環境変数を設定していると、 service というkeyとしてすべてのログに埋め込まれます。DataDogではこのserviceというkeyでサービスを区別できるので便利です。

parameters

Lambdaに対して機密情報などのパラメータを渡すときにはいろいろな方法があります。powertoolsのparametersユーティリティを使うと、SSM, SecretManager, DynamoDB, AppConfigなど好きなサービスから簡単にパラメータを受け取れます。

以下はSSMのパラメータストアを使う例です。

from aws_lambda_powertools.utilities import parameters

def handler(event, context):
    # 一個のパラメータを受け取る場合
    value = parameters.get_parameter("/my/parameter")

    # prefixで絞り込んで複数同時に読み込むこともできる
    values = parameters.get_parameters("/my/path/prefix")
    for k, v in values.items():
        print(f"{k}: {v}")

API呼び出し回数が気になるところですが、powertoolsではデフォルトで5秒間のキャッシュを持ちます。hot startであればキャッシュを使えるので、呼び出し回数を減らせます。
なお、cacheのTTLは変更可能ですので、もっと伸ばしてより呼び出し回数を削減することも可能です。とはいえ、cold startになってしまったら結局意味はないわけですが。

API Gatewayのrouting

powertoolsの1.15.0から、API Gatewayのroutingを簡単に書けるようになりました。

from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.event_handler.api_gateway import ApiGatewayResolver

app = ApiGatewayResolver()  # デフォルトではAPI Gateway REST API (v1)が使われます。

@app.get("/hello")
def get_hello_universe():
    return {"message": "hello universe"}

@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST)
def lambda_handler(event, context):
    return app.resolve(event, context)

@app.get("/hello") というPythonではよく使われているデコレータによるrouteの指定が使えます。 app.current_event にQuery Parameterなどが入っているので、以下のように取り出せます。

@app.post("/foo")
def get_foo():
    # Query Parameterをdictで取り出す
    query_strings_as_dict = app.current_event.query_string_parameters
    
    # payloadをjsonとして取り出す
    json_payload = app.current_event.json_body
    
    # bodyをそのまま取り出す
    payload = app.current_event.body

他にも @app.get("/<message>/<name>") というdynamic routingや、CORSの指定などもできるので、API Gatewayと組み合わせやすくなりました。
ただ、APIGatewayのproxy_typeによって、 ApiGatewayResolver() に渡す引数が異なりますのでご注意ください。

TerraformでのAPI Gateway構築

Lambda

こうして出来上がったコードとコンテナイメージをterraformでデプロイします。Lambdaはこんな感じで書きました。

resource "aws_lambda_function" "apiserver" {
  description   = "api server"
  function_name = local.apiserver_function_name
  role          = aws_iam_role.lambda_basic_exec_role.arn
  package_type  = "Image"
  image_uri     = "${aws_ecr_repository.apiserver.repository_url}:latest"
  timeout       = 30
  memory_size   = 128

  lifecycle {
    ignore_changes = [image_uri]
  }

  environment {
    variables = {
      POWERTOOLS_SERVICE_NAME = local.apiserver_function_name
    }
  }
}

注目は lifecycle で、ignore_changes を指定することで、コンテナイメージを更新したとしてもTerraformからは更新とみなさないようにしています。これにより、開発がイメージをデプロイしてイメージが更新されても、インフラであるterraformからは変更があるとはなりません。

API Gateway

API Gatewayを素直にterraformで書くと、 aws_api_gateway_resourceaws_api_gateway_methodを何重にも書くことになり大変つらくなります。

そこで、 aws_api_gateway_rest_api でopenapiファイルをtemplatefile()を使って以下のように読み込むことでこの手間を省けます。なお、lambda_arninvoke_role_arn を引数として渡していますが、これについては後述します。

resource "aws_api_gateway_rest_api" "apiserver" {
  name = local.apiserver_function_name
  body = templatefile("./openapi/apiserver.yaml", {
    lambda_arn      = aws_lambda_function.apiserver.invoke_arn,
    invoke_role_arn = aws_iam_role.lambda_apigateway_exec_role.arn,
  })
}

また、rest_apiが更新されるたびにdeploymentは再デプロイしないといけないのですが、以下のようにtriggerでbodyを参照することで、更新があれば自動で再デプロイされることになります。tfファイル自体を判断対象とするのではなく、resourceのbodyを判断対象とすることで、ファイル内に含まれている他のresourceが更新されても再デプロイされなくなります。

resource "aws_api_gateway_deployment" "apiserver" {
  rest_api_id       = aws_api_gateway_rest_api.apiserver.id
  stage_description = "apigateway for ${var.env} ${local.apiserver_function_name}"
  stage_name        = var.env

  // must re-deploy if something changed
  triggers = {
    redeployment = sha1(jsonencode(aws_api_gateway_rest_api.apiserver.body))
  }
  lifecycle {
    create_before_destroy = true
  }
}

API GatewayのOpenAPI定義

API Gatewayで使用できるOpenAPI定義にはx-amazon-apigateway-integration を指定することで、いろいろな付加情報を記載でき、それがAPI Gatewayの設定に使われます。

一例として、以下のようにしています。なお、LambdaはHTTPのPOSTメソッドを受け取って動作するため、"POST"を指定しています。

      x-amazon-apigateway-integration:
        httpMethod: "POST"
        uri: "${lambda_arn}"
        credential: "${invoke_role_arn}"
        passthroughBehavior: "never"
        type: "aws_proxy"

ここで ${lambda_arn} は、terraformの templatefile() 関数から渡される変数が埋め込まれます。このように変数で記述することで、arnなどをopenapiファイル内に書く必要がなくなります。

まとめ

弊社でのLambdaとAPI Gatewayの使い方をまとめてみました。powertoolsは便利だぞ。

ふぇ~ふぇ~(アルパカの鳴き声)

読者からのお便り

Q1: Chaliceじゃないの?

A: コンテナイメージを境界として、開発はコンテナイメージを作るまで、というようにしたいのです。Chaliceはインフラの部分まで踏み込んでいるのですが、そういうところはterraformでインフラの人がやるので不要と思っています。
また、今回はPythonですが、GoでもRustでも同じような構成ができる、ということもあります。

Discussion