os/exec で外部プロセスを正しく実行する
これは Go 4 Advent Calendar 2020 7日目の記事です。
この記事では Go の os/exec パッケージで外部プロセスを実行する際にやりがちな間違いをクイズ形式で紹介します。
外部プロセスを実行するなんていうのはとてもありふれた作業ですが、複数プロセスが走る以上、並行性を扱う必要がどうしても生じます。そして並行処理は人類には早すぎることがよく知られており、外部プロセスの実行も油断していると罠にはまってしまうことがあるので気をつけましょう、という話がメインです。
なお、対象 OS は Linux とします。
問題 1/4
以下はシェルスクリプト scan1.sh を実行してその標準出力を返すコードですが、バグがあります。どのようなバグでしょうか?
func Scan() ([]byte, error) { cmd := exec.Command("./scan1.sh") stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } if err := cmd.Start(); err != nil { return nil, err } b, err := ioutil.ReadAll(stdout) if err != nil { return nil, err } return b, nil }
答え:
exec.Cmd.Wait を呼び忘れているのでリソースリークします。具体的には、実行元プロセスが終了するまでゾンビプロセスが残り続けます。
これを避けるには、defer を使って exec.Cmd.Wait を必ず呼び出すようにします。Wait もエラーを返しうるのでこれを正しくハンドルするのはそこそこ面倒で、たとえば次のように名前付き戻り値を使って defer 内でエラーを書き換える方法などがあります。
func Scan() (b []byte, err error) { cmd := exec.Command("./scan1.sh") stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } if err := cmd.Start(); err != nil { return nil, err } defer func() { if werr := cmd.Wait(); werr != nil && err == nil { err = werr } }() b, err = ioutil.ReadAll(stdout) if err != nil { return nil, err } return b, nil }
面倒すぎますね! 今回のように標準出力結果だけに興味がある場合はexec.Cmd.Start ではなく exec.Cmd.Output を使ったほうがリソースリークの危険もなく圧倒的に短く記述できます。
func Scan() ([]byte, error) { return exec.Command("./scan1.sh").Output() }
一般論として、exec.Cmd.Run, exec.Cmd.Output, exec.Cmd.CombinedOutput で済む場合はそちらを優先して使い、exec.Cmd.Start は本当に必要な場合だけ使うようにしましょう。
問題 2/4
シェルスクリプト scan2.sh は実行中、一般のログを標準出力に、エラーログを標準エラー出力に書き出します。エラーメッセージはすべて "ERROR" という文字列を含み、一般のログに "ERROR" という文字列は含まれないとします。
以下は scan2.sh を実行してエラーが発生したかどうかをチェックするプログラムですが、バグがあります。どのようなバグでしょうか?
func Scan() error { cmd := exec.Command("./scan2.sh") b, err := cmd.CombinedOutput() if err != nil { return err } if strings.Contains(string(b), "ERROR") { return errors.New("output contains ERROR") } return nil }
答え:
exec.Cmd.CombinedOutput は標準出力と標準エラー出力を一つにまとめたものを返す関数です。2つの出力がどう混ぜられるかは定義されていないので、標準エラー出力に "ERROR" と書かれてもそれが検出できない可能性があります。
たとえばシェルスクリプトが標準出力に "ok", 標準エラー出力に "ERROR" という文字列をそれぞれ書き出した時に、CombinedOutput が "ERRoOkR" という文字列を返したとしても文句は言えないということです。そうなると上記のコードはエラーを検出することに失敗します。
もちろんこれは極端な例で、現実的な CombinedOutput の実装ではこのような短い出力の場合には変な混ぜ方をしない可能性が高いですが、特に出力サイズが大きい時に標準出力と標準エラー出力を行ごとにマージすることを期待することはできません。
よって、一般論として CombinedOutput の結果をパースしてはいけません。人間が見るデバッグログを取る用途なら良いでしょう。
今回の場合、標準エラー出力だけが欲しいので、exec.Cmd.Stderr
に bytes.Buffer を設定した上で exec.Cmd.Run を呼んでやるのが良いです。
func Scan() error { cmd := exec.Command("./scan2.sh") var buf bytes.Buffer cmd.Stderr = &buf if err := cmd.Run(); err != nil { return err } if strings.Contains(buf.String(), "ERROR") { return errors.New("output contains ERROR") } return nil }
しかしそもそもの話でいうと、エラーを検出するためにログをスクレイプするのは脆弱です。スクリプトに変更が加えられるなら、エラーが起こった際には別の終了コードを返すようにしたほうが安全でしょう。
問題 3/4
シェルスクリプト scan3.sh は実行中にログを標準出力と標準エラー出力に書き出しますが、よく分からない理由により、出力はすべて各バイトを 0x55 で XOR することにより暗号化(?) されています。
以下は scan3.sh を実行して XOR 暗号化を解除しつつ標準出力と標準エラー出力をまとめたものを返すコードですが、バグがあります。どのようなバグでしょうか?
type xorWriter struct { w io.Writer } func (x *xorWriter) Write(p []byte) (int, error) { q := make([]byte, len(p)) for i, b := range p { q[i] = b ^ 0x55 } return x.w.Write(q) } func Scan() ([]byte, error) { cmd := exec.Command("./scan3.sh") var buf bytes.Buffer cmd.Stdout = &xorWriter{&buf} cmd.Stderr = &xorWriter{&buf} if err := cmd.Run(); err != nil { return nil, err } return buf.Bytes(), nil }
答え:
exec.Cmd.Stdout と exec.Cmd.Stderr は別々のゴルーチンから同時に書き込みがされる可能性があります。xorWriter はスレッドセーフですが bytes.Buffer はそうではないので、データレースが発生します。
この問題は exec.Cmd.Stdout と exec.Cmd.Stderr にまったく同一の xorWriter を設定することで解決できます。
func Scan() ([]byte, error) { cmd := exec.Command("./scan3.sh") var buf bytes.Buffer xor := &xorWriter{&buf} cmd.Stdout = xor cmd.Stderr = xor if err := cmd.Run(); err != nil { return nil, err } return buf.Bytes(), nil }
これは、exec.Cmd.Stdout と exec.Cmd.Stderr が比較可能で同一の値の場合、同時に2つ以上のゴルーチンから書き込みがされないことが保証されているからです。
If Stdout and Stderr are the same writer, and have a type that can be compared with ==, at most one goroutine at a time will call Write. https://godoc.org/os/exec#Cmd
問題 4/4
シェルスクリプト scan4.sh は標準入力から読み込んだ行ごとに何らかの処理を行い標準出力に結果を書き出します。
以下は scan4.sh を実行してその結果を返すコードですが、バグがあります。どのようなバグでしょうか?
func Scan(input []byte) (b []byte, err error) { cmd := exec.Command("./scan4.sh") stdin, err := cmd.StdinPipe() if err != nil { return nil, err } stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } if err := cmd.Start(); err != nil { return nil, err } defer func() { if werr := cmd.Wait(); werr != nil && err == nil { err = werr } }() if _, err := stdin.Write(input); err != nil { return nil, err } if err := stdin.Close(); err != nil { return nil, err } b, err = ioutil.ReadAll(stdout) return b, err }
答え:
標準入力に書き込む間、標準出力から読み出していません。そのためパイプにデータが詰まりデッドロックする可能性があります。
説明のために scan4.sh は標準入力から読み取ったデータをそのまま標準出力に書き出すプログラムだったとしましょう。Go プログラムが標準入力に書き込んだデータは scan4.sh により読み出され、そのまま標準出力に書き出されていくことになりますが、Go プログラムは標準出力からデータを読み出していないため、データが標準出力パイプに溜まっていくことになります。
Linux のパイプは固定長のサイズを持っており、多くの場合デフォルトは 64KB です(pipe(7))。標準出力パイプのキャパシティに達すると scan4.sh の標準出力への書き込みはブロックし、scan4.sh の動作が停止します。
標準入力からの読み出しがストップすると、次は標準入力パイプにデータが溜まり始めます。これがキャパシティに達した時、Go プログラムの標準入力への書き込みがブロックします。デッドロックの完成です。
この問題を回避するには、exec.Cmd.StdinPipe や exec.Cmd.StdoutPipe でパイプを明示的に扱う代わりに exec.Cmd.Stdin や exec.Cmd.Stdout を設定して使う、あるいは各パイプを別々のゴルーチンで処理します。標準入力を動的に生成する必要があったりするのでない限り、明示的にパイプを使わない前者の方法が良いでしょう。
まとめ
- Start/Wait および StdinPipe/StdoutPipe/StderrPipe はできるだけ回避する
- Run/Output および Stdin/Stdout/Stderr の方が圧倒的にハマりどころが少ない
- プロセスとインタラクティブに通信したい場合などは例外
- パイプを明示的に扱うなら、パイプごとにゴルーチンを作ること
- CombinedOutput の結果をパースしてはいけない
- やっぱり並行処理は人類には早い