🍎

FaaSを使ったバックエンド開発を効率化するための環境整備(AWS Lambdaを例に)

2023/01/27に公開

はじめに

株式会社 MamaWell でエンジニアのお手伝いをしている、kkb0318 です。

先日、フロントエンドの記事を執筆しましたので今回はそのバックエンド版になります。

https://zenn.dev/kkb0318/articles/article1-ui-env

フロントエンドの記事で紹介したとおり、開発リソースがとても少ない状況なため、
いかに効率的に開発を進められるかが重要になってきます。

立ち上げ期において開発スピードを重視したい場合に使用するサービスの候補として、AWS Lambda のような FaaS が挙げられます。

しかし、個人的には FaaS にあまり良い思い出がありませんでした。
理由は、コンテナベースの開発等と比べて動作検証やテストがしづらく、開発体験が悪いと感じていたためです。
(コンソールで直接コード書いていたときの思い出が蘇る..)

そこで、FaaS を使いつつ開発体験を良くし、効率的にデプロイする仕組みづくりを行いましたので、その内容についてご紹介します。

バックエンド開発を効率化する施策

概要

バックエンドエンド開発を効率化するために、GitHub Actions, LocalStack, AWS SAM を使用しました。
ソースコードは GitHub で管理し、GitHub Actions で CI/CD を回しています。
ブランチ戦略と CI/CD のしくみはざっくり以下のとおりです。

feature/xxx -> develop -> main

  • main
    • 動作保証された本番用コードが入ったブランチ。
  • develop
    • とりあえず動作するコードが入ったブランチ。
  • feature/xxx
    • タスクに紐づく、開発中のコードが入ったブランチ。

モノレポなので、ブランチ戦略自体はフロントエンドと同じです。

feature/xxx, develop ブランチで Unit Test を実行し、develop ブランチで Integration Test を実行します。
また main ブランチで本番環境へデプロイされるよう設定します。(今回ここの話はしません)

フォルダ構成

フォルダ構成は以下のようにしました。
lambda1 フォルダが一つの Lambda 関数に対応しており、複数作成したい場合はフォルダごと増やしていく構成です。
Integration test や IaC のコードは全体共通にしたいため、ホームディレクトリ直下に置いています。

.
├── README.md
├── docker-compose.yaml        # Integration Test用
├── template.yaml              # IaC
├── lambda1                    # Lambda関数
│   ├── src/
│   └── tests/                 # Unit Test
├── tests                      # Integration Test
│   ├── Dockerfile             # LocalStack実行
│   └── integration/
└── tox.ini

AWS SAM テンプレート作成

AWS SAM は、IaC(Instastructure as Code)を実現できるツールでサーバーレスアプリケーションを構築するために用いられます。
今回は、まず SAM CLI でサンプルコードを作成し、そのコードをもとに改造しました。

SAM CLI のインストール方法は公式ページを参照してください。

本記事では Python を用いましたが、他の言語でも同様にできます。

(言語の選定について:当初、フロントエンドと同じ TypeScript をバックエンドにも採用する予定でしたが、メンバーのスキルを考慮して Python にしました。)

Unit Test を容易にするための関数設計

AWS SAM で作成したサンプルコードでは、app.py に全ての処理が記載されています。
app.py のハンドラ関数が簡単なデータ整形処理程度であればこれでも十分かもしれません。
しかし、データの整形に加えて、DB へのアクセス等の外部参照や副作用が発生する場合、とたんに単体テストがしづらくなります。
このような関数で Unit Test を書く場合は、例えば以下のような処理をテストコードに記載する必要があります。

  • 外部参照部分をモックにする
  • ハンドラ関数への入力データを定義する
  • ハンドラ関数からの出力をテストする

正しい入力データを用意し、妥当なアサーションチェックを記載したうえで、仮にテストケースが通らなかった場合、考えうるエラー箇所は以下のとおりです。

  • モックまでの処理
  • モックそのもの
  • モックの戻り値に対する処理

外部参照部分を含めた関数全体をテストしてしまうと、エラー箇所の特定が難しくなってしまいます。
さらに、網羅率をあげるために様々な種類のモックを用意すると、今度はテストコード自体が複雑になってしまいテストコードの保守が難しくなってしまいます。

そこで、以下のようにハンドラ関数を外部参照部分とそうでない純粋関数とを分割することで、テストしやすい構成に変更しました。

