94
68

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

WanoグループAdvent Calendar 2017

Day 5

goのnet/httpでのやらかし事例

Last updated at Posted at 2017-12-03

この記事はWanoグループアドベントカレンダーの5日目です。
ネタがないので、直近でgolangのnet/httpでやらかした事例を書きます。

作ってたもの

「CDNにキャッシュ載せるマン」です。
キャッシュに載せたいコンテンツのURLが記載されているファイルパスをコマンドライン引数に指定すると、それを読んでchannel経由でgoroutineに引き渡し、http.Getするやつ

間隔を空けないでひたすらgetリクエストを実行するので、CDN以外(とくに他所様のWebサイト)にやると普通にDOS攻撃になるやつですね。
(以前Qiitaに高速ダウンローダーとかいって同じような事してる人がいてツッコミ食らってた気がする)

事例その1 レスポンスを捨ててしまう

get.go
_, err := httpclient.Get(url)

CDNからoriginに対してファイル取得できることは確認できてたので、特にレスポンスの中身確認する必要ないかなーとか思って、上記のようにレスポンスを捨ててしまってました。

はい、何がマズいでしょうか。

そうですねBodyがCloseできませんNE

net/httpのgodocには以下の記載がある。

The client must close the response body when finished with it:

response bodyをcloseするのはクライアントの責務である。
いや、知ってましたよ。知ってましたとも。

get_close.go
resp, err := http.Get(url)
if err != nil {
  // handle error
}
defer resp.Body.Close()

上記のようにイディオム化してることは知ってたんですよ。

ただ、レスポンスの中身はいらなかったからつい。。。

BodyをCloseしないと何が起こるのか

tcpコネクションはpersistConnection構造体の中にnet.Connインターフェイスで保持しています。
BodyをCloseしないと同構造体のclosedフラグがセットされず、net.ConnがCloseされません。
すなわち、TCPコネクションがクローズされないために、そのまま続けてHttpリクエストを発行していくとファイルディスクリプタが枯渇することになります。

もうちょい詳細をいうと、persistConnectionはTransport構造体のdialConnメソッドの中で初期化されると同時に、goroutineでwriteloopとreadloopというメソッドを実行します。
net.Connのcloseはreadloopから抜けるとき、deferで実行されるようになっているんだけど、BodyがCloseされないとこのreadloopを抜けないようになっています。
(keep-aliveでコネクションが再利用される場合はBodyをCloseしてもループを抜けないけど)

transport.go
// persistConn wraps a connection, usually a persistent one
// (but may be used for non-keep-alive requests as well)
type persistConn struct {
	// alt optionally specifies the TLS NextProto RoundTripper.
	// This is used for HTTP/2 today and future protocols later.
	// If it's non-nil, the rest of the fields are unused.
	alt RoundTripper

	t         *Transport
	cacheKey  connectMethodKey
	conn      net.Conn
	tlsState  *tls.ConnectionState
	br        *bufio.Reader       // from conn
	bw        *bufio.Writer       // to conn
	nwrite    int64               // bytes written
	reqch     chan requestAndChan // written by roundTrip; read by readLoop
	writech   chan writeRequest   // written by roundTrip; read by writeLoop
	closech   chan struct{}       // closed when conn closed
	isProxy   bool
	sawEOF    bool  // whether we've seen EOF from conn; owned by readLoop
	readLimit int64 // bytes allowed to be read; owned by readLoop
	// writeErrCh passes the request write error (usually nil)
	// from the writeLoop goroutine to the readLoop which passes
	// it off to the res.Body reader, which then uses it to decide
	// whether or not a connection can be reused. Issue 7569.
	writeErrCh chan error

	writeLoopDone chan struct{} // closed when write loop ends

	// Both guarded by Transport.idleMu:
	idleAt    time.Time   // time it last become idle
	idleTimer *time.Timer // holding an AfterFunc to close it

	mu                   sync.Mutex // guards following fields
	numExpectedResponses int
	closed               error // set non-nil when conn is closed, before closech is closed
	canceledErr          error // set non-nil if conn is canceled
	broken               bool  // an error has happened on this connection; marked broken so it's not reused.
	reused               bool  // whether conn has had successful request/response and is being reused.
	// mutateHeaderFunc is an optional func to modify extra
	// headers on each outbound request before it's written. (the
	// original Request given to RoundTrip is not modified)
	mutateHeaderFunc func(Header)
}

事例その2 レスポンスのBodyを最後まで読んでない

上述のkeep-aliveが適用される条件がレスポンスのBodyを最後まで読むことなんですね。

以下はResponse構造体のコメントの引用

The default HTTP client's Transport does not attempt to reuse HTTP/1.0 or HTTP/1.1 TCP connections ("keep-alive") unless the Body is read to completion and is closed.

Bodyを最後まで読んでCloseしてないとkeep-aliveしないっつってますね。

以下はpersistConn構造体のreadloopメソッドからの抜粋

transport.go
		select {
		case bodyEOF := <-waitForBodyRead:
			pc.t.setReqCanceler(rc.req, nil) // before pc might return to idle pool
			alive = alive &&
				bodyEOF &&
				!pc.sawEOF &&
				pc.wroteRequest() &&
				tryPutIdleConn(trace)
			if bodyEOF {
				eofc <- struct{}{}
			}
		case <-rc.req.Cancel:
			alive = false
			pc.t.CancelRequest(rc.req)
		case <-rc.req.Context().Done():
			alive = false
			pc.t.cancelRequest(rc.req, rc.req.Context().Err())
		case <-pc.closech:
			alive = false
		}

上記はbool型変数のaliveを条件としたforループの末尾の内容
一番最初のcaseが問題の箇所です。
レスポンスのBodyをcloseするとwaitForBodyReadからBodyを最後まで読んだのかどうかを示す値がきます。
それがfalseだった時点でaliveがfalseとなるので、forループを抜けて、コネクションの再利用(keep-alive)はしないようになっています。
(tryPutIdleConnがコネクションをidleコネクションプールに入れる関数)

でも今回はBodyのデータ特にいらないんですよね。
そういう場合は以下のようにするといいらしい

discard.go
resp, err := httpclient.get(url)
if err != nil {
// handle error
}
defer func() {
  io.Copy(ioutil.Discard, resp.Body)
  resp.Body.Close()
}()

ioutil.Discardはいわゆる/dev/null的なやつで、io.Writerインターフェイスを実装しているんだけど、Writeメソッドを叩いても何もしないやつ。

ちょいネタ

goroutineの中で無限forループの中で実行している処理の中でdeferを使いたいんだけど、無限ループだからそもそも関数から抜けず、defer発動しないっていう場合の対応

defer.go
for {
  select {
  case hogehoe:
    func (){
      //やりたい処理
      defer // やりたい処理
    }()
  }
}

みたいに即時実行関数にしてしまえばdefer使えるなと思いました。
(みんな普通にやってる?)
そもそも別途関数定義してから呼び出してしまえばいい話なんですが、関数化するほどでもない処理を書き下したいときに使える。

感想

ドキュメントはちゃんと読もう

参考資料

94
68
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
94
68

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?