I've been thinking about running different ephemeral jobs with attached volumes, volumes that I could garbage collect as needed. This is a non-standard way of using docker, but I wanted to look to see how I could interact with the docker daemon programatically.

The use case is:

  1. Create a docker volume for a container
  2. Start up a docker container, with a specified environment
  3. Monitor the running of the container, kill if its running for too long
  4. Capture the output of the container
  5. Clean up the container
  6. Pull data from the volume
  7. Clean up the volume

Setting up go environemnt

First we need to setup a go project and create the go.mod file.

1
2
3
  mkdir dockeringo
  cd dockeringo
  go mod init dockeringo

Then we can add the modules that we'll need. In our case, our go.mod file should look like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
module dockeringo

go 1.16

require (
	github.com/containerd/containerd v1.5.1 // indirect
	github.com/docker/docker v20.10.6+incompatible // indirect
	github.com/docker/go-connections v0.4.0 // indirect
	github.com/sirupsen/logrus v1.8.1 // indirect
	google.golang.org/grpc v1.37.1 // indirect
)

And we can get those modules by

1
go mod download

This will create a go.sum file that is basically your dependancies.

Our types

We are going to build a simple controller type to hang our methods off of. Create a types.go file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
  package dockeringo

  import (
    "github.com/docker/docker/api/types"
    "github.com/docker/docker/client"
  )

  type Controller struct {
    cli *client.Client
  }

  type VolumeMount struct {
    HostPath string
    Volume   *types.Volume
  }

  func NewController() (c *Controller, err error) {
    c = new(Controller)

    c.cli, err = client.NewClientWithOpts(client.FromEnv)

    if err != nil {
      return nil, err
    }
    return c, nil
  }

Images

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
package dockeringo

import (
	"context"
	"io"
	"os"

	"github.com/docker/docker/api/types"
)

//https://gist.github.com/miguelmota/4980b18d750fb3b1eb571c3e207b1b92
func (c *Controller) EnsureImage(image string) (err error) {
	reader, err := c.cli.ImagePull(context.Background(), image, types.ImagePullOptions{})

	if err != nil {
		return err
	}
	defer reader.Close()
	io.Copy(os.Stdout, reader)
	return nil
}

Then create a simple test in images_test.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
package dockeringo

import "testing"

func TestEnsureImage(t *testing.T) {
	c, err := NewController()

	if err != nil {
		t.Error(err)
		t.FailNow()
	}

	err = c.EnsureImage("alpine")

	if err != nil {
		t.Error(err)
	}
}

We can run the test with:

1
go test --run Image
{"status":"Pulling from library/alpine","id":"latest"}
{"status":"Digest: sha256:69e70a79f2d41ab5d637de98c1e0b055206ba40a8145e7bddb55ccc04e13cf8f"}
{"status":"Status: Image is up to date for alpine:latest"}
PASS
ok  	dockeringo	0.685s

This makes sure that we have the image we want to run on our machine.

Container logs

Let's write a simple way to get the logs of a container. We won't write a test for this here, since we need to write the container run examples first.

container_log.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  package dockeringo

  import (
    "context"
    "io"
    "time"

    "github.com/docker/docker/api/types"
  )

  func (c *Controller) ContainerLog(id string) (result string, err error) {
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    reader, err := c.cli.ContainerLogs(ctx, id, types.ContainerLogsOptions{
      ShowStdout: true,
      ShowStderr: true})

    if err != nil {
      return "", err
    }

    buffer, err := io.ReadAll(reader)

    if err != nil && err != io.EOF {
      return "", err
    }

    return string(buffer), nil
  }

Running a container

