はじめに

こんにちは、エンジニアの遠藤です。

長らく時間が空いてしまいましたが、前回 試したいと書いた、 AWS CDK を使ったデプロイをようやく試すことができました。

デプロイまでの手順は、公式のチュートリアルに記載されている内容ですが、チュートリアルだけだと分からない、イベントソースの追加方法や、実際にデプロイされるリソースなどを見ていきたいと思います。

AWS CDK でのデプロイ

基本的には、 公式のチュートリアルに沿って進めていきますので、合わせて参照いただけると良さそうです。

環境準備

前回 の手順に加え、 aws-cdk のインストールが必要になります。

$ npm --version
8.13.0

$ node --version
v16.14.0

$ npm install -g aws-cdk
$ cdk --version
2.29.0 (build 47d7ec4)

余談ですが、 nodenv を使っていると、 npm -g でインストールしたコマンドが使えない場合があります。
そんな時は、 rehash を実行すると、参照できるようになる場合があります。

$ nodenv rehash

続けて、チュートリアルに従って、仮想環境を作成し、 Chalice を使えるようにします。

$ python -m venv demo
$ . demo/bin/activate
$ python -m pip install chalice
$ chalice --version
chalice 1.27.1, python 3.9.10, darwin 21.5.0

チュートリアルでは、 python3 となっていますが私の手元の環境では pyenv global で 3.9 を参照しているため、 python で実行しています。

CDK で Chalice をデプロイするための拡張パッケージをインストールする必要があるようです。

$ python -m pip install "chalice[cdkv2]"

ここまでできたら、プロジェクトを作成します。
作成時に、 [CDK] Rest API with a DynamoDB table を選択します。

