この記事は「2026 Japan AWS Jr. Champions 真夏のQiitaリレー」の26日目の記事となります。
過去の投稿(リンク集)は以下リンクからご覧ください。
はじめに
AWSの勉強をするとき、テキストを読んだり問題を解いたりするだけだと、なかなか頭に入ってこないことってありませんか?
私も同じで、実際の使用例を見たり、コンソールで直接触ってみたりした方が圧倒的に理解しやすいと感じています。
そこで今回は、AWSの各種サービスへの理解を深めることを目的に、バックエンドにAWSを使った簡単なお絵描きアプリを作ってみました!
使用したサービスは DynamoDB / API Gateway / Lambda / S3 / CloudFront / IAM の6つです。
「これからAWSを勉強したい!」「実際に手を動かしてアプリを作ってみたい!」という方の参考になれば嬉しいです。
作ったもの
アプリの概要
今回作成したのは、 AWSのサーバーレス構成とReactを活用した「ブラウザ上で複数人が同時にお絵描きできるリアルタイム共有お絵描きアプリ」 です。
機能・仕様
-
🎨 リアルタイムお絵描き同期
- Canvas上でマウスで描いた線が、接続中の全ユーザーの画面へリアルタイムで反映されます。
- 本アプリはルーム分け機能は実装していないため、アクセスした全ユーザーが1つの共通キャンバス(デフォルトルーム)に接続され、同じ画面を共有します。
-
📜 描画履歴の自動復元(過去ログ表示)
- 接続(または画面の再読み込み)時に、過去に描かれたすべての線のデータをDynamoDBから取得し、キャンバス上へ自動で復元・表示します。
- 途中参加のユーザーでも、それまでに描かれた内容を欠落なく復元して確認できます。
アーキテクチャ図
[ ユーザー (ブラウザ) ]
│
├─ (1) 静的コンテンツの配信 (HTTPS)
│ │
│ ▼
│ [ CloudFront (CDN) ] ──► [ S3 Bucket (React / Vite 静的ファイル) ]
│
└─ (2) リアルタイム双方向通信 (WSS)
│
▼
[ API Gateway (WebSocket API) ]
│
▼
[ Lambda Function (Node.js) ]
│
├─ (接続管理 & GSI検索) ────► [ DynamoDB: drawing-app-connections ]
│ (接続ID・参加ルームの管理 / RoomIndex)
│
└─ (描画履歴の保存/取得) ──► [ DynamoDB: drawing-app-history ]
(描画ストロークデータ / タイムスタンプ順)
アプリ画面
1. 初期画面
アプリ起動時の初期画面です。
2. 描画機能
キャンバス上でマウス操作を行うことで、自由に線を描画できます。
3. 描画履歴の保持・復元(永続化)
描画データ(座標履歴)はバックエンド側で保持されており、画面をリロードしても過去の描画内容が再描画されます。
4. リアルタイム同期(複数ブラウザ間共有)
あるユーザーが描画した線が、WebSocket通信を経由して別ブラウザのキャンバス上にも即座に同期・反映されます。
補足(留意点)
リアルタイム性能を優先しているため、高速な連続描画やネットワークの瞬断が発生した場合、一部の描画イベントがサーバーへ到達せず、リロード後に一部の線が完全に復元されない場合があります。
実装方法の紹介
DynamoDBテーブル作成
DynamoDBはサーバー管理が不要で超高速に動作するAWSのデータベース
接続管理テーブル
現在アプリ(WebSocket)に接続しているユーザーのIDを一覧で管理するテーブル
誰かが線を描いた時に「今部屋にいる他の誰にデータを送ればいいか」を特定するために接続情報を保存・参照
- テーブルを作成する
- DynamoDB コンソールで [テーブルの作成] をクリック
- テーブル名:
drawing-app-connections - パーティションキー:
connection_id(文字列) - [テーブルの作成] をクリック
- グローバルセカンダリインデックス (GSI)を作成する
- 該当テーブルの [インデックス] タブ ➔ [インデックスの作成] をクリック
- インデックス名:
RoomIndex - パーティションキー:
room_id(文字列) - 属性の投影:
キーのみ - [インデックスの作成] をクリック
描画履歴テーブル
キャンバスに描かれた線のデータ(座標、描画時刻)を保存するテーブル
途中から部屋に入ってきたユーザーや、ブラウザを再読み込みしたユーザーに「過去に描かれた線」を復元して表示するために使う
- テーブルを作成する
- DynamoDB コンソールで [テーブルの作成] をクリック
- テーブル名:
drawing-app-history - パーティションキー:
room_id(文字列) - ソートキー:
timestamp(数値) - [テーブルの作成] をクリック
Lambda用IAMロール作成
これから作成するLambda関数が「DynamoDBへの読み書き」や「API Gatewayを通じたユーザーへのメッセージ送信」を行えるようにするためのロール
- IAM コンソール ➔ [ロール] ➔ [ロールを作成]
- 信頼されたエンティティを選択の画面で AWS のサービス ➔ Lambda を選択
- 許可を追加画面で、以下のポリシー(または同等の権限)をアタッチ
AWSLambdaBasicExecutionRole-
AmazonDynamoDBFullAccess(※「セキュリティに関する補足」を参照)
- ロール名(例:
drawing-app-lambda-role)を入力して [ロールを作成] をクリック - ロール作成後に、インラインポリシーを作成・追加(API Gateway 接続管理権限):
- ポリシー名:
ApiGatewayManageConnectionsPolicy - サービス:
ExecuteAPI - アクション:
ManageConnections - リソース:
すべてのリソース(※最小権限に絞りたい場合は、API Gateway作成後、arn:aws:execute-api:<リージョン>:<12桁のアカウントID>:<API ID>/<ステージ名>/POST/@connections/*に書き換える)
- ポリシー名:
セキュリティに関する補足
今回は構築のしやすさを優先して AmazonDynamoDBFullAccess を付与していますが、本番運用やセキュリティを意識する場合は、対象リソースとアクションを最小権限に絞ったインラインポリシーを作成して、アタッチするのがベストプラクティスです。
💡 DynamoDBの最小権限ポリシー例(JSON)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowDynamoDBActionsOnDrawingAppTables",
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:DeleteItem",
"dynamodb:Query"
],
"Resource": [
"arn:aws:dynamodb:<リージョン>:<12桁のアカウントID>:table/drawing-app-connections",
"arn:aws:dynamodb:<リージョン>:<12桁のアカウントID>:table/drawing-app-connections/index/RoomIndex",
"arn:aws:dynamodb:<リージョン>:<12桁のアカウントID>:table/drawing-app-history"
]
}
]
}
Lambda関数作成
アプリの「バックエンド処理」を担当するプログラムを実行する場所
サーバーの構築や管理を一切行わずにコードを実行
ユーザーが接続した時、切断した時、線を描いた時、過去ログを要求した時に、コード(index.mjs)が分岐して処理
- Lambda コンソール ➔ [関数を作成]
- 関数名:
drawing-app-backend - ランタイム:
Node.js 24.x - カスタム実行ロール: 「既存のロールを使用する」を選択し、作成したLambda用IAMロール を指定
- 関数名:
- [関数の作成] をクリック
- 作成した関数の [設定] → [一般設定] からメモリを
512MBに変更する - Lambda コード(
index.mjs)を貼り付けて [Deploy] を押す
index.mjs
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
DeleteCommand,
QueryCommand,
} from "@aws-sdk/lib-dynamodb";
import {
ApiGatewayManagementApiClient,
PostToConnectionCommand,
} from "@aws-sdk/client-apigatewaymanagementapi";
// -----------------------------------------------------------------
// 1. クライアントの初期化(コンテナ再利用により実行速度を最適化)
// -----------------------------------------------------------------
const client = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(client, {
marshallOptions: { removeUndefinedValues: true },
});
// テーブル名の定義
const CONNECTIONS_TABLE = "drawing-app-connections";
const HISTORY_TABLE = "drawing-app-history";
// API Gateway 管理用クライアントの保持用変数(リクエスト時の URL から動的初期化)
let apiGwClient = null;
// -----------------------------------------------------------------
// 2. メインハンドラー関数
// -----------------------------------------------------------------
export const handler = async (event) => {
const { routeKey, connectionId, domainName, stage } = event.requestContext || {};
// 初回リクエスト時、または未初期化の場合のみ送信用クライアントを生成
if (!apiGwClient && domainName && stage) {
const callbackUrl = `https://${domainName}/${stage}`;
apiGwClient = new ApiGatewayManagementApiClient({ endpoint: callbackUrl });
}
// ログ出力(CloudWatch での追跡・障害調査用)
console.log(`[ROUTE]: ${routeKey}, [CONNECTION_ID]: ${connectionId}`);
try {
// ---------------------------------------------------------------
// ルート 1: $connect(WebSocket 接続確立時)
// ---------------------------------------------------------------
if (routeKey === "$connect") {
const roomId = event.queryStringParameters?.roomId || "default-room";
// 接続管理テーブルへ 接続ID と ルームID を保存
await docClient.send(
new PutCommand({
TableName: CONNECTIONS_TABLE,
Item: { connection_id: connectionId, room_id: roomId },
})
);
return { statusCode: 200, body: "Connected" };
}
// ---------------------------------------------------------------
// ルート 2: $disconnect(WebSocket 切断時)
// ---------------------------------------------------------------
if (routeKey === "$disconnect") {
// 接続管理テーブルから対象の接続IDを削除
await docClient.send(
new DeleteCommand({
TableName: CONNECTIONS_TABLE,
Key: { connection_id: connectionId },
})
);
return { statusCode: 200, body: "Disconnected" };
}
// 受信ボディのパースと共通データ(roomId)の抽出
const body = event.body ? JSON.parse(event.body) : {};
const roomId = body.roomId || "default-room";
// ---------------------------------------------------------------
// ルート 3: getHistory(過去の描画履歴要求時)
// ---------------------------------------------------------------
if (routeKey === "getHistory") {
// DynamoDB から対象ルームの描画履歴を時間順で取得
const historyResult = await docClient.send(
new QueryCommand({
TableName: HISTORY_TABLE,
KeyConditionExpression: "room_id = :r",
ExpressionAttributeValues: { ":r": roomId },
ScanIndexForward: true, // 古い順に取得
})
);
// ストロークデータのみ抽出
const strokes = (historyResult.Items || []).map((item) => item.stroke);
// 履歴が存在し、クライアントが有効な場合、リクエスト元へ送信
if (strokes.length > 0 && apiGwClient) {
await apiGwClient.send(
new PostToConnectionCommand({
ConnectionId: connectionId,
Data: Buffer.from(JSON.stringify({ action: "history", strokes })),
})
);
}
return { statusCode: 200, body: "History sent" };
}
// ---------------------------------------------------------------
// ルート 4: draw(線を描画した時・メイン同期処理)
// ---------------------------------------------------------------
if (routeKey === "draw") {
// 1. 描画ストロークを履歴テーブルへ保存
if (body.stroke) {
await docClient.send(
new PutCommand({
TableName: HISTORY_TABLE,
Item: {
room_id: roomId,
timestamp: Date.now(),
stroke: body.stroke,
},
})
);
}
// 2. GSI (RoomIndex) を使い、同じルームの全接続IDを取得
const roomMembers = await docClient.send(
new QueryCommand({
TableName: CONNECTIONS_TABLE,
IndexName: "RoomIndex",
KeyConditionExpression: "room_id = :r",
ExpressionAttributeValues: { ":r": roomId },
})
);
// 3. 自分以外の接続へメッセージをブロードキャスト(並列配信)
if (apiGwClient) {
const sendPromises = (roomMembers.Items || [])
.filter((m) => m.connection_id !== connectionId)
.map(async (m) => {
try {
await apiGwClient.send(
new PostToConnectionCommand({
ConnectionId: m.connection_id,
Data: Buffer.from(JSON.stringify(body)),
})
);
} catch (err) {
// 接続切れ(410 Gone / GoneException)のIDを自動削除(クリーニング)
if (err.name === "GoneException" || err.$metadata?.httpStatusCode === 410) {
await docClient.send(
new DeleteCommand({
TableName: CONNECTIONS_TABLE,
Key: { connection_id: m.connection_id },
})
);
}
}
});
// 全宛先への送信完了を待機
await Promise.all(sendPromises);
}
return { statusCode: 200, body: "Sent" };
}
// 定義外のルーティングエラー処理
return { statusCode: 400, body: "Unknown route" };
} catch (err) {
// 予期せぬ障害時の詳細ログ出力
console.error("FATAL HANDLER ERROR:", err);
return { statusCode: 500, body: err.message };
}
};
API Gatewayの作成・設定
ブラウザ(フロントエンド)とLambda(バックエンド)をつなぐ「通信の受付窓口」
通常のHTTP通信(1回送って1回返ってくる)とは異なり、WebSocket という規格を使うことで、サーバーとブラウザが常に双方向にリアルタイム通信(繋ぎっぱなし)をする
API作成
接続要求(
$connect)、切断($disconnect)、描画(draw)、履歴取得(getHistory)というユーザーの行動(ルート)に応じて、どのLambda関数を呼び出すかをマッピング(紐付け)する
- API Gateway コンソール ➔ [API を作成] ➔ WebSocket API の [構築] をクリック
- API 名:
drawing-app-api - ルート選択式 (Route Selection Expression):
$request.body.action - ルートの追加画面で以下を設定:
- 既定のルート:
$connectと$disconnectを追加 - カスタムルートの追加: ルートキーとして以下の2つを追加
drawgetHistory
- 既定のルート:
- 統合のアタッチ画面で、4 つのルート(
$connect,$disconnect,draw,getHistory)すべてに Lambda 関数 (drawing-app-backend) を割り当てる - ステージ名(例:
production)を指定して作成
デプロイ
設定したAPIをインターネット上に公開(有効化)し、実際に接続するためのURL(
wss://...)を発する
- API作成後、左メニューの [ルート] を開き、
$connect,$disconnect,draw,getHistoryの 4 つが存在することを確認 - 画面右上の [API をデプロイ] をクリックし、ステージ(
production)を選択してデプロイ - ステージ 画面を開き、WebSocket URL(
wss://...)をコピー
ローカルでのアプリの準備・動作確認・ビルド
ローカルで画面(React)を準備し、API Gateway(WebSocket)と通信テストを行う
問題なくリアルタイム描画ができたら、インターネット上に配信するための静的ファイル(HTML/JS)に変換(ビルド)する
プロジェクトの作成
ターミナル(またはコマンドプロンプト)を開き、以下のコマンドを順に実行する
# Vite + React のプロジェクトを作成
npm create vite@latest drawing-app -- --template react
# 作成したディレクトリに移動
cd drawing-app
# 依存パッケージのインストール
npm install
コードの配置
作成された drawing-app フォルダ内の src/App.jsx を開き、中身をすべて消去して App.jsx のコードを貼り付ける
注意:
App.jsx内のWEBSOCKET_URLは、作成した API Gateway の URL(wss://...)に書き換える
App.jsx
import React, { useEffect, useRef, useState } from "react";
// WebSocket サーバーの接続先エンドポイント URL
const WEBSOCKET_URL = "<WebSocket サーバーの接続先エンドポイント URL>";
export default function App() {
// -----------------------------------------------------------------
// 1. DOM参照 & 状態管理 (Refs & State)
// -----------------------------------------------------------------
const canvasRef = useRef(null); // <canvas> 要素への直接アクセス用
const socketRef = useRef(null); // WebSocket インスタンス保持用(再レンダリング対策)
const isDrawing = useRef(false); // マウスドラッグ中(描画中)かどうかを示すフラグ
const lastPoint = useRef({ x: 0, y: 0 }); // 直前のカーソル座標 { x, y }
const [isConnected, setIsConnected] = useState(false); // UI表示用の接続ステータス
// -----------------------------------------------------------------
// 2. 描画処理 & WebSocketデータ送信
// -----------------------------------------------------------------
/**
* キャンバスに描画を行い、必要に応じてWebSocket経由でサーバーへ送信する関数
* @param {Object} prev - 描画開始点 { x, y }
* @param {Object} curr - 描画終了点 { x, y }
* @param {string} color - 線の色(標準: 黒)
* @param {boolean} emit - true の場合のみサーバーへ送信(自分が描いた線の判定用)
*/
const drawLine = (prev, curr, color = "#000000", emit = true) => {
const canvas = canvasRef.current;
if (!canvas || !prev || !curr) return;
const ctx = canvas.getContext("2d");
// キャンバスへの線描画設定と実行
ctx.beginPath();
Object.assign(ctx, { strokeStyle: color, lineWidth: 3, lineCap: "round", lineJoin: "round" });
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(curr.x, curr.y);
ctx.stroke();
// 自分の操作による描画かつ通信が接続中の場合、サーバーへ描画データをブロードキャスト要求
if (emit && socketRef.current?.readyState === WebSocket.OPEN) {
socketRef.current.send(
JSON.stringify({ action: "draw", stroke: { prev, curr, color }, roomId: "default-room" })
);
}
};
// -----------------------------------------------------------------
// 3. WebSocket ライフサイクル管理
// -----------------------------------------------------------------
useEffect(() => {
const ws = new WebSocket(WEBSOCKET_URL);
socketRef.current = ws;
// A. 接続確立時:ステータスを更新し、過去の描画履歴を取得要求
ws.onopen = () => {
setIsConnected(true);
ws.send(JSON.stringify({ action: "getHistory", roomId: "default-room" }));
};
// B. 切断時:ステータスを未接続に更新
ws.onclose = () => setIsConnected(false);
// C. エラー発生時:ログ出力
ws.onerror = (err) => console.error("WebSocket Error:", err);
// D. サーバーからのメッセージ受信時
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// ① 他ユーザーのリアルタイム描画を反映(emit: false で再送信を防止)
if (data.action === "draw" && data.stroke) {
drawLine(data.stroke.prev, data.stroke.curr, data.stroke.color, false);
}
// ② 接続時に受け取った過去履歴の一括再描画
if (data.action === "history" && Array.isArray(data.strokes)) {
data.strokes.forEach((s) => drawLine(s.prev, s.curr, s.color, false));
}
} catch (err) {
console.error("Error parsing message:", err);
}
};
// クリーンアップ:コンポーネント破棄時にWebSocketを切断
return () => ws.close();
}, []);
// -----------------------------------------------------------------
// 4. マウス操作イベントハンドラ
// -----------------------------------------------------------------
// キャンバス要素内の相対座標を取得する共通関数
const getPos = (e) => {
const rect = canvasRef.current.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
// クリック開始(描画スタート)
const handleMouseDown = (e) => {
isDrawing.current = true;
lastPoint.current = getPos(e);
};
// ドラッグ中(描画実行)
const handleMouseMove = (e) => {
if (!isDrawing.current) return;
const currentPoint = getPos(e);
drawLine(lastPoint.current, currentPoint, "#000000", true);
lastPoint.current = currentPoint;
};
// クリック解除 / キャンバス領域外へ脱出時(描画終了)
const stopDrawing = () => { isDrawing.current = false; };
// -----------------------------------------------------------------
// 5. 画面レンダリング (UI)
// -----------------------------------------------------------------
return (
<div style={{ padding: "20px", fontFamily: "sans-serif" }}>
<h2>お絵描きアプリ</h2>
<p>ステータス: {isConnected ? "🟢 接続中" : "🔴 切断中"}</p>
{/* 描画用キャンバス */}
<canvas
ref={canvasRef}
width={800}
height={500}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={stopDrawing}
onMouseLeave={stopDrawing}
style={{
border: "2px solid #333",
borderRadius: "8px",
cursor: "crosshair",
backgroundColor: "#fff",
touchAction: "none", // モバイル等でのスクロール動作を無効化
}}
/>
</div>
);
}
ローカルで動かしてみる
以下のコマンドで開発用サーバーを起動する
npm run dev
ターミナルに表示された URL( 例:http://localhost:5173/)をブラウザで 2 つのタブで開き、片方で描いた線がもう片方にリアルタイムで反映されるか確認する
クラウド配布用のビルド(静的ファイルの出力)
ローカルで動作確認ができたら、S3 にアップロードするための静的ファイル(HTML/JS/CSS)を生成する
npm run build
実行すると、プロジェクト直下に dist というフォルダが生成される
この dist フォルダの中身を S3 にアップロードする
S3バケットの作成とファイルのアップロード
ローカルでビルドしたWebアプリの画面ファイルをアップロードして保管
S3 バケットの作成
- S3 コンソール ➔ [バケットを作成]
- バケット名: 一意の名称(例:
my-drawing-app-bucket-2026) - ブロックパブリックアクセス: 「パブリックアクセスをすべてブロック」のチェックを入れたまま(CloudFront からのみアクセスを許可する)
- バケット名: 一意の名称(例:
- [バケットを作成] をクリック
ビルドしたファイルのアップロード
- 作成したバケットを開き、[アップロード] をクリック
- ローカルの
drawing-app/distフォルダの「中身すべて(index.htmlやassetsフォルダなど)」をドラッグ&ドロップしてアップロード
CloudFrontディストリビューションの作成
S3に直接アクセスさせるのではなく、Webコンテンツ配信ネットワーク(CDN)である CloudFront を手前に置く
これにより、世界中のユーザーへ高速かつ安全にWebサイトを配信でき、S3バケットを非公開に保ったまま安全に公開できる
ディストリビューション作成
ユーザーがアクセスするWebサイトの入り口(
https://dxxxxxx.cloudfront.net)を作成し、後ろにあるS3バケットと接続
- CloudFront コンソール ➔ [ディストリビューションを作成]
- プラン: [Pay as you go] を選択
- ディストリビューション名: 任意の名称 (例:
my-drawing-app) - オリジン: 作成した S3 バケットを選択
- 設定: [Allow private S3 bucket access to CloudFront] にチェックを入れたままにする
- WAF: セキュリティ保護を有効にしない
- [ディストリビューションを作成] をクリック
- 作成したディストリビューションの [一般] タブから デフォルトルートオブジェクトを
index.htmlに設定する
S3 バケットポリシーの更新
- 作成したディストリビューションの [オリジン] タブ → 対象のオリジンを選択して [編集] をクリック
- 編集画面中央にある [ポリシーをコピー] をクリック
- S3 コンソールに戻り、該当バケット ➔ [アクセス許可] タブ ➔ [バケットポリシー] の [編集] をクリック
- コピーしたポリシーを貼り付けて [変更の保存]
最終動作確認
- DynamoDBの描画履歴テーブルを一度削除し、同じ設定で再作成する
- CloudFront のディストリビューション詳細画面を開く
-
[ディストリビューションドメイン名](例:
https://dxxxxxx.cloudfront.net)をコピーする - ブラウザでアクセスし、アプリが表示されて描画機能・過去ログ復元が正常に動くことを確認する
おわりに
今回は、AWSの理解を深めるために簡単なお絵描きアプリを作ってみました!
実際に手を動かして作ってみたことで、単にテキストを読むだけでは見えにくかった「各サービスの用途」「コンソールでの具体的な操作」「サービス同士の連携やデータの流れ」が、アプリ作成前と比べて格段にイメージできるようになりました。
特にAPI GatewayとLambdaのやり取りは、動かしてみて初めて納得できる部分が多かったです。
この記事を通して「自分も作ってみたい!」「似たようなアプリに挑戦してみたい!」と思っていただけたら嬉しいです。
私自身、フロントエンドやWebSocket通信の知識はほとんどありませんでしたが、チャットAIを活用しながら試行錯誤を重ねることで、実際に動くアプリを作ることができました。
AWSの 利用料金や消し忘れ には注意しつつ、ぜひ色々なサービスを触って、自分の作りたいものにチャレンジしてみてください!



