サンフランシスコで開催されるQCon 2024にお出かけですか? ミーティングを予約する!

より速いAPI、より速い開発: API Gatewayカスタムオーソライザー

カスタムオーソライザーを追加し、リモートキャッシュでAPIの待ち時間を短縮する方法

Ellery Addington-White
著者

Share

Lambdaのレイテンシを高速化するという前回の投稿では、AWS Lambda、AWS API Gateway、DynamoDBで基本的なREST APIをセットアップしました。そして、エンドユーザーのためにAPIエンドポイントのレイテンシを減らし、高速化するためにMomentoを追加しました。私たちのデモ・サービスは、典型的なソーシャル・ネットワーク・スタイルのサイトで見られるような、基本的なユーザ・プロフィール・サービスを模倣しています。今日はこれをもう少し拡張して、ユーザーのアバター画像を取得するための/profile-picと/cached-profile-picエンドポイントを追加します。また、専用のカスタム・オーソライザー・ラムダを活用したカスタム認証ロジックで、このエンドポイントを保護する方法を探ります。その後、集中型キャッシュを追加する利点と、サーバーレスアーキテクチャとキャッシュがどのように相性が良いのかについて深く掘り下げていきます。

AWS API Gatewayのカスタムオーソライザーとは何か?

新しいAPIエンドポイントの構築とセキュリティ確保に入る前に、AWS API Gatewayのカスタムオーソライザーとその利点について少し説明したいと思います。カスタムオーソライザーを使うと、サービスLambdaの前やプロキシ先のAWSリソースの前に呼び出されるLambda関数を定義できます。この関数はインバウンドリクエストを検査し、APIに必要な認証とカスタム認可を実行することができます。そして、その場でアクセスポリシーを作成し、その結果を返すことで、リクエストをサービスへ続行させるか、あるいは前もってブロックすることができます。認証ロジックをカスタムオーソライザーにカプセル化することで、サービスAPI全体でクリーンで再利用可能な認証パターンを作成し、認証ロジックをスタンドアロンコンポーネントにカプセル化することができます。これにより、サービスチームは迅速に行動できるようになり、最も安全なことを最も簡単に実行できるようになります。AWS API Gatewayのカスタムオーソライザーについての詳細は、Alex’s DeBrie氏の素晴らしいディープダイブを参照してください。

新しいエンドポイントの構築

最初にすべきことは、アバター画像を提供する新しいAPIエンドポイントを実際に構築することです。そのために、オプションのprofile_picプロパティを含むようにユーザーモデルを変更します。

interface User {
   id: string,
   name: string,
   followers: Array,
   profile_pic?: string
}

そして、/bootstrapエンドポイントを修正して、ランダムなプロフィール画像を取得し、他のユーザー・データと一緒にDynamoDBにbase64エンコードされた文字列として保存します。既存の/users/:idと/cached-users/:id APIはそのままにして、これらのエンドポイントを軽量に保つために、id、名前、フォロワーだけを返すようにします。新しい/pofile-pic/:idと/cached-profile-pic/:idエンドポイントを導入し、リクエストされたユーザーを検索し、クライアント上でレンダリングされるようにbase64エンコードされた画像を返します。

API Gatewayカスタムオーソライザーの追加

新しいAPIエンドポイントを扱うようにサービスを設定したら、それをAWS APIゲートウェイリソースに追加してユーザーに公開します。その前に、カスタムオーソライザーLambdaを書く必要があります。我々のものはとてもシンプルです。isFollowerAuthorizer.tsと呼ぶことにしましょう:

import {AuthorizerRequest} from "../models/authorizer";
import {DefaultClient} from "../repository/users/users";
import {UsersDdb} from "../repository/users/data-clients/ddb";
import {getMetricLogger} from "../monitoring/metrics/metricRecorder";

const ALLOW = 'Allow', DENY = 'Deny';
const ur = new DefaultClient(new UsersDdb());

export const handler = async (event: AuthorizerRequest): Promise => {
   const methodArn = event.methodArn;
   try {
       return await customAuthLogic(event.headers['Authorization'], methodArn, event.pathParameters['id'])
   } catch (error) {
       console.error(`fatal error occurred in authorizer err=${JSON.stringify(error)}`);
       throw new Error('Server Error'); // 500.
   }
}

