はじめに
業務アプリにチャット機能を入れると、「メッセージを送れる画面」を作るだけでは足りません。
参加者、権限、添付ファイル、既読、メンション、ピン留め、タスク、ノート、管理側の操作まで関係します。
最近、GCP上にチャットアプリの検証環境を作りました。この記事では、実プロジェクト固有のURL、アカウント、プロジェクトID、秘密情報は出さずに、構成と詰まった点を公開できる範囲で一般化します。
特に整理するのは、次の4点です。
- Cloud RunとCloud SQLをどう分けたか
- ローカルのDocker検証からGCP検証へどうつないだか
- チャットAPIとDBテーブルをどう分けたか
- ブラウザではCORSに見えたエラーが、実際にはDB接続問題だった話
先に結論
先に要点を書くと、次のとおりです。
- 公開側と社内側は、Web、API、DB権限、Secretを分ける
- ローカルでもフロント内蔵モックだけで確認せず、APIとPostgreSQLまでつなぐ
- チャットは
messagesテーブルだけで作らない - メンション、既読、ピン留め、添付、タブ、権限は別概念として持つ
- Cloud RunでAPIが並列に呼ばれると、Cloud SQL接続数が先に問題になることがある
- ブラウザの
Failed to fetchだけを見てCORSと決めつけない
用語の短い説明
この記事で使う主な用語を先に整理します。
| 用語 | 短い説明 |
|---|---|
| Cloud Run | コンテナ化したWebアプリやAPIを、サーバー管理少なめで動かせるGCPの実行環境 |
| Cloud SQL | PostgreSQLやMySQLなどのDBをマネージドサービスとして使えるGCPのサービス |
| API | 画面からサーバー側の処理を呼び出すための入口 |
| DB | データベース。ユーザー、チャット、メッセージなどを保存する場所 |
| CORS | ブラウザが「別のオリジンへの通信を許してよいか」を確認する仕組み |
| オリジン | URLのうち、スキーム、ホスト、ポートの組み合わせ。例: https://example.com
|
作ったものの概要
構成は大きく、公開側と社内側を分けています。検証環境ではWebもAPIもCloud Runに載せ、DBはCloud SQL for PostgreSQLを使います。
Browser
-> public-web
-> public-api
-> Cloud SQL for PostgreSQL
Internal Browser
-> internal-web
-> internal-api
-> Cloud SQL for PostgreSQL
migration / worker
-> Cloud Run Job
-> Cloud SQL for PostgreSQL
使った主な技術は次です。
- Cloud Run
- Cloud SQL for PostgreSQL
- Artifact Registry
- Cloud Build
- Secret Manager
- Terraform
- Identity Platform
- IAP
- Cloud Run Job
フロントエンドはReact / Vite系、APIはPython / FastAPI系、DBはPostgreSQLです。
リポジトリは境界で分ける
まず、アプリの中身を次のように分けました。
public-web/ # 利用者向けUI
internal-web/ # 社内向けUI
backend/
core/ # 共通のドメイン、ユースケース、ポート
infrastructure/ # DB接続などの共通アダプター
public-api/ # 公開側API
internal-api/ # 社内側API
local-dev/ # ローカルDocker専用
compose.yaml
ポイントは、backend/coreに業務ルールを寄せつつ、HTTPアプリとDB接続権限はpublic-apiとinternal-apiで分けることです。
共通化したい気持ちはありますが、APIの入口までまとめてしまうと、公開側に管理操作が混ざりやすくなります。そこで、共通化する場所と分離する場所を分けました。
公開側と社内側は入口を分ける
最初に決めたのは、公開Webと社内Web、公開APIと社内APIを分けることです。
公開側
public-web
public-api
社内側
internal-web
internal-api
コードの共通ロジックは共有してもよいですが、実行単位、URL、認証、DB権限、Secretは分けます。
backend/core
backend/infrastructure
-> public-api / internal-api / worker / migration が利用
この分け方にすると、公開APIに強すぎるDB権限を持たせずに済みます。また、社内画面だけに必要な管理操作を公開側のAPIへ混ぜずに済みます。
DBも、同じCloud SQLを見る場合でもログインユーザーを分けます。
public-api
-> public_api_db_user
-> 必要な読み書きだけ
internal-api
-> internal_api_db_user
-> 管理画面に必要な権限
migration
-> owner / migration user
-> DDL実行
「同じDBだから同じ接続情報でいい」とすると、後から権限を絞るのが難しくなります。最初から用途別に分けておく方が、安全側に倒しやすいです。
Terraformは「箱」を作り、Secretの実値は入れない
GCPリソースはTerraformで管理しました。
Terraformで作るものは、Cloud Run、Cloud SQL、Artifact Registry、IAM、Secret Managerの箱などです。
一方で、Secret Managerの中身、Terraform state bucketの初回作成、DNSの最終切り替え、メールドメインのSPF / DKIM / DMARCなどは、Terraformだけで完結させない運用にしています。
Terraform
-> resource definition
Secret payload
-> outside Git
秘密情報をGitに入れないのは当然ですが、Terraform stateにもsensitive値が残る可能性があります。state bucketのアクセス制御も含めて設計対象です。
ローカル検証はモックだけで終わらせない
GCPへ上げる前に、ローカルDockerでDB、API、Webをつないで確認します。
# DBコンテナを起動する
docker compose up -d db
# Alembicなどのマイグレーションを実行する
docker compose run --rm db-migrate
# ローカル確認専用のfixtureを投入する
docker compose --profile local-fixture run --rm local-db-fixture
# APIとWebをローカルで起動する
docker compose up -d --build public-api public-web
# API、DB、権限、チャット操作を通しで確認する
python3 backend/tests/chat_api_scenario.py
ここで大事なのは、フロントエンドの中だけで完結するモックにしないことです。
public-web
-> public-api
-> PostgreSQL
この接続で確認しておくと、画面表示、APIレスポンス、DBのテーブル設計、権限エラーが同時に見えます。
ローカル確認用データも、本番用seedとは分けました。
local fixture
-> ローカルDockerだけで使う確認データ
production master
-> 本番で使う正式データ
確認用データには実行ガードを置き、本番DBへ誤投入しない前提にしています。
チャットAPIは意外と多い
チャットは、POST /messagesだけ作ればよいわけではありません。
実際には、画面で自然に見える操作をAPIに分けていく必要があります。
| 画面でやりたいこと | API例 | 設計上のポイント |
|---|---|---|
| チャット一覧を見る | GET /chat/rooms |
未読数、メンション数、ピン留め順も一緒に考える |
| チャット詳細を見る | GET /chat/rooms/{room_id} |
自分が閲覧できる部屋かを必ず判定する |
| メッセージ一覧を見る | GET /chat/rooms/{room_id}/messages |
件数制限と並び順を固定する |
| メッセージを投稿する | POST /chat/rooms/{room_id}/messages |
投稿権限、添付、メンションを同時に扱う |
| 自分宛てを見る | GET /chat/mentions |
本文検索ではなくメンション情報から取る |
| ブックマークを見る | GET /chat/bookmarks |
ユーザーごとの保存状態にする |
| 既読にする | PUT /chat/rooms/{room_id}/read-state |
未読数とメンション未読数を分ける |
| 部屋をピン留めする | PUT /chat/rooms/{room_id}/pin |
一覧の並び順に影響する |
| 部屋をミュートする | PUT /chat/rooms/{room_id}/mute |
通知対象から外すだけで閲覧権限とは分ける |
| タブ内の項目を扱う | POST /chat/rooms/{room_id}/tabs/{key}/items |
タスク、ノート、ファイルなどを部屋に紐づける |
この一覧を先に作っておくと、DB設計で何を別テーブルにするべきか見えやすくなります。
DBはメッセージ本文だけでは足りない
最初にやりがちな失敗は、chat_messagesだけで頑張ろうとすることです。
しかし、実際には次のように分けた方が扱いやすいです。
| 概念 | テーブル例 | 分ける理由 |
|---|---|---|
| チャット部屋 | chat_rooms |
部屋名、種別、最終更新、公開範囲を持つ |
| 参加者と権限 | chat_room_members |
投稿可否、未読数、ミュート、ピン留めをユーザー別に持つ |
| メッセージ | chat_messages |
本文、送信者スナップショット、返信元などを持つ |
| メンション | chat_message_mentions |
自分宛て一覧や未読数を本文検索に依存させない |
| 添付ファイル | chat_message_attachments |
ファイル名、種別、サイズ、取得URLを本文と分ける |
| ブックマーク | chat_message_bookmarks |
ユーザーごとの保存状態にする |
| メッセージピン | chat_room_pinned_items |
部屋内で固定表示する対象を分ける |
| タブ | chat_room_tabs |
メッセージ、タスク、ノート、ファイルなどの入口を持つ |
| タブ項目 | chat_room_tab_items |
タスクやノートの一覧を部屋ごとに持つ |
| 概要ノート | chat_room_overview_notes |
チャット上部の共有メモを本文とは別に更新する |
たとえばメンションを本文だけで表現すると、後で困ります。
本文: @田中さん 確認お願いします
この文字列だけを保存していると、ユーザー名が変わったとき、自分宛て一覧を出したいとき、既読判定をしたいときに弱くなります。
そこで、本文とは別に「誰が誰にメンションしたか」を保存します。
chat_messages
id
room_id
body
chat_message_mentions
message_id
target_user_id
mention_type
この形にしておくと、本文の表示と通知・検索・未読判定を分けられます。
検証環境は既存環境と分ける
チャット機能はDBスキーマへの影響が大きい機能です。
そこで、既存の検証環境にそのままDDLや確認用データを流すのではなく、チャット検証専用の環境を分けました。
既存検証環境
-> 既存機能の確認
チャット検証環境
-> チャット用のDDL、API、画面、確認データ
分けたものは次です。
- Cloud SQL
- Cloud Runサービス
- Secret
- Artifact Registry
- Terraform state
- 確認用データ
チャットは画面・API・DBの変更範囲が広いので、「既存の検証環境に少し足す」より、独立した検証環境にした方が事故りにくいです。
CORSに見えたが、原因はCloud SQL接続だった
GCP上の検証で、ブラウザ側にFailed to fetchが出たことがありました。
CORSは、ブラウザが別オリジンのAPIを呼ぶときに「このWebサイトからのアクセスを許可してよいか」を確認する仕組みです。
例えば、画面とAPIのURLが分かれている場合に関係します。
# 画面とAPIのオリジンが違う例
https://app.example.com
-> https://api.example.com
API側が適切なレスポンスヘッダーを返さないと、ブラウザは安全のためにレスポンスを画面へ渡しません。そのため、フロントエンド側ではFailed to fetchのように見えることがあります。
ただし、Failed to fetchが出たからといって、必ずCORSが原因とは限りません。
実際の原因はCORSではなく、画面ロード時に複数APIが並列で走り、API側がCloud SQLへの新規接続を都度作成していたことでした。
チャット画面を開くと、たとえば次のようなリクエストが一気に走ります。
GET /chat/rooms
GET /chat/mentions
GET /chat/bookmarks
GET /chat/rooms/{room_id}
GET /chat/rooms/{room_id}/tabs
GET /chat/rooms/{room_id}/messages
GET /chat/rooms/{room_id}/side-summary
GET /chat/rooms/{room_id}/overview-note
結果としてDB接続が不安定になり、ブラウザから見るとCORSっぽい失敗に見えていました。
対策として、APIプロセス内で少数のDB接続を再利用するプールを持つようにしました。考え方は次のような実装です。
from contextlib import contextmanager
from queue import Empty, LifoQueue
from threading import BoundedSemaphore, Lock
import psycopg
# URLごとに接続プールを再利用するための簡易キャッシュ
_pools_lock = Lock()
_pools = {}
class ConnectionPool:
def __init__(self, url: str, max_size: int):
self._url = url
# 使い終わった接続を戻して、次のリクエストで再利用する
self._idle = LifoQueue()
# Cloud SQLへの同時接続数が増えすぎないように上限を持つ
self._semaphore = BoundedSemaphore(max_size)
@contextmanager
def connection(self):
acquired = self._semaphore.acquire(timeout=10)
if not acquired:
raise RuntimeError("database connection pool acquire timeout")
connection = None
try:
connection = self._checkout()
yield connection
connection.commit()
except Exception:
if connection is not None:
# 失敗した処理はDBへ中途半端に反映しない
connection.rollback()
raise
finally:
if connection is not None:
self._idle.put(connection)
self._semaphore.release()
def _checkout(self):
while True:
try:
connection = self._idle.get_nowait()
except Empty:
# 空き接続がないときだけ新規接続を作る
return psycopg.connect(self._url)
if not connection.closed:
return connection
実装の細部はプロジェクトごとに変わりますが、学びはシンプルです。
ブラウザのエラー表示だけで原因を決めつけず、Cloud Runログ、APIログ、DB接続、同時リクエストを並べて見る必要があります。
このとき見たポイントは、次のようなものです。
- Cloud RunログにAPI例外が出ていないか
- DB接続タイムアウトが出ていないか
- 画面表示時にAPIが何本並列で走っているか
- APIごとに新規DB接続を作っていないか
- Cloud SQLの接続数が想定より増えていないか
GCPへ上げる前に通すチェック
GCPに上げる前に、最低限以下を通します。
lint
build
contract test
component test
API integration scenario
Playwright E2E
GCP deploy config preflight
今回の場合は、たとえば次のような確認です。
# フロントエンドのLintを確認する
npm --prefix public-web run lint
# 本番ビルドで壊れないか確認する
npm --prefix public-web run build
# API契約やコンポーネントのテストを確認する
npm --prefix public-web run test:contract
npm --prefix public-web run test:component
# ブラウザ操作に近いE2Eを確認する
npm --prefix public-web run test:e2e
# チャットAPIを実DB込みで確認する
python3 backend/tests/chat_api_scenario.py
# GCPデプロイ前の環境変数・URL・dev認証混入を確認する
python3 scripts/gcp/validate_deploy_config.py --env-file path/to/public-api.env --service public-api
chat_api_scenario.pyでは、単に200が返るかだけでなく、次のような観点を見ます。
- 未ログインなら401になるか
- 権限がないユーザーは403になるか
- 見えないチャットは404扱いにできるか
- メンション一覧がDB由来で返るか
- メッセージ投稿後に添付ファイルを取得できるか
- タスクやノートを追加したとき、チャットのタイムラインにも反映できるか
- 古いversionで更新したとき409にできるか
ここまで見ると、画面単体のE2Eだけでは拾えないDB・権限・整合性の問題を見つけやすくなります。
本番ビルドにdev認証を混ぜない
検証では、ローカル用のdev認証を使いたくなることがあります。
ただし、本番ビルドにdev認証用の環境変数が混ざるのは危険です。
そこで、production build時にdev認証が混ざっていたら失敗させるチェックを入れます。
local
-> ALLOW_DEV_AUTH=true を許可
production build
-> ALLOW_DEV_AUTH / VITE_ALLOW_DEV_AUTH を拒否
この手のチェックは、後から人間が気をつけるより、ビルド時に落とす方が強いです。
最初から全機能を出さない
チャットアプリは、ボタンを置き始めると際限なく増えます。
初期提供で実装していない機能は、画面にデッドボタンとして出さないようにしました。
ユーザーにとって、押せるけど何も起きないボタンはかなりストレスです。
実装済み
-> 画面に出す
初期提供外
-> 出さない
これは地味ですが、業務アプリではとても大事です。
まとめ
GCP上にチャットアプリの検証環境を作ってみて、重要だったのは次の点でした。
- 公開側と社内側はWeb/API/権限を分ける
- 共通ロジックは共有しても、実行単位とDB権限は分ける
- Terraformは箱を管理し、Secret実値はGitに入れない
- ローカルのモック確認だけで終わらせず、DB/API/UIをつないで確認する
- チャットはメッセージ本文だけでなく、メンション、既読、ピン留め、添付、タブ、権限を別概念として持つ
- DBスキーマ影響が大きい機能は検証環境を分離する
- CORSっぽいエラーでも、DB接続や同時リクエストを疑う
- dev認証や確認用データは、本番ビルド・本番DBに入らないよう機械的に止める
参考・確認先
この記事の仕様確認日は2026-07-28です。実際に作業するときは、GCP側の設定画面、権限、料金、接続方式が変わる可能性があるため、公式情報を正としてください。
- Cloud Run documentation
- Connect from Cloud Run to Cloud SQL for PostgreSQL
- Managed Connection Pooling overview for Cloud SQL for PostgreSQL
- Cloud SQL documentation
- FastAPI CORS middleware
おわりに
チャットアプリは、見た目だけなら簡単に作れます。
ただ、業務で使う前提にすると、権限、未読、添付、検索、タブ、監査、検証環境まで含めて設計する必要があります。
小さく見える機能ほど、先に境界を分けておくと後で助かります。
Wealthy Designでは、Webシステム開発、クラウド活用、AIを使った業務改善に取り組んでいます。
会社の取り組みは、会社サイトにまとめています。
https://wealthy-design.com/
この記事は実装メモを公開向けに一般化したものです。実プロジェクトのURL、アカウント、プロジェクトID、秘密情報、顧客情報は含めていません。