この記事はWanoグループアドベントカレンダーの5日目です。
ネタがないので、直近でgolangのnet/httpでやらかした事例を書きます。
作ってたもの
「CDNにキャッシュ載せるマン」です。
キャッシュに載せたいコンテンツのURLが記載されているファイルパスをコマンドライン引数に指定すると、それを読んでchannel経由でgoroutineに引き渡し、http.Getするやつ
間隔を空けないでひたすらgetリクエストを実行するので、CDN以外(とくに他所様のWebサイト)にやると普通にDOS攻撃になるやつですね。
(以前Qiitaに高速ダウンローダーとかいって同じような事してる人がいてツッコミ食らってた気がする)
事例その1 レスポンスを捨ててしまう
_, 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するのはクライアントの責務である。
いや、知ってましたよ。知ってましたとも。
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してもループを抜けないけど)
// 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メソッドからの抜粋
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のデータ特にいらないんですよね。
そういう場合は以下のようにするといいらしい
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発動しないっていう場合の対応
for {
select {
case hogehoe:
func (){
//やりたい処理
defer // やりたい処理
}()
}
}
みたいに即時実行関数にしてしまえばdefer使えるなと思いました。
(みんな普通にやってる?)
そもそも別途関数定義してから呼び出してしまえばいい話なんですが、関数化するほどでもない処理を書き下したいときに使える。
感想
ドキュメントはちゃんと読もう