0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Blocksのサンプルアプリで、ローカルとAWS環境の動作を確認してみた

0
Posted at

2026年6月にパブリックプレビューになったAWS Blocksを初めて触ってみました。セットアップから起動までは手短に、この記事ではテンプレートから生成されたサンプルアプリを通じて、ローカル開発でどこまで本番に近い動作をするのか確認してみます。

検証バージョン: @aws-blocks/create-blocks-app@0.1.13 / @aws-blocks/blocks@0.2.0(2026年7月時点)

AWS Blocksとは

TypeScriptで書いたバックエンドコードがそのままインフラ定義になる、オープンソースのバックエンドツールキットです。
DistributedTable のようなBlockをインスタンス化すると、ローカルではインメモリ実装、デプロイするとDynamoDBになります。AWSアカウントなしでローカル開発を始められます。

とりあえず動かす

% npm create @aws-blocks/blocks-app@latest my-todo-app
% cd my-todo-app
% npm run dev

実際の出力から抜粋:

Creating Blocks app in ./my-todo-app...
Installing dependencies...

added 292 packages, and audited 313 packages

✓ Blocks app created!

Next steps:
  cd my-todo-app
  npm run dev

Then open http://localhost:3000

See README.md for an overview and AGENTS.md for AI agent instructions.

AWS Blocks collects anonymous usage data to improve the product.
No customer content or PII is collected.
To disable: npx blocks-telemetry --disable (or export AWS_BLOCKS_DISABLE_TELEMETRY=1)
Loading backend...
Deploying local resources...
AWS Blocks local server running on http://localhost:3000

ここまではAWSの認証情報は一度も要求されません。 http://localhost:3000 を開くと認証付きのTodoアプリが動いています。

image.png

image.png

生成されたサンプルアプリの中身

生成された aws-blocks/index.ts(バックエンド兼インフラ定義)のデータ部分を抜粋します。

import { Scope, DistributedTable } from '@aws-blocks/blocks';
import { z } from 'zod';

const scope = new Scope('my-app');

// Zod schema = runtime validation + TypeScript types + DynamoDB table shape.
const todoSchema = z.object({
  userId: z.string(),       // partition key — per-user isolation
  todoId: z.string(),       // sort key — unique within a user
  title: z.string(),
  completed: z.boolean(),
  priority: z.number(),
  version: z.number(),      // optimistic locking — incremented on each update
  createdAt: z.number(),
});

const todos = new DistributedTable(scope, 'todos', {
  schema: todoSchema,
  key: { partitionKey: 'userId', sortKey: 'todoId' },
  indexes: {
    byPriority: { partitionKey: 'userId', sortKey: 'priority' },
    byTitle:    { partitionKey: 'userId', sortKey: 'title' },
  },
});

コードの中身は…

  1. Zodスキーマで定義し、バリデーション・型・テーブル形状が一度に決まる
  2. セカンダリインデックスが最初から定義されている
  3. version フィールドと条件付き書き込みによる楽観ロックが実装済み
/**
 * Toggle todo completion with optimistic locking.
 * Uses `ifFieldEquals` to detect concurrent writes. On conflict,
 * throws ConditionalCheckFailedException — caller should re-read and retry.
 */
async toggleTodo(todoId: string) {
  const user = await auth.requireAuth(context);
  const todo = await todos.get({ userId: user.username, todoId });
  if (!todo) throw new Error('Todo not found');
  await todos.put(
    { ...todo, completed: !todo.completed, version: todo.version + 1 },
    { ifFieldEquals: { version: todo.version } },  // 読んだ時のversionと一致する時だけ書く
  );
}

テンプレートのこのメソッドにはコメントが添えられていて、「ifFieldEquals で並行書き込みを検出する。競合時は ConditionalCheckFailedException が投げられるので、呼び出し側は読み直してリトライすべし」という趣旨の説明が書かれています。DynamoDBの条件付き書き込み(ConditionExpression)と同じニュアンス。

余談ですが、公式チュートリアルと生成コードに若干差異がありました。
チュートリアルのコード例はキー指定が key: { partition: 'userId', sort: 'id' } ですが、実際に生成されたコード(0.2.0)は key: { partitionKey: 'userId', sortKey: 'todoId' }
クリーンアップのコマンドも、チュートリアルは npm run sandbox -- --destroy、テンプレートのpackage.jsonに定義されているのは npm run sandbox:destroy
プレビュー期間中なので、生成されたコードの方をよく確認するとよいかもしれません。

動作確認

