👱‍♀️

PythonプログラムのAWS CDKを使用したサーバーレスAPI化

2023/03/27に公開

概要

以下の記事の続きです。

https://zenn.dev/hikapoppin/articles/69be223c89fbd5

AWS CDKを使って簡単なサーバーレスアプリにしてみました。

背景

上記の記事のプログラムを作成中にAWS CDKの学習およびAWS Certified Developer - Associateの勉強をしており、せっかくなのでAWS上で稼働させてみることにしました。

システム構成図

大まかな処理フローは以下の通りです。

  1. ユーザーがCloudFrontのドメイン名にアクセス
  2. WAFがIPsetに記載されているアドレス以外からの通信をブロック
  3. CloudFrontを通じてS3バケットからhtmlファイルを取得し、ユーザーに表示
  4. ユーザーがWebブラウザ上でhtmlファイル内に埋め込まれているAPIGatewayのエンドポイントにアクセス
  5. APIGateway経由でLambda関数を呼び出し、Pythonプログラムを実行
  6. Secrets ManagerからSpotify上での認証に必要なシークレット情報を取得
  7. Spotify上のプレイリストを更新
  8. プログラムの処理終了時にSNSによってユーザーにメールを送信

なお、手動の場合とは別にEventBridgeによってcron式により毎週末に自動で実行され、プレイリストが更新されます。

それでは上の構成をCDKで実装していきます。

ディレクトリ構成

ディレクトリ構成は以下の通りです。

.
├── bin
│   └── spotipy.ts
├── lib
│   ├── backend-stack.ts
│   └── frontend-stack.ts
├── lambda
│   ├── common.py
│   ├── invoke.py
│   └── main.py
├── lambda_layer
    └─python
        └─lib
            └─python3.9
                └─site-packages
                    ├──boto3
                    └──spotipy
├── lambda_venv
├── webcontent
    └── index.html
├── cdk.json
├── secret.json

実際にはこれ以外にもフォルダやファイルがありますが、説明上必要なものだけに絞っています。

  • bin
    App層となるspotipy.tsを配置します。

  • lib
    スタックのファイルを配置します。今回そこまで作成するリソースは多くないので、スタックはざっくりとbackend-stack, frontend-stackの二つに分けます。スタックは分割するほど依存関係がややこしくなってデプロイ時のエラーの原因になりやすいので、できるだけ分割しないのがCDKのベストプラクティスとなっています。CDKのベストプラクティスについては以下を参照してください。

https://aws.amazon.com/jp/blogs/news/best-practices-for-developing-cloud-applications-with-aws-cdk/

  • lambda
    lambda関数を構成するPythonのファイルを配置します。各モジュールの処理内容については、後ほど説明します。

  • lambda_layer
    Pythonのプログラムで必要なライブラリを配置します。基本的にLambda上でPythonの外部ライブラリを使用する際は、このようにレイヤーを作成する必要があります。
    以下のようなコマンドで、インストール先のディレクトリを指定してライブラリを追加することが出来ます。

cd lambda_layer
pip install -t ./python/lib/python3.9/site-packages spotipy
  • lambda_venv
    ローカルでプログラムのテストをする際に使用するPythonの仮想環境です。Pythonではこちらの仮想環境を使うことで、ライブラリをグローバルにインストールしなくても、各プロジェクトに必要なライブラリだけを入れた環境を作ることが出来ます。

  • webcontent
    ユーザーに表示するhtmlファイルを格納します。

  • cdk.json
    スタックのデプロイ時にApp層で取得するパラメータを設定します。

  • secret.json
    Secrets Managerに格納するシークレット情報を入れます。

それでは、中身のコードを見ていきます。CDKの言語には、実装例の多いTypescriptを使用します。

Appのコード

spotipy.ts
#!/usr/bin/env node
import "source-map-support/register";
import * as cdk from "aws-cdk-lib";
import { FrontendStack } from "../lib/frontend-stack";
import { BackendStack } from "../lib/backend-stack";

const pjPrefix = 'Spotipy';

const app = new cdk.App();

// ----------------------- Load context variables ------------------------------
const envVals = app.node.tryGetContext('envVals')

const emailAddress = envVals['emailAddress'];
const ipAddress = envVals['ipAddress'];

