1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Django Channels + Redis でランダムマッチング匿名チャットを作ってAWSにデプロイするまで

1
Posted at

Django Channels + Redis でランダムマッチング匿名チャットを作ってAWSにデプロイするまで

はじめに

「同じ趣味を持つ知らない誰かと、気軽に話せるサービスがあったらいいな」と思い、登録不要・完全匿名の1対1チャットサービス「HashChat」 を作りました。

タグ(プログラミング・キャンプ・英会話など21種類)を選ぶだけで、同じタグを選んだ相手とランダムにマッチングして会話できます。

この記事では実装で詰まったところを中心に、技術的なポイントをまとめます。


技術スタック

分類 採用技術
バックエンド Django 6.0 + Django Channels 4
ASGIサーバー Daphne
データベース SQLite(運用中)
キャッシュ/PubSub Redis
インフラ AWS EC2(t3.micro) + CloudFront + Route 53 + ACM
SSL ACM証明書(CloudFront経由)

アーキテクチャ

ユーザー (HTTPS/WSS)
    ↓
CloudFront(SSL終端・無料枠で対応)
    ↓
EC2 t3.micro
  ├ Nginx(リバースプロキシ)
  ├ Daphne(ASGIサーバー)
  ├ Django + Channels(アプリ)
  └ Redis(マッチング待機キュー + Channel Layer)

WebSocket の SSL は CloudFront が終端するため、EC2 側は HTTP/WS のみ受け付ける構成にしました。wss:// → CloudFront → ws:// という流れです。


ランダムマッチングの実装

最も工夫したのがマッチング処理です。シンプルに「待機キュー」をRedisで管理しています。

# chat/consumers.py
async def connect(self):
    self.tag_name = self.scope['url_route']['kwargs']['tag_name']
    waiting_key = f'waiting:{self.tag_name}'

    # ブロックIP・レートリミットチェック
    if await self.check_blocked() or await self.check_connection_rate():
        await self.close()
        return

    await self.increment_connection()
    self._counted = True

    waiting_channel = cache.get(waiting_key)

    if waiting_channel is None:
        # 待機: 自分のチャンネル名をRedisに保存
        cache.set(waiting_key, self.channel_name, timeout=300)
        self.room_group_name = f'waiting_{safe_channel}'
        await self.channel_layer.group_add(self.room_group_name, self.channel_name)
        await self.accept()
        await self.send(text_data=json.dumps({
            'type': 'waiting',
            'message': '相手を待っています...',
        }))
    else:
        # マッチング成立: ルーム名を生成して両者を同じグループに追加
        cache.delete(waiting_key)
        room_name = f'room_{self.tag_name}_{uuid.uuid4().hex[:16]}'
        self.room_group_name = room_name

        await self.accept()
        await self.channel_layer.group_add(room_name, self.channel_name)
        await self.channel_layer.group_add(room_name, self.partner_channel)

        # 待機中の相手に通知
        await self.channel_layer.send(self.partner_channel, {
            'type': 'match_found',
            'room_name': room_name,
        })
        await self.send(text_data=json.dumps({
            'type': 'matched',
            'message': 'マッチングしました!',
            'room_name': room_name,
        }))

ポイントは cache.get / cache.set の部分で、同タグの待機者がいれば即マッチング、いなければ自分が待機者になるという仕組みです。


ハマりポイント①:接続数カウンターの競合状態

最初は接続数をこう実装していました。

# ❌ 非アトミック(並行接続でカウントがズレる)
cache.set(key, (cache.get(key) or 0) + 1, timeout=None)

cache.get()cache.set() の間に別の接続が来るとカウントが消えます。

# ✅ Redis の INCR でアトミックに処理
cache.add(key, 0, timeout=None)   # キーがなければ0で初期化
cache.incr(key)                    # 原子的にインクリメント

cache.add() は「キーが存在しない場合のみセット」なのでアトミックです。これと cache.incr() の組み合わせで競合を防げます。


ハマりポイント②:ブロックIP の disconnect 問題

