はじめに
AWS 上のリソースを IaC として定義して、自動デプロイをやりやすくする CDK (AWS Cloud Development Kit) があります。CDK をそれほど触ったことがなかったので、今回は AppSync DynamoDB を CDK でデプロイしてみます。
また、CDK は、CDK v1 と CDK v2 の 2 種類があります。CDK v1 のサポートは、2023 年 6月に終了する予定です。なので、CDK v2 を使ったデプロイ方法を整理していきます。
CDK Install
まず初めに、CDK をインストールしないといけません。Node.js のバージョン 16 を nvm を使ってインストールしたうえで、aws-cdk
をインストールしています。
nvm use v16.18.0
npm install -g aws-cdk
CDK のバージョンを確認します。この記事の時点で最新の 2.49.0
がインストールされました。
> cdk --version
2.49.0 (build 793dd76)
CDK のプロジェクト作成
手元の環境で、CDK のプロジェクトを作成します。インターネット上で情報が豊富な typescript を選びます。
cd ~/temp/cdkdir
mkdir cdk-appsync
cd cdk-appsync
cdk init app --language typescript
cdk init app
コマンドにより、いろいろ自動作成されました。
> ls -la
total 336
drwxr-xr-x 7 ec2-user docker 229 Oct 29 00:56 ./
drwxr-xr-x 3 ec2-user docker 25 Oct 28 23:59 ../
drwxr-xr-x 2 ec2-user docker 28 Oct 29 00:56 bin/
-rw-r--r-- 1 ec2-user docker 1412 Oct 29 00:56 cdk.json
drwxr-xr-x 8 ec2-user docker 184 Oct 29 00:56 .git/
-rw-r--r-- 1 ec2-user docker 93 Oct 29 00:56 .gitignore
-rw-r--r-- 1 ec2-user docker 157 Oct 29 00:56 jest.config.js
drwxr-xr-x 2 ec2-user docker 34 Oct 29 00:56 lib/
drwxr-xr-x 277 ec2-user docker 8192 Oct 29 00:56 node_modules/
-rw-r--r-- 1 ec2-user docker 65 Oct 29 00:56 .npmignore
-rw-r--r-- 1 ec2-user docker 573 Oct 29 00:56 package.json
-rw-r--r-- 1 ec2-user docker 301122 Oct 29 00:56 package-lock.json
-rw-r--r-- 1 ec2-user docker 536 Oct 29 00:56 README.md
drwxr-xr-x 2 ec2-user docker 33 Oct 29 00:56 test/
-rw-r--r-- 1 ec2-user docker 650 Oct 29 00:56 tsconfig.json
まず、テンプレートの時点で、cdk deploy
を行います。手を加える前に正しくデプロイできるか確認しておきましょう。
cdk deploy
cdk deploy
により CloudFormation の Stack が作成されました。Stack の中には CDK Medatata が作成されています。正常に CDK が動作することが確認できました。
CDK コードを作成する
このプロジェクト上で、CDK を使って、AppSync とそれに紐づく DynamoDB を作成していきます。Document を読み解いて CDK を記載するのは、初めはちょっと大変なので、サンプルコードから引っ張ってきます。
AWS が管理している Repository に、各言語での様々なサンプルが掲載されています。今回は、この中から以下のサンプルコードをほぼ丸パクリしていきます。
今回の構成では、lib/cdk-appsync-stack.ts
のファイルを編集して、AppSync と DynamoDB の定義を行います。わかりやすいように、全部のコードを記載します。後から大事なポイントを記載するので、これは読み飛ばして頂いて大丈夫です。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { CfnGraphQLApi, CfnApiKey, CfnGraphQLSchema, CfnDataSource, CfnResolver } from 'aws-cdk-lib/aws-appsync';
import { Table, AttributeType, StreamViewType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { Role, ServicePrincipal, ManagedPolicy } from 'aws-cdk-lib/aws-iam';
export class CdkAppsyncStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const tableName = 'items'
const itemsGraphQLApi = new CfnGraphQLApi(this, 'ItemsApi', {
name: 'items-api',
authenticationType: 'API_KEY'
});
new CfnApiKey(this, 'ItemsApiKey', {
apiId: itemsGraphQLApi.attrApiId
});
const apiSchema = new CfnGraphQLSchema(this, 'ItemsSchema', {
apiId: itemsGraphQLApi.attrApiId,
definition: `type ${tableName} {
${tableName}Id: ID!
name: String
}
type Paginated${tableName} {
items: [${tableName}!]!
nextToken: String
}
type Query {
all(limit: Int, nextToken: String): Paginated${tableName}!
getOne(${tableName}Id: ID!): ${tableName}
}
type Mutation {
save(name: String!): ${tableName}
delete(${tableName}Id: ID!): ${tableName}
}
type Schema {
query: Query
mutation: Mutation
}`
});
const itemsTable = new Table(this, 'ItemsTable', {
tableName: tableName,
partitionKey: {
name: `${tableName}Id`,
type: AttributeType.STRING
},
billingMode: BillingMode.PAY_PER_REQUEST,
stream: StreamViewType.NEW_IMAGE,
// The default removal policy is RETAIN, which means that cdk destroy will not attempt to delete
// the new table, and it will remain in your account until manually deleted. By setting the policy to
// DESTROY, cdk destroy will delete the table (even if it has data in it)
removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
});
const itemsTableRole = new Role(this, 'ItemsDynamoDBRole', {
assumedBy: new ServicePrincipal('appsync.amazonaws.com')
});
itemsTableRole.addManagedPolicy(ManagedPolicy.fromAwsManagedPolicyName('AmazonDynamoDBFullAccess'));
const dataSource = new CfnDataSource(this, 'ItemsDataSource', {
apiId: itemsGraphQLApi.attrApiId,
name: 'ItemsDynamoDataSource',
type: 'AMAZON_DYNAMODB',
dynamoDbConfig: {
tableName: itemsTable.tableName,
awsRegion: this.region
},
serviceRoleArn: itemsTableRole.roleArn
});
const getOneResolver = new CfnResolver(this, 'GetOneQueryResolver', {
apiId: itemsGraphQLApi.attrApiId,
typeName: 'Query',
fieldName: 'getOne',
dataSourceName: dataSource.name,
requestMappingTemplate: `{
"version": "2017-02-28",
"operation": "GetItem",
"key": {
"${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)
}
}`,
responseMappingTemplate: `$util.toJson($ctx.result)`
});
getOneResolver.addDependsOn(apiSchema);
const getAllResolver = new CfnResolver(this, 'GetAllQueryResolver', {
apiId: itemsGraphQLApi.attrApiId,
typeName: 'Query',
fieldName: 'all',
dataSourceName: dataSource.name,
requestMappingTemplate: `{
"version": "2017-02-28",
"operation": "Scan",
"limit": $util.defaultIfNull($ctx.args.limit, 20),
"nextToken": $util.toJson($util.defaultIfNullOrEmpty($ctx.args.nextToken, null))
}`,
responseMappingTemplate: `$util.toJson($ctx.result)`
});
getAllResolver.addDependsOn(apiSchema);
const saveResolver = new CfnResolver(this, 'SaveMutationResolver', {
apiId: itemsGraphQLApi.attrApiId,
typeName: 'Mutation',
fieldName: 'save',
dataSourceName: dataSource.name,
requestMappingTemplate: `{
"version": "2017-02-28",
"operation": "PutItem",
"key": {
"${tableName}Id": { "S": "$util.autoId()" }
},
"attributeValues": {
"name": $util.dynamodb.toDynamoDBJson($ctx.args.name)
}
}`,
responseMappingTemplate: `$util.toJson($ctx.result)`
});
saveResolver.addDependsOn(apiSchema);
const deleteResolver = new CfnResolver(this, 'DeleteMutationResolver', {
apiId: itemsGraphQLApi.attrApiId,
typeName: 'Mutation',
fieldName: 'delete',
dataSourceName: dataSource.name,
requestMappingTemplate: `{
"version": "2017-02-28",
"operation": "DeleteItem",
"key": {
"${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)
}
}`,
responseMappingTemplate: `$util.toJson($ctx.result)`
});
deleteResolver.addDependsOn(apiSchema);
}
}
大事なポイントをいくつか抜粋します。まず、冒頭にある import の部分です。CDK を利用する際には、ライブラリとして提供されているので、CDK 上で定義したいものに合わせて import を行います。今回は、AppSync, DynamoDB, IAM を定義するので、これらに関するものを import しています。
import * as cdk from 'aws-cdk-lib';
import { Construct } from 'constructs';
import { CfnGraphQLApi, CfnApiKey, CfnGraphQLSchema, CfnDataSource, CfnResolver } from 'aws-cdk-lib/aws-appsync';
import { Table, AttributeType, StreamViewType, BillingMode } from 'aws-cdk-lib/aws-dynamodb';
import { Role, ServicePrincipal, ManagedPolicy } from 'aws-cdk-lib/aws-iam';
AppSync で GraphQL API を宣言しているところはこちらです。AppSync の API 名を items-api
としています。認証タイプは API_KEY
です。
const itemsGraphQLApi = new CfnGraphQLApi(this, 'ItemsApi', {
name: 'items-api',
authenticationType: 'API_KEY'
});
AppSync のスキーマを定義する箇所はここです。通常の AppSync であれば graphql.schema で定義しますが、CDK の場合は TypeScript のコードの中に埋め込みます。コードの中には変数も使えるので、プログラム上のメリットが活かせるのが良い点ですね。
const tableName = 'items'
const apiSchema = new CfnGraphQLSchema(this, 'ItemsSchema', {
apiId: itemsGraphQLApi.attrApiId,
definition: `type ${tableName} {
${tableName}Id: ID!
name: String
}
type Paginated${tableName} {
items: [${tableName}!]!
nextToken: String
}
type Query {
all(limit: Int, nextToken: String): Paginated${tableName}!
getOne(${tableName}Id: ID!): ${tableName}
}
type Mutation {
save(name: String!): ${tableName}
delete(${tableName}Id: ID!): ${tableName}
}
type Schema {
query: Query
mutation: Mutation
}`
});
上記の定義により、次の graphql.schema
が生成されます。${tableName}
の変数が、それぞれ展開されて定義される形です。
type items {
itemsId: ID!
name: String
}
type Paginateditems {
items: [items!]!
nextToken: String
}
type Query {
all(limit: Int, nextToken: String): Paginateditems!
getOne(itemsId: ID!): items
}
type Mutation {
save(name: String!): items
delete(itemsId: ID!): items
}
type Schema {
query: Query
mutation: Mutation
}
DynamoDB のテーブル定義はこんな感じです。テーブル名や、パーティションキーなどが定義されています。
const itemsTable = new Table(this, 'ItemsTable', {
tableName: tableName,
partitionKey: {
name: `${tableName}Id`,
type: AttributeType.STRING
},
billingMode: BillingMode.PAY_PER_REQUEST,
stream: StreamViewType.NEW_IMAGE,
// The default removal policy is RETAIN, which means that cdk destroy will not attempt to delete
// the new table, and it will remain in your account until manually deleted. By setting the policy to
// DESTROY, cdk destroy will delete the table (even if it has data in it)
removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production code
});
AppSync のデータソースの定義や、それを使ったリゾルバーの定義がこの辺りです。データソースとして、DynamoDB のテーブルが定義されています。また、リゾルバーとして getOne
という Query が定義されています。裏側では、DynamoDB に対して GetItem
がされています。"${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)
の定義により、AppSync のパラメータとして渡された ItemsId
のデータを取得する内容となります。これは、CDK というよりも、AppSync のマッピングテンプレートの仕組みですね。
const dataSource = new CfnDataSource(this, 'ItemsDataSource', {
apiId: itemsGraphQLApi.attrApiId,
name: 'ItemsDynamoDataSource',
type: 'AMAZON_DYNAMODB',
dynamoDbConfig: {
tableName: itemsTable.tableName,
awsRegion: this.region
},
serviceRoleArn: itemsTableRole.roleArn
});
const getOneResolver = new CfnResolver(this, 'GetOneQueryResolver', {
apiId: itemsGraphQLApi.attrApiId,
typeName: 'Query',
fieldName: 'getOne',
dataSourceName: dataSource.name,
requestMappingTemplate: `{
"version": "2017-02-28",
"operation": "GetItem",
"key": {
"${tableName}Id": $util.dynamodb.toDynamoDBJson($ctx.args.${tableName}Id)
}
}`,
responseMappingTemplate: `$util.toJson($ctx.result)`
});
getOneResolver.addDependsOn(apiSchema);
CDK デプロイ
これらの CDK のコードを書いたあとに、cdk diff を実行すると、現在からの差分を確認できます。AppSync や DynamoDB や IAM Role が新規追加される様子がわかります。
> cdk diff
Stack CdkAppsyncStack
IAM Statement Changes
┌───┬──────────────────────────┬────────┬────────────────┬───────────────────────────────┬───────────┐
│ │ Resource │ Effect │ Action │ Principal │ Condition │
├───┼──────────────────────────┼────────┼────────────────┼───────────────────────────────┼───────────┤
│ + │ ${ItemsDynamoDBRole.Arn} │ Allow │ sts:AssumeRole │ Service:appsync.amazonaws.com │ │
└───┴──────────────────────────┴────────┴────────────────┴───────────────────────────────┴───────────┘
IAM Policy Changes
┌───┬──────────────────────┬────────────────────────────────────────────────────────────────┐
│ │ Resource │ Managed Policy ARN │
├───┼──────────────────────┼────────────────────────────────────────────────────────────────┤
│ + │ ${ItemsDynamoDBRole} │ arn:${AWS::Partition}:iam::aws:policy/AmazonDynamoDBFullAccess │
└───┴──────────────────────┴────────────────────────────────────────────────────────────────┘
(NOTE: There may be security-related changes not in this list. See https://github.com/aws/aws-cdk/issues/1299)
Resources
[+] AWS::AppSync::GraphQLApi ItemsApi ItemsApi
[+] AWS::AppSync::ApiKey ItemsApiKey ItemsApiKey
[+] AWS::AppSync::GraphQLSchema ItemsSchema ItemsSchema
[+] AWS::DynamoDB::Table ItemsTable ItemsTable5AAC2C46
[+] AWS::IAM::Role ItemsDynamoDBRole ItemsDynamoDBRole7D2E3F6D
[+] AWS::AppSync::DataSource ItemsDataSource ItemsDataSource
[+] AWS::AppSync::Resolver GetOneQueryResolver GetOneQueryResolver
[+] AWS::AppSync::Resolver GetAllQueryResolver GetAllQueryResolver
[+] AWS::AppSync::Resolver SaveMutationResolver SaveMutationResolver
[+] AWS::AppSync::Resolver DeleteMutationResolver DeleteMutationResolver
cdk deploy で実際にデプロイを行います。
cdk deploy
CloudFormation では、このように CDK で定義した各種リソースがデプロイされている様子がわかります。
AppSync
新しく API が作成され、以下の Schema が定義されています。CDK で指定したものが利用されています。
Query の定義はこんな感じです。CDK で定義した 4 つのリゾルバーが見えています。
DynamoDB
items
のテーブルが作成されています。
AppSync で操作してみる
AppSync の画面で、実際にクエリーを実行してみます。save
で Mutation をします。
裏側では、しっかり DynamoDB にデータが保存されています。
次に、AppSync でクエリーをして、先ほど格納したデータを取得できるか確認します。all
ですべての item を取得してみると、先ほど格納した 1 件のデータが取得できました。複数のデータを格納すると、複数のデータを取得できます。
検証を通じてわかったこと
- CDK v2 のサンプルコードが GitHub で公開されており、まずはそこから検証をし始めるのがやりやすい
- CDK v2 のコード上で、AppSync に紐づく
graphql.schema
の指定や、カスタムリゾルバーの指定を行うことが出来る。
参考資料