メインコンテンツへスキップ
友田 陽大
Go・Echo 本番運用
Go
Echo
リアルタイム
アーキテクチャ設計
セキュリティ
可観測性

Echo リアルタイム実装:WebSocket と SSE の使い分け・本番設計(切断検知・認証・スケール)

Go Echo(v5)でリアルタイム機能を本番品質に実装するガイド。SSE(text/event-stream + http.NewResponseController でフラッシュ)と WebSocket(gorilla/websocket でアップグレード)の実装、両者の使い分け、切断検知・WriteTimeout 無効化・Origin 検証・WebSocket 認証・複数インスタンスでのスケール(Redis Pub/Sub)までを実コードで解説します。

公開日
読了時間
10分
著者
友田 陽大
シェア

「通知をリアルタイムで出したい」「チャットを作りたい」「処理の進捗をライブで見せたい」——これらは要件としては一行ですが、実装の選択肢(SSE か WebSocket か)と本番の罠(切断検知・タイムアウト・認証・スケール)を外すと、繋がらない・落ちる・乗っ取られる機能になります。

この記事は、Go Echo 本番運用ガイドのリアルタイム編です。まず使い分けを明確にし、Echo v5 での SSEWebSocket の実装、そして本番で必ずぶつかる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/websocketUpgrader に渡すだけです。

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/websocketCheckOrigin は**デフォルトが「同一ホストのみ許可」**ですが、リバースプロキシ背後では正しく機能しないことがあるため、明示的に許可リストを実装するのが安全です。

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原則

  1. 方向で選ぶ。一方向通知は SSE、双方向は WebSocket。多くは SSE で足りる(YAGNI)。
  2. SSE は text/event-stream + http.NewResponseController(w).Flush()、切断は ctx.Done() で検知。
  3. 長時間接続は WriteTimeout=0BeforeServeFunc)。可能ならリアルタイム用にサーバーを分ける。
  4. WebSocket は reader/writer を分け、ping/pong で死活監視defer conn.Close() でリークを防ぐ。
  5. CheckOrigin を必ず実装して CSWSH を防ぐ。
  6. WS 認証は Cookie か接続後トークン(ブラウザはヘッダを付けられない)。
  7. 複数インスタンスは Redis Pub/Sub 等で橋渡し。インメモリ Hub だけでは全体に届かない。

リアルタイムは「繋ぐ」ことより「切る・守る・スケールさせる」ことの方が難しい領域です。まず SSE で要件が満たせないかを問い、必要な双方向性だけを WebSocket で足す——この順番が、保守しやすいリアルタイム機能の近道です。全体像はGo Echo 本番運用ガイドへ。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事の実装を、案件として承ります

Go / Echo のバックエンドを、設計から本番運用まで承ります

Echo v5 へのAPI設計・移行、クリーンアーキテクチャ(Controller/UseCase/Repository + DI)、ミドルウェアとセキュリティ、集中エラー処理、グレースフルシャットダウン、テストとCIまで。Go/Echo + google/wire で実際にクリーンアーキのバックエンドを構築した知見で、落ちない・追える・変更しやすいAPIを実装します。

プロジェクト単位(請負)・技術顧問のどちらにも対応可能です。まずは30分の無料技術相談から。

あわせて読みたい