// For local deployment
const backend = new BackendStack(app, `${pjPrefix}BackendStack`, {
    emailAddress: emailAddress,
});

const frontend = new FrontendStack(app, `${pjPrefix}FrontendStack`, {
    lambdaFunc: backend.lambdaFunc,
    ipAddress: ipAddress
})
frontend.addDependency(backend); 

pjPrefixを指定することで、各StackのIDの先頭にプロジェクト名を付与します。

なお、今回はスタックやConstructのIDはパスカルケースで記載します。まず、前提としてCDKではリソースに物理名を付けないのがベストプラクティスとなっています。
ただ、リソースに物理名をつけないと、ConstructのIDに基づいたハッシュ値が付与された名前がリソースに付与されます。その際、ConstructのIDにケバブケースやスネークケースを用いている場合、- や _ などの単語区切りの情報は生成されるリソース名から消える仕様となっています。そのため、単語区切りの記号を使用しないパスカルケースをID名に採用します。詳しくは以下の記事を参照してください。

https://qiita.com/tmokmss/items/721a99e9a62499d6d54a

SNSに使用するメールアドレスやWAFで設定するIPアドレスはユーザー固有の変数なので、app.node.tryGetContext()を使用してcdk.jsonから読み込んでいます。

また、今回FrontendStackはBackendStackに依存するので、frontend.addDependency(backend);のように依存関係を追加します。

なお、各スタックに与えられるパラメータは、スタック内に用意したインターフェイスを実装することで受け取ります。次の章でこの実装部分について見ていきます。

BackendStackのコード

backend-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import { Rule, RuleTargetInput, Schedule } from "aws-cdk-lib/aws-events";
import { LambdaFunction } from "aws-cdk-lib/aws-events-targets";
import { Topic } from "aws-cdk-lib/aws-sns";
import { EmailSubscription } from "aws-cdk-lib/aws-sns-subscriptions";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as iam from "aws-cdk-lib/aws-iam";
import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager";
import secretValue from "../secret.json";

export interface BackendStackProps extends cdk.StackProps {
  emailAddress: string;
}

export class BackendStack extends cdk.Stack {
  public readonly lambdaFunc: lambda.Function;

