Goからlocalのtest用DB(MySQL)をdockerで起動する

## 参考元

Using MySQL in Docker for local testing In Go　
https://blog.kowalczyk.info/article/w4re/using-mysql-in-docker-for-local-testing-in-go.html

github
https://github.com/kjk/go-cookbook/tree/master/start-mysql-in-docker-go


goもdockerも勉強中の自分には、とても参考になる記事でした。
上記のblogではpanicを呼んでいたり、設定値はconstで定義されていたので、そのあたりを書き直して自分にとって使いやすいようにしてみました。

blogを投稿されているKrzysztof Kowalczyk さんにQiitaに載せていいか聞いてみたところ快諾していただけました。

> Yes, of course you can. 


## やりたいこと

Goの中から、利用するDBを起動させたい

## Code

やっていることは、 ``docker container ls -a`` の出力結果から

- 既に起動していたらなにもしない
- containerが存在しなければ ``docker container run``
- containerがstopなら ``docker container start`` 


```go:main.go

package main

import (
	"bytes"
	"context"
	"log"
	"os"
	"os/exec"
	"strings"
	"time"

	"github.com/juju/errors"
	"go.uber.org/zap"
	"go.uber.org/zap/zapcore"
)

const (
	dockerStatusExited  = "exited"
	dockerStatusRunning = "running"
)

type LocalDockerDB struct {
	Image     string
	Container string
	MountDir  string
	IPAddr    string
	Port      string
	User      string
	Password  string
	Logger    *zap.Logger
}

type LocalDockerDBOption func(*LocalDockerDB)

func NewLocalDockerDB(options ...LocalDockerDBOption) *LocalDockerDB {
	d := &LocalDockerDB{
		Image:     "mysql:5.6",
		Container: "myproject-mysql-db",
		MountDir:  "/tmp/docker_db",
		Port:      "3306",
		User:      "gopher",
		Password:  "golangorgohome",
		Logger:    zap.NewNop(),
	}
	for _, option := range options {
		option(d)
	}
	return d
}

func (d *LocalDockerDB) Start(ctx context.Context) error {
	d.Logger.Info("local_docker_db", zap.String("status", "starting"))

	// docker daemon must be running
	if err := exec.Command("docker", "container", "ls").Run(); err != nil {
		return errors.Trace(err)
	}

	if err := os.MkdirAll(d.MountDir, 0755); err != nil {
		return errors.Annotatef(err, "failed to create mount dir %q", d.MountDir)
	}

	info, err := dockerContainerInfo(d.Container)

	if info != nil && info.status == dockerStatusRunning {
		d.Logger.Debug("local_docker_db",
			zap.String("container_info", "already running"),
			zap.String("container", d.Container),
		)
		d.IPAddr, err = decodeIPPort(info.mappings)
		return errors.Trace(err)
	}

	var cmd *exec.Cmd
	// start or resume container
	if info == nil && errors.IsNotFound(err) {
		d.Logger.Debug("local_docker_db",
			zap.String("container_info", "not_found"),
			zap.String("container", d.Container),
		)
		volumeMapping := "type=bind,source=" + d.MountDir + ",target=/var/lib/mysql"
		portMapping := d.Port + ":3306"
		cmd = exec.Command("docker", "container", "run", "--detach",
			"--name", d.Container,
			"--publish", portMapping,
			"--mount", volumeMapping,
			"--env", "MYSQL_USER="+d.User,
			"--env", "MYSQL_PASSWORD="+d.Password,
			"--env", "MYSQL_INITDB_SKIP_TZINFO=yes",
			"--env", "MYSQL_ALLOW_EMPTY_PASSWORD=yes",
			d.Image,
		)
	} else if info != nil && info.status != "" {
		d.Logger.Debug("local_docker_db",
			zap.String("container_info", info.status),
			zap.String("container", d.Container),
		)
		cmd = exec.Command("docker", "container", "start", info.id)
	} else {
		return errors.New("failed to start container")
	}

	var stdOut, stdErr bytes.Buffer
	cmd.Stdout = &stdOut
	cmd.Stderr = &stdErr
	err = cmd.Run()
	d.Logger.Debug("local_docker_db",
		zap.String("exec docker cmd", strings.Join(cmd.Args, " ")),
		zap.String("stdout", stdOut.String()),
		zap.String("stderr", stdErr.String()),
	)
	if err != nil {
		return errors.Trace(err)
	}

loop:
	for {
		select {
		case <-ctx.Done():
			err = ctx.Err()
			break loop
		default:
			info, err := dockerContainerInfo(d.Container)
			if err != nil {
				err = errors.Trace(err)
				break loop
			}
			if info != nil && info.status == dockerStatusRunning {
				d.IPAddr, err = decodeIPPort(info.mappings)
				break loop
			}
			time.Sleep(time.Second)
		}
	}

	return errors.Trace(err)
}

type containerInfo struct {
	id       string
	name     string
	mappings string
	status   string
}

func decodeContainerStatus(status string) string {
	// convert "Exited(0) 2 days ago" into statusExited
	if strings.HasPrefix(status, "Exited") {
		return dockerStatusExited
	}

	// convert "Up <time>" into statusRunning
	if strings.HasPrefix(status, "Up") {
		return dockerStatusRunning
	}
	return strings.ToLower(status)
}

func dockerContainerInfo(containerName string) (*containerInfo, error) {
	cmd := exec.Command("docker", "container", "ls", "-a", "--format", "{{.ID}}|{{.Status}}|{{.Ports}}|{{.Names}}")
	stdOutErr, err := cmd.CombinedOutput()
	if err != nil {
		return nil, errors.Annotate(err, string(stdOutErr))
	}

	s := string(stdOutErr)

	s = strings.TrimSpace(s)
	lines := strings.Split(s, "\n")
	for _, line := range lines {
		if line == "" {
			continue
		}
		parts := strings.Split(line, "|")
		if len(parts) != 4 {
			return nil, errors.Errorf("unexpected output from docker container ls %s. expected 4 parts, got %d (%v)", line, len(parts), parts)
		}
		id, status, mappings, name := parts[0], parts[1], parts[2], parts[3]
		if containerName == name {
			return &containerInfo{
				id:       id,
				name:     name,
				mappings: mappings,
				status:   decodeContainerStatus(status),
			}, nil
		}
	}
	return nil, errors.NotFoundf(containerName)
}

// given:
// 0.0.0.0:3307->3306/tcp
func decodeIPPort(mappings string) (string, error) {
	parts := strings.Split(mappings, "->")
	if len(parts) != 2 {
		return "", errors.Errorf("invalid mappings string: %q", mappings)
	}
	parts = strings.Split(parts[0], ":")
	if len(parts) != 2 {
		return "", errors.Errorf("invalid mappings string: %q", mappings)
	}
	return parts[0], nil
}

func NewLogger(level int) (*zap.Logger, error) {
	cfg := &zap.Config{
		Level:            zap.NewAtomicLevelAt(zapcore.Level(int8(level))),
		Development:      true,
		Encoding:         "console", // or json
		OutputPaths:      []string{"stdout"},
		ErrorOutputPaths: []string{"stderr"},
		EncoderConfig: zapcore.EncoderConfig{
			TimeKey:        "T",
			LevelKey:       "L",
			NameKey:        "N",
			CallerKey:      "C",
			MessageKey:     "M",
			StacktraceKey:  "S",
			EncodeLevel:    zapcore.CapitalColorLevelEncoder,
			EncodeTime:     zapcore.ISO8601TimeEncoder,
			EncodeDuration: zapcore.StringDurationEncoder,
			EncodeCaller:   zapcore.ShortCallerEncoder,
		},
	}

	zapOption := zap.AddStacktrace(zapcore.ErrorLevel)
	return cfg.Build(zapOption)
}

func main() {
	logger, _ := NewLogger(-1)
	d := NewLocalDockerDB(func(d *LocalDockerDB) {
		d.Port = "3307"
		d.Logger = logger
	})

	ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Second*10))
	defer cancel()
	if err := d.Start(ctx); err != nil {
		log.Fatal(errors.ErrorStack(err))
	}
}
```