ブロック済みIPを接続時に弾いても、disconnect() が呼ばれて接続数がマイナスにズレる問題が発生しました。

async def connect(self):
    self._counted = False  # フラグを初期化

    if await self.check_blocked():
        await self.close()
        return              # increment せずに終了

    await self.increment_connection()
    self._counted = True   # ここまで来た場合のみ True

async def disconnect(self, close_code):
    if getattr(self, '_counted', False):  # True の場合のみ decrement
        await self.decrement_connection()

「カウントアップした場合のみカウントダウン」という単純な発想ですが、非同期処理では意外と見落としやすいです。


ハマりポイント③:CloudFront 越しの WebSocket SSL

最初は EC2 に直接 Let's Encrypt で証明書を取ろうとしていたのですが、CloudFront + ACM の組み合わせで無料かつ自動更新が実現できました。

ただし注意点が2つ:

① ACM 証明書はバージニア(us-east-1)で取得する

CloudFront は us-east-1 の ACM しか使えません。東京リージョンで取っても使えないので注意。

② EC2 の Nginx は HTTP のみ受け付ける

server {
    listen 80;
    server_name hashchat.jp www.hashchat.jp;

    location / {
        proxy_pass http://127.0.0.1:8001;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
        proxy_read_timeout 300s;  # WebSocket の長時間接続に対応
    }
}

Django 側は SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') を設定することで、CloudFront から転送された X-Forwarded-Proto: https を正しく認識します。


セキュリティ対策

匿名チャットなのでセキュリティには特に気を配りました。

IPブロック

class ChatConsumer(AsyncWebsocketConsumer):
    @database_sync_to_async
    def check_blocked(self):
        return BlockedIP.objects.filter(
            ip_address=self.ip_address,
            is_active=True
        ).exists()

通報が3件以上届いたIPは自動的にブロックします。

接続レートリミット

@database_sync_to_async
def check_connection_rate(self):
    rate_key = f'ws_rate:{self.ip_address}'
    cache.add(rate_key, 0, timeout=60)
    return cache.incr(rate_key) > 10  # 1分間に10接続まで

NGワード(クライアントサイド)

英語はword boundaryで、日本語は部分文字列マッチで判定します。

function containsNgWord(text) {
    const lower = text.toLowerCase();
    return ngWords.find(w => {
        if (/^[a-z0-9 ]+$/.test(w)) {
            // 英語: \b で単語境界マッチ("shit" が "shitty" にヒットしない)
            return new RegExp(`\\b${escapeRegex(w)}\\b`).test(lower);
        }
        // 日本語: 部分文字列マッチ
        return lower.includes(w);
    }) || null;
}

コスト

t3.micro 1台で動いています。

項目 月額
EC2 t3.micro ¥1,530
EBS 8GB ¥118
CloudFront(1TB無料枠内) ¥0
ACM証明書 ¥0
Route 53 ¥77
ドメイン ¥273
合計 約¥2,000

ElastiCache を使わず Redis を EC2 に同居させることでコストを抑えています。


同時接続の限界

t3.micro(1GB RAM、2vCPU)での試算です。

  • メモリ:WebSocket 接続1本あたり約8KB → 理論上5万接続可能
  • 実質的なボトルネック:Daphne 単一プロセス+CPU ベースライン(0.2vCPU)
  • 現実的な上限:同時300〜500接続、活発なチャット100〜150ペア程度

スケールが必要になったら t3.medium への変更と Daphne マルチワーカー化を予定しています。


まとめ

  • Django Channels は WebSocket 実装が驚くほど簡単で、非同期処理も書きやすい
  • Redis は Channel Layer と マッチングキューの両方で活躍
  • CloudFront + ACM は小規模サービスのHTTPS化に最高のコスパ
  • 匿名サービスならではのセキュリティ(IPブロック・レートリミット)は最初から入れておくべき

よかったら触ってみてください 👇

フィードバック・改善案はサービス内の「ご意見・ご要望」から送れます。


使用技術: Django / Django Channels / Daphne / Redis / AWS EC2 / CloudFront / Route 53 / ACM / Nginx / SQLite

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?