  constructor(scope: Construct, id: string, props: BackendStackProps) {
    super(scope, id, props);

    // ----------------------- Define backend system ------------------------------

    // Define a topic for lambda
    const topic = new Topic(this, "Topic", {
      displayName: "Topic sent from Lambda",
    });

    topic.addSubscription(new EmailSubscription(props.emailAddress));

    // Store secret values for Secret Manager
    const secret = new secretsmanager.Secret(this, "Secret", {
      generateSecretString: {
        secretStringTemplate: JSON.stringify(secretValue),
        generateStringKey: "password",
      },
    });

    // Define a role for lambda
    const lambdaRole = new iam.Role(this, "LambdaRole", {
      assumedBy: new iam.ServicePrincipal("lambda.amazonaws.com"),
    });

    // Grant lambda to write to CloudWatch Logs
    lambdaRole.addManagedPolicy(iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"));

    // Grant lambda to get secrets values from secrets manager
    lambdaRole.addToPolicy(
      new iam.PolicyStatement({
        resources: ["*"],
        actions: ["secretsmanager:GetSecretValue", "sns:Publish"],
      })
    );

    // Define a lambda layer
    const lambdaLayer = new lambda.LayerVersion(this, "LambdaLayer", {
      code: lambda.AssetCode.fromAsset("lambda_layer"),
      compatibleRuntimes: [lambda.Runtime.PYTHON_3_9],
    });

    // Define a lambda Function
    const lambdaFunc = new lambda.Function(this, "LambdaFunc", {
      functionName: "Spotipy",
      runtime: lambda.Runtime.PYTHON_3_9,
      code: lambda.Code.fromAsset("lambda"),
      handler: "invoke.handler",
      layers: [lambdaLayer],
      role: lambdaRole,
      timeout: cdk.Duration.minutes(10),
      environment: {
        TOPIC_ARN: topic.topicArn,
        SECRET_ARN: secret.secretArn,
        ON_AWS: "True"
      },
    });

    this.lambdaFunc = lambdaFunc

    // Define an EventBridge rule
    const ruleToInvokeLambda = new Rule(this, "RuleToInvokeLambda", {
      schedule: Schedule.cron({
        minute: "0",
        hour: "15",
        weekDay: "L",
      }),
      targets: [new LambdaFunction(lambdaFunc, {
        event: RuleTargetInput.fromObject({ track_num: '1' }),
      })],
    });
  }
}

以下のようにインターフェイスを用意することで、スタックのpropsでapp.node.tryGetContext()から取得した値や他のスタックのリソースを受け取ることが出来るようになります。

export interface BackendStackProps extends cdk.StackProps {
  emailAddress: string;
}

Secret Managerのシークレットの値は、以下のようにローカルのJSONファイルから読み込んでいます。そのままコード内に書き込んでもよかったですが、Pythonのコードをローカルで実行する際にも同一のファイルからシークレット情報を取得できるため、JSONファイル内に定義します。

const secret = new secretsmanager.Secret(this, "Secret", {
      generateSecretString: {
        secretStringTemplate: JSON.stringify(secretValue),
        generateStringKey: "password",
      },
    });

今回実行するPythonのプログラムは、30曲追加する場合だとおよそ7~8分かかるので、Lambdaのタイムアウト値は余裕をもって10分に設定します。環境変数のTOPIC_ARNSECRET_ARNは、それぞれLambda関数内でboto3を使ってSNSとSecret Managerに接続する際に使用します。ON_AWSは実行するPythonプログラムがAWS上にあることを表す環境変数です。こちらの意味については後ほど説明します。

const lambdaFunc = new lambda.Function(this, "LambdaFunc", {
      functionName: "Spotipy",
      runtime: lambda.Runtime.PYTHON_3_9,
      code: lambda.Code.fromAsset("lambda"),
      handler: "invoke.handler",
      layers: [lambdaLayer],
      role: lambdaRole,
      timeout: cdk.Duration.minutes(10),
      environment: {
        TOPIC_ARN: topic.topicArn,
        SECRET_ARN: secret.secretArn,
        ON_AWS: "True"
      },
    });

EventBrideでは、以下のようにcron式を用いて毎週末の0時に起動させます。ここで注意点として、cron式で設定するイベント時刻はタイムゾーンがUTC(協定世界時)になっているため、日本標準時(JST)との時差を考慮する必要があります。
日本標準時(JST)はUTCに比べて9時間早いので、日本時間でイベント日時を設定する場合はその9時間前で設定します。今回は日本標準時の0時で起動させたいので、hour: "15"とします。

 const ruleToInvokeLambda = new Rule(this, "RuleToInvokeLambda", {
      schedule: Schedule.cron({
        minute: "0",
        hour: "15",
        weekDay: "L",
      }),
      targets: [new LambdaFunction(lambdaFunc, {
        event: RuleTargetInput.fromObject({ track_num: '1' }),
      })],
    });

Lambdaのハンドラー

invoke.py
import traceback
import json
import main 
from common import send_email
import sys
from urllib.parse import parse_qs
import io

def handler(event, context):
    try:
        # Refer to the spotipy_lambda.py to get information of options.
        # Output values of console into string variable
        with io.StringIO() as console_log:
            
            sys.stdout = console_log

            if 'track_num' in event:
                # Get parameter from EventBridge 
                main.diggin_in_the_crate(int(event['track_num'][0]))
            elif 'body' in event:
                # Parse body of POST request from APIgateway
                request_body=parse_qs(event['body'])
                main.diggin_in_the_crate(int(request_body['track_num'][0]))

            log_value = console_log.getvalue()
            sys.stdout = sys.__stdout__

        send_email(log_value)

