コツは 「Djangoで守り」「gRPCで流し」「Envoyで橋渡し」。この三段構えで、UIを止めないリアルタイム応答を実現する。
01. ねらい(TL;DR)
- Django: 認証・発行だけを素早く担当(短命JWT)
- gRPC‑Web + Envoy: ブラウザ ↔ サービスのサーバーストリーミングで“細かく即返す”
- 小分け返却: 推論や生成の途中経過をチャンクで返送→ 体感レイテンシを最小化
02. 全体アーキテクチャ(最小構成)
Browser (gRPC‑Web client)
│ JWT (Authorization: Bearer ...)
▼
Envoy (grpc_web + http_filters, CORS)
│ HTTP/2 → gRPC
▼
Infer gRPC Server (Python/asyncio)
│ streaming delta tokens
▼
Model Runtime (Triton / PyTorch / etc.)
Django (REST): /auth/issue → 短命JWTを返す(5分)
03. 実装ステップ
3.1 Django: 短命JWTの発行(入口を守る)
- Djangoは同期の重処理を持たせない(認証・認可に注力)
- 有効期限は5分など短く。リフレッシュは別エンドポイントで
# views.py
from django.http import JsonResponse
import jwt
from datetime import datetime, timedelta, timezone
SECRET = "<your-secret>"
def issue_token(request):
now = datetime.now(timezone.utc)
payload = {
"sub": str(request.user.id),
"exp": int((now + timedelta(minutes=5)).timestamp()),
"scope": ["infer:read"]
}
token = jwt.encode(payload, SECRET, algorithm="HS256")
return JsonResponse({"token": token})
ポイント
scope
などを入れて用途限定のJWTにする- 期限切れは401を返し、フロントで静かに再認証
3.2 gRPC(Python): サーバーストリーミングで小分け返却
# infer_service.py
import asyncio
from proto.infer_pb2 import InferReply
from proto.infer_pb2_grpc import InferServicer
class InferService(InferServicer):
async def Stream(self, request, context):
async for token in run_model_iter(request.prompt):
yield InferReply(delta=token) # 途中結果を即送信
async def run_model_iter(prompt: str):
# 実際はモデル呼び出し(LLM/Triton等)から逐次結果を受け取る
for ch in (prompt + "..."):
await asyncio.sleep(0.02) # ダミー遅延
yield ch
ポイント
- 本体処理はasync生成器で逐次
yield
- バックプレッシャはランタイム/フレームワーク任せにせずレート制御も検討
3.3 Envoy: gRPC‑Webブリッジ & CORS
最重要は HTTP/2の有効化 と grpc_webフィルタ、CORS。誤ると即切断や接続エラーの原因に。
# envoy.yaml (最小例)
static_resources:
listeners:
- name: listener_http
address: { socket_address: { address: 0.0.0.0, port_value: 8080 } }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: AUTO
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match: { prefix: "/infer.Infer/Stream" }
route: { cluster: infer_grpc, timeout: 0s }
http_filters:
- name: envoy.filters.http.cors
- name: envoy.filters.http.grpc_web
- name: envoy.filters.http.router
clusters:
- name: infer_grpc
connect_timeout: 2s
type: logical_dns
lb_policy: ROUND_ROBIN
http2_protocol_options: {}
load_assignment:
cluster_name: infer_grpc
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address: { address: infer-grpc, port_value: 50051 }
CORSヘッダ(Envoyまたはリバースプロキシで)
Access-Control-Allow-Origin: https://your-frontend.example
Access-Control-Allow-Headers: content-type,x-grpc-web,authorization
Access-Control-Allow-Methods: POST, OPTIONS
3.4 フロント(gRPC‑Webクライアント)
@improbable-eng/grpc-web
などを利用- 短命JWTを
Authorization: Bearer <token>
で送る
// client.ts
import {grpc} from "@improbable-eng/grpc-web";
import {Infer} from "./gen/infer_pb_service";
import {StreamRequest} from "./gen/infer_pb";
export function stream(prompt: string, token: string, onDelta: (s:string)=>void) {
const req = new StreamRequest();
req.setPrompt(prompt);
grpc.invoke(Infer.Stream, {
host: "https://api.example.com", // Envoyの公開エンドポイント
request: req,
metadata: { Authorization: `Bearer ${token}` },
onMessage: (res) => onDelta(res.getDelta()),
onEnd: (code, msg) => {
if (code !== grpc.Code.OK) console.error("gRPC end:", code, msg);
},
});
}
04. よくある落とし穴 → こう直す
症状 | 原因 | 対処 |
---|---|---|
ストリームがすぐ切れる | EnvoyがHTTP/1.1経由 | http2_protocol_options: {} をclusterに設定、上流はHTTP/2で疎通 |
CORSエラー | x-grpc-web 未許可 | Access-Control-Allow-Headers にx-grpc-web, authorization を追加 |
応答が詰まる | Djangoで同期的に推論呼び出し | 推論は別gRPCサーバに分離。DjangoはJWT/認可のみに |
途中でDEADLINE_EXCEEDED | ルートtimeout デフォルト | ストリーミングルートはtimeout: 0s (無制限)or 適切な長めの値 |
モバイルで切断多発 | Keepalive不足 | サーバ/EnvoyのHTTP/2 keepalive/tcp keepaliveを有効化 |
05. 運用で効く監視(SLO設計)
ユーザー体感
- p95 ストリーム開始までの時間(メッセージ1個目が届くまで)
- 途中途切れ率(RST/
UNAVAILABLE
)
プラットフォーム
- Envoy:
http2.rx_reset
,downstream_cx_active
,cluster.infer_grpc.upstream_rq_active
- gRPC: status別エラー率(
UNAVAILABLE
,DEADLINE_EXCEEDED
) - アプリ: 1秒あたりの
yield
回数、キュー滞留、モデル遅延(p50/p95)
SLO例
- p95開始 < 600ms、
UNAVAILABLE
< 0.5%、DEADLINE_EXCEEDED
< 1%
06. セキュリティ & レート制御
- 短命JWT + スコープ + 端末バインド(
jti
/aud
) - 1ユーザーあたり同時ストリーム数の制限(Envoyの
rate_limit
やアプリ側で) - 生成系はトークン上限と時間上限をサーバ側で enforcement
08. トラブルシュート早見表
- “手元ではOK/本番で切れる”: LB/プロキシのHTTP/2オフロード設定を再確認
- “CORSプリフライトで失敗”:
OPTIONS
応答に必要ヘッダが付与されているか - “途中から遅くなる”: モデル側のバッチ詰まり/GC/スレッド枯渇。
yield
のインターバル確認
09. 拡張のヒント
- モデル多重化: Triton Inference Server等でdynamic batching + ストリーミング併用
- 音声/映像: gRPC‑Web単体が厳しい場合はWebRTCでメディア、gRPC‑Webで制御を分離
- モバイル節電: デルタ圧縮や途中集約でメッセージ数を抑制
10. サンプルproto(抜粋)
syntax = "proto3";
package infer;
service Infer { rpc Stream(StreamRequest) returns (stream InferReply); }
message StreamRequest { string prompt = 1; }
message InferReply { string delta = 1; }
付録: Envoy CORS 明示例(VirtualHostレベル)
virtual_hosts:
- name: backend
domains: ["*"]
cors:
allow_origin_string_match:
- safe_regex: { regex: "https://your-frontend\\.example" }
allow_methods: "POST,OPTIONS"
allow_headers: "content-type,x-grpc-web,authorization"
max_age: "86400"
まとめ
- Djangoで守る(短命JWT・認可)
- gRPCで流す(サーバーストリーミングで小分け返却)
- Envoyで橋渡し(HTTP/2・grpc_web・CORSの三点セット)
この設計で“待たせない”体験を。