🐥

Serverless FrameworkでLambdaをコンテナデプロイ

2023/07/06に公開

はじめに

terraformでAWS Lambdaのコンテナデプロイしてるのを、Serverless Frameworkを使うともっといい感じにできそうなので試してみたメモ

Serverless Frameworkとは

https://www.serverless.com/framework/docs

Develop, deploy, troubleshoot and secure your serverless applications with radically less overhead and cost by using the Serverless Framework. The Serverless Framework consists of an open source CLI and a hosted dashboard. Together, they provide you with full serverless application lifecycle management.

Serverless Frameworkを使うとオーバーヘッドとコストを大幅に削減して、サーバーレスアプリケーションの開発、デプロイ、トラブルシューティング、セキュリティを実現できるとのこと。
要するにLambda関数をいい感じに開発・デプロイができるらしい。

コンセプト

Serverless Framework自体はAWS, Azure, GCPなど様々なプラットフォームに対応している

今回はAWS Lambdaを開発したいのでProvider CLI ReferencesのAWSの部分を眺めてみる

どうやら大きくFunctions, Events, Resources, Servicesという4つのコンセプトがあるらしい

Functions

AWS Lambda Functionsのこと。マイクロサービスのような独立したデプロイメントの単位。

Events

Lambda Functionsが実行されるトリガーとなるイベント。
例えばAPI GatewayのHTTPエンドポイントリクエストやS3バケットのアップロード、CloudWatchのタイマーなど。
Serverless FrameworkでAWS Lambda関数のイベントを定義すると、そのイベントのリソースを自動的に作ってくれる。

Resources

Functionsが使用するAWSリソースのこと。
例えば関数内でアクセスするDynamoDBテーブルやS3 Bucketなど。
トリガーとなるイベントだけでなく、Functionsが依存するAWSリソースのデプロイもできるらしい

Services

フレームワークを構成する単位。Service内に上述のFunctionやEvent,Resourceを定義する

インストール

https://www.serverless.com/framework/docs/providers/aws/guide/installation
筆者はmacを使っているので以下のような手順になりました

  • node.jsのインストール
    asdfを利用
asdf plugin add nodejs
asdf install nodejs latest
asdf global nodejs latest
  • serverless frameworkのインストール
npm install -g serverless

早速試してみる

sls createで雛形を作れるらしいので利用してみます

sls create --helpでtemplateの一覧が出てくるので、今回はpython+dockerのtemplateを利用します

mkdir sls_sample
cd sls_sample
sls create --template "aws-python-docker"

実行するとこんな感じの雛形ができます

.
├── Dockerfile
├── README.md
├── app.py
└── serverless.yml

デフォだとregionがus-east-1らしいので, serverless.yml内のregionの指定だけ修正しました。
serverless.ymlの内容は以下となります

service: sls-sample
frameworkVersion: '2'

provider:
  name: aws
  lambdaHashingVersion: 20201221
  ecr:
    images:
      appimage:
        path: ./
  region: ap-northeast-1

functions:
  hello:
    image:
      name: appimage

デプロイ。
この時のAWSのクレデンシャル情報は~/.aws/credentialsのdefaultプロファイルが利用されます。
(他のプロファイルを利用したい場合はserverless.yml内で指定する)

sls deploy --stage dev

stageオプションはデフォルトはdev.
dev以外を指定すると別環境で作成されます

これを実行するとまずdocker imageをbuildしECRレポジトリの作成とイメージのpushが行われます。
そしてローカルの.serverless/にcloudFormationのテンプレートが作成され、実際にcloudFormation経由でデフォルトで以下のリソースが作成されます

  • Lambda関数
  • Lambda関数用のロググループ
  • Lambda関数用の実行ロール
  • CloudFormation用のS3バケット

