ぽよメモ

レガシーシステム考古学専攻

Go 1.20で入ったexec.CommandのCancelとWaitDelayで外部コマンドを正しく終了させる

背景

Goでは外部コマンドの実行時に os/exec パッケージの CommandCommandContext を利用します。特に CommandContext を利用することで、 Goのcontextの流儀に従ってコマンドのタイムアウトや中断が可能であり大変便利です。

一方で、LinuxmacOSなどにおいてContextによるタイムアウト・中断時には外部コマンドに即座に SIGKILL が送られてキルされてしまうため、一部のケースでは孫プロセスが孤児プロセスとして残ってしまったり、後始末を正しく出来ないままコマンドが終了してしまうという問題が知られていました。

より安全にコマンドを終了させる方法として、まずは SIGINTSIGTERM を送り、一定時間内に終了しなければ SIGKILL で終了させるなどの方法が知られています。しかし、 CommandContext を使わずにContextによる中断のハンドリングを自分で行う必要があるなど初学者には難しい状況になっていました。

Go 1.20で導入されたCancelとWaitDelay

ひっそりとリリースノートに記載されている内容なので、あまり気にしておられない方も多いかと思います。 exec.Cmd 構造体に新たに二つのフィールドが追加されました。

Cancel の型は func() errorWaitDelay の型は time.Duration です。元々は下記のプロポーザルから実装されたもののようです。当初は KILL されるまでの時間と、ContextがDoneになったときに送信するシグナルを指定できるようにするはずだったようですが、途中で任意の実装を挟み込めるようになったようです。

Cancel

まず Cancel は、 CommandContext で渡したContextがDoneになったときに呼び出される関数で、デフォルトでは cmd.Process.Kill() を呼び出す関数が設定されるようです。

代わりに SIGTERM を送ったりする関数を与えることで、Contextをキャンセルしたりタイムアウトさせた際に送信するシグナルを指定できる他、任意の処理が出来るので標準入力を閉じたりネットワーク越しにリクエストを送ったりすることもできるようです。要するにこれまで手動でコンテキストをハンドリングして行っていたようなものをここに書いておくだけでよくなるということです。

WaitDelay

ContextがDoneになってから、 cmd.Process.Kill() されるまでの猶予時間です。この間に Cancel の処理を終わらせないと SIGKILL で終了させられてしまいます。

デフォルトは0になっているため、 Cancel を使う際は WaitDelay を手動設定しておかないとうまく処理できません。

使い方

SIGINTを送って死ななければSIGKILLで終了させる

まずは以前までの書き方をおさらいします。まず、 exec.CommandContext を使うとContextがDoneになったときにSIGKILLが送られてしまうのでこれは使えません。 exec.Command を使い、Contextのハンドリングは自分で行わなければいけません。

package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "os/signal"
    "time"
)

func run() error {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()
    cmd := exec.Command("bash", "-c", "trap 'echo \"signal received\"; sleep 10; echo \"done\"' SIGINT; sleep 120")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    if err := cmd.Start(); err != nil {
        return err
    }
    errCh := make(chan error, 1)
    go func() {
        defer close(errCh)
        errCh <- cmd.Wait()
    }()
    for {
        select {
        case exitErr := <-errCh:
            return exitErr
        case <-ctx.Done():
            fmt.Println("Send SIGINT")
            cmd.Process.Signal(os.Interrupt)
            select {
            case exitErr := <-errCh:
                return exitErr
            case <-time.After(5 * time.Second):
                fmt.Println("Send SIGKILL")
                cmd.Process.Kill()
                return <-errCh
            }
        }
    }
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

(適当に書いたので穴があるかも知れない)

ここではシグナルを受け取るとContextをキャンセルし、子プロセスに SIGINT を送って5秒待機、まだ終了しなければ SIGKILL を送って待ちます。

CancelWaitDelay を使うと以下の様に書けます。

package main

import (
    "context"
    "fmt"
    "os"
    "os/exec"
    "os/signal"
    "time"
)

func run() error {
    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()
    cmd := exec.CommandContext(ctx, "bash", "-c", "trap 'echo \"signal received\"; sleep 1; echo \"done\"' SIGINT; sleep 120")
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    cmd.Cancel = func() error {
        return cmd.Process.Signal(os.Interrupt)
    }
    cmd.WaitDelay = 5 * time.Second
    return cmd.Run()
}

func main() {
    if err := run(); err != nil {
        fmt.Fprintf(os.Stderr, "Error: %v\n", err)
        os.Exit(1)
    }
}

実行してみると下記の様にシグナルを受け取っていることがわかります。

❯ go run main.go                                  
^Csignal received
done
Error: exit status 130
exit status 1

シグナルを受け取ってからのスリープの時間を10秒などに延ばすと、 SIGKILL が送られていることがわかります。

❯ go run main.go
^Csignal received
Error: signal: killed
exit status 1

注意点

Windowsでは cmd.Process.Signal が実装されておらず、今回の方法で正しくプロセスが終了できるとは限らないことに注意が必要です。実際Windowsでのプロセスの正しい終了の仕方を全然知らない……知っている方がおられれば是非教えて頂きたいですね。