const customAuthLogic = async (authToken: string, methodArn: string, requestedUserId: string): Promise => {
   const startTime = Date.now()
   
   // Note: in a real api we would want to do a validation check(AuthN) first against
   // passed 'authToken' to get the verified user id. Since this is just a simple demo
   // we blindly trust the passed simple ID value.
   //
   //   ex:       Authorization: 1

   // Now perform custom app Authz logic here were checking if the requesting user is 
   // a follower of the profile pic owner based off path parameter in api resource.

   let user: undefined | User;
   if (process.env["CACHE_ENABLED"] === 'true') {
       user = await ur.getCachedUser(requestedUserId);
   } else {
       user = await ur.getUser(requestedUserId);
   }
   if (!user) {
       throw new Error(`no user found requestedUserId=${requestedUserId}`);
   }

   if (user.followers.indexOf(authToken) < 0) {
       console.info(`non follower tried to access a profile pic requestedResourceUserId=${user.id} requestingUser=${authToken}`);
       return generateAuthorizerRsp(authToken, DENY, methodArn, {})
   }
   console.info(`successfully authenticated resource request requestedResourceUserId=${user.id} requestingUser=${authToken}`);
   getMetricLogger().record([{
       value: Date.now() - startTime,
       labels: [{k: "CacheEnabled", v: `${process.env["CACHE_ENABLED"]}`}],
       name: "authTime"
   }]);
   getMetricLogger().flush();
   return generateAuthorizerRsp(authToken, ALLOW, methodArn, {id: authToken})
}

const generateAuthorizerRsp = (principalId: string, Effect: string, Resource: string, context: any) => ({
   principalId,
   policyDocument: {
       Version: '2012-10-17',
       Statement: [{Action: 'execute-api:Invoke', Effect, Resource}],
   },
   context,
});

ご覧のように、リモートキャッシュが有効かどうかを切り替える簡単な方法があり、DynamoDBで直接ユーザーを検索するか、ルックアサイドキャッシュから最初に取得しようとします。

そして、CDK経由で新しいAPIルートをカスタム・オーソライザーで配線する必要があります。1つは集中キャッシュを有効にし、もう1つはオーソライザーとサービスのキャッシュを無効にします。これらは/profile-pic/:idと/cached-profile-pic/:idです。 最初のものは、カスタムの認証チェックとサポートするサービスの両方のために、常にDynamoDBに移動します。ここで、CDKでキャッシュを有効にした場合としなかった場合のエンドポイントの配線方法を確認できます:

// Lambda for custom authorizer with cache
const customAuthLambdaWithCache = new NodejsFunction(this, 'CustomAuthFunctionWithCache', {
   entry: join(__dirname, '../../src/functions', 'isFollowerAuthorizer.ts'),
   ...nodeJsFunctionProps,
   environment: {
       "RUNTIME": "AWS",
       "CACHE_ENABLED": "true"
   }
});
// Lambda for custom authorizer with no cache
const customAuthLambdaNoCache = new NodejsFunction(this, 'CustomAuthFunctionNoCache', {
   entry: join(__dirname, '../../src/functions', 'isFollowerAuthorizer.ts'),
   ...nodeJsFunctionProps,
   environment: {
       "RUNTIME": "AWS",
       "CACHE_ENABLED": "false"
   }
});

// Read perms for lambdas
dynamoTable.grantReadData(customAuthLambdaWithCache);
dynamoTable.grantReadData(customAuthLambdaNoCache);


// Add profile-img api with custom authorizer that does not use caching
api.root.addResource('profile-pic').addResource('{id}',
   {
       defaultMethodOptions: {
           authorizationType: AuthorizationType.CUSTOM,
           authorizer: new RequestAuthorizer(this, 'IsFollowerAuthorizerNoCache', {
               authorizerName: 'authenticated-and-friends-no-cache',
               handler: customAuthLambdaNoCache,
               // Don't cache at GW level we want follower updates enforced 
               // as quickly as possible for this demo
               resultsCacheTtl: Duration.seconds(0),
               identitySources: [
                   IdentitySource.header("Authorization"),
               ]
           })
       },
   }
).addMethod("GET", svcLambdaIntegration);
// Add cached-profile-img api with custom authorizer that does use caching
api.root.addResource('cached-profile-pic').addResource('{id}',
   {
       defaultMethodOptions: {
           authorizationType: AuthorizationType.CUSTOM,
           authorizer: new RequestAuthorizer(this, 'IsFollowerAuthorizerWithCache', {
               authorizerName: 'authenticated-and-friends-with-cache',
               handler: customAuthLambdaWithCache,
               // Don't cache at GW level we want follower updates enforced as quickly as possible for this demo
               resultsCacheTtl: Duration.seconds(0),
               identitySources: [
                   IdentitySource.header("Authorization"),
               ]
           })
       },
   }
).addMethod("GET", svcLambdaIntegration);