        return {
            'isBase64Encoded': False,
            'statusCode': 200,
            'headers': {},
            'body': json.dumps({"result": "Successfully processed!!"})
        }
    except BaseException:
        error_message = traceback.format_exc()
        return {
            'isBase64Encoded': False,
            'statusCode': 500,
            'headers': {},
            'body': json.dumps({"result": "An error occured", "error_message": error_message})
        }

ハンドラーは、Lambda関数を呼び出した際に一番最初に実行される関数です。
ハンドラーでは、main関数の実行および、SNSを使用した処理結果のメール送信を行います。SNSではmain関数で行った処理の標準出力を送信したいので、io.StringIOを使用します。StringIOを使うと、文字列をファイルのように扱うことができます。そのため、sys.stdout = console_logのようにすると標準出力がStringIOオブジェクトにリダイレクトされ、print文などで表示される文字列が蓄積されていきます。蓄積された文字列はgetvalue()メソッドを使用して取得し、SNSでメールを送信する関数の引数として渡します。こちらの内容は以下の記事を参考にさせていただきました。

https://blog.utgw.net/entry/2016/12/06/000000

また、今回Lambdaの呼び出し元がEventBridgeとAPIGatewayなので、ハンドラーの引数のeventに入る内容が異なっています。EventBridgeではtrack_numキーに値が格納されているので、track_numキーが存在する場合に処理を時刻します。APIGatewayの場合はbodyにリクエストの本文を丸ごと入れているので、track_num=1のようなキーと値が一体化したような文字列として入っています。そのため、以下のようにbodyの文字列をパースして、キーと値のセットに分解します。

if 'track_num' in event:
    # Get parameter from EventBridge 
    main.diggin_in_the_crate(int(event['track_num'][0]))
elif 'body' in event:
    # Parse body of POST request from APIgateway
    request_body=parse_qs(event['body'])
    main.diggin_in_the_crate(int(request_body['track_num'][0]))

AWS SDK(boto3)を使用したSNSとSecrets Managerの操作

シークレットの取得

def get_secret(local_test_flag=False):
    """Get secrets values from AWS Secrets Manager.

    Args:
        local_test_flag (bool, optional): Secret values are obtained from local json file when it's True. Defaults to False.
    """

    if local_test_flag == True:
        json_open = open('../secret.json', 'r')
        json_load = json.load(json_open)
        secret = json_load
    else:
        secret_name = os.environ['SECRET_ARN']
        region_name = "us-east-1"

        # Create a Secrets Manager client
        session = boto3.session.Session()
        client = session.client(
            service_name='secretsmanager',
            region_name=region_name
        )

        try:
            get_secret_value_response = client.get_secret_value(
                SecretId=secret_name
            )
        except ClientError as e:
            raise e

        else:
            if 'SecretString' in get_secret_value_response:
                secret_raw = get_secret_value_response['SecretString']
            else:
                secret_raw = base64.b64decode(
                    get_secret_value_response['SecretBinary'])

        # Convert secrets into dictionary object.
        secret = ast.literal_eval(secret_raw)

    return secret

AWSのリソースをPython上で扱うには、Python用のAWS SDKのライブラリであるboto3を使用します。boto3のコードは、基本的にマネジメントコンソールで生成されるサンプルコードを元にしています。

引数のlocal_test_flagは、コードをローカル上で実行するか否かを判断するためのフラグです。こちらがTrueになっている場合は、以下のようにローカル環境のjsonファイルからシークレット情報を取得します。

if local_test_flag == True:
        json_open = open('../secret.json', 'r')
        json_load = json.load(json_open)
        secret = json_load

jsonファイルには、以下のような形でSpotifyAPIを操作するのに必要なシークレット情報を入れます。

secret.json
{
    "my_id": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "my_secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "redirect_uri": "http://localhost:8888/callback/",
    "username": "XXXXXX",
    "playlist_id": "XXXXXXXXXXXXXXXXXXXXXX"
}

フラグがFalseの場合は、secret_name = os.environ['SECRET_ARN']のようにして、Lambda関数の環境変数に設定されたSecrets ManagerのARNを取得し、そのARNを元にSDKがSecrets Managerに対してシークレット情報を取得するAPIコールを行います。

ただ、この際にSecrets Managerから返される変数secretは辞書型ではなく文字列型なので、単純に要素を取得することができません。そのため、secret = ast.literal_eval(secret_raw)のようにして、辞書型に変換します。そうすることで、Python上でシークレット情報を容易に扱えるようになります。

こちらで取得したシークレット情報は、以下の関数を用意して、SpotifyAPIを使うmain関数から参照できるようにします。

def authenticate(local_test_flag=False):
    """Execute authentication process on spotify.
    """

    secret = get_secret(local_test_flag)

    token = util.prompt_for_user_token(
        secret['username'], scope, secret['my_id'], secret['my_secret'], secret['redirect_uri'])

