Goでツールを量産する僕の方法
TIME rest time current/total
TopicsPlaceHolder

Goでツールを量産する僕の方法

Go Conference 2019 Summer

Jul 12th, 2019

Profile

songmu

【宣伝】みんなのGo言語

第二版がでます。1章結構書き直しました。特にGo Modulesなど。

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

レビューに参加しました

【宣伝】Nature Remo買ってください!

最近のGo活動

kibelasync

kibelaのCLI。

アジェンダ

注意事項など

私とGoとコマンドラインツール

作ってきたツール類

30を超えている。特に使っているものが以下

メンテナンスしてるもの

ツールを配布する

ツールのリリースについて

僕の場合

ワンコマンドで実行、もしくはCI上で実行するが、中身としては以下のような手順で実施している。

パッケージに同梱するもの

依存ライブラリのLICENSE同梱

gocredits

OSSを頒布するということ

ついでにCLA(Contributor License Agreement)の話

とはいえ

最近の雛形の話とか

godzil

github.com/Songmu/godzil

main pakcageをどこに置くか

ツールやライブラリで使い分ける人もいるようですが、僕は一律、 cmd/ を切ってそれ以下にコードを配置している。

mainをcmd以下に置く理由

package main 及び、 main.main() はボイラープレートのみに

ライブラリとして再利用が可能

ちょっとした動作確認が簡単

コマンドが複数書ける

コマンド名も、リポジトリ名と別のものが付けられる

ボイラープレート func() main 変遷

昔のmain()

func main() {
    os.Exit((&ghch.CLI{ErrStream: os.Stderr, OutStream: os.Stdout}).Run(os.Args[1:]))
}

今のmain()

func main() {
    log.SetFlags(0)
    err := kibelasync.Run(os.Args[1:], os.Stdout, os.Stderr)
    if err != nil && err != flag.ErrHelp {
        log.Println(err)
        exitCode := 1
        if ecoder, ok := err.(interface{ ExitCode() int }); ok {
            exitCode = ecoder.ExitCode()
        }
        os.Exit(exitCode)
    }
}

エントリーポイント

err := kibelasync.Run(os.Args[1:], os.Stdout, os.Stderr)

エラーハンドリング

if err != nil && err != flag.ErrHelp {

内部で flagSet作るときに flag.NewFlagSet("myapp", flag.ContinueOnError) にしているので、 flag.ErrHelp がここまで上がってくることがある。その場合は正常終了としている。

flagパッケージのデフォルトのflagSet(= flag.CommandLine) はExitOnErrorになってる(flag parseエラーでos.Exit(2)が呼ばれる)けど、これはEasy Usageのためのやつだと認識していて、基本的には使わない主義。

終了コード判定

エラーがあったら基本終了コード1だけど、errの中から終了コードが取り出せる場合はそれで終了する。

exitCode := 1
if ecoder, ok := err.(interface{ ExitCode() int }); ok {
    exitCode = ecoder.ExitCode()
}
os.Exit(exitCode)

ExitCoder interface

ExitCode() int がimplされていたらそこから終了コードを取る。結構そういうinterfaceをimplしているパッケージはある。例えばurfave/cli にもそういうinterfaceが定義されている。他にも github.com/Code-Hex/exit とか。

// github.com/urfave/cli/errors.go
type ExitCoder interface {
    error
    ExitCode() int
}

err.(interface{ ExitCode() int }) とassertionすれば良いだけなので、外部パッケージに依存しなくていいのもポイント。

とはいえあまり、ちゃんとExitCodeを綿密に定義することは少ないが。

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

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

コマンド実行の例

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

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

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

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

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

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

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

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

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

コマンドラッパーがやること

  1. コマンドを起動する
  2. コマンドを起動するときに情報を渡す(ことも)
  3. コマンドの終了を待ち受ける
  4. コマンド終了後の処理をする

コマンドを起動する

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の抽象化の素晴らしさ

コマンドに情報を渡す

子プロセスに情報を受け渡す方法

環境変数

コマンドライン引数

上限が存在するという難点

そうそう超えないが、機械的にコマンド組み立てたりしてて油断してると超えることも

大きな情報を共有するためには?

:thinking:

一時ファイル経由

一時ファイルを作って、そのファイルパスを環境変数などで通知する

Pros

Cons

標準入力にパイプで書き込む

r, w := io.Pipe()
cmd := exec.Command(...)
cmd.Stdin = r
cmd.Start()
// wに書き込む処理
fmt.Fprint(w, ...)

子プロセス側

os.Stdin から読み出せば良い

ExtraFilesによるファイルディスクリプタ渡し

標準入力だけでは足りない場合

r, w, _ := os.Pipe()
cmd := exec.Command(...)
cmd.ExtraFiles = []*os.File{r}
cmd.Start()
// wに書き込む処理

子プロセス側

// 3から始まる(0:Stdin, 1:Stdout, 2:Stderr)
pipe := os.NewFile(uintptr(3), "pipe")

逆に、入出力を逆にすることもできる。

パイプのPros/Cons

Pros

Cons

ソケット経由

Pros

Cons

休憩

ちょっと駆け足になってしまったのでこのあたり理解深めたければ
「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)

まとめ

以上