開発日記: OpenPNE製SNSをAWSサーバーレスでフルリプレースする話

背景

10年以上前から、OpenPNE 2.x(PHP5/MySQL/CentOS5)で運営してきたプライベートコミュニティSNSがある。 バイク・クルマ好きが集まるクローズドな場で、日記・フレンド・コミュニティといったいわゆるmixi的なフィーチャーが揃っている。

問題は OpenPNEが開発停止・EOL環境 であること。PHPもMySQLもOSも全てEOL。セキュリティリスクを抱えたまま何年も動かし続けてきたが、さすがにいい加減なんとかしなければということで、フルリプレースに踏み切ることにした。

方針はシンプルに3つ。

  • セキュアでメンテナブルなモダン構成への刷新
  • サーバーレス化によりサーバー管理から解放される
  • 既存ユーザーのデータ・体験を継続させる

規模感は最大3,000ユーザー、想定アクティブは1,000人ほど。小規模だが「移行失敗=コミュニティ消滅」なのでプレッシャーはそれなりにある。


技術スタック選定

フロントエンド

項目採用技術
フレームワークNext.js 15 (App Router)
言語TypeScript(any禁止)
スタイルTailwind CSS
UIコンポーネントshadcn/ui
データフェッチTanStack Query v5
状態管理Zustand(UIステートのみ)
フォームReact Hook Form + Zod
AWS連携AWS Amplify JS v6

Next.js 15のApp Routerを採用した理由は、Server Componentsによるサーバーサイドデータフェッチで初期表示を高速化できるため。 SNSはページ遷移のたびに複数エンティティを取得するのでこの恩恵は大きい。

バックエンド

項目採用技術
参照APIAWS AppSync (GraphQL)
更新APIAWS API Gateway + Lambda (REST)
認証Amazon Cognito User Pools
DBDynamoDB(シングルテーブル設計)
ファイルS3 + CloudFront
通知EventBridge → Lambda → SES
IaCAWS CDK (TypeScript)

なぜAPIを2系統に分けたか

参照系にGraphQL(AppSync)、更新系にREST(API Gateway) というハイブリッド構成にした。

SNSの画面は「プロフィール + 投稿一覧 + 通知件数」のように複数エンティティを同時に取得するケースが多い。GraphQLのフィールド選択なら画面ごとに必要なデータを1リクエストで取れる。

一方、投稿作成・フレンド申請などの更新操作は単一操作が多く、RESTの方がシンプルに書ける。EventBridgeとの連携もLambda内で明示的に記述できる。

両方GraphQLにまとめる選択肢もあるが、Mutationの副作用(EventBridge発行など)をGraphQLのMutationとして設計するのが少し不自然に感じたのが本音。

なぜDynamoDBか

3,000ユーザー規模ならRDBでも十分。それでもDynamoDBを選んだのは:

  1. サーバーレスとの親和性 - RDSだとコールドスタート問題が出る(Aurora Serverless v2はそれなりのコストがかかる)
  2. オートスケール - オンデマンドキャパシティで管理不要
  3. 月額コスト - この規模なら$10以下で収まる想定

デメリットは設計の複雑さ。アクセスパターンを先に全部洗い出してからテーブル設計しないと後で詰む。


DynamoDB シングルテーブル設計

これがこのプロジェクトで一番頭を使った部分。

基本方針

  • テーブル名: sns(1テーブルのみ)
  • GSI × 2でアクセスパターンをカバー
  • SKにプレフィックスでエンティティ種別を明示
GSI1: authorId (PK) + createdAt (SK)  → タイムライン構築
GSI2: GSI2PK (PK) + createdAt (SK)   → 未読通知カウント・スレッドソート

エンティティとキー設計(一部抜粋)

ユーザー

PK: USER#<userId>  /  SK: #PROFILE

投稿(日記)

PK: USER#<userId>  /  SK: POST#<ISO8601>#<postId>
PK: POST#<postId>  /  SK: #META  ← 詳細取得用のコピー

投稿はユーザーPKで保持しつつ、詳細取得用にPOST#<id>でも持つ二重保持。DynamoDBでは非正規化が正義。

フレンド関係

PK: USER#<userId>  /  SK: FRIEND#<friendId>
※ 承認時に双方向で作成

承認操作はTransactWriteItemsで双方向レコードを原子的に作成する。

タイムライン取得の実装例

SNSの「タイムライン」はシングルテーブル設計最大の難所。

// Step1: フレンドID一覧取得
const friendsResult = await dynamodb.query({
  TableName: 'sns',
  KeyConditionExpression: 'PK = :pk AND begins_with(SK, :prefix)',
  FilterExpression: '#status = :accepted',
  ExpressionAttributeValues: {
    ':pk': `USER#${userId}`,
    ':prefix': 'FRIEND#',
    ':accepted': 'accepted'
  }
})

