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