このサンプルアプリのローカル実装で、楽観ロックがどう動くのか確認してみます。
公式ドキュメントによれば、ローカルではインメモリ実装、AWS環境では実際のDynamoDBになるとのこと。条件付き書き込みの動作を、並行アクセスまで含めてローカルで確認できるのか。テンプレートに実装が揃っているので、これを使って試してみることにしました。
また、セカンダリインデックスの動作も確認してみます。

確認準備

生成されたプロジェクトの AGENTS.md を見ると、「The JSON-RPC transport is invisible」と書いてあります。通信はJSON-RPC 2.0で、単一エンドポイント /aws-blocks/api にPOSTする形式のようです。

同じく AGENTS.md には「Do NOT use curl/fetch against the API unless troubleshooting connectivity」ともありますが、今回はセカンダリインデックスや楽観ロックを確認するため、各確認セクションにあるClaudeに作ってもらったスクリプトでJSON-RPCを直接叩きます。

確認1: セカンダリインデックスのクエリ

確認内容:
priority 1(AWS Blocksの記事を書く)→ 3(ゴミ出し)→ 2(買い物)の順で登録し、byPriority インデックスでクエリします。

await Array.fromAsync(
  todos.query({ index: 'byPriority', where: { userId: { equals: user.username } } })
);
確認スクリプト(test-gsi.mjs)
// test-gsi.mjs — GSI確認(node test-gsi.mjs で実行)
const url = "http://localhost:3000/aws-blocks/api";
let cookie = "";

async function rpc(method, params) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) },
    body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }),
  });
  const sc = res.headers.getSetCookie?.() ?? [];
  if (sc.length) cookie = sc.map(c => c.split(";")[0]).join("; ");
  return res.json();
}

// サインアップ
await rpc("authApi.setAuthState", [{ action: "signUp", username: "tester", password: "P@ssw0rd123" }]);

// Todoを登録順に作成(priority: 1 → 3 → 2)
await rpc("api.createTodo", ["AWS Blocksの記事を書く", 1]);
await rpc("api.createTodo", ["ゴミ出し", 3]);
await rpc("api.createTodo", ["買い物", 2]);

// 全Todo取得(デフォルト: todoId順)
const defaultList = (await rpc("api.listTodos", [])).result;
console.log("デフォルト順:", defaultList.map(t => `[P${t.priority}] ${t.title}`));

// byPriorityでクエリ
const byPriority = (await rpc("api.listTodos", ["priority"])).result;
console.log("priority順:", byPriority.map(t => `[P${t.priority}] ${t.title}`));

実行結果:

% node test-gsi.mjs 
デフォルト順: [ '[P1] AWS Blocksの記事を書く', '[P3] ゴミ出し', '[P2] 買い物' ]
priority順: [ '[P1] AWS Blocksの記事を書く', '[P2] 買い物', '[P3] ゴミ出し' ]

priorityを指定すると、登録順ではなく優先度順で返ってきました。

確認2: 楽観ロックの動作確認(ローカル環境)

ローカル環境で楽観ロックが正しく機能するか確認します。同じTodoに対して toggleTodo を10回同時に実行してみます。

各リクエストは以下の処理を行います:

  1. 現在のTodoを読み取り(version付き)
  2. completedを反転
  3. versionを+1してDynamoDBに条件付き書き込み(元のversionと一致する場合のみ成功)

もし複数のリクエストが同じversionを読み取った場合、1つ目は成功しますが、2つ目以降は条件不一致でエラーになるはずです。

確認スクリプト(test-race.mjs)
// test-race.mjs — 楽観ロック競合確認(node test-race.mjs で実行)
const url = "http://localhost:3000/aws-blocks/api"; // sandboxではエンドポイントを差し替え
let cookie = "";

async function rpc(method, params) {
  const res = await fetch(url, {
    method: "POST",
    headers: { "content-type": "application/json", ...(cookie ? { cookie } : {}) },
    body: JSON.stringify({ jsonrpc: "2.0", method, params, id: 1 }),
  });
  const sc = res.headers.getSetCookie?.() ?? [];
  if (sc.length) cookie = sc.map(c => c.split(";")[0]).join("; ");
  return res.json();
}

await rpc("authApi.setAuthState", [{ action: "signUp", username: "racer", password: "P@ssw0rd123" }]);
const todo = (await rpc("api.createTodo", ["競合テスト", 2])).result;

const results = await Promise.all(
  Array.from({ length: 10 }, () => rpc("api.toggleTodo", [todo.todoId]))
);
const ok = results.filter(r => r.result?.success).length;
const ng = results.filter(r => r.error);
console.log(`成功=${ok}, 失敗=${ng.length}`);
if (ng[0]) console.log("エラー例:", JSON.stringify(ng[0].error));

