コツは 「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 などを利用
  • 短命JWTAuthorization: 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-Headersx-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の三点セット)

この設計で“待たせない”体験を。

投稿者 kojiro777

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です