.
├── src
│   ├── __init__.py
│   ├── adapter.py          # input を整形する純粋関数
│   ├── app.py              # ハンドラ関数
│   ├── output.py           # responseに合うよう整形する純粋関数
│   ├── repository.py       # 外部参照する関数(DynamoDBと接続)
│   └── requirements.txt
└── tests
    ├── __init__.py
    ├── requirements.txt
    └── unit
        ├── __init__.py
        ├── data/
        ├── test_adapter.py
        ├── test_output.py
        └── test_handler.py

app.py ハンドラ関数は、"順番に処理を行う"役割に特化させています。

lambda1/src/app.py
from . import adapter, output, repository

def lambda_handler(event, context):
    event = adapter.run(event)
    event = repository.run(event)
    return output.run(event)

adapter.py や output.py は以下のような純粋関数(入力が同じであれば何度呼び出しても同じ戻り値が返る関数)として定義しています。

lambda1/src/adapter.py
def run(event):
    # ここに処理を書く
    return event

repository.py は、(極力)入力データをそのまま DynamoDB に送るだけの関数として定義しています。

lambda1/src/repository.py
import os

import boto3


def run(event):
    endpoint_url = os.getenv("ENDPOINT_URL")
    try:
        dynamoDB = boto3.resource("dynamodb", endpoint_url=endpoint_url)
        table = dynamoDB.Table("table1")
        response = table.put_item(
            Item=event
        )
        return response
    except Exception as e:
        print(e)
        return {"statusCode": 500}

adapter.py や output.py は純粋関数なため、モックは必要ありません。
期待する入力と出力を定義すればよいだけなので、テストコードが簡単になります。
また repository.py は、モックを作って単体テストすることもできますが、今回のような例では、関数への引数をそのまま DynamoDB に送り、DynamoDB からの戻り値もそのまま返しているだけなので、モックしたい関数(ここでいう table.put_item) ≒ repository.py そのもの という解釈ができます。
そのため、repository.py への入力(adapter.py の出力)と repository.py の出力(output.py への入力)についてテストができていれば良いことになります。
したがって、repository.py の Unit Test は作成せず、Integration Test でチェックするという運用にしました。

このような単純な処理であれば、コードの設計次第では純粋関数だけの Unit Test で十分になるため、とてもテストが書きやすく保守しやすくなります。

ローカルでは python -m pytest tests/unit のように Unit Test を実行することができますので、開発しながら簡単に Unit Test で動作検証することができます。

LocalStack による Integration Test

LocalStack は、ローカルでクラウドサービスの実行確認やテストを行うことができる、クラウドサービスエミュレーターです。

今回はこのツールを、API Gateway-Lambda-DynamoDB の Integration Test 用に使用しました。

ローカルに LocalStack CLI を入れて実行することもできますが、ローカル環境を汚したくないのと、後の GitHub Actions での実行を容易にしたいという考えから、Docker Image を使用しました。

早速ですが、docker-compose, DockerFile は以下のとおりです。

docker-compose.yaml
version: "3"

services:
  localstack:
    build:
      context: ./tests
      dockerfile: Dockerfile
    environment:
      - SERVICES=dynamodb
      - AWS_DEFAULT_REGION=ap-northeast-1
      - DATA_DIR=/tmp/localstack/data
    volumes:
      - ./:/var/lib/localstack
    ports:
      - 4566:4566

※ マウント先は/var/lib/localstack でないとエラーになります

tests/Dockerfile
FROM localstack/localstack:latest

WORKDIR /var/lib/localstack

# credentialのダミーを作成
RUN mkdir /root/.aws/ \
    && touch /root/.aws/config \
    && echo -e "[default]\naws_access_key_id = dummy\naws_secret_access_key = dummy" > /root/.aws/credentials

COPY requirements.txt .
RUN python -m pip install -r requirements.txt

CMD python -m pytest tests/integration -v

LocalStack はローカルで実行されるため aws credential 情報は使用しませんが、credential ファイルが存在していないとエラーになるため、ダミー credential ファイルを作成しています

テストコードの例は以下のとおりです。

tests/integration/test_dynamodb.py
import json
import os

import boto3
import pytest
from test_py.src import app

