kakakakakku blog

Weekly Tech Blog: Keep on Learning!

Lambda 関数(コンテナ)をテストできる testcontainers-python v4.8.0 の新機能「AWSLambdaContainer」

2024年8月14日にリリースされた testcontainers-python v4.8.0 の新機能を確認していたら new: Added AWS Lambda module と書いてあって,これは何だろう〜と気になって試してみた❗️

github.com

簡単に言えば,テスト実行時に testcontainers-python で AWS Lambda 関数(コンテナ)を起動して,AWS Lambda RIE (Runtime Interface Emulator) エンドポイント /2015-03-31/functions/function/invocations を呼び出したレスポンスを assert できる機能だった💡AWS Lambda 関数(コンテナ)の振る舞いをデプロイする前にテストできる \( 'ω')/

さっそく AWSLambdaContainer を試す

ディレクトリ構成

.
├── Dockerfile
├── requirements-test.txt
├── src
│   └── app.py
└── tests
    └── test_app.py

👾 Dockerfile

まず Dockerfile を作る.Python ベースイメージを使えば AWS Lambda RIC (Runtime Interface Clients)AWS Lambda RIE (Runtime Interface Emulator) をセットアップしなくて OK👌 Ubuntu などをベースイメージにする場合は別途セットアップする必要がある.

docs.aws.amazon.com

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

COPY src/app.py ${LAMBDA_TASK_ROOT}

CMD [ "app.handler" ]

👾 src/app.py

AWS Lambda 関数で実行するコードはサンプルとして受け取った event をそのまま返す実装にした.

def handler(event, context):
    return event

👾 tests/test_app.py

以下のドキュメントを参考にしつつテストコードを書いてみた❗️

testcontainers-python.readthedocs.io

まず DockerImageDockerfile からコンテナイメージをビルドしつつ,AWSLambdaContainer で AWS Lambda 関数(コンテナ)を起動する💡そして send_request() で AWS Lambda RIE (Runtime Interface Emulator) のエンドポイント /2015-03-31/functions/function/invocations を呼び出す.

今回は4種類の assert を実装した👌

  • エンドポイント
  • ステータスコード
  • レスポンス (title)
  • レスポンス (url)
import re

from testcontainers.aws import AWSLambdaContainer
from testcontainers.core.image import DockerImage


def test_function():
    with DockerImage(path='.') as image:
        with AWSLambdaContainer(image=image, port=8080) as func:
            pattern = r'http://localhost:\d+/2015-03-31/functions/function/invocations'
            assert re.match(pattern, func.get_api_url())

            response = func.send_request(
                {
                    'title': 'kakakakakku blog',
                    'url': 'https://kakakakakku.hatenablog.com/',
                }
            )
            body = response.json()

            assert response.status_code == 200
            assert body['title'] == 'kakakakakku blog'
            assert body['url'] == 'https://kakakakakku.hatenablog.com/'

ちなみに get_api_url() という関数はドキュメントには載ってなかったけど,実装を確認しているときに発見した.ポート部分は毎回変わるけど http://localhost:61522/2015-03-31/functions/function/invocations という値が返ってくる😀 まさに RIE エンドポイント \( 'ω')/

github.com

👾 requirements-test.txt

AWSLambdaContainer の依存する ServerContainer は HTTP リクエストを操作するライブラリとして内部的に HTTPX を使っているようだった.

www.python-httpx.org

よって,AWSLambdaContainer のレスポンスは HTTPX オブジェクトになるため,セットアップを忘れると ModuleNotFoundError: No module named 'httpx' というエラーが出る.今回 requirements-test.txt は以下のようにした.

httpx==0.27.0
pytest==8.3.2
testcontainers==4.8.0

✅ 動作確認

OK👌

$ python -m pytest --verbose

tests/test_app.py::test_function PASSED                                                                                                                                     [100%]

まとめ

testcontainers-python v4.8.0 の新機能「AWSLambdaContainer」を試してみた❗️

ちなみに個人的には AWS Lambda 関数を実装するときは handler()main() を分割して,main() をテストしている.ドキュメントにもベストプラクティス「Lambda ハンドラーをコアロジックから分離します」と紹介されていたりする📝

docs.aws.amazon.com

今回の AWSLambdaContainer では AWS Lambda RIE (Runtime Interface Emulator) を使って AWS Lambda 関数(コンテナ)の振る舞いをテストできるため,あくまで個人的には単体テストとしてではなく統合テストとして活用できそうかなと思った.