container_run.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
  package dockeringo

  import (
    "context"
    "fmt"

    "github.com/docker/docker/api/types"
    "github.com/docker/docker/api/types/container"
    "github.com/docker/docker/api/types/mount"
  )

  func (c *Controller) ContainerRun(image string, command []string, volumes []VolumeMount) (id string, err error) {
    hostConfig := container.HostConfig{}

    //	hostConfig.Mounts = make([]mount.Mount,0);

    var mounts []mount.Mount

    for _, volume := range volumes {
      mount := mount.Mount{
        Type:   mount.TypeVolume,
        Source: volume.Volume.Name,
        Target: volume.HostPath,
      }
      mounts = append(mounts, mount)
    }

    hostConfig.Mounts = mounts

    resp, err := c.cli.ContainerCreate(context.Background(), &container.Config{
      Tty:   true,
      Image: image,
      Cmd:   command,
    }, &hostConfig, nil, nil, "")

    if err != nil {
      return "", err
    }

    err = c.cli.ContainerStart(context.Background(), resp.ID, types.ContainerStartOptions{})
    if err != nil {
      return "", err
    }

    return resp.ID, nil
  }

  func (c *Controller) ContainerWait(id string) (state int64, err error) {
    resultC, errC := c.cli.ContainerWait(context.Background(), id, "")
    select {
    case err := <-errC:
      return 0, err
    case result := <-resultC:
      return result.StatusCode, nil
    }
  }

  func (c *Controller) ContainerRunAndClean(image string, command []string, volumes []VolumeMount) (statusCode int64, body string, err error) {
    // Start the container
    id, err := c.ContainerRun(image, command, volumes)
    if err != nil {
      return statusCode, body, err
    }

    // Wait for it to finish
    statusCode, err = c.ContainerWait(id)
    if err != nil {
      return statusCode, body, err
    }

    // Get the log
    body, _ = c.ContainerLog(id)

    err = c.cli.ContainerRemove(context.Background(), id, types.ContainerRemoveOptions{})

    if err != nil {
      fmt.Printf("Unable to remove container %q: %q\n", id, err)
    }

    return statusCode, body, err
  }

Now we can write a test to see if everything is running:

container_run_test.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  package dockeringo

  import (
    "testing"
  )

  func TestContainerRun(t *testing.T) {
    c, err := NewController()

    if err != nil {
      t.Error(err)
    }

    statusCode, body, err := c.ContainerRunAndClean("alpine", []string{"echo", "hello world"}, []VolumeMount{})

    if err != nil {
      t.Error(err)
      t.FailNow()
    }

    if body != "hello world\r\n" {
      t.Errorf("Expected 'hello world'; received %q\n", body)
    }

    if statusCode != 0 {
      t.Errorf( "Expect status to be 0; received %q\n", statusCode);
    }
  }

And the run the test:

1
go test --run Container
PASS
ok  	dockeringo	1.414s

I'm not saying that it's a great test, but it does test something!

Volumes

Containers have volumes, lets look at how to create them:

volumes.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
  package dockeringo

  import (
    "context"

    "github.com/docker/docker/api/types"
    "github.com/docker/docker/api/types/filters"
    volumetypes "github.com/docker/docker/api/types/volume"
  )

  func (c *Controller) FindVolume(name string) (volume *types.Volume, err error) {
    volumes, err := c.cli.VolumeList(context.Background(), filters.NewArgs())

    if err != nil {
      return nil, err
    }

    for _, v := range volumes.Volumes {
      if v.Name == name {
        return v, nil
      }
    }
    return nil, nil
  }

  func (c *Controller) EnsureVolume(name string) (created bool, volume *types.Volume, err error) {
    volume, err = c.FindVolume(name)

    if err != nil {
      return false, nil, err
    }

    if volume != nil {
      return false, volume, nil
    }

    vol, err := c.cli.VolumeCreate(context.Background(), volumetypes.VolumeCreateBody{
      Driver: "local",
      //		DriverOpts: map[string]string{},
      //		Labels:     map[string]string{},
      Name: name,
    })

    return true, &vol, err
  }

  func (c *Controller) RemoveVolume(name string) (removed bool, err error) {
    vol, err := c.FindVolume(name)

    if err != nil {
      return false, err
    }
	
    if vol == nil {
      return false, nil
    }

    err = c.cli.VolumeRemove(context.Background(), name, true)

    if err != nil {
      return false, err
    }

    return true, nil
  }