@pytest.fixture()
def table():
    dynamoDB = boto3.resource("dynamodb", endpoint_url="http://localhost:4566/")
    table = dynamoDB.create_table(
        TableName="table1",
        KeySchema=[
            {"AttributeName": "item1", "KeyType": "HASH"},
            {"AttributeName": "item2", "KeyType": "RANGE"},
        ],
        AttributeDefinitions=[
            {"AttributeName": "item1", "AttributeType": "S"},
            {"AttributeName": "item2", "AttributeType": "S"},
        ],
        ProvisionedThroughput={"ReadCapacityUnits": 1, "WriteCapacityUnits": 1},
    )

    # tableをreturn
    yield table

    # test終了後table削除
    table.delete()


def test_lambda_handler(table):

    event = {
        "item1": "item1",
        "item2": "item2",
    }

    os.environ["ENDPOINT_URL"] = "http://localhost:4566/"

    res = app.lambda_handler(event, "")

    assert res["statusCode"] == 200

    data = table.get_item(
        Key={"item1": "item1", "item2": "item2"},
    )

    # 以下、assertion
    assert data["item1"] == "item1"

これで docker-compose を立ち上げ、以下のコマンドで Integration test を動かすことができます。

docker-compose exec localstack python -m pytest tests/integration -v

このようにしておくことで、コマンド一つで Integration Test が実行できるようになり、開発体験がとてもよくなりました。

また、後述の CI/CD も簡単に記述できるようになります。

CI/CD の導入

GitHub Actions を用いて Unit Test, Integration Test および本番環境への自動デプロイを実装しました。

Unit Test の共通化

Lambda 関数は今後いくつも作成していく想定であるため、毎回同じような GitHub Actions の設定を追加していくのは面倒です。
そのため、以下のように Unit Test の処理を共通化したジョブを定義しました。

.github/actions/ut/action.yml
inputs:
  func_name:
    description: "lambda function name"
    required: true
runs:
  using: "Composite"
  steps:
    - uses: actions/setup-python@v3
      with:
        python-version: "3.10"
    - name: install
      run: python -m pip install -r tests/requirements.txt
      shell: bash
      working-directory: ./api/${{inputs.func_name}}
    - name: run unit test
      run: python -m pytest tests/unit
      shell: bash
      working-directory: ./api/${{inputs.func_name}}

関数名を引数(func_name)にして Unit Test を実行できるようにすることで、今後新しい関数を作成した場合にも簡単に Unit Test を追加できるようにしました。

Unit Test の実行

上記で共通化した Unit Test の処理は、以下のように呼び出しています。

.github/workflows/api-ut.yml
on:
  push:
    branches:
      - develop
      - feature/*
    paths:
      - "api/**"
jobs:
  api-ut-lambda1:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: ./.github/actions/ut
        with:
          func_name: "lambda1"

これで、Job を複製して関数名(func_name)を変更するだけで、新しい関数に対する Unit Test のジョブを作成することができます。

Integration Test の実行

ローカルでは docker-compose を用いて実装したので少し面倒かなと思っていたのですが、 GitHub Actions では 以下のように docker-compose を簡単に使うことができました。

.github/workflows/api-it.yml
on:
  push:
    branches:
      - develop
    paths:
      - "api/**"
jobs:
  api-it:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - run: docker compose up -d
        working-directory: ./api/
      - name: execute integration test
        run: docker compose exec localstack python -m pytest tests/integration -v
        working-directory: ./api/

やっていることは、単純に docker compose up して Integration Test を実行しているだけです。

LocalStack の章でも説明したとおり、Integration Test をコマンド一つで立ち上がるように設定したので、GitHub Actions の設定をとてもシンプルにすることができました。

さいごに

本プロジェクトは、執筆時点(2023/1)では現在進行形で進んでいます。
フロントエンドに続き、バックエンドの開発環境も整備したことで、ローカル開発での不満が大幅に減少しました。
まだ開発途中なので、この仕組みがどこまでスケールできるか楽しみです。
とりあえず開発スピード重視で作った仕組みでもあるので、もし今後スケールできなそうであれば、都度アップデートしていきたいと思っています。

エンジニア募集

株式会社 MamaWell ではエンジニアを募集しています。

勤務形態はリモートで、働く曜日や日時は自由です。副業・アルバイトも募集中です。

モダンな開発に興味がある方、新規プロダクト立ち上げに興味のある方はぜひ、以下までご連絡ください!

https://mamawell.jp/engineer_recruit/

Discussion