関連記事

kakakakakku.hatenablog.com

Zenn Book で「LocalStack 実践入門」を公開しました

今週月曜日(2024年8月5日)に Zenn Book で完全無料の学習コンテンツ「LocalStack 実践入門 | AWS アプリケーション開発ワークショップ」を公開しましたー🎉

AWS エミュレーターの LocalStack に実践的に入門するワークショップです❗️

zenn.dev

概要 🚀

アプリケーションを AWS 上で稼働させていて,マネージドサービスを中心に組み合わせたりすると,ローカル環境での開発がしにくく感じたり,AWS サービスに依存したアプリケーションコードの単体テストがしにくく感じることがあるはずです💡そんなときには LocalStack が便利だぞ〜👏という技術的な選択肢を紹介したく,ワークショップの開発を企画しました.

アプリケーションコードは Python (Boto3) を使っていて,単体テストは pytest を使っています.比較的簡単なコードですし,コード解説も載せているため,Python の経験がなくても大丈夫かなと思います👌

www.localstack.cloud

読者層 🎃

このワークショップは「LocalStack 未経験者」はもちろん「AWS 初学者」にもおすすめです❗️

僕自身は過去に AWS を教える仕事(テクニカルトレーナー)をしていて,現在も AWS 未経験者の多い組織では AWS 導入の支援や人材育成のサポートをしています.そういった経験の中でよく聞くのは「AWS を試しながら学習したいけど課金が怖い(無料利用枠もよくわからない)」という声です.もちろんある程度 AWS に慣れれば,料金ページや無料利用枠を理解しながら「試しながら学ぶ」というサイクルを回せますが,初学者にとっては第一歩を踏み出すこと自体にハードルの高さがあって〜という相談もよく受けます👀

LocalStack を使えば,AWS アカウントを作らずに AWS の学習ができます.macOS などのラップトップ上に「使い捨てできる自分専用の AWS 環境」を構築できるようなイメージです🌍 もちろん完璧なエミュレーターではないですし,サポートしている AWS サービスも限られていますが,それでも学習用途であれば十分かなと思います👌

docs.localstack.cloud

ワークショップ構成 🧪

ワークショップは Chapter.1 から Chapter.7 まであります.Chapter ごとにステップバイステップに進められて,Chapter を進めるごとにアーキテクチャも広がっていくように作っています.そして Chapter.8 は「応援購入」のための付録です.ワークショップに関連する小ネタを紹介しています.次のワークショップを企画するモチベーションにも繋がりますので,よろしければぜひ❗️

  • Chapter.1: ワークショップ環境をセットアップしよう
  • Chapter.2: LocalStack を使ってみよう
  • Chapter.3: Python コードで Amazon SQS と Amazon S3 を操作しよう
  • Chapter.4: AWS CloudFormation でデプロイを自動化しよう
  • Chapter.5: AWS SAM でサーバレスアプリケーションをデプロイしよう
  • Chapter.6: 使い捨ての LocalStack で単体テストを実行しよう
  • Chapter.7: Amazon API Gateway で API をデプロイしよう
  • Chapter.8: 付録(応援購入)

GitHub Codespaces 🌍

今回のワークショップでは「環境構築でつまづいて欲しくないな〜」ということを意識しました.そこでワークショップで使える統一的なオンライン環境として GitHub Codespaces を選んだところは工夫したポイントの一つで,ワークショップを進めやすくなっているはずです💪

github.co.jp

GitHub Codespaces では自動的に Python 3.12・AWS CLI・Docker・GitHub CLI を含んだ環境を起動するようにしています.

{
    "name": "aws-application-workshop-using-localstack",
    "image": "mcr.microsoft.com/devcontainers/python:1-3.12-bullseye",
    "features": {
        "ghcr.io/devcontainers/features/aws-cli:1": {},
        "ghcr.io/devcontainers/features/docker-in-docker:2": {},
        "ghcr.io/devcontainers/features/github-cli:1": {}
    }
}

ちなみに LocalStack・LocalStack AWS CLI・LocalStack AWS SAM CLI も GitHub Codespaces で自動的にセットアップできますが,LocalStack に関連するツールセットを意識することも重要かなと考えて,ワークショップでは意図的にセットアップしてもらうようにしています💡

github.com

