Controlling docker in golang

So meta

Published May 15, 2021 #golang, #docker

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.

  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:

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

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:

  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

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:

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:

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:

  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:

  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:

  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:

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:

  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:

  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:

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:

  if [ ! -f "output" ]; then
      date > output
      cat output
      exit 1
  fi

  cat output
  exit 0

Now lets create a Dockerfile that runs this:

FROM debian:10

COPY script.sh /usr/bin/

WORKDIR /volume

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

And we'll build this with

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

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:

go test --run Persistent
PASS
ok  	dockeringo	4.186s

Final thoughts

Docker is cool.

References

  1. https://stackoverflow.com/questions/48470194/defining-a-mount-point-for-volumes-in-golang-docker-sdk

Read next

See also

Docker One Liners

Why install

I use docker in my workflow as an application and environment manager. I switch between multiple physical computers a lot, and like to have things self contained within work spaces that I can move from one computer to another easily, normally using `git`. Here are a few tricks that I use to have the lightest touch on my local installation as possible. Orientation If you want to install a specific set of software, you use a `Dockerfile` to create an `image`.

Read more

Developing React Inside Docker

Clean up after your mess

Can we build a node application without installing node locally? We sure can! Lets walk through the process. First make sure that docker is installed. This is handy if you are working on a remote server for example. Bootstrap Then lets start building out the Dockerfile that we will use. mkdir testapp cd testapp Create a Dockerfile.initial that has node:14 in it. Start up the container with Dockerfile.initial FROMnode:14WORKDIR/appCMD bash

Read more

Emacs Tramp tricks

Replacing terminals with emacs

Emacs is amazing. It’s a very different sort of thing than a code text editor like Vim or an IDE like VSCode. It’s a different way of thinking of how to interact with a computer, where you build up techniques on top of simple tricks that let you get amazing things done. Of course, part of the appeal/challenge is that you need to figure out how to make it work yourself.

Read more