Goによる外部プロセス起動ベストプラクティス及びtimeoutパッケージ徹底解決
TIME rest time current/total
TopicsPlaceHolder

Goによる外部プロセス起動ベストプラクティス及びtimeoutパッケージ徹底解決

Go Conference 2019 Spring

May 18th, 2019

Profile

songmu

【宣伝】みんなのGo言語

【宣伝】Go言語による並行処理

レビューに参加しました

最近のGo活動

注意事項など

アジェンダ

コマンドラッパーを書くということ

Goから外部コマンドを起動するケース

コマンド実行の例

世の中はコマンドラッパーで満ち溢れている

コマンドラッパーとはコマンドを引数で受け取ってそれを実行しつつ別のこともやってくれるもの。

コマンドラッパーを自分で作る

プロジェクトで独自のラッパーシェルを作ることも多いでしょう

好きなやつ (with-soundコマンド)

テストにコケる度にシーザーが死ぬ仕組みを作りました by moznion
https://moznion.hatenadiary.com/entry/20130305/1362467136

% cpanm App::WithSound
% with-sound /path/to/cmd

コマンド実行中に音を出す、コマンド失敗したら別の音を出す

コマンドラッパーを書くのは楽しい

コマンドを起動する

Goで外部コマンドを起動する

簡単!

import "os/exec"
cmd := exec.CommandContext("/path/to/command", "option...")
// 同期的呼び出し
err := cmd.Run()

// バックグランド呼び出し
cmd.Start()
// do something
cmd.Wait()

連携も簡単

コマンドの出力を受け取る

bytes.Buffer が基本。io.Reader/Writerをimplしてる便利なやつ。

var outbuf, errbuf bytes.Buffer
cmd.Stdin = &outbuf
cmd.Stderr = &errbuf
err := cmd.Run()
fmt.Println("stdout: " + outbuf.String())
fmt.Println("stderr: " + errbuf.String())

標準出力もエラー出力もマージ出力も欲しい

io.MultiWriter で複数Writerに書き出せる

var outbuf, errbuf, mergedBuf bytes.Buffer
outWtr := io.MultiWriter(&outbuf, &mergedBuf)
errWtr := io.MultiWriter(&errbuf, &mergedBuf)
cmd.Stdin = outWtr
cmd.Stderr = errWtr
err := cmd.Run()
fmt.Println("stdout: " + outbuf.String())
fmt.Println("stderr: " + errbuf.String())
fmt.Println("merged: " + mergedBuf.String())

コマンドの出力を維持しつつ出力を受け取る

cmd.StdoutPipe() で読み出しパイプを作り、io.TeeReader で別の場所に書き出したあと、io.Copy を使ってos.Stdoutに書き戻している。

pipe, err := cmd.StdoutPipe()
var buf bytes.Buffer
reader := io.TeeReader(pipe, buf)
err := cmd.Start()
_, err := io.Copy(os.Stdout, reader)
err := cmd.Wait()
fmt.Println(buf.String())

(MultiWriterを使っても同様のことは実現できるけど説明のため)

コマンド出力にタイムスタンプを自動付与したい

golang.org/x/text/transform.Transformer が便利

import "github.com/Songmu/timestamper"
import "golang.org/x/text/transform"

pipe, err := cmd.StdoutPipe()
defer pipe.Close()
reader := transform.NewReader(pipe, timestamper.New())
err := cmd.Start()
_, err := io.Copy(os.Stdout, reader)
err := cmd.Wait()

応用編

コマンドの標準出力もエラー出力もOSの出力にそれぞれ出しつつ、標準出力とエラー出力の内容はそれぞれ変数に保持し、マージ出力も保持しマージ出力にはタイムスタンプを付与する。

みたいなこともできるようになります。horensoは内部的にはそういうことをやっている。

LLでの外部コマンド起動の様子(Perlの場合)

backtickやsystem()などの同期呼び出し方法はあるが、バックグラウンドで呼び出そうとすると大変。

pipe開いて, forkしてpipeつないで、不必要なFDはちゃんと閉じる