GitHub Codespaces を使ったワークショップ(ハンズオン)の実施はとても便利で,今後もっと流行るかもしれないな〜と思っています❗️

Zenn Book 📕

今回のワークショップを企画するときに配信プラットフォームも悩みました.

ワークショップ以外に解説動画を編集して Udemy や YouTube に公開する案は,準備コストが大きすぎることとアップデートがしにくいことを懸念して避けました.また LocalStack に関する書籍を執筆する案も考えて,出版までの道のりが長すぎることと,今回書籍化するほどのボリュームではなかったこともあって避けました💨

最終的に Zenn Book を選びました.Zenn での執筆は初体験でしたが,Markdown 拡張・プレビュー機能・GitHub 連携など,執筆体験が良かったです❗️さらにコンテンツを基本無料で公開しつつ一部の Chapter のみ有料にしたり,バッジを贈れたり,執筆者のモチベーションに繋がる仕組みが整っているのも良いな〜と思いました.

まとめ 🐸

ぜひ「LocalStack 実践入門 | AWS アプリケーション開発ワークショップ」をお試しください❗️

好評であれば続編も考えたいな〜 \( 'ω')/

zenn.dev

ポスト 🦜

Amazon Inspector Lambda 標準スキャンで Lambda Layer に含まれる CVE を検出する

Amazon Inspector Lambda 標準スキャンを有効化すると「AWS Lambda 関数」「AWS Lambda Layer」を対象に脆弱性 (CVE) を検出できる🔐 Lambda Layer もサポートしていることを確認してみた👌

docs.aws.amazon.com

requests 2.30.0

今回は Python パッケージの requests を使う.requests 2.30.0 には Medium レベルの脆弱性 CVE-2023-32681 が含まれているため,今回は意図的に requests 2.30.0 を含んだ Lambda Layer を作っておく.AWS SAM を使えば Lambda Layer も簡単に作れる❗️

AWSTemplateFormatVersion: 2010-09-09
Transform: AWS::Serverless-2016-10-31

Resources:
  requests2300:
    Type: AWS::Serverless::LayerVersion
    Properties:
      LayerName: requests-2-30-0
      ContentUri: requests-2.30.0/
      CompatibleRuntimes:
        - python3.12
    Metadata:
      BuildMethod: python3.12

pypi.org

Lambda Layer を追加した動作確認用の AWS Lambda 関数も作っておく.コードは以下のように requests を使って API(今回は random.dog)を呼び出すシンプルな実装にしておく🐕

import requests


def lambda_handler(event, context):
    print(requests.get('https://random.dog/woof.json').json())

動作確認

すると Amazon Inspector で CVE を検出できた❗️

標準スキャンから除外する

ドキュメントに書いてある通り,Amazon Inspector Lambda 標準スキャンの対象になるのは 過去90日間に呼び出された・または更新された AWS Lambda 関数になるため,日常的に実行している AWS Lambda 関数は基本的にすべて対象になる.もし除外する場合は InspectorExclusion: LambdaStandardScanning というタグを付ければ OK👌

Powertools for AWS Lambda (Python) の Parameters で DynamoDB GSI からパラメータを取得するカスタムプロバイダ

Powertools for AWS Lambda (Python) の Parameters を使うと AWS Systems Manager Parameter Store / AWS Secrets Manager / AWS AppConfig / Amazon DynamoDB から AWS Lambda 関数で使うパラメータ(何かしらの値)を簡単に取得できる❗️また取得したパラメータを内部的にキャッシュして,パラメータの過剰な取得を抑制する仕組みもある.個人的には AWS Systems Manager Parameter Store をバックエンドによく使っている👌

docs.powertools.aws.dev

Amazon DynamoDB からパラメータを取得する場合は Powertools for AWS Lambda (Python) の DynamoDBProvider を使う.しかし DynamoDBProvider は現状 Amazon DynamoDB テーブルからしかパラメータを取得できず,データ構造的に Amazon DynamoDB テーブルの GSI (Global Secondary Index) からパラメータを取得したいという場面があったりする.今回は「カスタムプロバイダ」を実装して GSI からパラメータを取得してみた \( 'ω')/

1. Amazon DynamoDB テーブルからパラメータを取得する

まずは Amazon DynamoDB テーブルからパラメータを取得する.今回は複数値を取得する get_multiple() を前提にする.

👾 template.yaml(一部)