エンドポイントのテスト

手始めに、ユーザー0の基本情報を調べてみます。

$ curl https://j2azklgb6h.execute-api.us-east-1.amazonaws.com/prod/users/0 -s | jq .
{
 "id": "0",
 "followers": [
   "95",
   "77",
   "22",
   "65",
   "39"
 ],
 "name": "Relaxed Elephant"
}

ユーザー0には5人のフォロワー{95, 77, 22, 65, 39}がいることがわかります。カスタムオーソライザーをテストしてみましょう。まず、フォロワー22のプロフィール画像を取得してみましょう。(注意: ユーザー0のフォロワーはランダムに生成されるため、異なるフォロワーになります。認証ヘッダーの値を調整して、テストユーザーに合うようにしてください)。

$ curl -s -o /dev/null -w "\nStatus: %{http_code}\n" \
  -H "Authorization: 22" \
 https://j2azklgb6h.execute-api.us-east-1.amazonaws.com/prod/profile-pic/0

 Status: 200
$

ステータス200が返ってくるのがわかるでしょう。レスポンスの本文を見ると、そのユーザーのプロフィール画像がbase64エンコードされた文字列になっています。このテストでは、カスタム・オーソライザーが機能していることを確認するためのテストなので、レスポンスのボディを/dev/nullに書き込んでいます。

ここで、ユーザー0のフォロワーではないユーザー44のauthヘッダをつけて再試行すると、APIからhttp 403ステータスが返され、明示的に拒否されます。

$ curl -s -o /dev/null -w "\nStatus: %{http_code}\n" \
-H "Authorization: 44" \
https://j2azklgb6h.execute-api.us-east-1.amazonaws.com/prod/profile-pic/0

Status: 403
$

パフォーマンスの評価

これらのエンドポイントを比較するために、単純なcurlスクリプトでサービスAPIをテストし、それぞれのAPIを100回ヒットさせ、レイテンシを測定するために記録しているカスタムメトリクスのいくつかをグラフ化します。

前のテストと同じ成功したリクエストを使って、プロファイル画像のエンドポイントをキャッシュオンとキャッシュオフでヒットさせます:

No Cache:

$ for i in `seq 1 100`; do
   curl -o /dev/null -H "Authorization: 22"  https://j2azklgb6h.execute-api.us-east-1.amazonaws.com/prod/profile-pic/0 -s
done;

With Cache:

$ for i in `seq 1 100`; do
   curl -o /dev/null -H "Authorization: 22"  https://j2azklgb6h.execute-api.us-east-1.amazonaws.com/prod/cached-profile-pic/0 -s
done;

これらの小さなロード・ジェネレーター・スクリプトのそれぞれを何度か繰り返し実行した後、CloudWatchで報告するカスタム・メトリクスをチャート化し、サーバーレスAPIの様々な部分のレイテンシーを比較することができます。平均応答時間を見ると、オーソライザーとサービスにリモート・キャッシュを追加することで、レイテンシーを低下させることができたことがわかります。

この傾向は、P99のレスポンスタイムを見ても、テールレイテンシの上位パーセンタイルまで続いています。

Serverless + Remote Caching = ❤️

ご覧のように、新しいプロフィール画像APIのカスタムオーソライザーとサービスでキャッシュを有効にすることで、明確なメリットが得られます。API Gatewayのカスタムオーソライザを使うことで、AuthN/AuthZの決定をサービスコードから引き出すことができ、開発チームのパフォーマンスと安全性を向上させることができます。アプリケーションを複数のファンクションに分割することは素晴らしいことですが、顧客のために高速性を維持するためには、リモートキャッシュの必要性がより重要になります。

APIゲートウェイのカスタム・オーソライザ対応APIを高速化する方法について、さらに質問(または独自のアイデア!)がある場合は、私たちのDiscordサーバーに参加してください!

Share