概要
write: broken pipe
といったクライアント側の強制的なコネクション切断でのエラーハンドリングをする際の知見まとめ。
環境
- golang/go 1.13.3
事前知識
知っておくと良い知識を先に説明します。
そもそもpipeとは
pipeはプロセス間通信をするための単方向のデータチャネルです。IOストリームを扱います。
読み出し側と書き込み側それぞれのfdを経由してプロセス間の通信を可能にします。
例えば親子プロセスで通信を行いたい場合、親プロセスでpipeを開きそれをforkして子プロセスを用意します。
ref: https://inzkyk.github.io/ocamlunix-jp/pipes.html
そして親プロセスの書き込みfd・子プロセスの読み出しfdをそれぞれクローズすれば、以下のように子プロセス→親プロセスへ通信することができます。
pipeはシンプルで良い概念なのでこれを利用した実装が様々なところにあります。
具体例
具体例としてよく挙がるのはシェルコマンドの|
です。
例えばコマンドA | コマンドB
のように入力すると、IOストリームが以下のようになります。
ref: リダイレクトとパイプを図示してみた (2) - とあるソフトウェア開発者のブログ
コマンドAのstdoutをpipeの書き込み側に、コマンドBのstdinをpipeの読み出し側としてセットすることで、プロセス間の通信を行っています。
どちらかがクローズしていた場合
pipeは単方向のストリームですが、どちらかがクローズしていた場合はどうなるでしょうか?
クローズする側 | もう一方の挙動 |
---|---|
書き込み側がクローズ | pipeから読み出そうとするとEOFが返る |
読み出し側がクローズ | pipeに書き込もうとするとSIGPIPE というシグナルが発生。write(2)の戻り値としては EPIPE |
ちなみにSIGPIPEはハンドリングしないとプロセスが強制終了します。
世にも恐ろしいSIGPIPE、ソケットプログラミングの落とし穴 - 百日半狂乱
GoのSIGPIPE
↑で強制終了すると書きましたが、 signal package - os/signal - pkg.go.dev にあるようにGoでは少し扱いやすくなってます。
SIGPIPEをsignal.Notifyで受信しない場合
fdが1
か2
(つまりstdoutかstderr)においてクローズ済でbroken pipeになった場合、プログラムはSIGPIPEシグナルで終了します。
しかしそれ以外のfdであればSIGPIPEシグナルに対して特にアクションは起こさず、EPIPEエラーで失敗するだけです。
つまりコマンドラインプログラムは通常のUnixプログラムのように振る舞いますが、そうでないプログラム(例えばサーバ)ではクラッシュしません。
SIGPIPEをsignal.Notifyで受信する場合
fdに関係なく、SIGPIPEシグナルはNotifyチャネルに配信され、書き込みはEPIPEエラーで失敗します。
pipeの種類
名前無しパイプ(無名パイプ)
いわゆるパイプで、最初の説明の図のように親子プロセス間の通信に使います。
シェルの|
は一見親子関係にないプロセス間通信に見えますが、考えてみるとターミナルという親プロセスで繋がっているのでそれによって無名パイプで通信できるのでしょう。
名前付きパイプ
↑のパイプを拡張したもので、親子関係にないプロセス間通信を可能にします。
名前付きパイプは永続的でプロセスが消滅しても存在し続けるので、使わなくなったら削除する必要があります。
ソケット
パイプを使った通信には
- ローカルのマシンとしか通信できない
- (名前無しパイプでは)二つのプロセスは親子関係である必要がある
といった制限があります。
ソケットはこれらの問題を解決するために一般化されたパイプで、これによりクライアント・サーバ間通信ができます。
broken pipeを発生させてみる
よくあるのはheadを用いた検証方法です。
以下のようなstdoutへ大量にwriteするコードを用意して、
func main() { run() } func run() { for { _, err := fmt.Println("run!") if err != nil { fmt.Fprintln(os.Stderr, "run error: ", err) } } }
シェルの|
を使ってheadに流します。
$ go run main.go | head run! run! run! run! run! run! run! run! run! run! signal: broken pipe
headは途中でpipeをクローズしてしまうので、このようにSIGPIPEが飛んでプロセスが終了します。
_, err := fmt.Println("run!")
でエラーをハンドリングしようとしてますが、SIGPIPEによるプロセス落ちのせいで出力されずに終わってます。
SIGPIPEをハンドリングする
以下のようにSIGPIPEをハンドリングすると、write(2)からEPIPE
が返ってくることを確認できます。
func main() { signal_chan := make(chan os.Signal, 1) signal.Notify(signal_chan, syscall.SIGPIPE) go func() { select { case <-signal_chan: os.Exit(1) } }() run() } func run() { for { _, err := fmt.Println("run!") if err != nil { if errors.Is(err, syscall.EPIPE) { fmt.Fprintln(os.Stderr, "catch broken pipe error") } else { fmt.Fprintln(os.Stderr, err) } } } }
実行してみると
$ go run main.go | head run! run! run! run! run! run! run! run! run! run! catch broken pipe error catch broken pipe error catch broken pipe error catch broken pipe error exit status 1
このようにEPIPEが取得できます。
注意としてstdoutはpipeでheadにつながっているので、head側がクローズした後は以下のようにstderrに出力させます。
fmt.Fprintln(os.Stderr, "catch broken pipe error")
write: broken pipe
冒頭に戻ってサーバサイドで発生するwrite: broken pipe
エラーについて説明します。
どんなときに発生するか
こちらで説明されているように
net/http/server.go
のoutput bufferは4KBで、それ以上のレスポンスは分割して送られます。
最初の4KBは問題なく送れたものの、以降でクライアント側がRSTパケット送ってきてそのまま強制的にクローズしてしまった場合に起きます。
RSTでなくFINによるクローズでは?
通常のFIN
を用いたハーフクローズがクライアント側から始まった場合は
For incoming server requests, the context is canceled when the client's connection closes, the request is canceled (with HTTP/2), or when the ServeHTTP method returns.
ref: http package - net/http - pkg.go.dev
にあるようにcontext.Done()
が発火するので、これやcontext.Canceled
をハンドリングすれば対応できます。
対処法
Go 1.13より前はerrorの種類をtype assertionしてチェックする必要がありました。
先程のheadの場合であれば
func isBrokenPipe(err error) bool { perr, ok := err.(*os.PathError) if ok { if perr.Err == syscall.EPIPE { return true } } return false }
だし、ネットワーク経由の強制コネクションクローズであれば
func isBrokenPipe(err error) bool { nerr, ok := err.(*net.OpError) if ok { if nerr.Err == syscall.EPIPE { return true } serr, ok := nerr.Err.(*os.SyscallError) if ok { if serr.Err == syscall.EPIPE { return true } if serr.Err == syscall.ECONNRESET { return true } } } return false }
といったハンドリングです。
しかしGo 1.13からはerrors.Is()
やerrors.As()
が導入されたので、以下のように非常にシンプルにチェックできます。
func isBrokenPipe(err error) bool { if errors.Is(err, syscall.EPIPE) { return true } if errors.Is(err, syscall.ECONNRESET) { return true } return false }
その他
write: connection reset by peer
のケース
クライアントからRSTパケットを受けて発生するエラーです。
これも同様にハンドリングしておきます。
if errors.Is(err, syscall.ECONNRESET) { return true }
まとめ
write: broken pipe
が起きた際の対応方法をまとめました。
クライアント側が一方的に切断することで起きるエラーなのでユーザ影響はないですが、きちんとハンドリングしておかないと不要にサーバエラーのアラートが鳴ってしまい良くないです。
なのでサーバ側はこれらのエラーを握り潰しておくと良いです。