And lets write some tests:

volumes_test.go:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
  package dockeringo

  import (
    "testing"
  )

  func TestSingleCreate(t *testing.T) {
    c, err := NewController()

    if err != nil {
      t.Error(err)
    }

    created, _, err := c.EnsureVolume("myvolume")
    if created != true {
      t.Errorf("Should have created the volume the first time")
    }

    created, _, err = c.EnsureVolume("myvolume")
    if created != false {
      t.Errorf("Should not have created the volume the second time")
    }

    removed, err := c.RemoveVolume("myvolume")
    if removed != true {
      t.Errorf("Should have removed the volume")
    }
  }

  func TestEnsureVolume(t *testing.T) {
    c, err := NewController()

    if err != nil {
      t.Error(err)
    }

    _, volume, err := c.EnsureVolume("myvolume")

    if err != nil {
      t.Error(err)
    }

    if volume.Name != "myvolume" {
      t.Errorf("Expected volume name to be %s; got %s\n", "myvolume", volume.Name)
      t.FailNow()
    }

    removed, err := c.RemoveVolume("myvolume")

    if err != nil {
      t.Error(err)
    }

    if removed != true {
      t.Errorf("Volume should have been removed but wasn't")
    }

  }

And now we can run the tests:

1
go test --run Volume
PASS
ok  	dockeringo	3.245s

Testing persisent volumes

Lets first create a simple script that will look for a file, and if it finds it prints it out and exits with a success. If it doesn't find it, it created it with the current date, prints it out, and exits with a failure.

Call this script.sh:

1
2
3
4
5
6
7
8
  if [ ! -f "output" ]; then
      date > output
      cat output
      exit 1
  fi

  cat output
  exit 0

Now lets create a Dockerfile that runs this:

1
2
3
4
5
6
7
FROM debian:10

COPY script.sh /usr/bin/

WORKDIR /volume

CMD "bash" "/usr/bin/script.sh"

And we'll build this with

1
docker build . -t testimage

Now lets create a persistent_volume_test.go file, where we will

  1. Create a volume
  2. Start the testimage container with the volume mounted
  3. Run it a second time
  4. Make sure that the output is the same
  5. Remove the volume
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package dockeringo

import "testing"

func TestPersistentVolume(t *testing.T) {
	c, err := NewController()

	if err != nil {
		t.Error(err)
		t.FailNow()
	}

	created, volume, err := c.EnsureVolume("persistentvolume")

	if err != nil {
		t.Error(err)
		t.FailNow()
	}

	if created != true {
		t.Errorf("Should have created a volume at the start")
	}

	mounts := []VolumeMount{
		{
			HostPath: "/volume",
			Volume:   volume,
		},
	}

	statusCode, body1, err := c.ContainerRunAndClean("testimage", []string{}, mounts)

	// Second run

	statusCode, body2, err := c.ContainerRunAndClean("testimage", []string{}, mounts)

	if err != nil {
		t.Error(err)
		t.FailNow()
	}

	if statusCode != 0 {
		t.Error("Second run should not have created a file")
	}

	if body1 != body2 {
		t.Errorf("%s\nShould have been equal to:\n%s\n", body1, body2)
	}

	c.RemoveVolume("persistentvolume")
}

And now, lets run it:

1
go test --run Persistent
PASS
ok  	dockeringo	4.186s

Final thoughts

Docker is cool.

Previously

benbjohnson/litestream

2021-05-11

Next

Sending files with wormhole tools I didn’t know

2021-05-19

howto

Previously

SQL in Org-Mode Everything in org-mode

2021-04-17

Next

Deploying OpenFaaS on Digital Ocean with Terraform Everything functional

2021-06-02