$ chalice new-project


   ___  _  _    _    _     ___  ___  ___
  / __|| || |  /_\  | |   |_ _|/ __|| __|
 | (__ | __ | / _ \ | |__  | || (__ | _|
  \___||_||_|/_/ \_\|____||___|\___||___|


The python serverless microframework for AWS allows
you to quickly create and deploy applications using
Amazon API Gateway and AWS Lambda.

Please enter the project name
[?] Enter the project name: cdkdemo
[?] Select your project type: [CDK] Rest API with a DynamoDB table
   REST API
   S3 Event Handler
   Lambda Functions only
   Legacy REST API Template
 > [CDK] Rest API with a DynamoDB table

Your project has been generated in ./cdkdemo

サンプルコードの中身

出来上がった構成を見てみましょう。

$ cd cdkdemo
$ tree             
.
├── README.rst
├── infrastructure
│   ├── app.py
│   ├── cdk.json
│   ├── requirements.txt
│   └── stacks
│       ├── __init__.py
│       └── chaliceapp.py
├── requirements.txt
└── runtime
    ├── app.py
    └── requirements.txt

※ tree は brew でインストールできます。

基本的な構成としては、 infrastructure の下に CDK で構築するインフラの情報(今回は DynamoDBのみ) について記述されています。
そして、 runtime の下に、 Lambda にデプロイされる内容が書かれています。

さらっと、中身を眺めてみましょう。

まずは CDK ですが、 ./infrastructure/stacks/chaliceapp.py に内容があります。

import os

from aws_cdk import aws_dynamodb as dynamodb

try:
    from aws_cdk import core as cdk
except ImportError:
    import aws_cdk as cdk

from chalice.cdk import Chalice


RUNTIME_SOURCE_DIR = os.path.join(
    os.path.dirname(os.path.dirname(__file__)), os.pardir, 'runtime')


class ChaliceApp(cdk.Stack):

    def __init__(self, scope, id, **kwargs):
        super().__init__(scope, id, **kwargs)
        self.dynamodb_table = self._create_ddb_table()
        self.chalice = Chalice(
            self, 'ChaliceApp', source_dir=RUNTIME_SOURCE_DIR,
            stage_config={
                'environment_variables': {
                    'APP_TABLE_NAME': self.dynamodb_table.table_name
                }
            }
        )
        self.dynamodb_table.grant_read_write_data(
            self.chalice.get_role('DefaultRole')
        )

    def _create_ddb_table(self):
        dynamodb_table = dynamodb.Table(
            self, 'AppTable',
            partition_key=dynamodb.Attribute(
                name='PK', type=dynamodb.AttributeType.STRING),
            sort_key=dynamodb.Attribute(
                name='SK', type=dynamodb.AttributeType.STRING
            ),
            removal_policy=cdk.RemovalPolicy.DESTROY)
        cdk.CfnOutput(self, 'AppTableName',
                      value=dynamodb_table.table_name)
        return dynamodb_table

ここでは、 DynamoDB の作成(_create_ddb_table)を行い、作成したテーブル名を環境変数に引き渡して、 Lambda がデプロイされています。
また、作成した Lambda の Role で DynamoDB を操作できるように、権限をいじっています。
権限周りの操作も、デプロイの流れで出来るのは良いですね。

続けて、 Lambda 側のコードを見てみます。

import os
import boto3
from chalice import Chalice


app = Chalice(app_name='cdkdemo')
dynamodb = boto3.resource('dynamodb')
dynamodb_table = dynamodb.Table(os.environ.get('APP_TABLE_NAME', ''))


@app.route('/users', methods=['POST'])
def create_user():
    request = app.current_request.json_body
    item = {
        'PK': 'User#%s' % request['username'],
        'SK': 'Profile#%s' % request['username'],
    }
    item.update(request)
    dynamodb_table.put_item(Item=item)
    return {}


@app.route('/users/{username}', methods=['GET'])
def get_user(username):
    app.log.debug("This is a debug statement")
    app.log.error("This is an error statement")
    key = {
        'PK': 'User#%s' % username,
        'SK': 'Profile#%s' % username,
    }
    item = dynamodb_table.get_item(Key=key)['Item']
    del item['PK']
    del item['SK']
    return item

API のインターフェースとしては、 POST と GET の2つが提供される構成になっています。
POST で受け取ったユーザを DynamoDB に書き込み、 GET で参照します。

  • @app.route('/users', methods=['POST'])
  • @app.route('/users/{username}', methods=['GET'])

お試しデプロイ

それでは、デプロイまで進めます。
プロジェクトルートで必要なモジュールをインストールします。

python -m pip install -r requirements.txt

AWS のアカウントを登録していない人は事前に、 ~/.aws/config に、アクセス情報を書いておきましょう。

[default]
aws_access_key_id = AKIXXXXXXXXXXXXXXXX
aws_secret_access_key = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
region = ap-northeast-1

設定できたら、 CDK のセットアップをします。

$ cd infrastructure
$ cdk bootstrap
Creating deployment package.
 ⏳  Bootstrapping environment aws://1234567890/ap-northeast-1...
Trusted accounts for deployment: (none)
Trusted accounts for lookup: (none)
Using default execution policy of 'arn:aws:iam::aws:policy/AdministratorAccess'. Pass '--cloudformation-execution-policies' to customize.
 ✅  Environment aws://12345678906/ap-northeast-1 bootstrapped (no changes).

デプロイします。

$ cdk deploy
Creating deployment package.
Reusing existing deployment package.

✨  Synthesis time: 22.86s

This deployment will make potentially sensitive changes according to your current security approval level (--require-approval broadening).
Please confirm you intend to make the following modifications:

IAM Statement Changes
...{長いので中略}...
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)

Do you wish to deploy these changes (y/n)? y
cdkdemo: deploying...
[0%] start: Publishing xxxx:current_account-current_region
[0%] start: Publishing xxxx:current_account-current_region
[50%] success: Published xxxx:current_account-current_region
[100%] success: Published xxxx:current_account-current_region
cdkdemo: creating CloudFormation changeset...

 ✅  cdkdemo

✨  Deployment time: 136.97s

Outputs:
cdkdemo.APIHandlerArn = arn:aws:lambda:ap-northeast-1:1234567890:function:cdkdemo-APIHandler-xxxxxxxx
cdkdemo.APIHandlerName = cdkdemo-APIHandler-xxxxxxxx
cdkdemo.AppTableName = cdkdemo-xxxxxxxx
cdkdemo.EndpointURL = https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/
cdkdemo.RestAPIId = xxxxxxxx
Stack ARN:
arn:aws:cloudformation:ap-northeast-1:1234567890:stack/cdkdemo/xxxxxxxx

✨  Total time: 159.83s

以上のような出力が表示され、3分弱くらいでデプロイが完了しました。

では、デプロイできたものを、CloudFormation のデザイナで見てみます。

template1-designer

デプロイされたリソースは全部で以下の 9 つです。

論理ID タイプ
APIHandler AWS::Lambda::Function
APIHandlerInvokePermission AWS::Lambda::Permission
AppTable815C50BC AWS::DynamoDB::Table
CDKMetadata AWS::CDK::Metadata
ChaliceAppDefaultRolePolicyCAAAC186 AWS::IAM::Policy
DefaultRole AWS::IAM::Role
RestAPI AWS::ApiGateway::RestApi
RestAPIDeploymentcbbdb5c310 AWS::ApiGateway::Deployment
RestAPIapiStage AWS::ApiGateway::Stage

かなりシンプルな結果かなと思います。

デフォルトで生成される API の入口が2つ (GET と POST) なのにも関わらず、 Lambda が1つしかないのはポイントになるかなと思っています。

念の為、この API が動くことを確認してみます。
チュートリアルでは、 httpie を使っていますが、 curl で叩きます。

# POST
$ curl -X POST https://xxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/users  -d '{"username":"hoge", "name":"fuga"}' -H "Content-Type: application/json"
{}

# GET
$ curl https://xxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/api/users/hoge
{"username":"hoge","name":"fuga"}

動いていそうですね。

イベントソースの追加

せっかくなので、 API Gateway 以外のイベントソースを設定してみます。

こちらのイベントソースを上から、3つほど試してみましょう。

runtime/app.py の末尾に適当に以下を追加して、デプロイを試してみます。

# CloudWatchEvent
@app.schedule('rate(10 minutes)')
def rate_handler(event: CloudWatchEvent):
    app.log.error("This is an error statement from schedule")


# S3Event
@app.on_s3_event(bucket=os.environ.get('APP_BUCKET_NAME', ''))
def event_handler(event: S3Event):
    app.log.info("Event received for bucket: %s, key %s",
                 event.bucket, event.key)

# SNSEvent
@app.on_sns_message(topic='mytopic')
def sns_event_handler(event: SNSEvent):
    app.log.info("Message received with subject: %s, message: %s",
                 event.subject, event.message)

  • CloudWatchEvent
  • S3Event
  • SNSEvent

を追加してみました。
こちらで、デプロイを実行します。

$ cdk deploy
...
NotImplementedError: Unable to package chalice apps that @app.on_s3_event decorator. CloudFormation does not support modifying the event notifications of existing buckets. You can deploy this app using `chalice deploy`.

おっと、なるほど。
S3 イベントは、 CDK(CloudFormation) では使えないから、 chalice deploy してね。と。
こういうところは危ないですね。
実案件で採用し、ある程度構築したことろで気付くと、一部だけ手動デプロイなどになって、とてもややこしいプロジェクトになってしまうので、要注意です。

なかなか難しいところではありますが、技術選定の際に、一度必要となる全ての機能を試してみることは大事かなと思います。

気を取り直して、別のイベントを選んでデプロイしてみましょう。

# CloudWatchEvent
@app.schedule('rate(10 minutes)')
def rate_handler(event: CloudWatchEvent):
    app.log.error("This is an error statement from schedule")


# SNSEvent
@app.on_sns_message(topic='cdkdemo_sns')
def sns_event_handler(event: SNSEvent):
    app.log.info("Message received with subject: %s, message: %s",
                 event.subject, event.message)


# SQSEvent
@app.on_sqs_message(queue='cdkdemo_sqs')
def sqs_event_handler(event: SQSEvent):
    app.log.info("Event: %s", event.to_dict())

S3 イベントの代わりに、 SQS イベントを追加し、

  • CloudWatchEvent
  • SNSEvent
  • SQSEvent

をトリガーとする、 function をデプロイしてみます。

$ cdk depoloy
...
The stack named cdkdemo failed to deploy: UPDATE_ROLLBACK_COMPLETE: Topic does not exist (Service: AmazonSNS; Status Code: 404; Error Code: NotFound;...

トリガーとしている Topic が存在しないよ、と怒られました。
なるほど、この辺りは CDK で定義を追加しておかないといけないようですね。
API Gateway は、自動で作ってくれたので、こちらももしかしたら作ってくれるかも?と思いましたが、そうはいかないようです。

というわけで、 cdkdemo/infrastructure/stacks/chaliceapp.py も、以下のように編集してみました。

from aws_cdk import aws_sns as sns  # 追加
from aws_cdk import aws_sqs as sqs  # 追加

class ChaliceApp(cdk.Stack):

    def __init__(self, scope, id, **kwargs):
        super().__init__(scope, id, **kwargs)
        self.dynamodb_table = self._create_ddb_table()
        self.topic = self._create_sns_topic()
        self.queue = self._create_sqs()
        self.chalice = Chalice(
            self, 'ChaliceApp', source_dir=RUNTIME_SOURCE_DIR,
            stage_config={
                'environment_variables': {
                    'APP_TABLE_NAME': self.dynamodb_table.table_name,
                    'APP_TOPIC_NAME': self.topic.topic_name,   # 追加
                    'APP_QUEUE_NAME': self.queue.queue_name,   # 追加
                }
            }
        )
        self.dynamodb_table.grant_read_write_data(
            self.chalice.get_role('DefaultRole')
        )

    def _create_ddb_table(self):
        dynamodb_table = dynamodb.Table(
            self, 'AppTable',
            partition_key=dynamodb.Attribute(
                name='PK', type=dynamodb.AttributeType.STRING),
            sort_key=dynamodb.Attribute(
                name='SK', type=dynamodb.AttributeType.STRING
            ),
            removal_policy=cdk.RemovalPolicy.DESTROY)
        cdk.CfnOutput(self, 'AppTableName',
                      value=dynamodb_table.table_name)
        return dynamodb_table

    # 追加
    def _create_sns_topic(self):
        return sns.Topic(self, "cdkdemo_sns")

    # 追加
    def _create_sqs(self):
        return sqs.Queue(
            self, "cdkdemo_sqs",
            visibility_timeout=cdk.Duration.seconds(60),
        )

_create_sns_topic_create_sqs を追加して、それぞれ環境変数に、Topic Name, Queue Name を指定するように修正しました。
デプロイしてみます。

$ cdk depoly
...
The stack named cdkdemo failed to deploy: UPDATE_ROLLBACK_COMPLETE: Topic does not exist (Service: AmazonSNS; Status Code: 404; Error Code: NotFound;...

おや。
これでも無いよ、と言われます。

一旦、 Lambda の SNS / SQS イベントはコメントアウトし、リソースの追加だけして Lambda の環境変数に何が設定されるか確認してみます。

  • APP_QUEUE_NAME cdkdemo-cdkdemosqsBF08DBA1-cM1A2A7CW9qM
  • APP_TOPIC_NAME cdkdemo-cdkdemosnsE41F5B69-1T9HJ9UE9HXQV

なるほど。
適当な識別子が付与されて、名前が変わっていました。
こちらをイベントソースに設定する必要があるようですね。

# SNSEvent
@app.on_sns_message(topic=os.environ.get('APP_TOPIC_NAME', ''))
def sns_event_handler(event: SNSEvent):
    app.log.info("Message received with subject: %s, message: %s",
                 event.subject, event.message)


# SQSEvent
@app.on_sqs_message(queue=os.environ.get('APP_QUEUE_NAME', ''))
def sqs_event_handler(event: SQSEvent):
    app.log.info("Event: %s", event.to_dict())

というわけで、 topic, queue にそれぞれ、環境変数から取得した値を設定しました。
これで、 deploy します。

$ cdk deploy
...
jsii.errors.JSIIError: Resource referenced in Fn::Sub expression with logical ID: 'Token[TOKEN' was not found in the template

失敗してしまいました。。。
このエラーメッセージで調べると、以下の issues が見つかりました。
https://github.com/aws/chalice/issues/1681

どうやら、 CloudFormation のテンプレートに不正な値が入ってしまうようですね。
そして、 SQS の場合は、 queue_name ではなく queue_arn を使って回避できるようにしました、とあります。
なるほどなるほど。
そして sns の方は今の所回避策がなさそうです。

むぅ...たまたま上から見ていって、3つのうち、2つが使えないというのは先行きが不安になります。

試しに3つだけ、と思いましたが、残り3つしかないので、全て見てしまいます。

KinesisEvent

こちらも CDK を使って追加した場合は、参照できませんでした。
また、引数のオプションを見る限り、今のところ回避策もなさそうです。

DynamoDBEvent

こちらは、 ARN を指定しているため、問題なくデプロイできました。
ただし、テンプレートで生成される DynamoDB には、 stream が存在しないため、テーブル生成箇所を、以下のように修正する必要があります。

    def _create_ddb_table(self):
        dynamodb_table = dynamodb.Table(
            self, 'AppTable',
            partition_key=dynamodb.Attribute(
                name='PK', type=dynamodb.AttributeType.STRING),
            sort_key=dynamodb.Attribute(
                name='SK', type=dynamodb.AttributeType.STRING
            ),
            removal_policy=cdk.RemovalPolicy.DESTROY,
            stream=dynamodb.StreamViewType.NEW_AND_OLD_IMAGES # 追加する
        )

LambdaFunction

こちらは、とくにトリガーのない、ただの Lambda 関数を作成するだけなので、問題なくデプロイできました。
とりあえず、現状 CDK とのマッピングができないと思われる、 s3, sns, kinesis を使いたい場合は、こちらでデプロイしておいて、後で手動でトリガーを設定する、という運用で回避はできそうな気がします。

ちなみに、 lambda_function の引数の型は、以下の通りです。

@app.lambda_function()
def lambda_event_handler(lambda_context, event):
    app.log.info(type(lambda_context)) # <class 'awslambdaric.lambda_context.LambdaContext'>
    app.log.info(type(event)) # <class 'dict'>

サンプルで出来上がったもの

というわけで、最終的に、以下のリソースを定義してデプロイしたことになります。

  • API Gateway
  • CloudWatchEvent
  • SNSEvent
  • DynamoDBEvent
  • LambdaFunctionEvent

この状態で、 CloudFormation のリソースを見てみます。

論理ID タイプ
RestAPIDeploymentcbbdb5c310 AWS::ApiGateway::Deployment
RestAPI AWS::ApiGateway::RestApi
RestAPIapiStage AWS::ApiGateway::Stage
CDKMetadata AWS::CDK::Metadata
AppTable815C50BC AWS::DynamoDB::Table
RateHandlerRateHandlerEvent AWS::Events::Rule
ChaliceAppDefaultRolePolicyCAAAC186 AWS::IAM::Policy
DefaultRole AWS::IAM::Role
cdkdemokinesisC8332BC2 AWS::Kinesis::Stream
DynamoEventHandlerDynamoEventHandlerDynamodbEventSource AWS::Lambda::EventSourceMapping
SqsEventHandlerSqsEventHandlerSqsEventSource AWS::Lambda::EventSourceMapping
APIHandler AWS::Lambda::Function
DynamoEventHandler AWS::Lambda::Function
LambdaEventHandler AWS::Lambda::Function
RateHandler AWS::Lambda::Function
SqsEventHandler AWS::Lambda::Function
APIHandlerInvokePermission AWS::Lambda::Permission
RateHandlerRateHandlerEventPermission AWS::Lambda::Permission
cdkdemosqsBF08DBA1 AWS::SQS::Queue

リソースの数は19まで増えました。
API 以外の LambdaFunction はそれぞれ、別々にデプロイされるようですね。

トリガーのない、 LambdaFunction を複数設定しても、別々にデプロイされていたので、 API Gateway だけ、1つの Function にまとまるのだと思います。
このあたりも興味深い作りになっています。

デザイナでも見てみます。

template1-designer_2-1

とくに無駄なリソースができている印象もないですし、なかなか良さそうに見えます。

次回...

さて、本当は、直接 CloudFormation によるデプロイもしてみて、それぞれで serverless framework と比較するところまでまとめたかったのですが、想像以上にハマってしまったので、記事を分けて書いていきたいと思います。

他にも、 Terraform も提供されているので、こちらも次々回くらいにまとめられたら、と思っています。

最初のサンプルを動かすレベルでこれだけハマってしまうと、ちょっと実案件にもっていくのは厳しい印象を持ってしまいます。
また、できることも serverless framework に比べるとまだ少ないように感じました。
ドキュメントが追いついていないのかもしれませんが...。

とはいえ、頻繁にアップデートは走っているようなので、今後の品質向上などに期待したいところです。