LoginSignup
1
0

DynamoDBを使って外部APIのレスポンスをキャッシュする仕組みを作った

Posted at

DynamoDBについてTTLを設定したテーブルでAPIのレスポンスをキャッシュするという案を考え、実装したときの備忘録。

※AWSでサーバーレスなアプリケーションを組む際に有効な選択肢となるAPI Gatewayにもキャッシュする仕組みがありますが、今回は事前にアプリケーション内でLambdaの関数URLを使っていたこともあり採用できませんでした

外部APIについて

  • データが頻繁に更新されるようなAPIではない
  • 1日当たりの1000回までのリクエスト制限がある

DynamoDBを選んだ理由

  • AWSの無料枠内で十分なものを構築できる
  • TTL(TimeToLive)機能を設定することで指定時間を過ぎた項目について自動で削除できる
    • 今回のキャッシュという目的に合致している

アプリケーションについて

今回の話からは外れるので関係してそうな箇所を少しだけ

  • Nuxt3を使用
    • NITRO_PRESET=aws-lambdaを指定してビルドしたものをServerless Frameworkを使用してAWSにデプロイ
    • 静的コンテンツ(ビルドによって生成された_nuxtフォルダ、favicon.icoimages以下の画像ファイルなどドキュメントルート以下に配置するリソース等)はserverless-s3-syncプラグインを使用してS3にアップロード
    • リクエストはCloudFrontが処理
    • CloudFrontディストリビューションは以下の2つのオリジンを持つ
      • Lambdaの関数URLを向いたnuxt-ssr-engineオリジン
      • S3のバケットを向いたnuxt-static-resourcesオリジン

ビヘイビアの設定は以下のように静的コンテンツへのリクエストとのマッチを優先しています。
image.png

DynamoDBテーブルについて

Serverless Frameworkを使っているため、serverless.ymlの中で以下のようなCloudFormationテンプレートを元に作成しています。
※リソース名やテーブル名は記事用に書き換えてます

serverlss.yml
resources:
  Resources:
    # 外部APIのレスポンスをキャッシュするためのDynamoDBテーブル
    APICacheDynamoDBTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: api-cache
        AttributeDefinitions:
          - AttributeName: request_identifier
            AttributeType: S
        KeySchema:
          - AttributeName: request_identifier
            KeyType: HASH
        BillingMode: PROVISIONED
        ProvisionedThroughput:
          ReadCapacityUnits: 5
          WriteCapacityUnits: 5
        TimeToLiveSpecification:
          AttributeName: ttl
          Enabled: true

属性について

  • request_identifier
    • APIのパスとクエリパラメータを合わせた文字列を格納します
    • 具体的には以下のようなデータが入ります
      • /api/v1/prefectures
      • /api/v1/cities
      • /api/v1/population/composition/perYear?prefCode=1&cityCode=01100
  • response_data
  • ttl
    • TimeToLiveSpecificationに設定しているttl属性
    • unixタイムスタンプを格納します

その他、外部APIについて日当たりのリクエスト数の制限があるので、日別の外部APIへのリクエスト数を管理するテーブルも作成しました。

serverlss.yml(抜粋)
resources:
  Resources:
    # 外部APIへのリクエスト数を日別で管理するDynamoDBテーブル
    APIRequestsDynamoDBTable:
      Type: AWS::DynamoDB::Table
      Properties:
        TableName: api-requests
        AttributeDefinitions:
          - AttributeName: date
            AttributeType: N
        KeySchema:
          - AttributeName: date
            KeyType: HASH
        BillingMode: PROVISIONED
        ProvisionedThroughput:
          ReadCapacityUnits: 1
          WriteCapacityUnits: 1

属性について

  • date
    • yyyymmddの日付文字列を数値の属性として格納します
  • request_count
    • リクエスト数を格納します
    • UpdateTableコマンドを使用してインクリメントしています

修正前後のイメージ図

S3バケットとか端折ってますが今回の修正に関わる箇所は以下のような感じ
sakai-nuxt改修前後のイメージ.drawio.png

  • 修正前
    • リクエストの度、外部APIにリクエスト
  • 修正後
    • ①DynamoDBテーブルにデータがあるかチェック(GetItem)
    • ②データが見つかったらそのデータをそのままユーザーに返す(③,④を経由しない)
    • ③,④を経由した際はLambdaに返ってきたときにDynamoDBにキャッシュする(PutItem)

開発環境について

元々Nuxt3のアプリケーションを開発するのにDockerを使用した開発を構築していたのでそこに追加する形でdynamodb-localのDockerイメージを使用しています。

