Carpe Diem

備忘録

pipeエラーのハンドリング

概要

write: broken pipeといったクライアント側の強制的なコネクション切断でのエラーハンドリングをする際の知見まとめ。

環境

事前知識

知っておくと良い知識を先に説明します。

そもそもpipeとは

pipeはプロセス間通信をするための単方向のデータチャネルです。IOストリームを扱います。
読み出し側と書き込み側それぞれのfdを経由してプロセス間の通信を可能にします。

例えば親子プロセスで通信を行いたい場合、親プロセスでpipeを開きそれをforkして子プロセスを用意します。

f:id:quoll00:20191107004201p:plain

ref: https://inzkyk.github.io/ocamlunix-jp/pipes.html

そして親プロセスの書き込みfd・子プロセスの読み出しfdをそれぞれクローズすれば、以下のように子プロセス→親プロセスへ通信することができます。

f:id:quoll00:20191107004212p:plain

pipeはシンプルで良い概念なのでこれを利用した実装が様々なところにあります。

具体例

具体例としてよく挙がるのはシェルコマンドの|です。

例えばコマンドA | コマンドBのように入力すると、IOストリームが以下のようになります。

f:id:quoll00:20191106174522p:plain

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が12(つまり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エラーについて説明します。

どんなときに発生するか

こちらで説明されているように

stackoverflow.com

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が起きた際の対応方法をまとめました。

クライアント側が一方的に切断することで起きるエラーなのでユーザ影響はないですが、きちんとハンドリングしておかないと不要にサーバエラーのアラートが鳴ってしまい良くないです。
なのでサーバ側はこれらのエラーを握り潰しておくと良いです。

ソース