Go
docker

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
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))
    }
}

実行結果

# 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以外もどんどん管理していけたらと思っています。