Amazon DynamoDB テーブルは Powertools ドキュメントの例を参考に AWS SAM (AWS CloudFormation) で以下のように構築した.パーティションキーは id で,ソートキーは sk となる.

Resources:
  Table:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: parameters
      AttributeDefinitions:
        - AttributeName: id
          AttributeType: S
        - AttributeName: sk
          AttributeType: S
      KeySchema:
        - AttributeName: id
          KeyType: HASH
        - AttributeName: sk
          KeyType: RANGE
      BillingMode: PAY_PER_REQUEST

そして,同じく Powertools ドキュメントに載っているサンプルデータを AWS CLI で登録しておく.

$ aws dynamodb put-item --table-name parameters \
  --item '{ "id": { "S": "config" }, "sk": { "S": "endpoint_comments" }, "value": { "S": "https://jsonplaceholder.typicode.com/comments/" } }'
$ aws dynamodb put-item --table-name parameters \
  --item '{ "id": { "S": "config" }, "sk": { "S": "limit" }, "value": { "S": "10" } }'

最終的に以下のようになる👌

id sk value
config endpoint_comments https://jsonplaceholder.typicode.com/comments/
config limit 10

👾 app.py

AWS Lambda 関数は簡単に実装できる❗️Amazon DynamoDB テーブル parameters を参照するように DynamoDBProvider を初期化して,get_multiple() を使って config という値をキーにパラメータを取得している.デフォルトではパラメータを 5秒間 キャッシュするけど,今回は動作確認も兼ねて max_age を設定して 30秒間 にした.

from aws_lambda_powertools.utilities import parameters

dynamodb_provider = parameters.DynamoDBProvider(table_name='parameters')


def lambda_handler(event, context):
    configs = dynamodb_provider.get_multiple('config', max_age=30)
    print(configs)

動作確認

AWS Lambda 関数を定期的に実行しつつ,途中で以下のコマンドを実行して3つ目の config を追加する.値は適当💨

$ aws dynamodb put-item --table-name parameters \
  --item '{ "id": { "S": "config" }, "sk": { "S": "sort" }, "value": { "S": "ASC" } }'

結果的にプロパティのキャッシュが切れてから3つ目の config も取得された👌

{'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/', 'limit': '10'}
{'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/', 'limit': '10'}
{'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/', 'limit': '10'}
{'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/', 'limit': '10', 'sort': 'ASC'}

2. Amazon DynamoDB GSI (Global Secondary Index) パラメータを取得する

今度は Amazon DynamoDB GSI (Global Secondary Index) からパラメータを取得する.しかし Powertools for AWS Lambda (Python) の DynamoDBProvider は GSI をサポートしていないためカスタムプロバイダを実装する.同じく今回は複数値を取得する get_multiple() を前提にする.GSI サポートは issue にもなかった💨(特に需要なさそう?)

👾 template.yaml(一部)

Amazon DynamoDB テーブルは少し構造を変えて uuid をパーティションキーにした.そして,id-index GSI では id をパーティションキーにして,最初の例と同じデータを取得できるようにした.

Resources:
  Table:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: parameters
      AttributeDefinitions:
        - AttributeName: uuid
          AttributeType: S
        - AttributeName: id
          AttributeType: S
      KeySchema:
        - AttributeName: uuid
          KeyType: HASH
      BillingMode: PAY_PER_REQUEST
      GlobalSecondaryIndexes:
        - IndexName: id-index
          KeySchema:
            - AttributeName: id
              KeyType: HASH
          Projection:
            ProjectionType: ALL

同じようにサンプルデータを AWS CLI で登録しておく.

$ aws dynamodb put-item --table-name parameters \
  --item '{ "uuid": { "S": "d33b9dca-952c-4218-b05e-cbd2222ef766" }, "id": { "S": "config" }, "sk": { "S": "endpoint_comments" }, "value": { "S": "https://jsonplaceholder.typicode.com/comments/" } }'
$ aws dynamodb put-item --table-name parameters \
  --item '{ "uuid": { "S": "c2be3412-1107-48d0-b992-750b8fcd4d42" }, "id": { "S": "config" }, "sk": { "S": "limit" }, "value": { "S": "10" } }'

最終的に以下のようになる👌

uuid id sk value
d33b9dca-952c-4218-b05e-cbd2222ef766 config endpoint_comments https://jsonplaceholder.typicode.com/comments/
c2be3412-1107-48d0-b992-750b8fcd4d42 config limit 10