    sp = spotipy.Spotify(auth=token)

    return sp

main関数では、先頭に以下のようにコードを追加します。

import spotipy
import spotipy.util as util
import common
import argparse
import random
import os

# For local testing
local_test_flag = True

# Unflag local testing flag when runnig on AWS environment
if os.getenv('ON_AWS'):
    local_test_flag = False

sp = common.authenticate(local_test_flag)
secret = common.get_secret(local_test_flag)
username = secret['username']
playlist_id = secret['playlist_id']

local_test_flagはデフォルトでTrueになっていますが、Lambda関数上で環境変数ON_AWSが存在する場合には、Falseとするようにします。こうすることでローカル上での実行とAWSのLambda上での実行を切り替えることが出来ます。

メールの送信

def send_email(log_value):
    """Send an email form AWS SNS.
    """

    region_name = "us-east-1"

    # Create a SNS client
    session = boto3.session.Session()
    client = session.client(
        service_name='sns',
        region_name=region_name
    )
    params = {
        'TopicArn': os.environ['TOPIC_ARN'],
        'Subject': 'Lambda process completed!',
        'Message': log_value
    }

    client.publish(**params)

こちらもSecrets Managerの場合と同じく、Lambda関数内の環境変数からSNSトピックのARNを参照します。送信する内容は、引数のlog_valueとします。この変数には、ハンドラーからmain関数の実行結果の標準出力が格納されるようになっています。このようにして、ユーザーに実行結果を送付します。

FrontendStackのコード

frontend-stack.ts
import * as cdk from "aws-cdk-lib";
import { Construct } from "constructs";
import * as lambda from "aws-cdk-lib/aws-lambda";
import * as apigw from "aws-cdk-lib/aws-apigateway";
import * as iam from "aws-cdk-lib/aws-iam";
import { CfnOutput, RemovalPolicy } from "aws-cdk-lib";
import * as s3 from "aws-cdk-lib/aws-s3";
import * as s3deploy from "aws-cdk-lib/aws-s3-deployment";
import * as cloudfront from "aws-cdk-lib/aws-cloudfront";
import * as origins from "aws-cdk-lib/aws-cloudfront-origins";
import * as wafv2 from "aws-cdk-lib/aws-wafv2";
import * as fs from "fs";


export interface FrontendStackProps extends cdk.StackProps {
  lambdaFunc: lambda.Function;
  ipAddress: string;
}

export class FrontendStack extends cdk.Stack {
  public readonly APIEndpoint: cdk.CfnOutput;

  constructor(scope: Construct, id: string, props: FrontendStackProps) {
    super(scope, id, props);

    // ----------------------- Define frontend system ------------------------------

    // Define a s3 bucket
    const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
    });

    // Define an OAI for s3 bucket
    const originAccessIdentity = new cloudfront.OriginAccessIdentity(this, "OriginAccessIdentity", { comment: `identity-for-S3` });

    // Define a bucket policy
    const S3BucketPolicy = new iam.PolicyStatement({
      actions: ["s3:GetObject"],
      effect: cdk.aws_iam.Effect.ALLOW,
      principals: [
        new iam.CanonicalUserPrincipal(
          originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
      ],
      resources: [`${websiteBucket.bucketArn}/*`],
    });

    // Add bucket policy
    websiteBucket.addToResourcePolicy(S3BucketPolicy);

    // Define an IPset for waf
    const CfnIPset = new wafv2.CfnIPSet(this, "CfnIPSet", {
      addresses: [props.ipAddress],
      ipAddressVersion: "IPV4",
      scope: "CLOUDFRONT",
    });

    //Define a WebACL
    const CfnWebACL = new wafv2.CfnWebACL(this, "CfnWebACL", {
      defaultAction: {
        block: {}
      },
      scope: "CLOUDFRONT",
      visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "CFnWebACL",
        sampledRequestsEnabled: true,
      },
      rules: [
        {
          name: "ruleForWebACL",
          priority: 100,
          statement: {
            ipSetReferenceStatement: {
              arn: CfnIPset.attrArn,
            },
          },
          visibilityConfig: {
            cloudWatchMetricsEnabled: true,
            metricName: "IPSet",
            sampledRequestsEnabled: true,
          },
          action: {
            allow: {}
          },
        }
      ]
    });