確認スクリプトでは以下のコードで10回のtoggleを実行しています:

const results = await Promise.all(
  Array.from({ length: 10 }, () => rpc("api.toggleTodo", [todo.todoId]))
);

10個のHTTPリクエストがほぼ同時にネットワークに送信されますが、サーバー側で複数リクエストが並列処理されるかどうかは、サーバーの実装次第のはずです。

実行結果:

% node test-race.mjs
成功=10, 失敗=0

すべて成功しました。データを確認すると、versionは1から11に増えており、10回の更新が正しく記録されています。
ローカル開発サーバーでは、複数のリクエストが順番に処理されており、競合が発生しなかったようです。

この結果から下記が予想されます:

  • ローカル環境では複数リクエストが実質的に順次処理されるため、競合状態を再現できない
  • 本番環境のLambdaでは複数の関数がに並列実行されるため、結果が異なる可能性がある

公式ドキュメントには以下のように記載されています:

Sandboxes are useful when you need to test behavior that differs between local implementations and real services, such as DynamoDB query performance or IAM permission boundaries.

(出典: AWS Blocks concepts - Sandbox deployments

まさにこのケースです。

ローカルデータの実体

ちなみにローカルのデータは .bb-data/<Scope名>-<Block ID>/data.json にただのJSONとして保存されています。

[
  [
    "[\"tester-race\",\"mrxmmex5-4vgmwi\"]",
    {
      "userId": "tester-race",
      "todoId": "mrxmmex5-4vgmwi",
      "title": "競合テスト",
      "completed": false,
      "priority": 2,
      "version": 11,
      "createdAt": 1784818114313
    }
  ]
]

確認3: sandbox(DynamoDB)で確認する

実際のDynamoDB環境ではどうなるのか確認します。
npm run sandbox でsandboxにデプロイし、テストを実行しました。

確認手順:

  1. npx cdk bootstrap 実行(初回のみ)
  2. npm run sandbox でデプロイ
  3. デプロイ完了後に表示されるエンドポイントURLを確認
  4. test-race.mjsurl をsandboxエンドポイントに変更
  5. node test-race.mjs で10並列テスト実行
  6. 終了後 npm run sandbox:destroy でクリーンアップ

実行結果:

% node test-race.mjs
成功=8, 失敗=2
エラー例: {"code":500,"message":"The conditional request failed","data":{"name":"ConditionalCheckFailedException"}}

image.png

sandbox環境では10回中2回が ConditionalCheckFailedException で失敗しています。
これは、sandbox環境だと複数のLambda関数が並列実行され、同じversionを読み取ったリクエスト同士が競合したためです。楽観ロックが期待通りに機能し、条件不一致のリクエストを正しく拒否しています。

これで、ローカルと本番で挙動が変わる境界が確認できました。
本番環境では、ConditionalCheckFailedException をキャッチしてリトライする処理が必要になります。

確認4: ローカル環境のRealtime機能

テンプレートの src/index.ts のRealtimeに関する部分に以下のコメントがあります:

// Realtime: listen for changes from other tabs/users
(async () => {
  try {
    const channel = await api.subscribeTodos();
    const sub = channel.subscribe(() => load());
    await sub.established;
  } catch { /* realtime not available in local dev */ }
})();

コメントには「ローカル開発では利用不可」とありますが、実際にローカル環境の動作を確認してみました。

realtime.gif

このように、確認したところローカルでもリアルタイム同期が動作していました。

node_modules/@aws-blocks/blocks/docs/bb-realtime.md には以下の記載があります:

In local dev (npm run dev), Realtime uses an in-process EventEmitter with a WebSocket bridge to browser clients.

つまり:

  • AWS環境: API Gateway WebSocket + DynamoDB
  • ローカル環境: EventEmitter + WebSocket bridge

実装方式は異なりますが、ローカルでもWebSocketは動作し、複数クライアント間のリアルタイム同期が機能しました。

まとめ

  • AWS BlocksはAWSアカウントなしで、ローカル開発ができる
  • 生成されるテンプレート自体がセカンダリインデックス・楽観ロック・リアルタイム同期を備えている
  • ローカル環境では並行処理による競合が再現されない: 楽観ロックの10並列テストで競合ゼロ(順次処理される)
  • sandboxや本番環境では並行処理による競合が再現される: 同じテストで10回中2回が ConditionalCheckFailedException で失敗
  • ローカルとsandboxで挙動が変わる境界を実証できた。並行制御コードはsandbox環境での確認が必須
  • Realtime機能も、異なる実装(EventEmitter vs AWS WebSocket)ながらローカルで動作する

参考リンク

0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?