## 実行結果


```bash
# containerは存在しない
docker container ls -a
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

# 起動
go run main.go
2017-11-23T02:30:38.881+0900	INFO	workspace/main.go:52	local_docker_db	{"status": "starting"}
2017-11-23T02:30:38.934+0900	DEBUG	workspace/main.go:77	local_docker_db	{"container_info": "not_found", "container": "myproject-mysql-db"}
2017-11-23T02:30:39.428+0900	DEBUG	workspace/main.go:107	local_docker_db	{"exec docker cmd": "docker container run --detach --name myproject-mysql-db --publish 3307:3306 --mount type=bind,source=/tmp/docker_db,target=/var/lib/mysql --env MYSQL_USER=gopher --env MYSQL_PASSWORD=golangorgohome --env MYSQL_INITDB_SKIP_TZINFO=yes --env MYSQL_ALLOW_EMPTY_PASSWORD=yes mysql:5.6", "stdout": "4a7336c9364b14df0813463161335d8690d9c6e777f5c631bb406b5f55d2e181\n", "stderr": ""}

# 確認
mysql -h0.0.0.0 -ugopher -pgolangorgohome --port=3307 -e "show databases" --batch  information_schema
mysql: [Warning] Using a password on the command line interface can be insecure.
Database
information_schema

# 既に起動中に、再度起動
go run main.go
2017-11-23T02:32:34.746+0900	INFO	workspace/main.go:52	local_docker_db	{"status": "starting"}
2017-11-23T02:32:34.830+0900	DEBUG	workspace/main.go:66	local_docker_db	{"container_info": "already running", "container": "myproject-mysql-db"}

# 一度、停止
docker container stop myproject-mysql-db
myproject-mysql-db

# 停止中のcontainerを起動
go run main.go
2017-11-23T02:35:03.844+0900	INFO	workspace/main.go:52	local_docker_db	{"status": "starting"}
2017-11-23T02:35:03.899+0900	DEBUG	workspace/main.go:94	local_docker_db	{"container_info": "exited", "container": "myproject-mysql-db"}
2017-11-23T02:35:04.289+0900	DEBUG	workspace/main.go:107	local_docker_db	{"exec docker cmd": "docker container start 4a7336c9364b", "stdout": "4a7336c9364b\n", "stderr": ""}

# 確認
 mysql -h0.0.0.0 -ugopher -pgolangorgohome --port=3307 -e "show databases" --batch  information_schema
mysql: [Warning] Using a password on the command line interface can be insecure.
Database
information_schema
```

## 感想

初心者の自分には、外部コマンドの出力結果をparseしてstructに落とし込んで処理していく方法がとても参考になりました。os/exec.CmdでGoからdocker以外もどんどん管理していけたらと思っています。