「通知をリアルタイムで出したい」「チャットを作りたい」「処理の進捗をライブで見せたい」——これらは要件としては一行ですが、実装の選択肢(SSE か WebSocket か)と本番の罠(切断検知・タイムアウト・認証・スケール)を外すと、繋がらない・落ちる・乗っ取られる機能になります。
この記事は、Go Echo 本番運用ガイドのリアルタイム編です。まず使い分けを明確にし、Echo v5 での SSE と WebSocket の実装、そして本番で必ずぶつかる4つの罠の対策を解説します。
この記事のルール:Echo の API は 公式ドキュメント(v5・2026年6月時点) に基づきます。WebSocket は標準
net/http互換のためgithub.com/gorilla/websocket(公式 Cookbook が採用)を用います。coder/websocket等の代替も同様に使えます。アーキテクチャ選定の全体像はWebSocket/SSE のアーキテクチャ決定も参照してください。
0. 最初に決める:SSE か WebSocket か
実装に入る前に、方向性で選びます。ここを間違えると、不要に複雑な実装を背負います。
| 観点 | SSE(Server-Sent Events) | WebSocket |
|---|---|---|
| 通信方向 | サーバー → クライアント(一方向) | 双方向 |
| プロトコル | 普通の HTTP(GET) | 専用(HTTP からアップグレード) |
| 自動再接続 | ブラウザが標準で行う | 自前実装が必要 |
| プロキシ/FW 通過 | HTTP なので通りやすい | 環境によっては弾かれる |
| 実装コスト | 低い | やや高い |
| 向く用途 | 通知・進捗・株価・ライブフィード | チャット・ゲーム・協調編集 |
判断の目安:サーバーからの一方向通知(通知バッジ、ジョブ進捗、ダッシュボード更新)なら、迷わず SSE。HTTP のままで実装が軽く、再接続もブラウザ任せにできます。クライアントからも頻繁に送る双方向(チャット、リアルタイム協調)なら WebSocket。「とりあえず WebSocket」は、多くの通知系では過剰です(YAGNI)。
1. SSE:一方向ストリーミングの本命
Echo v5 では c.Response() が http.ResponseWriter を返すので、SSE は標準的な HTTP ストリーミングとして素直に書けます。ポイントは text/event-stream ヘッダと、http.NewResponseController(w).Flush() による即時送出、そして c.Request().Context().Done() での切断検知です。
func (h *Handler) Stream(c *echo.Context) error {
w := c.Response()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
rc := http.NewResponseController(w) // Go 1.20+ の標準。Flush を型アサーション無しで呼べる
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-c.Request().Context().Done():
// クライアント切断・サーバー停止 → ループを抜ける(goroutine リーク防止)
return nil
case ev := <-h.events: // 配信したいイベント源(チャネル)
// SSE の書式:data: <payload>\n\n
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Name, ev.JSON); err != nil {
return err
}
if err := rc.Flush(); err != nil { // バッファを今すぐ送る
return err
}
case <-ticker.C:
// ハートビート(コメント行)。プロキシのアイドル切断を防ぐ
fmt.Fprint(w, ": ping\n\n")
_ = rc.Flush()
}
}
}
クライアントは標準の EventSource で受けるだけです(自動再接続つき)。
const es = new EventSource("/api/v1/stream");
es.addEventListener("progress", (e) => update(JSON.parse(e.data)));
1.1 SSE 最大の罠:WriteTimeout に殺される
デプロイ編で設定した WriteTimeout は、長時間接続の SSE/WebSocket を途中で切断します。リアルタイム経路では、サーバーの WriteTimeout を **0(無効)**にします。Echo v5 では StartConfig.BeforeServeFunc で設定します。
sc := echo.StartConfig{
Address: ":8080",
BeforeServeFunc: func(s *http.Server) error {
s.WriteTimeout = 0 // 長時間ストリーミングを切らない
s.ReadHeaderTimeout = 5 * time.Second // ヘッダ系の保護は残す
return nil
},
}
設計の分離:通常 API は
WriteTimeoutを効かせたい、SSE は効かせたくない——要件が矛盾します。理想は、リアルタイム用のサーバー(ポート)を通常 API と分けること。難しければ、上記のようにWriteTimeout=0にしつつ、SSE ハンドラ側でidle タイムアウトを自前管理します。
2. WebSocket:双方向通信の実装
WebSocket は HTTP 接続をアップグレードして確立します。net/http 互換なので、c.Response() と c.Request() を gorilla/websocket の Upgrader に渡すだけです。
import "github.com/gorilla/websocket"
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: checkOrigin, // ← セキュリティ上、必ず実装(第4章)
}
func (h *Handler) WS(c *echo.Context) error {
conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err // アップグレード失敗(既にレスポンス書込済みのことが多い)
}
defer conn.Close()
for {
// 読み取り(クライアント → サーバー)
mt, msg, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
c.Logger().Error("ws read error", "error", err)
}
break // 切断 → ループを抜ける
}
// 書き込み(サーバー → クライアント):ここではエコー
if err := conn.WriteMessage(mt, msg); err != nil {
break
}
}
return nil
}
2.1 読み書きを別 goroutine に分け、ping/pong で死活監視する
本番では、読み取りと書き込みを別 goroutineに分け、ping/pong で切断を検知します(gorilla の定石)。1接続 = 2 goroutine(reader/writer)+ 送信チャネル、という構成にします。
// writer:送信専用 goroutine。送信チャネルと定期 ping を一手に握る
func (cl *Client) writePump() {
pingTicker := time.NewTicker(30 * time.Second)
defer func() { pingTicker.Stop(); cl.conn.Close() }()
for {
select {
case msg, ok := <-cl.send:
_ = cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if !ok {
_ = cl.conn.WriteMessage(websocket.CloseMessage, nil)
return
}
if err := cl.conn.WriteMessage(websocket.TextMessage, msg); err != nil {
return
}
case <-pingTicker.C:
_ = cl.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
if err := cl.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return // pong が返らなければ相手は死んでいる
}
}
}
}
SetReadDeadline + SetPongHandler で、pong が来るたびに読み取り期限を延ばします。これでゾンビ接続(TCP は生きているが相手は応答しない)を回収できます。
3. 本番の罠:切断検知とリソースリーク
リアルタイムの事故の大半は、接続が切れたのに goroutine が残ることです。1接続あたり数 goroutine が漏れると、接続を繰り返すうちにメモリと goroutine が枯渇します。
| 罠 | 対策 |
|---|---|
| SSE で client 切断に気づかない | c.Request().Context().Done() を select し、必ずループを抜ける |
| WS のゾンビ接続 | ping/pong + ReadDeadline で死活監視し、無応答を切る |
| goroutine リーク | 1接続のライフサイクル(reader/writer)を必ず defer conn.Close() で閉じる |
| バックプレッシャ | 送信チャネルをバッファ付きにし、詰まったら遅い client を切る(全体を巻き込まない) |
| グレースフル停止 | SIGTERM 時に全接続へ close を送ってから終了 |
「遅い1クライアントが全体のレイテンシを悪化させる」のを防ぐには、送信が詰まった接続を切る判断が要ります。リアルタイム系は「全員に届けようと粘る」より「届かない相手は切る」方が全体の健全性を保てます。
4. セキュリティ:Origin 検証と WebSocket 認証
4.1 CheckOrigin:CSWSH を防ぐ
WebSocket には同一オリジンポリシーが効きません。CheckOrigin を実装しないと、悪意あるサイトがユーザーの Cookie を使って WebSocket を確立する **CSWSH(Cross-Site WebSocket Hijacking)**が可能になります。必ず Origin を許可リストで検証します。
var allowedOrigins = map[string]bool{"https://app.example.com": true}
func checkOrigin(r *http.Request) bool {
return allowedOrigins[r.Header.Get("Origin")] // 既定の「常に true」は危険
}
gorilla/websocket の CheckOrigin は**デフォルトが「同一ホストのみ許可」**ですが、リバースプロキシ背後では正しく機能しないことがあるため、明示的に許可リストを実装するのが安全です。
4.2 WebSocket の認証:ブラウザはヘッダを付けられない
ブラウザの WebSocket API はカスタムヘッダ(Authorization)を付けられません。そのためJWT 認証をそのまま流用できません。現実的な選択肢:
- Cookie(httpOnly):同一サイトなら Cookie は自動送出される。CSRF/Origin 対策と併用。最も素直。
- 接続後の最初のメッセージでトークンを送る:アップグレード自体は通し、最初のフレームで JWT を検証、ダメなら即 close。
- Sec-WebSocket-Protocol(subprotocol)にトークンを載せる:仕様準拠だが扱いがやや特殊。
アップグレード前に Echo のミドルウェアで Cookie 認証を通し、CheckOrigin で Origin を縛る、の二段が実装も堅牢性もバランスが良い構成です。
5. スケール:複数インスタンスでは「インメモリ Hub」だけでは届かない
ここが本番の最重要ポイントです。WebSocket/SSE の接続は、それを確立した特定のインスタンスに固定されます。インスタンスが2台以上あると、インスタンス A に繋がっている user へ、インスタンス B のイベントが届きません。
[インスタンスA] ── user1, user2 ←┐
│ A 内のイベントは A の接続にしか届かない
[インスタンスB] ── user3, user4 ←┘ B のイベントは B の接続にしか届かない
解決策は、メッセージブローカ(Redis Pub/Sub 等)越しに配信することです。各インスタンスはブローカを購読し、受け取ったメッセージを自分が抱える接続に配ります。
イベント発生 → [Redis Pub/Sub に publish]
↓ 全インスタンスが購読
[A]→A の接続へ [B]→B の接続へ 全 user に届く
- 単一インスタンス(小規模):インメモリの Hub(接続レジストリ)で十分。KISS。
- 複数インスタンス(本番):Redis Pub/Sub・NATS・Kafka 等でインスタンス間を橋渡し。あるいは、リアルタイム配信自体をマネージドサービス(API Gateway WebSocket / Ably / Pusher)に逃がす判断もあります。
この「接続のアフィニティ」と「ブローカ越し配信」の設計判断は、リアルタイムアーキテクチャの決定ガイドで詳しく扱っています。可観測性(接続数・配信レイテンシのメトリクス)はOpenTelemetry 連携で計測します。
まとめ:リアルタイムを本番品質にする7原則
- 方向で選ぶ。一方向通知は SSE、双方向は WebSocket。多くは SSE で足りる(YAGNI)。
- SSE は
text/event-stream+http.NewResponseController(w).Flush()、切断はctx.Done()で検知。 - 長時間接続は
WriteTimeout=0(BeforeServeFunc)。可能ならリアルタイム用にサーバーを分ける。 - WebSocket は reader/writer を分け、ping/pong で死活監視。
defer conn.Close()でリークを防ぐ。 CheckOriginを必ず実装して CSWSH を防ぐ。- WS 認証は Cookie か接続後トークン(ブラウザはヘッダを付けられない)。
- 複数インスタンスは Redis Pub/Sub 等で橋渡し。インメモリ Hub だけでは全体に届かない。
リアルタイムは「繋ぐ」ことより「切る・守る・スケールさせる」ことの方が難しい領域です。まず SSE で要件が満たせないかを問い、必要な双方向性だけを WebSocket で足す——この順番が、保守しやすいリアルタイム機能の近道です。全体像はGo Echo 本番運用ガイドへ。