AWS CDKで構築するイベント駆動型アーキテクチャの実装戦略

OGP

はじめに

こんにちは、ZOZOMO部FBZブロックの山村です。普段はFulfillment by ZOZO(以下、FBZ)が提供するAPIシステムを開発・運用しています。

FBZは、AWS Lambda(以下、Lambda)を中心にAWSが提供するフルマネージドサービスを活用したサーバーレスアーキテクチャを採用しています。
以下の記事にてサービス構成やアーキテクチャ戦略の詳細を説明しています。

techblog.zozo.com

今回は、イベント駆動型のアーキテクチャを開発するうえで直面した課題と、その開発経験をもとにどのようにアプローチしたかをご紹介します。

目次

背景・課題

FBZの新機能を開発するため、新たにデータ連携用のマイクロサービスを作ることになりました。
今回の開発では、初期ローンチ時のインフラコストをなるべく抑え、かつサービスのスケールにも対応できるよう、FBZと同じくLambdaを中心としたイベント駆動型のアーキテクチャを採用しました。
しかし、新規開発にあたっては、FBZを開発、運用する上で存在していた以下の課題と向き合う必要がありました。

インフラリソース定義のための知識が必要であり、プロジェクトの参入難度を上げていた

FBZでは、構成管理ツールとしてServerless Frameworkを使用しています。
アプリケーションの構成やインフラリソースは、YAML形式の設定ファイルに記述しています。
コード管理できることはメリットですが、500以上のLambda関数やそれに関連するリソースの多いFBZの開発では、この設定ファイルへの記述を煩雑に感じることが多々ありました。
アプリケーション側のコードとは異なる言語での設定を必要とするため、これに慣れていない開発者にとっては新たな技術スタックに馴染むまでの一定の時間と労力が必要です。
特にリソースの定義においては、正確なパラメータや設定を把握しなければならないため、プロジェクトへの新規参入が一定の学習コストを伴っている状態でした。

イベント駆動ではアプリケーションコードに合わせて関連リソース定義も必要になる

例えば、API GatewayへのリクエストをトリガーにLambdaが起動し、DynamoDBへアクセスする処理があるとします。
下記はLambdaとDynamoDBをそれぞれ2つずつ定義していますが、同じような記述が必要になり、設定ファイル自体が肥大化していきます。

service: my-serverless-app

provider:
  name: aws
  runtime: python3.11

# Lambda関数の定義
functions:
  writeDataToDynamoDB:
    handler: src/write_data_to_dynamodb.main
    events:
      - http:
          path: writeData
          method: post
    environment:
      DYNAMODB_TABLE: MyDynamoDBTable

  getDataFromDynamoDB:
    handler: src/get_data_from_dynamodb.main
    events:
      - http:
          path: getData
          method: get
    environment:
      DYNAMODB_TABLE: MyDynamoDBTableWithGSI

# DynamoDBの定義
resources:
  Resources:
    MyDynamoDBTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: myDataTable
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: N
        KeySchema:
          - AttributeName: id
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST

    MyDynamoDBTableWithGSI:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: myGSITable
        AttributeDefinitions:
          - AttributeName: id
            AttributeType: N
        KeySchema:
          - AttributeName: id
            KeyType: HASH
          - AttributeName: id
            KeyType: HASH
        BillingMode: PAY_PER_REQUEST
        # セカンダリインデックスの設定
        GlobalSecondaryIndexes:
          - IndexName: MyGlobalSecondaryIndex
            KeySchema:
              - AttributeName: name
                KeyType: HASH
            Projection:
              ProjectionType: ALL

上記は簡単な例ですが、実際には関連リソース間のIAMロールやポリシーを紐づける記述も必要になってきます。そして数多くのリソースが存在する場合、冗長な記述の設定ファイルであることが想像できます。
リソースや開発者が増えていくにつれ、記述の際にはリソースの命名規則にも揺らぎが生じることもありました。

記述時点では正しく記述できているか気づくことができず、試行錯誤の回数が多くなっていた

また、記述の不足や間違いがあると、期待されるリソースを作成できません。記述時点では気づくことができず、デプロイ時のエラーにより発覚するケースも発生しました。
例えば、API Gatewayを使用してLambda関数をトリガーする場合、YAMLファイル内でのAPI Gatewayの設定が不十分だと、リソースは正しく構築されません。

functions:
  writeDataToDynamoDB:
    handler: src/write_data_to_dynamodb.main
    events:
      # メソッドの定義が不足している
      - http:
          path: writeData
    environment:
      DYNAMODB_TABLE: MyDynamoDBTable

また、リソース間の依存関係が適切に定義されていない場合、デプロイは成功してもデプロイ後のLambda関数は期待通りに動作しません。
下記の例は、Lambda関数がDynamoDBテーブルに依存している場合、その依存関係がYAMLファイルに正しく記述されていないため、実行時にエラーとなります。

functions:
  writeDataToDynamoDB:
    handler: src/write_data_to_dynamodb.main
    events:
      - http:
          path: writeData
          method: post
    # 存在しないテーブルに依存している
    environment:
      DYNAMODB_TABLE: myTable

このような状態に気づかないままデプロイをしてしまうと、下記のような手順が必要になります。

  1. デプロイ時もしくはLamda関数実行時にエラー発生
  2. 切り戻しが必要な状態であれば変更前の正常な状態のコードを再デプロイ
  3. 原因調査、修正
  4. 変更後のコードを再度デプロイ

リソースが増えていくとともにデプロイにも時間がかかり、原因調査なども含めると多くの時間を要してしまいます。
デプロイ過程で発生するこのような手戻りが、効率的に開発するうえでのネックになっていました。

