title: 【新旧比較】Python Lambda の「実装」と「ユニットテスト」をシンプルにする ~ クラス方式から Lambda Web Adapter へ ~
tags: AWS Lambda Python ユニットテスト LambdaWebAdapterを使う時、おーつきの従来アプリを依存から極力遠ざけシンプルに開発する新旧開発手法をご紹介します。
はじめに
Lambda を書くとき、こんな悩みありませんか?
- ローカルで動かすたびに
lambda_handler(event, context)のeventを手で組み立てるのがダルい - ユニットテストのために、毎回ダミーの
event/contextを作っている - Python のランタイム EOL が来るたびに、移行作業に追われる
この記事では、「ローカル実行」と「ユニットテスト」を簡素にする ための実装方式を、旧方式(クラスベース) と 新方式(Lambda Web Adapter) で並べて紹介します。
題材は「料金計算」というどこにでもある処理にして、同じロジックを2通りで実装 します。手を動かしながら違いを体感できる構成にしました。
この記事のゴール
「Lambda = handler を書くもの」という前提を一度外して、ただのアプリとして書く → そのまま Lambda に乗せる という発想に切り替えることです。
対象読者
- Lambda を Python で書いているが、テストの書き方がしっくり来ていない方
- インフラ(IAM ロール)の設計を、アプリ側でちゃんと活かしたい方
- 0→1 でサーバーレスを始める / チームに展開(Enablement)したい方
旧方式:クラスベースで「ローカル実行」と「Lambda 実装」を両立する
まずは筆者が従来からやっている方式です。考え方はシンプルで、
- ロジックはクラスにまとめる(Lambda に依存させない)
- ローカルでは
mainでインスタンス化して実行 - テストは そのインスタンスを叩くだけ
- Lambda には
handlerから呼ぶ薄い入り口 を一枚かぶせる
ポイントは「ロジックを lambda_handler の中に直接書かない」ことだけです。これだけでローカルでも DevContainer でも、Lambda 上でも同じコードが動きます。
サンプル:料金計算クラス
# calculator.py
from dataclasses import dataclass
@dataclass
class PriceCalculator:
"""税率・割引率を受け取って合計金額を計算する素のロジック。
AWS にも Lambda にも一切依存しない。"""
tax_rate: float = 0.10 # 消費税 10%
def total(self, unit_price: int, quantity: int, discount_rate: float = 0.0) -> int:
if unit_price < 0 or quantity < 0:
raise ValueError("unit_price と quantity は 0 以上で指定してください")
subtotal = unit_price * quantity
discounted = subtotal * (1 - discount_rate)
total = discounted * (1 + self.tax_rate)
return int(total) # 円なので整数に丸める
① ローカルで実行する(main)
# calculator.py(末尾に追記)
if __name__ == "__main__":
calc = PriceCalculator()
print(calc.total(unit_price=1000, quantity=3, discount_rate=0.2))
# 1000 * 3 = 3000 → 20%引きで 2400 → 税込 2640
$ python calculator.py
2640
event を組み立てる必要がありません。ただの Python クラスなので、当然そのまま動きます。
② ユニットテストする
# test_calculator.py
import pytest
from calculator import PriceCalculator
class TestPriceCalculator:
def setup_method(self):
self.calc = PriceCalculator()
def test_税込・割引込みの合計(self):
assert self.calc.total(1000, 3, 0.2) == 2640
def test_割引なし(self):
assert self.calc.total(500, 2) == 1100 # 1000 → 税込 1100
def test_負の数はエラー(self):
with pytest.raises(ValueError):
self.calc.total(-1, 1)
$ pytest -v
test_calculator.py::TestPriceCalculator::test_税込・割引込みの合計 PASSED
test_calculator.py::TestPriceCalculator::test_割引なし PASSED
test_calculator.py::TestPriceCalculator::test_負の数はエラー PASSED
event / context のモックが一切要らない のが効いています。テスト対象が「ただのクラス」だからです。
③ Lambda に乗せる(薄い入り口を一枚かぶせる)
Lambda 用の handler は、event を受け取ってクラスに渡すだけの薄い層にします。
# lambda_function.py
from calculator import PriceCalculator
def lambda_handler(event, context):
calc = PriceCalculator()
total = calc.total(
unit_price=event["unit_price"],
quantity=event["quantity"],
discount_rate=event.get("discount_rate", 0.0),
)
return {"total": total}
Tips:static メソッドでも同じことができる
状態を持たないロジックなら @staticmethod にして PriceCalculator.total(...) と呼ぶ形でもOKです。「インスタンス化が要らない」ぶん handler がさらに薄くなります。要は ロジックを handler の外に出す のが本質です。
この方式の評価
| 観点 | 評価 |
|---|---|
| ローカル実行 | ◎ python xxx.py でそのまま動く |
| ユニットテスト | ◎ event モック不要、ロジックを直接叩ける |
| Lambda 実装 | ○ handler は薄い入り口だけ |
| ランタイム更新 | △ マネージドランタイムの EOL に追従が必要 |
「実装」と「テスト」はこれで十分シンプルになります。残る課題は最後の行、ランタイムの更新です。ここを解決するのが次の新方式です。
新方式:AWS 推奨の Lambda Web Adapter(LWA)
AWS の builders.flash で紹介された方式です。記事のテーマはずばり 「Lambda handler のテスト戦略 ~ Lambda Web Adapter でランタイムアップデートの苦労から解放されよう」 でした。
発想を変えます。
handler を書くのをやめて、ただの Web アプリとして書く。
Lambda Web Adapter(LWA) は、HTTP を喋る普通のコンテナを Lambda 上でそのまま動かすためのアダプタです。Lambda のイベント ⇄ HTTP リクエストの変換を LWA が肩代わりしてくれるので、アプリ側のコードから lambda_handler が消えます。
サンプル:同じ料金計算を FastAPI で書く
ロジック(calculator.py)は 旧方式とまったく同じものを再利用 します。入り口だけ Web アプリにします。
# app.py
from fastapi import FastAPI
from pydantic import BaseModel
from calculator import PriceCalculator
app = FastAPI()
class PriceRequest(BaseModel):
unit_price: int
quantity: int
discount_rate: float = 0.0
@app.post("/price")
def calc_price(req: PriceRequest):
calc = PriceCalculator()
return {"total": calc.total(req.unit_price, req.quantity, req.discount_rate)}
① ローカルは「ただの Web アプリ」(ここがシームレス)
$ uvicorn app:app --reload --port 8080
$ curl -X POST localhost:8080/price \
-H "Content-Type: application/json" \
-d '{"unit_price":1000,"quantity":3,"discount_rate":0.2}'
{"total":2640}
ブラウザで http://localhost:8080/docs を開けば、Swagger UI で叩くこともできます。ローカルの開発体験が普通の Web 開発とまったく同じになります。
② テストも Web アプリのやり方そのまま
# test_app.py
from fastapi.testclient import TestClient
from app import app
client = TestClient(app)
def test_price_api():
res = client.post("/price", json={
"unit_price": 1000, "quantity": 3, "discount_rate": 0.2,
})
assert res.status_code == 200
assert res.json() == {"total": 2640}
event / context のモックではなく、実際の HTTP リクエストでテストできます。ロジック単体のテスト(旧方式の test_calculator.py)はそのまま残せるので、「ロジックの単体テスト」+「API の結合テスト」 を素直な形で両立できます。
③ Lambda に乗せる(Dockerfile に LWA を1行足すだけ)
# Dockerfile
FROM public.ecr.aws/docker/library/python:3.13-slim
# ★ これが LWA。これ1行で「Web アプリ → Lambda」になる
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 \
/lambda-adapter /opt/extensions/lambda-adapter
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8080"]
アプリのコードには Lambda 固有の記述が一切ありません。同じイメージを、ローカルでも・ECS でも・Lambda でも動かせます(ポータビリティ)。
ランタイムアップデートの苦労から解放
マネージドランタイムだと Python のバージョン EOL のたびに移行対応が発生します。LWA + コンテナイメージなら ベースイメージ(python:3.13-slim の部分)を自分のタイミングで上げるだけ。AWS のランタイム廃止スケジュールに振り回されません。これが今回の方式の最大の旨味です。
新旧比較
| 観点 | 旧方式(クラス + handler) | 新方式(LWA + Web アプリ) |
|---|---|---|
| アプリのコード | handler を意識する | handler が消える(ただの Web アプリ) |
| ローカル実行 | python xxx.py |
uvicorn(Swagger UI も使える) |
| テスト | ロジック単体テスト | ロジック単体 + HTTP 結合テスト |
| ランタイム更新 | マネージドの EOL に追従 | ベースイメージを自分で更新 |
| ポータビリティ | Lambda 専用 | ローカル / ECS / Lambda 共通 |
実行ロール(PassRole)でインフラ設計をアプリに活かす
もう一つ大きいのが、「インフラ側で設計した IAM ロールを、アプリがそのまま使える」 という点です。
アプリにクレデンシャルを書かない
Lambda の実行ロールは、起動時に環境変数として自動注入されます。boto3 はデフォルトのクレデンシャルチェーンでこれを拾うので、アプリ側はキーを一切書きません。
import boto3
# キーもプロファイルも書かない。実行ロールの権限がそのまま使われる
s3 = boto3.client("s3")
s3.put_object(Bucket="my-bucket", Key="result.json", Body=b"...")
つまり、インフラ担当が設計したロール(=許可された操作)が、そのままアプリの「できること」になるわけです。最小権限の設計が、アプリの挙動として素直に効いてきます。
PassRole とは
「この Lambda にこのロールを背負わせる」とき、デプロイ/設定する側に必要なのが iam:PassRole 権限です。ロールを“渡す(Pass)”ことを許可するもので、これがあるおかげで「アプリ開発者はロジックに集中し、インフラ側が権限を設計する」という分業が成立します。
# template.yaml(CloudFormation 抜粋)
Resources:
# インフラ側が設計する「アプリのできること」
PriceFunctionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal: { Service: lambda.amazonaws.com }
Action: sts:AssumeRole
Policies:
- PolicyName: AppLeastPrivilege
PolicyDocument:
Version: "2012-10-17"
Statement:
# 例:このバケットの書き込みだけ許可(最小権限)
- Effect: Allow
Action: s3:PutObject
Resource: arn:aws:s3:::my-bucket/*
PriceFunction:
Type: AWS::Lambda::Function
Properties:
PackageType: Image
Code: { ImageUri: !Ref ImageUri }
# ↓ ここで PassRole されたロールを背負う
Role: !GetAtt PriceFunctionRole.Arn
Timeout: 30
Tips:これが DevOps 的に効く理由
アプリのコードに権限が一切ハードコードされず、「権限の設計(CloudFormation)」と「ロジックの実装(アプリ)」が完全に分離されます。レビューも監査も、ロールの定義ファイルを見れば済みます。インフラ設計をアプリに“活かす”とは、まさにこの状態のことです。
まとめ
- 旧方式:ロジックをクラスに切り出し、handler を薄くする。これだけで テストから event モックが消える。今でも十分有効。
- 新方式(LWA):handler を捨てて ただの Web アプリとして書く。ローカル開発がシームレスになり、ランタイム更新の苦労からも解放される。
-
ロジック(
calculator.py)は新旧で完全に共通。つまり旧方式で書いていても、入り口を差し替えれば新方式に移行できます。 - **実行ロール(PassRole)**で、インフラ側の権限設計をアプリにそのまま活かせる。アプリにはクレデンシャルを書かない。
「まず旧方式でロジックを切り出す → 入り口だけ LWA に差し替える」という順で進めれば、無理なく新方式に乗り換えられます。0→1 の一歩目として、ぜひ手元で uvicorn から試してみてください。
参考
- AWS builders.flash「Lambda handler のテスト戦略 ~ Lambda Web Adapter でランタイムアップデートの苦労から解放されよう」
- aws-lambda-web-adapter(GitHub)