👾 dynamodb.py

次にカスタムプロバイダ DynamoDBIndexProvider を実装する.サンプルとして実装量をできる限り減らしているけど,汎用的に実装するのであれば DynamoDBProvider の実装を参考にして,指定できる値を増やしたり,LastEvaluatedKey を評価したりすると良いと思う👀

github.com

カスタムプロバイダの実装方法は Powertools ドキュメントにも載っているけど,BaseProvider を継承して _get()_get_multiple() を実装すれば OK👌今回は複数値を前提にしているため,_get()NotImplementedError を返して,_get_multiple() では GSI に Query を実行している.

import boto3
from aws_lambda_powertools.utilities.parameters import BaseProvider


class DynamoDBIndexProvider(BaseProvider):
    def __init__(self, table_name, index_name):
        self.index_name = index_name
        self.table = boto3.resource('dynamodb').Table(table_name)
        super().__init__()

    def _get(self):
        raise NotImplementedError

    def _get_multiple(self, id):
        response = self.table.query(
            IndexName=self.index_name,
            KeyConditionExpression='id = :id',
            ExpressionAttributeValues={':id': id},
        )

        return {item['sk']: item['value'] for item in response['Items']}

👾 app.py

こっちはほぼ同じで,Powertools for AWS Lambda (Python) の DynamoDBProviderDynamoDBIndexProvider に置き換えた程度👌 GSI 名を index_name で指定している〜

from dynamodb import DynamoDBIndexProvider

dynamodb_provider = DynamoDBIndexProvider(table_name='parameters', index_name='id-index')


def lambda_handler(event, context):
    configs = dynamodb_provider.get_multiple('config', max_age=30)
    print(configs)

動作確認

同じく AWS Lambda 関数を定期的に実行しつつ,途中で3つ目の config を追加する.

$ aws dynamodb put-item --table-name parameters \
  --item '{ "uuid": { "S": "571a981c-9002-4e2f-b3e5-207f8a0ce1fd" }, "id": { "S": "config" }, "sk": { "S": "sort" }, "value": { "S": "ASC" } }'

結果的にプロパティのキャッシュが切れてから3つ目の config も取得された👌

{'limit': '10', 'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/'}
{'limit': '10', 'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/'}
{'limit': '10', 'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/'}
{'limit': '10', 'endpoint_comments': 'https://jsonplaceholder.typicode.com/comments/', 'sort': 'ASC'}

まとめ

Powertools for AWS Lambda (Python) の Parameters で Amazon DynamoDB テーブルの GSI (Global Secondary Index) からパラメータを取得する場合はカスタムプロバイダを実装しよう❗️

Amazon S3 署名付き URL 経由のアップロードでオブジェクトメタデータを設定する

Amazon S3 署名付き URL を発行してオブジェクトをアップロードするときに「オブジェクトメタデータ」も設定できる👌できるのかな〜と気になって試してみたらできた.署名付き URL 経由でアップロードされたオブジェクトに対して付加情報を設定しておくという活用ができそう \( 'ω')/

👾 main.py

今回は Python (boto3) で試した.generate_presigned_url() を実行するときに ParamsMetadata を設定すれば OK👌ようするに署名付き URL を "発行するときに" オブジェクトメタデータを指定しておくことになる.

以下の main.py を実行して署名付き URL を発行すると,署名付き URL 自体に x-amz-meta-key1=value1&x-amz-meta-key2=value2&x-amz-meta-key3=value3 というクエリパラメータが追加された.

import os

import boto3

s3 = boto3.client('s3')

url = s3.generate_presigned_url(
    'put_object',
    Params={
        'Bucket': os.environ['BUCKET'],
        'Key': 'sample.png',
        'Metadata': {
            'key1': 'value1',
            'key2': 'value2',
            'key3': 'value3',
        },
    },
    ExpiresIn=600,
)

print(url)

動作確認

次に適当なファイルとして sample.png を署名付き URL 経由でアップロードする👌

$ PRESIGNED_URL=xxx
$ curl -X PUT --upload-file sample.png ${PRESIGNED_URL}

AWS CLI でオブジェクトメタデータを確認すると,期待通りに設定できていた❗️

$ aws s3api head-object --bucket ${BUCKET} --key sample.png --query 'Metadata' | jq .
{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}