はじめに
皆さん、こんにちは。
私は業務でデータ利活用基盤を取り扱っているため、dbtやIceberg、そしてAWS GenUに取り組む必要があると考えています。特に AWS Japan Top Engineer として、GenUを扱い、その活用を広めることが責務だと感じています。
しかし、私はこれまで CloudFormation を好んで使っており、(逆張り思考も重なって)Cfn テンプレートをシンプルかつ汎用性・拡張性の高い形で作ることに注力してきました。そのため、改めてGenU の CDK コードを読もうとしても、なかなか理解が進みませんでした。
そこで、CDK を学びながら、その過程を記事としてまとめることにしました。
前回までのおさらい
前回までで、以下が完了しました。
- ①AWS CDK のセットアップ
- ②AWS CDK の動作確認
- ③GenU の概要
- ④GenU CDK スタックの概要
- ⑤CloudFrontWafStack スタックの確認
- ⑥RagKnowledgeBaseStack スタックの確認
- ⑦WebSearchAgentStack スタックの確認
- ⑧GuardrailStack スタックの確認
GenU の CDK は最大で以下の 6 つの子スタックを作成します。
CloudFrontWafStack
RagKnowledgeBaseStack
AgentStack
GuardrailStack
-
GenerativeAiUseCasesStack
※メインスタック DashboardStack
今回は GenU 内の本丸である GenerativeAiUseCasesStack
スタックを解説していきたいと思います。
ボリュームが多いですが、焦らずに見ていきます。
GenerativeAiUseCasesStack スタック
GenerativeAiUseCasesStack は GenU の本体となる生成 AI アプリケーションのスタックです。
GenerativeAiUseCasesStack の実体は packages/cdk/lib/generative-ai-use-cases-stack.ts
にあります。
import { Stack, StackProps, CfnOutput } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import {
Auth,
Api,
Web,
Database,
Rag,
RagKnowledgeBase,
Transcribe,
CommonWebAcl,
} from './construct';
import { CfnWebACLAssociation } from 'aws-cdk-lib/aws-wafv2';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
import { Agent } from 'generative-ai-use-cases-jp';
import { UseCaseBuilder } from './construct/use-case-builder';
import { StackInput } from './stack-input';
export interface GenerativeAiUseCasesStackProps extends StackProps {
params: StackInput;
// RAG Knowledge Base
knowledgeBaseId?: string;
knowledgeBaseDataSourceBucketName?: string;
// Agent
agents?: Agent[];
// Guardrail
guardrailIdentifier?: string;
guardrailVersion?: string;
// WAF
webAclId?: string;
// Custom Domain
cert?: ICertificate;
}
export class GenerativeAiUseCasesStack extends Stack {
public readonly userPool: cognito.UserPool;
public readonly userPoolClient: cognito.UserPoolClient;
constructor(
scope: Construct,
id: string,
props: GenerativeAiUseCasesStackProps
) {
super(scope, id, props);
process.env.overrideWarningsEnabled = 'false';
const params = props.params;
// Auth
const auth = new Auth(this, 'Auth', {
selfSignUpEnabled: params.selfSignUpEnabled,
allowedIpV4AddressRanges: params.allowedIpV4AddressRanges,
allowedIpV6AddressRanges: params.allowedIpV6AddressRanges,
allowedSignUpEmailDomains: params.allowedSignUpEmailDomains,
samlAuthEnabled: params.samlAuthEnabled,
});
// Database
const database = new Database(this, 'Database');
// API
const api = new Api(this, 'API', {
modelRegion: params.modelRegion,
modelIds: params.modelIds,
imageGenerationModelIds: params.imageGenerationModelIds,
endpointNames: params.endpointNames,
customAgents: params.agents,
queryDecompositionEnabled: params.queryDecompositionEnabled,
rerankingModelId: params.rerankingModelId,
crossAccountBedrockRoleArn: params.crossAccountBedrockRoleArn,
userPool: auth.userPool,
idPool: auth.idPool,
userPoolClient: auth.client,
table: database.table,
knowledgeBaseId: params.ragKnowledgeBaseId || props.knowledgeBaseId,
agents: props.agents,
guardrailIdentify: props.guardrailIdentifier,
guardrailVersion: props.guardrailVersion,
});
// WAF
if (
params.allowedIpV4AddressRanges ||
params.allowedIpV6AddressRanges ||
params.allowedCountryCodes
) {
const regionalWaf = new CommonWebAcl(this, 'RegionalWaf', {
scope: 'REGIONAL',
allowedIpV4AddressRanges: params.allowedIpV4AddressRanges,
allowedIpV6AddressRanges: params.allowedIpV6AddressRanges,
allowedCountryCodes: params.allowedCountryCodes,
});
new CfnWebACLAssociation(this, 'ApiWafAssociation', {
resourceArn: api.api.deploymentStage.stageArn,
webAclArn: regionalWaf.webAclArn,
});
new CfnWebACLAssociation(this, 'UserPoolWafAssociation', {
resourceArn: auth.userPool.userPoolArn,
webAclArn: regionalWaf.webAclArn,
});
}
// Web Frontend
const web = new Web(this, 'Api', {
// Auth
userPoolId: auth.userPool.userPoolId,
userPoolClientId: auth.client.userPoolClientId,
idPoolId: auth.idPool.identityPoolId,
selfSignUpEnabled: params.selfSignUpEnabled,
samlAuthEnabled: params.samlAuthEnabled,
samlCognitoDomainName: params.samlCognitoDomainName,
samlCognitoFederatedIdentityProviderName:
params.samlCognitoFederatedIdentityProviderName,
// Backend
apiEndpointUrl: api.api.url,
predictStreamFunctionArn: api.predictStreamFunction.functionArn,
ragEnabled: params.ragEnabled,
ragKnowledgeBaseEnabled: params.ragKnowledgeBaseEnabled,
agentEnabled: params.agentEnabled || params.agents.length > 0,
flows: params.flows,
flowStreamFunctionArn: api.invokeFlowFunction.functionArn,
optimizePromptFunctionArn: api.optimizePromptFunction.functionArn,
webAclId: props.webAclId,
modelRegion: api.modelRegion,
modelIds: api.modelIds,
imageGenerationModelIds: api.imageGenerationModelIds,
endpointNames: api.endpointNames,
agentNames: api.agentNames,
inlineAgents: params.inlineAgents,
useCaseBuilderEnabled: params.useCaseBuilderEnabled,
// Frontend
hiddenUseCases: params.hiddenUseCases,
// Custom Domain
cert: props.cert,
hostName: params.hostName,
domainName: params.domainName,
hostedZoneId: params.hostedZoneId,
});
// RAG
if (params.ragEnabled) {
const rag = new Rag(this, 'Rag', {
envSuffix: params.env,
kendraIndexArnInCdkContext: params.kendraIndexArn,
kendraDataSourceBucketName: params.kendraDataSourceBucketName,
kendraIndexScheduleEnabled: params.kendraIndexScheduleEnabled,
kendraIndexScheduleCreateCron: params.kendraIndexScheduleCreateCron,
kendraIndexScheduleDeleteCron: params.kendraIndexScheduleDeleteCron,
userPool: auth.userPool,
api: api.api,
});
// File API から data source の Bucket のファイルをダウンロードできるようにする
// 既存の Kendra を import している場合、data source が S3 ではない可能性がある
// その際は rag.dataSourceBucketName が undefined になって権限は付与されない
if (rag.dataSourceBucketName) {
api.allowDownloadFile(rag.dataSourceBucketName);
}
}
// RAG Knowledge Base
if (params.ragKnowledgeBaseEnabled) {
const knowledgeBaseId =
params.ragKnowledgeBaseId || props.knowledgeBaseId;
if (knowledgeBaseId) {
new RagKnowledgeBase(this, 'RagKnowledgeBase', {
modelRegion: params.modelRegion,
knowledgeBaseId: knowledgeBaseId,
userPool: auth.userPool,
api: api.api,
});
// File API から data source の Bucket のファイルをダウンロードできるようにする
if (props.knowledgeBaseDataSourceBucketName) {
api.allowDownloadFile(props.knowledgeBaseDataSourceBucketName);
}
}
}
// Usecase builder
if (params.useCaseBuilderEnabled) {
new UseCaseBuilder(this, 'UseCaseBuilder', {
userPool: auth.userPool,
api: api.api,
});
}
// Transcribe
new Transcribe(this, 'Transcribe', {
userPool: auth.userPool,
idPool: auth.idPool,
api: api.api,
});
// Cfn Outputs
new CfnOutput(this, 'Region', {
value: this.region,
});
if (params.hostName && params.domainName) {
new CfnOutput(this, 'WebUrl', {
value: `https://${params.hostName}.${params.domainName}`,
});
} else {
new CfnOutput(this, 'WebUrl', {
value: `https://${web.distribution.domainName}`,
});
}
new CfnOutput(this, 'ApiEndpoint', {
value: api.api.url,
});
new CfnOutput(this, 'UserPoolId', { value: auth.userPool.userPoolId });
new CfnOutput(this, 'UserPoolClientId', {
value: auth.client.userPoolClientId,
});
new CfnOutput(this, 'IdPoolId', { value: auth.idPool.identityPoolId });
new CfnOutput(this, 'PredictStreamFunctionArn', {
value: api.predictStreamFunction.functionArn,
});
new CfnOutput(this, 'OptimizePromptFunctionArn', {
value: api.optimizePromptFunction.functionArn,
});
new CfnOutput(this, 'InvokeFlowFunctionArn', {
value: api.invokeFlowFunction.functionArn,
});
new CfnOutput(this, 'Flows', {
value: Buffer.from(JSON.stringify(params.flows)).toString('base64'),
});
new CfnOutput(this, 'RagEnabled', {
value: params.ragEnabled.toString(),
});
new CfnOutput(this, 'RagKnowledgeBaseEnabled', {
value: params.ragKnowledgeBaseEnabled.toString(),
});
new CfnOutput(this, 'AgentEnabled', {
value: (params.agentEnabled || params.agents.length > 0).toString(),
});
new CfnOutput(this, 'SelfSignUpEnabled', {
value: params.selfSignUpEnabled.toString(),
});
new CfnOutput(this, 'ModelRegion', {
value: api.modelRegion,
});
new CfnOutput(this, 'ModelIds', {
value: JSON.stringify(api.modelIds),
});
new CfnOutput(this, 'ImageGenerateModelIds', {
value: JSON.stringify(api.imageGenerationModelIds),
});
new CfnOutput(this, 'EndpointNames', {
value: JSON.stringify(api.endpointNames),
});
new CfnOutput(this, 'SamlAuthEnabled', {
value: params.samlAuthEnabled.toString(),
});
new CfnOutput(this, 'SamlCognitoDomainName', {
value: params.samlCognitoDomainName ?? '',
});
new CfnOutput(this, 'SamlCognitoFederatedIdentityProviderName', {
value: params.samlCognitoFederatedIdentityProviderName ?? '',
});
new CfnOutput(this, 'AgentNames', {
value: Buffer.from(JSON.stringify(api.agentNames)).toString('base64'),
});
new CfnOutput(this, 'InlineAgents', {
value: params.inlineAgents.toString(),
});
new CfnOutput(this, 'UseCaseBuilderEnabled', {
value: params.useCaseBuilderEnabled.toString(),
});
new CfnOutput(this, 'HiddenUseCases', {
value: JSON.stringify(params.hiddenUseCases),
});
this.userPool = auth.userPool;
this.userPoolClient = auth.client;
this.exportValue(this.userPool.userPoolId);
this.exportValue(this.userPoolClient.userPoolClientId);
}
}
このスタックでは、以下のリソースを作成しています。
Auth
Database
Api
CommonWebAcl
Web
Rag
RagKnowledgeBase
UseCaseBuilder
Transcribe
上から 1 つずつ見ていきます。
GenerativeAiUseCasesStack > Auth リソース
Auth リソースの実体は packages/cdk/lib/construct/auth.ts
にあります。
以下のソースコードが Auth の定義です。
import { Duration } from 'aws-cdk-lib';
import {
UserPool,
UserPoolClient,
UserPoolOperation,
} from 'aws-cdk-lib/aws-cognito';
import {
IdentityPool,
UserPoolAuthenticationProvider,
} from '@aws-cdk/aws-cognito-identitypool-alpha';
import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Construct } from 'constructs';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs';
export interface AuthProps {
selfSignUpEnabled: boolean;
allowedIpV4AddressRanges?: string[] | null;
allowedIpV6AddressRanges?: string[] | null;
allowedSignUpEmailDomains?: string[] | null;
samlAuthEnabled: boolean;
}
export class Auth extends Construct {
readonly userPool: UserPool;
readonly client: UserPoolClient;
readonly idPool: IdentityPool;
constructor(scope: Construct, id: string, props: AuthProps) {
super(scope, id);
const userPool = new UserPool(this, 'UserPool', {
// SAML 認証を有効化する場合、UserPool を利用したセルフサインアップは利用しない。セキュリティを意識して閉じる。
selfSignUpEnabled: props.samlAuthEnabled
? false
: props.selfSignUpEnabled,
signInAliases: {
username: false,
email: true,
},
passwordPolicy: {
requireUppercase: true,
requireSymbols: true,
requireDigits: true,
minLength: 8,
},
});
const client = userPool.addClient('client', {
idTokenValidity: Duration.days(1),
});
const idPool = new IdentityPool(this, 'IdentityPool', {
authenticationProviders: {
userPools: [
new UserPoolAuthenticationProvider({
userPool,
userPoolClient: client,
}),
],
},
});
if (props.allowedIpV4AddressRanges || props.allowedIpV6AddressRanges) {
const ipRanges = [
...(props.allowedIpV4AddressRanges
? props.allowedIpV4AddressRanges
: []),
...(props.allowedIpV6AddressRanges
? props.allowedIpV6AddressRanges
: []),
];
idPool.authenticatedRole.attachInlinePolicy(
new Policy(this, 'SourceIpPolicy', {
statements: [
new PolicyStatement({
effect: Effect.DENY,
resources: ['*'],
actions: ['*'],
conditions: {
NotIpAddress: {
'aws:SourceIp': ipRanges,
},
},
}),
],
})
);
}
idPool.authenticatedRole.attachInlinePolicy(
new Policy(this, 'PollyPolicy', {
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
resources: ['*'],
actions: ['polly:SynthesizeSpeech'],
}),
],
})
);
// Lambda
if (props.allowedSignUpEmailDomains) {
const checkEmailDomainFunction = new NodejsFunction(
this,
'CheckEmailDomain',
{
runtime: Runtime.NODEJS_LATEST,
entry: './lambda/checkEmailDomain.ts',
timeout: Duration.minutes(15),
environment: {
ALLOWED_SIGN_UP_EMAIL_DOMAINS_STR: JSON.stringify(
props.allowedSignUpEmailDomains
),
},
}
);
userPool.addTrigger(
UserPoolOperation.PRE_SIGN_UP,
checkEmailDomainFunction
);
}
this.client = client;
this.userPool = userPool;
this.idPool = idPool;
}
}
この中では、以下の AWS リソースを生成するコンストラクトを定義しています。
UserPool
IdentityPool
NodejsFunction
GenerativeAiUseCasesStack > Auth > UserPool リソース
UserPool
は Cognito ユーザプールのリソースです。
以下のソースコードが UserPool の定義です。
const userPool = new UserPool(this, 'UserPool', {
// SAML 認証を有効化する場合、UserPool を利用したセルフサインアップは利用しない。セキュリティを意識して閉じる。
selfSignUpEnabled: props.samlAuthEnabled
? false
: props.selfSignUpEnabled,
signInAliases: {
username: false,
email: true,
},
passwordPolicy: {
requireUppercase: true,
requireSymbols: true,
requireDigits: true,
minLength: 8,
},
});
const client = userPool.addClient('client', {
idTokenValidity: Duration.days(1),
});
この中では、Cognito ユーザプールおよびアプリケーションクライアントを生成しています。
- 名前は
UserPool
-
selfSignUpEnabled
: ユーザ自らサインアップ (アカウント作成) することを許可するか- パラメータの
samlAuthEnabled
が true なら false (許可しない) - パラメータの
samlAuthEnabled
が false なら パラメータのselfSignUpEnabled
- つまり、SAML 認証をする場合は一律許可せず、SAML 認証をしない場合はパラメータで制御可能
-
samlAuthEnabled
の初期値は false (stack-input.ts`で設定) -
selfSignUpEnabled
の初期値は true (stack-input.ts`で設定)
- パラメータの
-
signInAliases
: ユーザープールに登録またはサインインする方法- ユーザ名は不可、メールアドレスは許可
-
passwordPolicy
: パスワードポリシー- パスワードに大文字が必要
- パスワードに記号が必要
- パスワードに数字が必要
- パスワードの最小文字数は 8 文字
- ユーザプールに
client
アプリケーションクライアントを追加-
idTokenValidity
: ID トークンの有効期限を 1 日に設定
-
GenerativeAiUseCasesStack > Auth > IdentityPool リソース
IdentityPool
は Cognito ID プールのリソースです。
以下のソースコードが IdentityPool の定義です。
const idPool = new IdentityPool(this, 'IdentityPool', {
authenticationProviders: {
userPools: [
new UserPoolAuthenticationProvider({
userPool,
userPoolClient: client,
}),
],
},
});
if (props.allowedIpV4AddressRanges || props.allowedIpV6AddressRanges) {
const ipRanges = [
...(props.allowedIpV4AddressRanges
? props.allowedIpV4AddressRanges
: []),
...(props.allowedIpV6AddressRanges
? props.allowedIpV6AddressRanges
: []),
];
idPool.authenticatedRole.attachInlinePolicy(
new Policy(this, 'SourceIpPolicy', {
statements: [
new PolicyStatement({
effect: Effect.DENY,
resources: ['*'],
actions: ['*'],
conditions: {
NotIpAddress: {
'aws:SourceIp': ipRanges,
},
},
}),
],
})
);
}
idPool.authenticatedRole.attachInlinePolicy(
new Policy(this, 'PollyPolicy', {
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
resources: ['*'],
actions: ['polly:SynthesizeSpeech'],
}),
],
})
);
この中では、Cognito ID プールを生成し、認証済みユーザに与えるロールを指定しています。
- 名前は
IdentityPool
-
authenticationProviders
: 認証プロバイダーに前述のユーザプール、アプリケーションクライアントを指定 - 認証済みユーザのロールをアタッチ
- AWS WAF による IPv4、IPv6 制限をかけている場合、認証されたユーザのソース IP アドレスが該当の IP アドレスレンジでない場合、すべてのアクションを拒否する
SourceIpPolicy
ポリシー - Amazon Polly の合成音声を生成する
polly:SynthesizeSpeech
を許可するポリシー
- AWS WAF による IPv4、IPv6 制限をかけている場合、認証されたユーザのソース IP アドレスが該当の IP アドレスレンジでない場合、すべてのアクションを拒否する
GenerativeAiUseCasesStack > Auth > NodejsFunction リソース
NodejsFunction
は NodeJS ランタイムの Lambda 関数のリソースです。
以下のソースコードが NodejsFunction の定義です。
// Lambda
if (props.allowedSignUpEmailDomains) {
const checkEmailDomainFunction = new NodejsFunction(
this,
'CheckEmailDomain',
{
runtime: Runtime.NODEJS_LATEST,
entry: './lambda/checkEmailDomain.ts',
timeout: Duration.minutes(15),
environment: {
ALLOWED_SIGN_UP_EMAIL_DOMAINS_STR: JSON.stringify(
props.allowedSignUpEmailDomains
),
},
}
);
userPool.addTrigger(
UserPoolOperation.PRE_SIGN_UP,
checkEmailDomainFunction
);
}
この中では、Lambda 関数を生成し、ユーザプールのサインアップ前処理に設定しています。
- パラメータ
allowedSignUpEmailDomains
に値がある場合、Lambda 関数を作成する-
allowedSignUpEmailDomains
の初期値は null - 例えば、
allowedSignUpEmailDomains: ["amazon.com"]
とした場合、amazon.com ドメインのメールアドレスしかサインアップ (アカウント作成) できなくなる (参照)
-
- Lambda 関数
- 名前は
CheckEmailDomain
- ソースコードは
./lambda/checkEmailDomain.ts
(後述) - タイムアウトは最長の 15 分
- 環境変数
ALLOWED_SIGN_UP_EMAIL_DOMAINS_STR
にallowedSignUpEmailDomains
の値を設定
- 名前は
- 前述のユーザプールのトリガーを設定
- 実行タイミング
UserPoolOperation.PRE_SIGN_UP
はサインアップ要求前 - 処理内容として Lambda 関数を指定する
- 実行タイミング
checkEmailDomain.ts の処理
この CheckEmailDomain
関数の実体は [packages/cdk/lambda/checkEmailDomain.ts
] にあります。
import { PreSignUpTriggerEvent, Context, Callback } from 'aws-lambda';
const ALLOWED_SIGN_UP_EMAIL_DOMAINS_STR =
process.env.ALLOWED_SIGN_UP_EMAIL_DOMAINS_STR;
const ALLOWED_SIGN_UP_EMAIL_DOMAINS: string[] = JSON.parse(
ALLOWED_SIGN_UP_EMAIL_DOMAINS_STR!
);
// メールアドレスのドメインを許可するかどうかを判定する
const checkEmailDomain = (email: string): boolean => {
// メールアドレスの中の @ の数が1つでない場合は、常に許可しない
if (email.split('@').length !== 2) {
return false;
}
// メールアドレスのドメイン部分が、許可ドメインの"いずれか"と一致すれば許可する
// それ以外の場合は、許可しない
// (ALLOWED_SIGN_UP_EMAIL_DOMAINSが空の場合は、常に許可しない)
const domain = email.split('@')[1];
return ALLOWED_SIGN_UP_EMAIL_DOMAINS.includes(domain);
};
/**
* Cognito Pre Sign-up Lambda Trigger.
*
* @param event - The event from Cognito.
* @param context - The Lambda execution context.
* @param callback - The callback function to return data or error.
*/
exports.handler = async (
event: PreSignUpTriggerEvent,
context: Context,
callback: Callback
) => {
try {
console.log('Received event:', JSON.stringify(event, null, 2));
const isAllowed = checkEmailDomain(event.request.userAttributes.email);
if (isAllowed) {
// 成功した場合、イベントオブジェクトをそのまま返す
callback(null, event);
} else {
// 失敗した場合、エラーメッセージを返す
callback(new Error('Invalid email domain'));
}
} catch (error) {
console.log('Error ocurred:', error);
// エラーがError型であるか確認し、適切なエラーメッセージを返す
if (error instanceof Error) {
callback(error);
} else {
// エラーがError型ではない場合、一般的なエラーメッセージを返す
callback(new Error('An unknown error occurred.'));
}
}
};
この Lambda 関数では サインアップ (アカウント作成) 要求に含まれるメールアドレスのドメイン部分が、許可ドメインのいずれかと一致すれば許可、いずれも一致しない場合は拒否します。
- ドメイン検証が NG の場合、エラーメッセージ Invalid email domain を返します。
Auth だけでかなり長くなってきたため、記事を分割します。
次回は GenU 内の GenerativeAiUseCasesStack
スタックの Database
リソースから解説したいと思います。