エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

コードリーディング: Go 標準ライブラリ net/http/httputil ReverseProxy での WebSocket の取り扱い

エンジニアリンググループの山口 (@no_clock) です。

クラウド電子カルテ「エムスリーデジカル」のシステム水平分割(参考)を実施するにあたり、 HTTP リクエストを分割システム群にルーティングするリバースプロキシを実装しました。実装規模は Go 言語で 500 行ほど。既に本番環境で運用しています。

この記事は、その際のコードリーディング内容を整理したものです。なお、 エムスリー Advent Calendar 2020 2 日目の記事です。

前提: 標準ライブラリ net/http/httputil ReverseProxy と WebSocket プロトコル

net/http/httputil パッケージの ReverseProxy

Go 言語の標準ライブラリ net/http/httputil パッケージReverseProxy があります。

これを用いると、簡単にリバースプロキシが実現できます。 func NewSingleHostReverseProxy(target *url.URL) をご覧いただくと分かりやすいのですが、 Director にリクエストを書き換える関数を注入するだけです。

WebSocket プロトコル

RFC 6455 で標準化されている双方向通信のためのプロトコルです。

ごく簡単にハンドシェイク方法を説明すると、次のようになります。

  1. クライアントは HTTP リクエストを行う。リクエストヘッダには Connection: UpgradeUpgrade: websocket 等を含める (4.1. Client Requirements) 。
  2. サーバは WebSocket 接続を受け入れる場合、 ステータスコード 101 のレスポンスを行う。レスポンスヘッダには Connection: UpgradeUpgrade: websocket 等を含める (4.2.2. Sending the Server's Opening Handshake) 。
  3. 以降、 WebSocket として通信する。

本編: コードリーディング

本題です。 ReverseProxy は WebSocket 接続をどのように取り扱うのか、 src/net/http/httputil/reverseproxy.gofunc (*ReverseProxy) ServeHTTP を読み解いていきましょう。

先にポイントを挙げておきます。

  • クライアントからのリクエスト(ハンドシェイク)を透過する
  • サーバからのレスポンス(ハンドシェイク)を透過する
  • WebSocket として通信するために、 HTTP としての解釈をやめる

なお、記事公開時点で最新の Go 1.15.5 をベースとしています。

l.244-l.272: リクエストの透過

リバースプロキシ先のリクエスト情報作成は、元々のリクエストを outreq に複製することから始まります。

ここでのポイントは l.244-l.272 です。 Connection: Upgrade を含む場合は Connection, Upgrade ヘッダをリバースプロキシ先に透過します (keep-alive, close は透過せず、 Transport.DisableKeepAlives の設定値を用います) 。

これによって、クライアントからの WebSocket のハンドシェイクをリバースプロキシ先に透過できます。

212 func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
(略)
233   outreq := req.Clone(ctx)
(略)
244   reqUpType := upgradeType(outreq.Header)
245   removeConnectionHeaders(outreq.Header)
(略)
267   // After stripping all the hop-by-hop connection headers above, add back any
268   // necessary for protocol upgrades, such as for websockets.
269   if reqUpType != "" {
270     outreq.Header.Set("Connection", "Upgrade")
271     outreq.Header.Set("Upgrade", reqUpType)
272   }
(略)
288   res, err := transport.RoundTrip(outreq)
(略)
362 }
(略)
533 func upgradeType(h http.Header) string {
534   if !httpguts.HeaderValuesContainsToken(h["Connection"], "Upgrade") {
535     return ""
536   }
537   return strings.ToLower(h.Get("Upgrade"))
538 }

l.295-l.301: レスポンスの透過…?

サーバからのレスポンスはどうでしょうか。

l.295 でステータスコード 101 であることを認識すると、 modifyResponsehandleUpgradeResponse を呼びだすだけになっています。そして、 modifyResponse はフィールドで与えた関数を実行するのみ。正体は handleUpgradeResponse にありそうです。

288   res, err := transport.RoundTrip(outreq)
(略)
294   // Deal with 101 Switching Protocols responses: (WebSocket, h2c, etc)
295   if res.StatusCode == http.StatusSwitchingProtocols {
296     if !p.modifyResponse(rw, res, outreq) {
297       return
298     }
299     p.handleUpgradeResponse(rw, outreq, res)
300     return
301   }
302 
303   removeConnectionHeaders(res.Header)

l.540-l.595: レスポンス、ハイジャック、コピー!

handleUpgradeResponse を見ていきます。複数要素が関係するので、ひとつずつ分解します。

l.580-l.588: レスポンスの透過

リバースプロキシ先からのレスポンス(ヘッダ部のみ)をクライアントにそのまま返します。このレスポンス返却をもって、ハンドシェイクが完了します。