    // Define a distribution for s3 bucket
    const distribution = new cloudfront.Distribution(this, "Distribution", {
      defaultBehavior: {
        origin: new origins.S3Origin(websiteBucket, {
          originAccessIdentity,
        }),
      },
      comment: "s3-distribution",
      defaultRootObject: "index.html",
      webAclId: CfnWebACL.attrArn,
    });

    // Define an APIGateway REST API
    const apiGateway = new apigw.RestApi(this, "ApiGateway", {
      restApiName: "diggin-in-the-crates"
    });

    // Add method request to execute Lambda function (sync)
    apiGateway.root.addResource('sync').addMethod(
      "POST",
      new apigw.LambdaIntegration(props.lambdaFunc))

    // Add method request to execute Lambda function (async)
    apiGateway.root.addResource('async').addMethod(
      "POST",
      new apigw.LambdaIntegration(props.lambdaFunc, {
        proxy: false,
        requestParameters: {
          "integration.request.header.X-Amz-Invocation-Type": "'Event'"
        },
        // Convert JSONPath into JSON format
        requestTemplates: {
          "application/x-www-form-urlencoded": "{ \"body\": $input.json('$') }"
        },
        integrationResponses: [
          {
            statusCode: "202",
          }
        ]
      }),
      {
        methodResponses: [
          {
            statusCode: "202",
          }
        ],
      });


    this.APIEndpoint = new cdk.CfnOutput(this, "APIEndpoint", {
      value: apiGateway.url,
      exportName: "APIEndpoint",
    });

    // Generate html content
    const fileName = "./webcontent/index.html";
    let htmlContent = fs.readFileSync(fileName, "utf8");
    const APIEndpoint = apiGateway.url

    // Embed APIGateway Endpoint dynamically
    htmlContent = htmlContent.replace("APIEndpoint", APIEndpoint);

    // Deploy webcontent in the local folder into s3 bucket
    new s3deploy.BucketDeployment(this, "DeployWebsite", {
      sources: [
        s3deploy.Source.data(
          "/index.html",
          htmlContent),
        s3deploy.Source.data(
          "/style.css",
          fs.readFileSync("./webcontent/style.css", "utf8")),
      ],
      destinationBucket: websiteBucket,
      distribution: distribution,
      distributionPaths: ["/*"],
    });
  }
}

インターフェイスでは、BackendStackで作成したLambda関数とWAFで使うIPアドレスを受け取ります。

export interface FrontendStackProps extends cdk.StackProps {
  lambdaFunc: lambda.Function;
  ipAddress: string;
}

今回はCloudFront経由のみでS3にアクセスさせたいので、パブリックアクセスブロックし、OAIを用います。以下のようにOAIのアイデンティティを作成し、バケットポリシーで操作を許可します。なお、S3バケット生成時には、propsにremovalPolicy: RemovalPolicy.DESTROY, autoDeleteObjects: trueを指定してあげることで、スタックの削除時に自動でバケット内のオブジェクトの削除およびバケット自体の削除も一緒に行われるようになります。

// Define a s3 bucket
const websiteBucket = new s3.Bucket(this, "WebsiteBucket", {
    removalPolicy: RemovalPolicy.DESTROY,
    autoDeleteObjects: true,
    blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
});

// Define an OAI for s3 bucket
const originAccessIdentity = new cloudfront.OriginAccessIdentity(this, "originAccessIdentity", { comment: `identity-for-S3` });

// Define a bucket policy
const S3BucketPolicy = new iam.PolicyStatement({
    actions: ["s3:GetObject"],
    effect: cdk.aws_iam.Effect.ALLOW,
    principals: [
        new iam.CanonicalUserPrincipal(
            originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId
        ),
    ],
    resources: [`${websiteBucket.bucketArn}/*`],
});

// Add bucket policy
websiteBucket.addToResourcePolicy(S3BucketPolicy);

WAFはシンプルにアクセスを許可するIPsetを用意し、CloudFrontと紐づけます。IPアドレスはスタックのpropsから取得します。
なお今回のコードは基本的にL2 Constructで実装していますが、WAFはL2 Constructが利用できないのでL1 Constructで実装します。

