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?

GCP上にチャットアプリの検証環境を作った話 Cloud Run・Cloud SQLで詰まった点

0
Posted at

はじめに

業務アプリにチャット機能を入れると、「メッセージを送れる画面」を作るだけでは足りません。

参加者、権限、添付ファイル、既読、メンション、ピン留め、タスク、ノート、管理側の操作まで関係します。

最近、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-apiinternal-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側の設定画面、権限、料金、接続方式が変わる可能性があるため、公式情報を正としてください。

おわりに

チャットアプリは、見た目だけなら簡単に作れます。

ただ、業務で使う前提にすると、権限、未読、添付、検索、タブ、監査、検証環境まで含めて設計する必要があります。

小さく見える機能ほど、先に境界を分けておくと後で助かります。

Wealthy Designでは、Webシステム開発、クラウド活用、AIを使った業務改善に取り組んでいます。

会社の取り組みは、会社サイトにまとめています。
https://wealthy-design.com/

この記事は実装メモを公開向けに一般化したものです。実プロジェクトのURL、アカウント、プロジェクトID、秘密情報、顧客情報は含めていません。

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?