構成管理ツールの見直し

上記の課題を受け、新規開発するマイクロサービスでは構成管理ツールの見直しを行いました。
煩雑であったYAMLへの記述を回避するため、プログラミング言語で記述できるAWS Cloud Development Kit(以下、AWS CDK)を利用することにしました。

実際にAWS CDKを利用して感じたメリットは大きく以下の3つが挙げられます。

  • アプリケーションで利用している言語をCDKがサポートしていれば、同様の言語で記述できる
  • 静的型付け言語を選択することで、型安全性が高まり信頼性と効率が向上した
  • プログラマブルに書けるため、コードの再利用でリソースを追加できるようになった

アプリケーション側と同様の言語で記述できる

今回開発するマイクロサービスでは、アプリケーションの実装にGoを採用しています。
Goを使用してリソースを定義できるAWS CDKでは、通常のコードと同様にインフラストラクチャを記述でき、煩雑に感じていたYAMLファイルへの記述を回避できます。

静的型付け言語での記述

静的型付け言語であるGoで記述することで、型安全性が高まり信頼性と効率の向上を実感しました。AWS CDKのコードが正確に記述されているかをデプロイ前に確認できるため、デプロイ時に発覚する設定ミス等の問題を最小限に抑えられます。
以下は、AWS CDKをGoで記述してAPI GatewayとLambda関数をデプロイする例です。

package main

import (
    "github.com/aws/aws-cdk-go/awscdk"
    "github.com/aws/aws-cdk-go/awscdk/awsapigateway"
    "github.com/aws/aws-cdk-go/awscdk/awslambda"
)

func main() {
    app := awscdk.NewApp(nil)

    stack := awscdk.NewStack(app, "MyStack", nil)

    // Lambda関数
    lambdaFn := awslambda.NewFunction(stack, "MyLambda", &awslambda.FunctionProps{
        Runtime: awslambda.Runtime_GO_1_X(),
        Handler: "main",
        Code:    awslambda.AssetCode_FromAsset("path/to/your/code"),
    })

    // API Gateway
    api := awsapigateway.NewRestApi(stack, "MyApi", &awsapigateway.RestApiProps{
        RestApiName: "MyApi",
        Description: "My API",
    })

    apiResource := api.Root().AddResource("myresource")
    apiResource.AddMethod("GET", &awsapigateway.LambdaIntegrationProps{
        Handler: lambdaFn,
    })
 
    app.Synth(nil)
}

このように、設定の記述はよりプログラマブルであることがわかります。
さらに静的型付け言語であるGoで記述することで、開発者はコンパイル時に型エラーを検出し、リソースの構築や変更において信頼性と効率を向上させることができるようになりました。
また、アプリケーション側の言語とインフラ設定の言語が統一されることで、開発時のコンテキストスイッチの切り替えが不要となり、抱えていたストレスから開放されました。

コードの再利用性

プログラマブルに記述できるため、コードの再利用でリソースを追加できるようになりました。
GoでLambda関数等のリソースを作成する際、基本となる処理をメソッド化することで、リソースの一貫性を確保できます。

package main

import (
    "fmt"
    "os"

    "github.com/aws/aws-cdk-go/awscdk"
    "github.com/aws/aws-cdk-go/awscdk/awslambda"
    // リソース名を定義しているパッケージ
    name "github.com/st-tech/example-resource-name"
)

func main() {
    app := awscdk.NewApp(nil)
    stack := awscdk.NewStack(app, "MyLambdaStack", nil)

    // Lambda関数1
    // ハンドラ名はnameパッケージを参照
    createLambdaFunction(stack, name.Handler1, "path/to/code1")

    // Lambda関数2
    createLambdaFunction(stack, name.Handler2, "path/to/code2")

    app.Synth(nil)
}

// Lambda関数を作成するメソッド
func createLambdaFunction(stack awscdk.Stack, handler, codePath string) {
    // リソース名の生成
    resourceName := fmt.Sprintf("%s%s", os.Getenv("stageName"), handler)

    awslambda.NewFunction(&stack, resourceName, &awslambda.FunctionProps{
        Runtime: awslambda.Runtime_GO_1_X(),
        Handler: handler,
        Code:    awslambda.AssetCode_FromAsset(codePath),
    })
}

上記の例では、createLambdaFunctionメソッドにLambda関数の作成ロジックを切り出すことで、同じ構造のLambda関数を作成する際にコードを再利用できます。新しいLambda関数2が追加された場合も、メソッドの引数としてLambda関数の異なるパラメータを指定できるため、簡単に作成できます。
また、リソース名は明示的に指定しなければ自動で生成されますが、この例ではメソッド内でhandler引数(ハンドラの名前)を元にリソース名を生成しています。生成されたリソース名は一貫性を持ち、Lambda関数が異なるハンドラに対しても名前が適切に付与されます。このようにして、関数名とリソース名を結びつけることで一貫性を確保できます。

さらに、ハンドラ名は別のパッケージに定義したものを参照しています。アプリケーション側のコードからも同様のパッケージを参照できるので、インフラ側で定義している名前との揺らぎが無くなります。

このように、プログラミング言語で記述することの強みを活かし、より柔軟で効率的なインフラストラクチャの構築が可能になりました。

さいごに

本記事では、イベント駆動型アーキテクチャの構成管理において生じていた課題と、AWS CDKを利用したよりプログラマブルな課題解決の例を紹介しました。
構成管理ツールの利用を検討している方がいれば、ぜひ参考にしてみてください。

ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。

hrmos.co

カテゴリー