// Define an IPset for waf
const CfnIPset = new wafv2.CfnIPSet(this, "CfnIPSet", {
    addresses: [props.ipAddress],
    ipAddressVersion: "IPV4",
    scope: "CLOUDFRONT",
});

//Define a WebACL
const CfnWebACL = new wafv2.CfnWebACL(this, "CfnWebACL", {
    defaultAction: {
        block: {}
    },
    scope: "CLOUDFRONT",
    visibilityConfig: {
        cloudWatchMetricsEnabled: true,
        metricName: "CFnWebACL",
        sampledRequestsEnabled: true,
    },
    rules: [
        {
            name: "ruleForWebACL",
            priority: 100,
            statement: {
                ipSetReferenceStatement: {
                    arn: CfnIPset.attrArn,
                },
            },
            visibilityConfig: {
                cloudWatchMetricsEnabled: true,
                metricName: "IPSet",
                sampledRequestsEnabled: true,
            },
            action: {
                allow: {}
            },
        }
    ]
});

ここまで準備が出来たら、CloudFrontのディストリビューションを作成します。defaultRootObjectには、ユーザーに表示するindex.htmlを指定します。

// Define a distribution for s3 bucket
const distribution = new cloudfront.Distribution(this, "Distribution", {
    defaultBehavior: {
        origin: new origins.S3Origin(WebsiteBucket, {
            originAccessIdentity,
        }),
    },
    comment: "s3-distribution",
    defaultRootObject: "index.html",
    webAclId: CfnWebACL.attrArn,
});

今回、APIGatewayはhtmlのフォーム経由でPOSTリクエストを受け取ります。Lambda関数を同期的に呼び出す際にはLambdaのプロキシ統合を用いますが、今回は処理にかなり時間がかかり、同期処理だとLambdaからのレスポンスがタイムアウトしてしまうので、非同期呼び出しのメソッドも追加します。Lambda関数を非同期に呼び出す際には、HTTPヘッダーにintegration.request.header.X-Amz-Invocation-Type": "'Event'を設定します。

また、今回htmlのフォーム経由でデータを送信しており、Content-typeがapplication/x-www-form-urlencodedとなっています。そのため、マッピングテンプレートを使用して、リクエストをJSON形式にします。"application/x-www-form-urlencoded": "{ \"body\": $input.json('$') }"とすることで、JSONPath形式のPOSTリクエストの本文をbodyキーの値として格納します。なおLambdaを非同期で呼び出した場合のステータスコードは202になるので、レスポンスは統合レスポンス、メソッドレスポンス共に202とします。

// Define an APIGateway REST API
const apiGateway = new apigw.RestApi(this, "ApiGateway", {
    restApiName: "diggin-in-the-crates"
});

// Add method request to execute Lambda function (sync)
apiGateway.root.addResource('sync').addMethod(
    "POST",
    new apigw.LambdaIntegration(props.lambdaFunc))

// Add method request to execute Lambda function (async)
apiGateway.root.addResource('async').addMethod(
    "POST",
    new apigw.LambdaIntegration(props.lambdaFunc, {
        proxy: false,
        requestParameters: {
            "integration.request.header.X-Amz-Invocation-Type": "'Event'"
        },
        // Convert JSONPath into JSON format
        requestTemplates: {
            "application/x-www-form-urlencoded": "{ \"body\": $input.json('$') }"
        },
        integrationResponses: [
            {
                statusCode: "202",
            }
        ]
    }),
    {
        methodResponses: [
            {
                statusCode: "202",
            }
        ],
    });

ここで、S3に配置するhtmlファイルの中身について見ていきます。今回ちゃんとしたフロントエンドを作るまでの余力がなかったので、簡素なものになっています。
内容としては、プレイリストに追加する曲数をフォームで指定して、POSTリクエストをAPIGatewayのエンドポイントに送信しています。

index.html
<!DOCTYPE html>
<html>

<head>
  <link rel="stylesheet" href="style.css">
  <meta charset="UTF-8" />
  <title>Diggin' In The Crates</title>
  <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
  <script>
    function submitForm() {
      var trackNum = $("#track_num").val();

      $.ajax({
        url: "APIEndpoint" + "async",
        type: "POST",
        data: { track_num: trackNum },
        contentType: "application/x-www-form-urlencoded",
      })
        .always(function () {
          alert("Your request has been sent to API.");
        });
    }
  </script>