// Step2: 各フレンドの投稿をGSI1で並列取得
const friendIds = friendsResult.Items.map(i => i.SK.replace('FRIEND#', ''))
const postQueries = friendIds.map(fid =>
  dynamodb.query({
    TableName: 'sns',
    IndexName: 'GSI1-authorId-createdAt',
    KeyConditionExpression: 'authorId = :authorId',
    ExpressionAttributeValues: { ':authorId': fid },
    ScanIndexForward: false,
    Limit: 20
  })
)
const results = await Promise.all(postQueries)

// Step3: マージ・ソート・上位20件
const allPosts = results.flatMap(r => r.Items)
allPosts.sort((a, b) => b.createdAt.localeCompare(a.createdAt))
return allPosts.slice(0, 20)

フレンドが多いユーザーほどParallel Queryが増えるが、この規模なら問題ない。1,000フレンドを超えるようなユースケースは想定していない。


機能仕様(Phase 1 MVP)

コア機能

  • 認証: Cognito + 招待制(外部から自由登録不可)
  • プロフィール: ニックネーム・自己紹介・アイコン・車種情報
  • フレンド: 申請/承認/拒否/解除
  • 日記: テキスト + 画像複数枚、公開範囲設定(全体/フレンドのみ/自分のみ)、コメント
  • コミュニティ: 作成/参加/退会、トピック投稿
  • 通知: アプリ内 + メール(SES)、種別ごとのON/OFF
  • タイムライン: フレンドの日記新着(無限スクロール)
  • 管理画面: ユーザー管理・強制退会・投稿削除

招待制の仕組み

外部からの自由登録を防ぐため、CognitoのPre-SignUp Triggerを使っている。

管理者/メンバー → POST /invitations
  → 招待トークン(UUID)生成 → DynamoDBに保存(TTL: 7日)
  → SESで招待メール送信

新規ユーザー → /register?token=xxx
  → Lambda Pre-SignUp Trigger でトークン検証
  → 有効なら登録許可 / 無効なら拒否

非同期通知フロー

POST /posts (Lambda)
  → DynamoDB に投稿保存
  → EventBridge に post.created イベント発行
  → 通知Lambda が起動
      → フレンド一覧取得
      → DynamoDB に通知レコード作成(各フレンド分)
      → SES でメール通知(設定ONのユーザーのみ)

メイン処理と通知処理を非同期で切り離すことで、投稿APIのレイテンシに影響を与えない。

Phase 2(将来)

  • 個人メッセージ(スレッド型)
  • あしあと
  • イベント機能
  • 全文検索(OpenSearch追加)
  • Push通知(PWA)

データ移行計画

既存のOpenPNEには26GBのBLOB画像がMySQLに格納されている(当時の設計…)。これをS3に移行しつつDynamoDBに全データを移す必要がある。

移行対象の絞り込み

全データを移行するのではなく、直近5年以内のアクティブユーザーのデータのみに絞る。パスワードは移行不可なので、初回ログイン時にリセットしてもらう。

移行スクリプト構成

Pythonで以下の順序で実行する使い捨てスクリプト群を用意。

1_extract.py         → MySQL → JSON エクスポート
2_build_id_map.py    → 旧整数ID → UUID マッピング生成
3_rewrite_urls.py    → 本文内の旧URL一括書き換え
4_upload_images.py   → BLOB → S3 アップロード(10並列)
5_import_dynamo.py   → DynamoDB BatchWriteItem
6_create_cognito_users.py → Cognito ユーザー一括作成
7_send_invitation.py → 招待メール一括送信(SES)

旧OpenPNEのURLは?m=pc&a=page_h_diary&c_diary_id=123のようなクエリ形式。本文内に埋め込まれているリンクを正規表現で新URLに書き換えるのが地味に大変。


現状と今後

現在はmonorepo構成を整え、CDKのスタック設計とDynamoDBのテーブル定義をほぼ確定させたところ。

packages/
├── frontend/    # Next.js
├── functions/   # Lambda(1関数1責務)
├── graphql/     # AppSync schema + resolvers
└── infra/       # AWS CDK

次のステップは認証周り(Cognito + Pre-SignUp Trigger)から実装を始め、フレンド・日記機能と順番に積み上げていく予定。

移行作業が一番リスクが高いので、新システムが一通り動いた段階でステージング環境への移行リハーサルを繰り返してから本番カットオーバーする計画。


設計で意識したこと

  • Scanは絶対に使わない: 本番コードではQueryのみ。全アクセスパターンを事前に定義してGSIで対応
  • Lambda 1関数1責務: まとめると後で辛くなる
  • 冪等性: 同じリクエストを2回処理しても問題ない実装
  • 認可はLambda内で必ず確認: event.requestContext.authorizer.claims.subresource.ownerId を照合
  • CDKは差分確認してから: cdk diff の出力を確認してから cdk deploy

小規模SNSとはいえ、長年使われてきたコミュニティのデータが乗るので、雑に作れない。丁寧にやっていく。