はじめに
Nx Plugin for AWSは、コマンドを1つ打つだけで「Webサイト」「API(サーバー側のプログラム)」「データベース」のひな形をまとめて作ってくれるツールです。
以前、このツールでtRPC APIとReactのWebサイトを作り、パソコンの中でビルド(プログラムを動く形に変換する作業)が通るところまで試した記事を書きました。
そのときはログイン機能とAWSへの本番デプロイまでは試せていませんでした。
この記事はその続きです。積読(つんどく、買ったのに読んでいない本のこと)を管理するアプリ「My Bookshelf」を題材に、以下をすべて実際にやってみるハンズオンとなっています。
- アプリのひな形を作る
- ログイン機能(Cognitoというサービス)を足す
- 本当にAWSにデプロイする
- ブラウザでログインして、本を1冊登録する
この記事の手順をそのまま真似すれば、自分の手元でも同じところまで到達できます。専門用語が出てくるたびに、できるだけかんたんな言葉で説明を添えていきます。
作るもの
本のタイトルと著者を登録して、「積読」「読書中」「読了」の3つの状態で管理するだけの、シンプルなアプリです。ログインした人ごとに、自分の本だけが見える仕組みにします。他の人が登録した本は見えません。
前提条件
始める前に、以下を用意してください。
- Node.js(JavaScriptを動かす土台になるソフト) バージョン22以上
- Git
- UV(Pythonのツールを管理するソフト) バージョン0.5.29以上
- デプロイ先のAWSアカウントの認証情報(
aws sts get-caller-identityというコマンドを打って、エラーが出ない状態) - Docker(またはFinch)。パソコンの中でデータベースを動かすときに使います
pnpm(Node.jsのパッケージを管理するツールの一つ)は、あらかじめインストールせずにnpx pnpm@latestという書き方でそのつど呼び出します。
すでにpnpmが入っている人は、pnpmとだけ打っても構いません。
この記事の途中で、実際にAWSにリソース(サーバーやデータベースなどの部品)を作ります。今回使うのは小さな構成なので費用はわずかですが、お金がかかることに変わりはありません。
記事の最後にある「後片付け」の手順まで、必ず最後までやってください。
ワークスペースを作る
npx pnpm@latest create @aws/nx-workspace nx-bookshelf-aws
cd nx-bookshelf-aws
「ワークスペース」とは、アプリのコードをまとめて入れておくフォルダのことです。
実行すると、インフラ(AWSの環境)をどう作るかを選ぶ画面が出るので、CDK(AWSの環境をプログラムのコードで組み立てる仕組み)を選びます。
tRPC APIとReactサイトを追加する
npx pnpm@latest nx g @aws/nx-plugin:ts#api bookshelf-api --framework=trpc --no-interactive
npx pnpm@latest nx g @aws/nx-plugin:ts#website bookshelf-website --no-interactive
tRPCとは、ブラウザ側のプログラムとサーバー側のプログラムが、型(データの形の決まりごと)を保ったまま通信できる仕組みです。
型がずれるとエラーで気づけるので、通信のバグを減らせます。
このコマンドで、packages/bookshelf-api(サーバー側)とpackages/bookshelf-website(ブラウザ側)というフォルダが作られます。
なお、APIを作るコマンドの名前は、以前の記事で使ったts#trpc-apiからts#api --framework=trpcに変わっていました。
バージョンによって変わることがあるので、迷ったらnx g @aws/nx-plugin:ts#api --helpと打って確認すると安心です。
ログイン機能(Cognito)を追加する
npx pnpm@latest nx g @aws/nx-plugin:ts#website#auth --project=bookshelf-website --no-interactive
Cognitoは、ログイン画面やユーザー登録の仕組みを、自分で作らなくても用意してくれるAWSのサービスです。
このコマンドを実行すると、Webサイト側にログイン画面が追加されます。
実際にどんな画面が出るかは、この記事の後半で確認します。
DynamoDBテーブルを追加してAPIとつなぐ
本のデータを保存するために、DynamoDBを使います。
npx pnpm@latest nx g @aws/nx-plugin:ts#dynamodb bookshelf-table --no-interactive
npx pnpm@latest nx g @aws/nx-plugin:connection --sourceProject=bookshelf-website --targetProject=bookshelf-api --no-interactive
npx pnpm@latest nx g @aws/nx-plugin:connection --sourceProject=bookshelf-api --targetProject=bookshelf-table --no-interactive
ts#dynamodbというコマンドは、ElectroDBというライブラリを使って、データの形を決めるコードを作ってくれます。
サンプルのコードが生成されるので、それを本のデータ用に書き換えます。
// packages/bookshelf-table/src/entities/book.ts
export const createBookEntity = async () =>
new Entity(
{
model: { entity: 'book', version: '1', service: 'BookshelfTable' },
attributes: {
id: { type: 'string', required: true },
ownerId: { type: 'string', required: true },
title: { type: 'string', required: true },
author: { type: 'string', required: true },
status: {
type: ['unread', 'reading', 'done'] as const,
required: true,
default: 'unread',
},
createdAt: { type: 'string', required: true, default: () => new Date().toISOString(), readOnly: true },
updatedAt: { type: 'string', required: true, default: () => new Date().toISOString(), watch: '*', set: () => new Date().toISOString() },
},
indexes: {
primary: {
pk: { field: 'pk', composite: ['ownerId'] },
sk: { field: 'sk', composite: ['id'] },
},
},
},
{ client: getDynamoDBClient(), table: await resolveTableName() },
);
ここでのポイントはownerIdです。
データベースからデータを探すときの目印を「パーティションキー」と呼びますが、ここではownerId(本を登録した人のID)をパーティションキーにしています。
本の一覧を取り出すときは、必ず「自分のownerId」で絞り込む形になるので、仕組みとして他人の本が見えないようになっています。
インフラプロジェクトを追加してパソコンの中で動かす
npx pnpm@latest nx g @aws/nx-plugin:ts#infra infra --no-interactive
npx pnpm@latest dev
pnpm devを実行すると、サーバー、Webサイト、データベースが、パソコンの中でまとめて立ち上がります。
データベースはDockerのコンテナとして動くので、Dockerが起動していないとここで失敗します。
事前に動いているか確認してください。
ブラウザでhttp://localhost:4200を開いて、エラーなく画面が出れば次に進めます。
ログインした人を見分ける仕組みを作る
サーバー側のプログラムで実装が必要なのは、「今リクエストしてきたのは誰か」を調べる部分です。
今回のAPIは、SigV4という方法で本人確認をします。
CognitoのログイントークンをそのままJWTとして読み取るわけではありません。
生成されたコードを読むと、リクエストの中にcognitoAuthenticationProviderという形で、Cognitoが発行したユーザーのID(sub、サブと読みます)が入っていることが分かりました。
これを取り出すミドルウェア(通信の途中に挟む処理)を1つ追加します。
// packages/bookshelf-api/src/middleware/identity.ts
return t.procedure.use(async (opts) => {
const cognitoAuthenticationProvider =
opts.ctx.event.requestContext?.identity?.cognitoAuthenticationProvider;
let sub: string | undefined = undefined;
if (cognitoAuthenticationProvider) {
const providerParts = cognitoAuthenticationProvider.split(':');
sub = providerParts[providerParts.length - 1];
}
if (!sub) {
throw new TRPCError({ code: 'FORBIDDEN', message: `Unable to determine calling user` });
}
const { Users } = await cognito.listUsers({
UserPoolId: process.env.USER_POOL_ID!,
Limit: 1,
Filter: `sub="${sub}"`,
});
// ...
});
Filterの中身を文字列のつなぎ合わせで作っているので、一見「外から変な値を送り込めるのでは」と心配になるかもしれません。
ですが、このsubはAWS側がSigV4の署名チェックを通したあとにセットする値で、リクエストする人が自由に書き換えられる場所ではありません。
外からの入力をそのままフィルタに渡しているわけではない、という前提つきの実装だと理解しておいてください。
こうして取り出したsubを、本を作る・変える・消す処理のそれぞれでownerIdとして使います。
CDKスタックに組み込む
packages/infra/src/stacks/application-stack.tsというファイルに、ここまで作った部品を組み立てるコードを書きます。
const identity = new UserIdentity(this, 'Identity');
const table = new BookshelfTable(this, 'BookshelfTable', {
deletionProtection: false,
removalPolicy: RemovalPolicy.DESTROY,
});
const api = new BookshelfApi(this, 'Api', {
integrations: BookshelfApi.defaultIntegrations(this).build(),
});
api.grantInvokeAccess(identity.identityPool.authenticatedRole);
Object.values(api.integrations).forEach((integration) => {
table.grantReadWriteData(integration.handler);
integration.handler.addEnvironment('USER_POOL_ID', identity.userPool.userPoolId);
identity.userPool.grant(integration.handler, 'cognito-idp:ListUsers');
});
new BookshelfWebsite(this, 'BookshelfWebsite');
DynamoDBのテーブルは、何もしないと「削除保護」が有効になっています。
これは、間違って消せないようにする安全装置です。
この記事では最後にテーブルごと壊す前提なので、あえてdeletionProtection: falseにして安全装置を外しています。
本番で使うアプリを真似るときは、ここは外さないでください。
ビルドしてAWSにデプロイする
npx pnpm@latest build
「ワークスペースが同期されていません」というメッセージが出たら、案内どおりnx syncというコマンドを挟んでから、もう一度ビルドしてください。
npx pnpm@latest nx sync
npx pnpm@latest build
ビルドが通ったら、いよいよAWSへのデプロイです。
npx pnpm@latest nx bootstrap infra
npx pnpm@latest nx deploy-sandbox infra
「bootstrap」は、デプロイに必要な準備をAWSアカウント側に整えるコマンドです。
すでに準備済みなら数秒で終わります。
「deploy-sandbox」が実際にAWSへ配置する本番のコマンドで、目安として5分前後かかります(手元では、ap-northeast-1という東京リージョンのスタックが264秒、ウェブサイトの保護に使うus-east-1(バージニア北部)のスタックが31秒でした)。
デプロイが終わると、以下のようなAWSの部品ができあがっています。
- API Gateway(外からのリクエストを受け取る窓口)とLambda(サーバーを用意しなくても動くプログラム) 4つ分(本の作成・一覧・状態変更・削除)
- DynamoDBテーブル(データベース本体、暗号化つき)
- CloudFront(世界中に配信するための仕組み)とS3(ファイルを置く倉庫) 2つ
- Cognito UserPool(ユーザー台帳)とIdentityPool(一時的なアクセス権を発行する係)
- WAF(不正なアクセスをブロックする壁) 2つ
「フルスタックアプリケーションを数日ではなく数分で」という謳い文句どおり、コマンドを打ち始めてからここまで、実質10分もかかっていません。
Cognitoでユーザーを作ってログインする
このアプリは、誰でも自由に会員登録できる設定にはなっていません。
AWS CLIを使って、自分でテスト用のユーザーを作ります。
--user-pool-idは、デプロイのときにターミナルへ表示された値、または後述のコマンドで取得できる値に置き換えてください。
aws cognito-idp admin-create-user \
--user-pool-id <デプロイで出力されたUser Pool ID> \
--username bookshelf-test-user \
--user-attributes Name=email,Value=testuser@example.com Name=email_verified,Value=true \
--temporary-password '<好きな仮パスワード>' \
--message-action SUPPRESS
続けてnx load-runtime-config bookshelf-websiteというコマンドで、アプリのURL(CloudFrontのアドレス)を確認し、ブラウザで開きます。
Cognitoのログイン画面が出てくるので、さきほど作ったユーザー名とパスワードを入力してください。仮のパスワードなので、そのまま新しいパスワードの設定を求められます。
ここで出てくるのがMFAの設定画面です。
このユーザープールは、何もしないとMFAが必須になっていて、パスワード変更の直後にMFAの設定を求められます。
SMSか、認証アプリのどちらかを選べます。
受け取れる電話番号がなければ、認証アプリを選んでください。
画面に出てくる「シークレットキー」という文字列を、スマートフォンの認証アプリ(Google Authenticator等)に手入力すれば、QRコードを読み込まなくても登録できます。
コードを入力してログインが通ると、アプリの画面が表示されます。
本を登録してみる
タイトルと著者を入力して「追加」ボタンを押すと、一覧に本が出てきます。
状態を選ぶメニューを「読書中」に変えて、ページを読み込み直しても、ちゃんと「読書中」のまま残っているはずです。
ブラウザからtRPC経由でLambdaが呼ばれ、DynamoDBへの書き込みと読み出しが、実際に一往復していることになります。
どんな構成になってるの?
作った構成は以下のようになっています。
デプロイして初めて分かること
一番の落とし穴は、後片付けでした。
nx destroy-sandboxというコマンドで、CDKで作った部品はきれいに消えます。
ですが、Cognitoのユーザープールだけは、AWSアカウントに残り続けます。
npx pnpm@latest nx destroy-sandbox infra --force
公式ドキュメントには「Cognitoユーザープールは削除保護が有効になった状態で作成されるため、自動的には削除されません」とだけ書かれています。
実際にやってみると、削除保護を外す一手間が、そのままでは通らないことに気づきます。
aws cognito-idp update-user-pool \
--user-pool-id <User Pool ID> \
--deletion-protection INACTIVE
これをそのまま実行すると、設定の組み合わせが合わないというエラーで弾かれます。
ユーザープールの設定を変えるコマンドは、一部分だけの上書きではなく、設定全体を丸ごと渡し直す仕組みになっているからです。
先にdescribe-user-poolというコマンドで今のSMSの設定を確認し、同じ値を渡し直してください。
aws cognito-idp describe-user-pool --user-pool-id <User Pool ID> \
--query "UserPool.SmsConfiguration"
aws cognito-idp update-user-pool \
--user-pool-id <User Pool ID> \
--auto-verified-attributes email phone_number \
--sms-configuration SnsCallerArn=<上で確認したARN>,ExternalId=<同上>,SnsRegion=<同上> \
--deletion-protection INACTIVE
aws cognito-idp delete-user-pool --user-pool-id <User Pool ID>
さらに、削除したあとに、CognitoがSMSを送るために使っていたIAMロール(権限のかたまり)が、孤立して残っていないかも確認してください。
残っていた場合は、ロールにくっついているポリシー(権限の設定)を先に削除してから、ロール自体を削除します。
aws iam list-role-policies --role-name <孤立したロール名>
aws iam delete-role-policy --role-name <ロール名> --policy-name <ポリシー名>
aws iam delete-role --role-name <ロール名>
DynamoDBのテーブルのようにdeletionProtection: falseの1行を足せば済む話ではなく、Cognito周りの後片付けは、手動でひと通り追いかける必要がありそうです。
完成したら
完成したら以下のような画面がでてきます。
後片付けの最終確認
最後に、CloudFormationのスタック、S3のバケット、DynamoDBのテーブル、CognitoのUserPool、IAMロールが残っていないか、コマンドで確認してください。
aws cloudformation list-stacks --query "StackSummaries[?contains(StackName, 'bookshelf') && StackStatus != 'DELETE_COMPLETE']"
aws dynamodb list-tables --query "TableNames" --output text | tr '\t' '\n' | grep -i bookshelf
aws cognito-idp list-user-pools --max-results 20 --query "UserPools[].Name"
何も出てこない、または関係のないものだけになっていれば、後片付けは完了です。
短命な検証環境として、一通り作って壊すところまでやってみると、公式ドキュメントの手順だけでは見えない部分がいくつもあることが分かります。
この記事を最後までやり終えたら、次はDynamoDBの検索用インデックスを増やして本を探せるようにするなど、自分なりのアレンジを試してみてください。
最後まで読んでいただきありがとうございました。