my @command = ('path/to/command');
pipe my $rpipe, $wpipe;
unless (my $pid = fork) {
    if (defined $pid) {
        # child process
        close $rpipe; close $wpipe;
        open STDERR, '>&', $wpipe;
        open STDOUT, '>&', $wpipe;
        exec @command;
        die "exec(2) failed:$!";
    } else {
        close $rpipe; close $wpipe;
        die "fork(2) failed:$!";
    }
} else {
    # main process
    close $wpipe;
    while (my $log <$wpipe>) {
        # do something
    }
    close $wpipe;
}

Goの抽象化の素晴らしさ

コマンドを停止する

どういうときに停止させたいか

killコマンドとはなにか

kill - terminate a process
The command kill sends the specified signal to the specified process or process group
-- man 1 kill

killコマンドとはプロセスまたはプロセスグループにシグナルを送るものである。

シグナルとは

シグナル抜粋

シグナルハンドリング

プロセスはシグナルをトラップし、ハンドリングすることができる。以下のようなことも可能。

ただしSIGKILLとSIGSTOPはハンドリングできない。

プロセスを停止させたい場合はまずはSIGTERMで正常終了を促すのがお作法。それでも止まらない場合はSIGKILLを送る。

Goによるコマンドの停止

標準に exec.CommandContext がある

ctx, cancel := context.WithCancel(context.Background())
cmd := command.CommandContext(ctx, "sleep", "100")
err := cmd.Start()
cancel() // <- 停止
err = cmd.Wait()

タイムアウトも可能

ctx, cancel := context.WithCancel(context.Background(), time.Second*10)
defer cancel()
cmd := command.CommandContext(ctx, "sleep", "100")
err := cmd.Run() // <- 10秒で停止

これでいいのでは…?

func (c *Cmd) Start() 抜粋

select {
case <-c.ctx.Done():
    c.Process.Kill() // <- 内部的に p.Signal(Kill) 呼び出してるいる…!
case <-c.waitDone:
}

exec.CommandContextの問題

SIGKILLで強制停止している

孫プロセスなどがあった場合に止められない

2000 pts/0    Ss     0:00 -main
2001 pts/0    S+     0:00  \_ sh -c echo; sleep 100000000
2002 pts/0    S+     0:00      \_ sleep 100000000

sleep だとそれほど害はないが、シェル経由で呼び出したコマンドが暴走した場合、 sh を止めたとしても暴走プロセスは止まらないため、リソースを食い続けることになる。

もっとOS固有の細かい制御をしたい!

など

オフィシャルの見解

https://github.com/golang/go/issues/21135

いろいろな提案がされたものの、Russ Cox氏直々にClose。「標準じゃないパッケージでやればいいよね」

コマンドを正しく停止させる処理を書く

改めて、プロセスを「正しく停止」させるということ

github.com/Songmu/timeout

一定時間を超えたコマンドをタイムアウトさせ、その際コマンドを正しく終了させる。

import "github.com/Songmu/timeout"
cmd := exec.Command("/path/to/command")
tio := &timeout.Timeout{
    Cmd:       cmd,
    Duration:  10 * time.Second,
    KillAfter:  5 * time.Second,
    Signal     syscall.SIGTERM, // Default SIGTERM on Unix
}
err := tio.RunContext(context.Background())

go-timeoutというコマンドも

% go get github.com/Songmu/timeout/cmd/go-timeout
% go-timeout 30 /path/to/command

作った動機

アイデアはGNU timeoutから

https://www.gnu.org/software/coreutils/manual/html_node/timeout-invocation.html

coreutilsに入っている、一定実行時間をコマンドをタイムアウトさせるコマンド。

% timeout 30 /path/to/command

Macだと brew install coreutils で入る。cronなどで便利。バッチスキー(バッチが好きな人)の道具箱に常備されている。(他の便利道具に setlock, sotflimit 等がある)

Songmu/timeoutがやっていること

このあたりは、GNU timeoutとだいたい同じことをやっている

プロセスグループを作る

それぞれアプローチが異なる。

GNU timeoutの場合

timeout.cのsend_sig