docker-compose.development.yml
services:
  dynamodb:
    image: amazon/dynamodb-local
    command: -jar DynamoDBLocal.jar -sharedDb -dbPath . -optimizeDbBeforeStartup
    volumes:
      - dynamodb:/home/dynamodblocal
    ports:
      - 8000:8000

  awscli:
    image: amazon/aws-cli
    environment:
      # dynamodbコンテナに対して接続するためのaws-cliなので認証キー/シークレットキーの設定はダミーでOK
      - AWS_ACCESS_KEY_ID=fake_access_key
      - AWS_SECRET_ACCESS_KEY=face_secret_access_key
      - AWS_DEFAULT_REGION=ap-northeast-1
    tty: true
    command:
      - /bin/sh
    entrypoint: [""]

volumes:
  dynamodb:
    driver: local

こちらのコンテナ環境、開発を進めるのにとても便利ですが稀に重複できないはずのパーティションキーが重複するといったことが発生しました。。
image.png

  • 本番では発生していないのでとりあえず放置中:triumph:
  • ドキュメントあまり細かく読んでないのでもしかしたらdynamodbコンテナのオプションとかで解決できる問題かもしれない??

上記docker-compoese.development.ymlの中でdynamodbというサービス名で動かしているため、Nuxt3のアプリケーションからはhttp://dynamodb:8000というエンドポイントURLでアクセスできます。
本番は当然別のURL(https://dynamodb.ap-northeast-1.amazonaws.com)となるため環境変数を使用することで開発環境と本番環境で接続先を切り替えられるようにしています。

.env.local
# DynamoDBのエンドポイント(開発環境用)
DYNAMODB_ENDPOINT=http://dynamodb:8000

キャッシュする仕組みのサンプルコード

外部APIへのリクエストを行う処理はリクエストに必要となるAPIキーを隠す目的でserver/api配下に構築しています(フロントエンドから直接外部APIへのリクエストは行わない)。
元々、外部APIへのリクエストを行うのに$fetch.createから作成できる固有のデータフェッチを行う関数をNuxt3のserver/utilsの中に作成していたので、それを拡張する形で実装しました。

以下は抜粋した修正前と修正後のコードです。
一部名称について置き換えているのと、アプリケーション固有の実装(onResponse内のレスポンスデータのエラーハンドリング等)は除いています。

server/utils/useHogeApiFetch.ts(修正前)
export const useHogeApiFetch = () => {
  const baseURL = "[外部APIのベースURL]";

  /**
   * 外部APIからのデータ取得を行う
   */
  const hogeApiFetch = $fetch.create({
    baseURL,
    method: "GET",
    onRequest({ options }) {
      options.headers = new Headers(options.headers);
      options.headers.set("X-API-KEY", process.env?.HOGE_API_KEY ?? "");
    },
    onResponse({ request, response }) {
      // アプリケーション固有のエラーハンドリング処理(省略)
      // アプリケーション固有のデータ返却処理(省略)
    },
  });

  return {
    hogeApiFetch,
  };
};
server/utils/useHogeApiFetch.ts(修正後)
import {
  DynamoDBClient,
  GetItemCommand,
  PutItemCommand,
  UpdateItemCommand,
} from "@aws-sdk/client-dynamodb";
import { marshall, unmarshall } from "@aws-sdk/util-dynamodb";

export const useHogeApiFetch = () => {
  const baseURL = "[外部APIのベースURL]";

  /**
   * TTL秒(1週間)
   *
   * 60 * 60 * 24 * 7 = 604800
   */
  const TTL_SECOND = 604800;

  /**
   * 外部APIからのデータ取得を行う
   * @param {string} apiPath - 「外部APIのベースURL」以降のクエリパラメータを含むパス
   * @returns {Promise<T>}
   */
  const hogeApiFetch = async <T>(apiPath: string): Promise<T> => {
    const dynamodbClient = new DynamoDBClient({
      region: process.env.AWS_DEFAULT_REGION,
      endpoint: process.env.DYNAMODB_ENDPOINT,
    });

    // リクエスト識別子を元にDynamoDBテーブルを検索
    const queryCommand = new GetItemCommand({
      TableName: "api-cache",
      Key: marshall({
        request_identifier: `/${apiPath}`,
      }),
    });
    const response = await dynamodbClient.send(queryCommand);

    // データが取得出来てたら外部APIへのリクエストを実施せずに早期リターン
    // response_data属性の型がM(Object)かL(List)かでunmarshallの分岐
    if (response.Item !== undefined && "response_data" in response?.Item) {
      if ("M" in response.Item.response_data) {
        return {
          data: unmarshall(response.Item.response_data.M!),
          error: null,
        } as T;
      } else if ("L" in response.Item.response_data) {
        return {
          data: response.Item.response_data.L?.map((item) => unmarshall(item.M!)),
          error: null,
        } as T;
      }
    }

    // 外部APIからデータ取得
    return $fetch.create<T>({
      baseURL,
      method: "GET",
      onRequest({ options }) {
        options.headers = new Headers(options.headers);
        options.headers.set("X-API-KEY", process.env?.HOGE_API_KEY ?? "");
      },
      async onResponse({ request, response }) {
        // アプリケーション固有のエラーハンドリング処理(省略)
      
        // DynamoDBに外部APIのレスポンスJSONをキャッシュ
        const urlData = new URL(request.toString());
        const unixTime = Math.floor(new Date().getTime() / 1000);
        const putItemCommand = new PutItemCommand({
          TableName: "api-cache",
          Item: marshall({
            request_identifier: urlData.pathname + urlData.search,
            response_data: response._data.result,
            ttl: unixTime + TTL_SECOND,
          }),
        });

        // 外部APIへの日当たりのリクエスト数を管理するテーブルを更新(インクリメント)
        const yyyymmdd = parseInt(new Date().toISOString().slice(0, 10).replace(/-/g, ""));
        const updateItemCommand = new UpdateItemCommand({
          TableName: "api-requests",
          Key: marshall({ date: yyyymmdd }),
          UpdateExpression: "SET request_count = if_not_exists(request_count, :initial) + :increment",
          ExpressionAttributeValues: marshall({ ":initial": 0, ":increment": 1 }),
        });

        await Promise.all([dynamodbClient.send(putItemCommand), dynamodbClient.send(updateItemCommand)]);

        // アプリケーション固有のデータ返却処理(省略)
      },
    })(apiPath);
  };

  return {
    hogeApiFetch,
  };
};

動作確認

修正前

※本番は今回の修正をデプロイ済みなので開発環境で修正を開始する前のバージョンに戻して動作確認しています。。

修正前の時点での動作は以下のような感じ(アプリケーションの中で使用する6つのマスタデータの取得を行っています)
image.png

DynamoDBのキャッシュの仕組み導入前(ローカル環境で確認)2.png

  • リクエストの度に外部APIへの通信を行っているためレスポンスの取得時間は速くなったり遅くなったりしていることがわかります(都道府県を取得するAPIのみ条件で絞り込んでる)。
  • 都道府県のデータを取得するAPIについてページリロードで5回取得したところ191ms~432msで推移しました。

修正後

以下はDynamoDBのキャッシュテーブルが空の状態での初回アクセス時の様子です。
image.png

  • DynamoDBテーブルへのGetItemのチェック処理や外部APIから取得したデータをPutItemでインサートする処理を追加したため、初回の処理は修正前より全体的に100ms程度遅くなっているようです。

以下はDynamoDBテーブルにキャッシュされたあとの2回目以降のアクセス時の様子です。
image.png
image.png

  • 修正前のときよりも全体的に応答を返すまでの時間が短くなっていることが確認できました。
  • 都道府県のデータを取得するAPIについてページリロードで5回取得したところ60ms~70msで推移しました。

image.png

  • マスタデータ以外のデータを取得するAPIについても全体的に2回目以降の応答スピードが上がっていることが確認できました。

以下はマネジメントコンソール上でDynamoDBテーブルについて探索を行ったときの様子
image.png

  • request_identifier、response_data、ttl属性が存在することを確認できました。
  • response_data属性には項目ごとに異なる型(M,L)のデータを入れられていることを確認できました。

image.png

  • 日別のリクエスト数について保持できていることを確認

その他注意事項

本番ではLambdaがDynamoDBを操作するため、Lambdaに紐づくIAMロールとして以下のようなポリシーの追記が必要でした。。:upside_down:

serverless.yml
provider:
  name: aws
  stage: dev
  region: ${env:AWS_DEFAULT_REGION}
  runtime: nodejs18.x
+  iamRoleStatements:
+    - Effect: "Allow"
+      Action:
+        - dynamodb:GetItem
+        - dynamodb:PutItem
+        - dynamodb:UpdateItem
+      Resource:
+        - !Join
+          - ":"
+          - - "arn:aws:dynamodb"
+            - ${env:AWS_DEFAULT_REGION}
+            - ${aws:accountId}
+            - "table/api-cache"
+        - !Join
+          - ":"
+          - - "arn:aws:dynamodb"
+            - ${env:AWS_DEFAULT_REGION}
+            - ${aws:accountId}
+            - "table/api-requests"
  • 今回はサンプルコードで紹介したコードの中でGetItemCommandPutItemCommandUpdateItemCommandを使用しているため3つのアクションに対する許可が必要でした。
  • ここで設定するものはServerless Frameworkを使用したデプロイを行うときのIAMユーザーに紐づくロールとは別なので注意が必要。
  • マネジメントコンソールからだとLambdaの「アクセス権限」のページで確認できる

image.png

参考サイト

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0