580   res.Body = nil // so res.Write only writes the headers; we have res.Body in backConn above
581   if err := res.Write(brw); err != nil {
582     p.getErrorHandler()(rw, req, fmt.Errorf("response write: %v", err))
583     return
584   }
585   if err := brw.Flush(); err != nil {
586     p.getErrorHandler()(rw, req, fmt.Errorf("response flush: %v", err))
587     return
588   }

l.550-l.574: ハイジャック

ハンドシェイクの後は、 WebSocket の通信を通さなければなりません。

ここで Hijacker とか Hijack() といった物騒な言葉が飛び出してきます。 Hijack() メソッド のコメントを意訳すると「コネクションを自由に使えるようにしてあげるけど、 HTTP サーバとして何もしなくなるからよろしく」とあります。

540 func (p *ReverseProxy) handleUpgradeResponse(rw http.ResponseWriter, req *http.Request, res *http.Response) {
(略)
550   hj, ok := rw.(http.Hijacker)
(略)
574   conn, brw, err := hj.Hijack()

さらに実装を追うと src/net/http/server.go#L1978 に辿り着き、ここでコネクションの状態が StateHijacked になります。

1976 // Hijack implements the Hijacker.Hijack method. Our response is both a ResponseWriter
1977 // and a Hijacker.
1978 func (w *response) Hijack() (rwc net.Conn, buf *bufio.ReadWriter, err error) {
(略)
1992   rwc, buf, err = c.hijackLocked()
(略)
1998 }
(コード順序入れ替え)
311 func (c *conn) hijackLocked() (rwc net.Conn, buf *bufio.ReadWriter, err error) {
(略)
317   c.hijackedv = true
(略)
327   c.setState(rwc, StateHijacked)

つまり、 handleUpgradeResponse メソッドの中で、 HTTP でのやり取りから純粋な TCP 接続に戻しているのです。

l.589-l.593: コピー( WebSocket 通信の透過)

ハンドシェイクが完了し、いよいよ WebSocket の通信が始まります。

が、リバースプロキシとしてはデータを解釈することはありません。コネクションがある限り右から左、左から右へ流すだけです。

589   errc := make(chan error, 1)
590   spc := switchProtocolCopier{user: conn, backend: backConn}
591   go spc.copyToBackend(errc)
592   go spc.copyFromBackend(errc)
593   <-errc

l.561-l.579: 接続終了

Hijack するとコネクションの管理も自力で行う必要があります。クライアント、リバースプロキシ先、双方との接続をきちんと閉じるようになっています。

561   backConnCloseCh := make(chan bool)
562   go func() {
563     // Ensure that the cancelation of a request closes the backend.
564     // See issue https://golang.org/issue/35559.
565     select {
566     case <-req.Context().Done():
567     case <-backConnCloseCh:
568     }
569     backConn.Close()
570   }()
571 
572   defer close(backConnCloseCh)
573 
574   conn, brw, err := hj.Hijack()
(略)
579   defer conn.Close()

まとめ: ReverseProxy は WebSocket 接続をどう扱っていたか

コードリーディングの内容を整理します。 ReverseProxy は、 WebSocket 接続を以下のように扱っていました。

  1. WebSocket のハンドシェイクに必要なリクエストヘッダを透過する
  2. リバースプロキシ先からのレスポンスコード 101 をトリガーに、 HTTP として扱うのをやめる
  3. データを流すだけの作業に専念し、接続を閉じる

まさか WebSocket に対応しているとは思っておらず、「標準ライブラリだけで全部出来るじゃないか…」と驚きをもってコードリーディングを終えました。

余談: チームは簡単に超えられる

f:id:t-yamag:20201130104236p:plain

余談ですが、電子カルテチームで Go を選んだのは初めてでした。

ただし、「エムスリー初」ではありません。弊社は技術選定がチームに委ねられており、 Go での開発・運用経験が豊富なチームもあります。

今回は、その経験豊富な BIR チームの Slack チャンネルに飛び込んで、実装方針をざっくばらんに相談させてもらいました。チームによって技術スタックが異なる、という強みが活きています。

クラウド電子カルテのトップを一緒に走りませんか?

非常に地味な内容でしたが、サービスの成長に合わせて「何を使って解決していくか」を突き詰めていくのは楽しいものです。

12/14(Mon) 19:00- の採用説明会(オンライン)でも、『エムスリーデジカルの「これまでの5年」を支えた技術、「これからの5年」を支える技術』として技術面を詳しくお話しします。興味があればぜひご参加ください!

また、クラウド電子カルテに限らずエムスリーのエンジニアに興味をお持ちでしたら、 TechTalk (社内 LT 会) やカジュアル面談へのご参加もお待ちしております。

参考