/* timeout.c */
static int
send_sig (pid_t where, int sig)
{
  /* If sending to the group, then ignore the signal,
     so we don't go into a signal loop.  Note that this will ignore any of the
     signals registered in install_cleanup(), that are sent after we
     propagate the first one, which hopefully won't be an issue.  Note this
     process can be implicitly multithreaded due to some timer_settime()
     implementations, therefore a signal sent to the group, can be sent
     multiple times to this process.  */
  if (where == 0)
    signal (sig, SIG_IGN);
  return kill (where, sig);
}

Songmu/timeout の場合

呼び出しプロセス をプロセスグループリーダーにしている。

func (tio *Timeout) getCmd() *exec.Cmd {
    if tio.Cmd.SysProcAttr == nil {
        tio.Cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
    }
    return tio.Cmd
}

そしてそのプロセスグループに対してシグナルを送っている(後述)

プロセスグループへのシグナルの送り方

syscall.Killを使う

func Kill(pid int, signum syscall.Signal) (err error)

cmd.Process.Signal(sig os.Signal) ではプロセスグループにシグナルを送れないので、syscall.Kill にpidの負値を渡す。 また、Process.Signal()os.Signal を受け取るが、 syscall.Kill() が受け取るのは syscall.Signal である点も注意。

syssig, ok := sig.(syscall.Signal)
err := syscall.Kill(-cmd.Process.Pid, syssig)

これで正しくプロセスを停止させれるようになった?

これで完璧か?

timeout.cを見てみる

シグナルを送ったあとに、更にSIGCONTを送っている。これはどういうことか。

/* The normal case is the job has remained in our
   newly created process group, so send to all processes in that.  */
if (!foreground)
  {
    send_sig (0, sig);
    if (sig != SIGKILL && sig != SIGCONT)
      {
        send_sig (monitored_pid, SIGCONT);
        send_sig (0, SIGCONT);
      }
  }

一時停止しているプロセスはすぐには止まらない

→SIGCONTを送ることで強制的に再開させる

timeout_unix.go抜粋

これで正しくプロセスを停止させられるようになりました。

func (tio *Timeout) getCmd() *exec.Cmd {
    if tio.Cmd.SysProcAttr == nil {
        tio.Cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
    }
    return tio.Cmd
}

func (tio *Timeout) terminate() error {
    sig := tio.signal()
    syssig, ok := sig.(syscall.Signal)
    if !ok || tio.Foreground {
        return tio.Cmd.Process.Signal(sig)
    }
    err := syscall.Kill(-tio.Cmd.Process.Pid, syssig)
    if err != nil {
        return err
    }
    if syssig != syscall.SIGKILL && syssig != syscall.SIGCONT {
        return syscall.Kill(-tio.Cmd.Process.Pid, syscall.SIGCONT)
    }
    return nil
}

おまけ: 終了コードを正しく取得する

errorから取得する

このサンプルでは、 w.Signaled() で判定して、シグナルを受けた場合は、 シグナル番号に128を足した値を返却している。ただ、この128を加算するルールもあくまでシェルのルールであるため、厳密とは言えないかもしれない。

err := cmd.Wait()
exitCode, signaled := resolveExitCode(err)

func resolveExitCode(err error) (int, bool) {
    if err != nil {
        if exiterr, ok := err.(*exec.ExitError); ok {
            if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
                if w.Signaled() {
                    return int(w.Signal())+128, true
                }
                return status.ExitStatus(), false
            }
        }
        // The exit codes in some platforms aren't integer. e.g. plan9.
        return -1, false
    }
    return 0, false // 正常終了
}

github.com/Songmu/wrapcommander

https://github.com/Songmu/wrapcommander

→ wrapcommanderはそのあたりをいい感じに解決してくれます

var est *wrapcommander.ExitStatus = wrapcommander.ResolveExitStatus(err)

Go 1.12からProcess.ExitCodeで取れるようになりました

cmd := exec.Command("/path/to/cmd")
cmd.Run()
exitCode := cmd.ProcessState.ExitCode()

これもシグナル終了かどうかを取りたい場合は、以下のように syscall.WaitStatus を取り出してください。

st, ok := cmd.Process.Sys().(syscall.WaitStatus)

まとめ

以上