ローカルからデプロイしたLambda関数の動作確認(invoke

sls invoke --function hello
{
    "statusCode": 200,
    "body": "{\"message\": \"Hello, world! Your function executed successfully!\"}"
}

Lambda + API GatewayでCRUDアプリを作ってみる

簡単なLambda関数を作成することができたので、次にServerless FrameworkでLambda + API GatewayでちょっとしたCRUDアプリを作ってみます.

プロジェクトの作成

pythonは3.8

pyenv local 3.8.0

依存管理はpoetryを利用する

まずプロジェクトの雛形を作成

poetry new todo_app

今回はアプリケーションがDynamoDBを利用するのでAWS SDKのboto3をインストールする

poetry add boto3

アプリケーションの実装

TodoItemモデルを実装する
todo_app/item.py

rom __future__ import annotations

import dataclasses
import uuid
from dataclasses import dataclass, field
from enum import Enum
from typing import Dict


class Status(Enum):
    OPEN = 1
    DOING = 2
    DONE = 3

    @classmethod
    def from_str(cls, s: str) -> Status:
        return cls[s.strip().upper()]


@dataclass(frozen=True)
class TodoItem:
    name: str
    id: uuid.UUID = field(default_factory=uuid.uuid4)
    status: Status = Status.OPEN

    def start(self) -> TodoItem:
        item = dataclasses.replace(self, status=Status.DOING)
        return item

    def done(self) -> TodoItem:
        item = dataclasses.replace(self, status=Status.DONE)
        return item

    @classmethod
    def from_dict(cls, dict: Dict[str, str]) -> TodoItem:
        return TodoItem(
            id=uuid.UUID(dict["id"]),
            name=dict["name"],
            status=Status.from_str(dict["status"])
        )

    def to_dict(self) -> Dict[str, str]:
        return {"id": str(self.id), "name": self.name, "status": self.status.name}

repositoryを実装する
todo_item/repository.py

import os
from typing import Any, Dict, List

import boto3

from todo_app.item import TodoItem


class ItemTable:
    def __init__(self) -> None:
        dynamodb = boto3.resource("dynamodb")
        self.table = dynamodb.Table(os.environ["DYNAMO_TABLE"])

    def get_item(self, id: str) -> Dict[str, Any]:
        res = self.table.get_item(Key={"id": id})
        return res["Item"]

    def list_item(self) -> List[Dict[str, Any]]:
        res = self.table.scan()
        return res["Items"]

    # upsert
    def save(self, item: TodoItem) -> None:
        self.table.put_item(Item=item.to_dict())
        return None

    def delete(self, id: str) -> None:
        self.table.delete_item(Key={"id": id})
        self.table
        return None

handlerを実装する
todo_item/handler.py

import json

from todo_app.item import TodoItem
from todo_app.repository import ItemTable


table = ItemTable()


def ping(event, context):
    return {
        "statusCode": 200,
        "body": json.dumps({"message": "OK"}, indent=2, ensure_ascii=False),
    }


def list(event, context):
    items = table.list_item()
    return {
        "statusCode": 200,
        "body": json.dumps({"results": items}, indent=2, ensure_ascii=False),
    }


def get(event, context):
    id = event["pathParameters"]["id"]
    item = table.get_item(id)
    return {
        "statusCode": 200,
        "body": json.dumps({"results": item}, indent=2, ensure_ascii=False),
    }


def create(event, context):
    body = json.loads(event["body"])
    name = body["name"]
    item = TodoItem(name=name)
    table.save(item)
    return {
        "statusCode": 200,
        "body": json.dumps({"results": "success"}, indent=2, ensure_ascii=False),
    }


def delete(event, context):
    id = event["pathParameters"]["id"]
    table.delete(id)
    return {
        "statusCode": 200,
        "body": json.dumps({"results": "suceess"}, indent=2, ensure_ascii=False),
    }


def start(event, context):
    id = event["pathParameters"]["id"]
    item = TodoItem.from_dict(table.get_item(id))
    item = item.start()
    table.save(item)
    return {
        "statusCode": 200,
        "body": json.dumps({"results": "suceess"}, indent=2, ensure_ascii=False),
    }


def done(event, context):
    id = event["pathParameters"]["id"]
    item = TodoItem.from_dict(table.get_item(id))
    item = item.done()
    table.save(item)
    return {
        "statusCode": 200,
        "body": json.dumps({"results": "suceess"}, indent=2, ensure_ascii=False),
    }

Dockerfileの実装

base imageはawsが提供しているlambda用のpythonイメージを利用することにします

https://hub.docker.com/r/amazon/aws-lambda-python

awsが提供しているイメージには以下が含まれています

https://goropikari.hatenablog.com/entry/aws_lambda_ric_rie

ランタイムインターフェイスクライアントはruntimeがLambda Serviceと通信するのに必要な機能です. オフィシャルのPythonイメージなどをベースイメージとして利用する場合はパッケージをインストールする必要があります

ランタイムインターフェイスエミュレータは、ローカル環境でLambdaのプログラムを動かすツールです

Dockerfileは以下のようになります
Dockerfile

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

COPY todo_app ./todo_app
COPY poetry.lock pyproject.toml ./

RUN pip install --upgrade pip poetry && \
    poetry config virtualenvs.create false && \
    poetry install --no-root

CMD [ "todo_app.handler.ping" ]

これを元にdocker buildしローカルでdocker runするとエミュレーターが起動します

docker run --rm -it -p 9000:8080 -e AWS_DEFAULT_REGION=ap-northeast-1 -e AWS_ACCESS_KEY_ID=XXX -e AWS_SECRET_ACCESS_KEY=XXX -e AWS_SECURITY_TOKEN -e DYNAMO_TABE=todo-app-item-dev <IMAGE:TAG>

curlで動作確認できます

❯ curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d {}
{"statusCode": 200, "body": "{\n  \"message\": \"OK\"\n}"}% 

serverless.yml

severless.ymlを実装します

ポイントとしては以下です

serverless.yml

service: todo-app
provider:
  name: aws
  region: ap-northeast-1
  environment:
    DYNAMO_TABLE: ${self:service}-item-${opt:stage}
  #profile: private # profile in ~/.aws/credentials
  ecr:
    images:
      todoapp:
        path: ./
        file: Dockerfile
  
  iamRoleStatements:
    - Effect: "Allow"
      Action:
        - "dynamodb:*"
      Resource:
        - "*"

functions:
  ping:
    image:
      name: todoapp
      command: todo_app.handler.ping
    events:
      - httpApi:
          path: /ping
          method: GET
  list:
    image:
      name: todoapp
      command: todo_app.handler.list
    events:
      - httpApi:
          path: /items
          method: GET
  get:
    image:
      name: todoapp
      command: todo_app.handler.get
    events:
      - httpApi:
          path: /items/{id}
          method: GET
  create:
    image:
      name: todoapp
      command: todo_app.handler.create
    events:
      - httpApi:
          path: /items
          method: POST
  delete:
    image:
      name: todoapp
      command: todo_app.handler.delete
    events:
      - httpApi:
          path: /items/{id}
          method: DELETE 
  start:
    image:
      name: todoapp
      command: todo_app.handler.start
    events:
      - httpApi:
          path: /items/{id}/start
          method: PATCH
  done:
    image:
      name: todoapp
      command: todo_app.handler.done
    events:
      - httpApi:
          path: /items/{id}/done
          method: PATCH
resources:
  Resources:
    testTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: ${self:service}-item-${opt:stage}
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: S
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

デプロイ

sls deploy --stage dev --verbose

Discussion