</head>

<body>
  <h1>Welcome to Diggin' In The Crates</h1>
  <form>
    <label for="track_num">Please put the number of songs to add to your playlist:</label>
    <input type="text" id="track_num" name="track_num" /><br /><br />
    <button type="button" onclick="submitForm()">Submit</button>
  </form>
</body>

</html>

今回は非同期で呼び出すのでレスポンスは気にしないことにします。と言うより、CORSを有効にするにはLamdaのハンドラー側でレスポンスにヘッダーを加えたりする必要があるのですが、Lambdaを非同期で呼び出したときのレスポンスはステータスコード202だけが帰ってくるというもので固定なので、変えることが出来ません。

form actionに設定するAPIGatewayのエンドポイントは、以下のようにデプロイ時に取得し、htmlファイル内の"APIEndpoint"の文字列と置換することで、動的にhtml内に埋め込むようにします。

// Generate html content
const fileName = "./webcontent/index.html";
let htmlContent = fs.readFileSync(fileName, "utf8");
const APIEndpoint = apiGateway.url

// Embed APIGateway Endpoint dynamically
htmlContent = htmlContent.replace("APIEndpoint", APIEndpoint);

htmlファイルの中身が準備できたら、S3のオブジェクトとして生成し、バケットのデプロイを行います。

// Deploy webcontent in the local folder into s3 bucket
new s3deploy.BucketDeployment(this, "DeployWebsite", {
    sources: [
        s3deploy.Source.data(
            "/index.html",
            htmlContent),
	s3deploy.Source.data(
          "/style.css",
          fs.readFileSync("./webcontent/style.css", "utf8")),
    ],
    destinationBucket: websiteBucket,
    distribution: distribution,
    distributionPaths: ["/*"],
});

以上のコードが準備出来たら、スタックのデプロイを行い、AWS環境上にリソースを配備します。

APIのテスト

スタックのデプロイが完了したら、curlコマンドを使ってAPIエンドポイントを呼び出してみます。
呼び出しには以下のコマンドを使います。${ENDPOINT_URL}にはスタックの出力に記載されたAPIエンドポイントを指定します。

Lambdaを非同期で呼び出す場合

curl -X POST --data-urlencode "track_num=1" "${ENDPOINT_URL}async

Lambdaを同期で呼び出す場合

curl -X POST --data-urlencode "track_num=1" "${ENDPOINT_URL}sync

非同期で呼び出す場合、レスポンスのbodyは空で帰って来るので何も表示されません。同期呼び出しの場合には次のようなメッセージが表示されます。

ブラウザからのテスト

curlで正常に動くことが分かったので、ブラウザからもテストします。
まずは、CloudFrontのディストリビューションのドメイン名にアクセスします。

すると、次のようにhtmlファイルの内容が表示されます。

曲数を30にして送信してみます。
APIにリクエストを送信した旨のメッセージが表示されました。

Spotifyプレイリストのプレイリストを見ると、ちゃんと30曲追加されています。

また、SNSを通じて次のようなメールが送信されてきます。

これで、ブラウザからも正常に動作することが確認できました!

感想

  • CDKを使うためにTypescriptの学習から行ったのですが、しばらくPythonしか触っていなかったのでTypescriptのような静的型付け言語は少し大変でした。自分で新しい型を定義したり、引数や戻り値にも全て型注釈を入れたりするのは中々ハードコアですね。C言語以来の懐かしさを感じました。
  • Lambdaのプログラムからインフラの設定まで全てコードで書いて一括でデプロイできるというのはなかなか楽しかったです。インフラの設定もマネコンでポチポチするよりもコードで書いてる方が楽しいですね。最近はアプリとインフラの境目が無くなってきていると言われていますが、その言葉の意味を身をもって体験できました。

参考にしたサイト

Typescriptの学習には以下を使用しました。

https://typescriptbook.jp/

CDKの実装は以下を参考にしました。

https://cdkworkshop.com/
https://github.com/aws-samples/baseline-environment-on-aws

次はCDK Pipelinesを用いてCI/CDを実装します。

実装したコード

以下にこの記事の内容を実装したコードをまとめています。
https://github.com/hikarunakatani/spotipy-serverless-app

Discussion