golangで便利ツール作ろうと思ったらos/execでハマった話

# はじめに

PoCの初期段階などで、いくつかのシステムを組み合わせて動作させるような場面では、
リポジトリが分散し、開発者ごとに違う言語で小規模なシステムを構築していることがあります。

そんな中で、動作確認をしたり、不具合を再現させたりする際、以下のような手作業が発生していました。
1. システムAを実行してCSVを吐く
2. CSVをシステムB直下にコピーする
3. システムBを実行して結果を得る

プログラマーの三大美徳の怠惰担当の私は、手動で何かをすることが嫌いなので、自動化するツールを作成していきます。
シェルスクリプトで書いても良いのですが、実行するデータパターンが複雑だったり、分岐したりすると面倒なので、私はgolangで作成することが多いです。

この記事では、そんな中でハマった出来事と、回避方法をご紹介します。
根本解決していないものが多いので要注意&コメントお待ちしています。

# 先にまとめ

- 変なところでハマる場合は、最初からシェルスクリプトで書いたほうが早い
- os/execでの実行結果と、ターミナルで手で叩いたときの出力結果は異なることがある

# 環境

以下環境で作業しています。
主に開発中の手元で動かす便利ツールなので、スピードを優先し、他環境でのことはあまり考えずに作っています。

- macOS
- zsh

# やりたかったこと

- ファイルコピー
- システムの実行(docker exec)

# 具体例

## ファイルコピー

複数のCSVファイルを特定のpathにコピーすることを考える。

### シェルスクリプトの場合

```shell
cp /path/to/csv/*.csv /path/to/dist
```

### golangの場合

golangでファイルコピーしようとすると、どうやら1ファイルごとに作成と中身のコピーが必要らしい。
https://qiita.com/cotrpepe/items/93e4a072c249a933e795

### 失敗例

コード長くなるのが嫌だったので、os/execを利用して以下のようなコードを書いてみる。

```go
	cmd := exec.Command("cp", "/path/to/csv/*.csv", "/path/to/dist")
	log.Println(cmd.String())
	err := cmd.Run()
	if err != nil {
		log.Fatal(err)
	}
```

出力結果は以下のようになり、失敗する。

```
2020/04/05 19:18:59 /bin/cp /path/to/csv/*.csv /path/to/dist
2020/04/05 19:18:59 exit status 1
exit status 1
```

ターミナルにて以下を実行すると成功するため、ターミナル環境とos/execでの実行環境が異なるものと思われる。

```
/bin/cp /path/to/csv/*.csv /path/to/dist
```

ファイル名指定のコピーは成功したのでワイルドカードを認識出来ていないものと考える。

### 回避方法

golang側でコピー用の関数を書いても良いが、コピー回数が増えそうなので、CSVファイル数が多い場合が気になる。
ここは、あくまでos/execを利用して、以下のように実装した。
golangからOSへの命令回数は `ls` `cp` がそれぞれ1回ずつに抑えられているはず。

```go
// 指定したパスに存在する全てのcsvを宛先にコピーする
func copyCSV(org, dist string) {
	csvs := getFileByExtension(org, ".csv")
	csvs = append(csvs, dist)

	cmd := exec.Command("cp", csvs...)
	log.Println(cmd.String())
	err := cmd.Run()
	if err != nil {
		log.Fatal(err)
	}
}

// 拡張子に合致するファイル一覧を取得
func getFileByExtension(path, extension string) []string {
	files, err := ioutil.ReadDir(path)
	if err != nil {
		log.Fatal(err)
	}

	var results []string
	for _, file := range files {
		if file.IsDir() {
			continue
		}
		if strings.HasSuffix(file.Name(), extension) {
			results = append(results, path+file.Name())
		}
	}
	return results
}
```

## docker exec

Python環境をdocker上に用意しており、普段は以下の様なコマンドで実行していた。

```
$ docker exec -it container_name bash
# cd /path/to/project
# python hogehoge.py
```

### シェルスクリプトの場合

docker内で実行するため、コマンドをまとめて`bash -c`で実行する。

```shell
docker exec container_name bash -c "cd /path/to/project && python hogehoge.py"
```

### golangの場合

実行方法はこちらの記事が参考になる。
https://qiita.com/nkz0914ssk/items/5429e75e5c0711add93a

### 失敗例

上記のライブラリは、設定項目が多くライブラリの仕様を理解する必要があり、手軽に実行出来なかったので、os/execを利用してみる。

```go
func execDocker() {
	cmd := exec.Command("docker", "exec", "container_name", "bash", "-c", `"cd /path/to/project && python hogehoge.py"`)
	log.Println(cmd.String())
	err := cmd.Run()
	if err != nil {
		log.Fatal(err)
	}
}
```

出力結果は以下の通り。

```
2020/04/05 19:44:30 /usr/local/bin/docker exec container_name bash -c "cd /path/to/project && python hogehoge.py"
2020/04/05 19:44:30 exit status 127
exit status 1
```

上記のうち、以下のコマンドはたしかにターミナル上で手動で実行する分には動作するが、os/execからはうまく動かない。

```
/usr/local/bin/docker exec container_name bash -c "cd /path/to/project && python hogehoge.py"
```

また、コピーの際も同様だが、上記出力からわかるように、エラー時は標準エラー出力を受け取るわけではなく、exit statusのみを受け取ってしまうようで、問題の切り分けが難しい。
なお、`out, err := cmd.Output()` としても`out`は空になる。

### 回避方法

様々な方法を試したが、上手く行かなかったので、最終的にはこの部分だけシェルスクリプトに逃がすことにした。
(手元で動かすだけだから許されることであって、当然ながら本番稼働するような場合はアーキテクチャから見直しが必要)

```go
	cmd := exec.Command("hogehoge.sh", arg1, arg2)
```

```shell
#!/usr/local/bin/zsh

docker exec container_name bash -c "cd /path/to/project && python hogehoge.py"
```

# まとめ

最初にも書いたが、単純に数が多いだけなら最初からシェルスクリプト起点で書くのが良いと思う。
しかし、実行パターンが複雑だったり、進捗を表示したかったりと、一定の条件下では、慣れている言語で書いた方が早いことが多い。
特にPoC中などの速度が求められる場面で、ツール作成に時間をかけたりハマっていると、最初から手動でやれと言われてしまいかねない。

品質と生産性の取るべきバランスは環境ごとに変わるものであって一定ではないことを、今後も心に留めながらエンジニアリングをしていきたいと思う。