Interacting With the Docker Engine in Go

May 16, 2025

Docker with Go illustration

Docker and Go mascot illustration
by Ashley McNamara

Introduction

Lately, I've been hacking on a little side project called Vauban (when not spending time job hunting).

It's also a good excuse to play with Docker's Go SDK. Since Docker itself is written in Go, we are just a few imports from getting a nice SDK. Cool, right?

This guide walks through a small Go program that interacts with the Docker Engine. We'll compare it with equivalent CLI commands and see how you can build a basic Docker playground in Go.

You can find the full source here.

And here is the end result we'll get: Playground Screenshot

Prerequisites

  • Go installed (v1.18+ recommended)
  • Docker Engine running locally (desktop or daemon)
  • A terminal on macOS, Linux, or WSL2 (Windows)

1. Bootstrapping your Go module

First, create a new folder and initialize a Go module:

mkdir docker-playground && cd docker-playground
go mod init github.com/you/docker-playground

Then add the Docker client library as a dependency:

go get github.com/docker/docker/client

2. Connecting to Docker from Go

In your main.go, start by importing the Docker client and creating a client instance:

import (
    "context"
    "github.com/docker/docker/client"
)

func main() {
    ctx := context.Background()
    cli, err := client.NewClientWithOpts(
        client.FromEnv,
        client.WithAPIVersionNegotiation(),
    )
    if err != nil {
        panic(err)
    }
    defer cli.Close()
    // Your code goes here...
}
  • FromEnv reads DOCKER_HOST, DOCKER_API_VERSION, etc.
  • WithAPIVersionNegotiation ensures your code talks the right API version for your engine.

3. Listing and grouping containers

The snippet retrieves all containers (running or stopped) and groups them by their Compose service:

// List all containers
containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})

// Group into a map[serviceName][]container.Summary
composeServices := groupContainersByComposeService(containers)

Under the hood, ContainerList maps to docker container ls --all. You can reproduce it:

docker container ls --all --format "{{json .}}"

The helper function simply reads the com.docker.compose.service label:

func groupContainersByComposeService(cs []container.Summary) map[string][]container.Summary {
    m := map[string][]container.Summary{}
    for _, c := range cs {
        s := c.Labels["com.docker.compose.service"]
        if s == "" {
            s = "non-compose"
        }
        m[s] = append(m[s], c)
    }
    return m
}

This is equivalent to filtering with:

docker ps -a --filter "label=com.docker.compose.service=myservice"

4. Printing service summaries

Once grouped, you can iterate in sorted order and print out:

  • Service name
  • Number of containers
  • Compose project path & config files (via labels)
  • Container name, status, and image
for service, ctList := range sortedServices {
    fmt.Printf("Service %s: %d containers\n", service, len(ctList))
    // show labels and details
}

This mirrors:

docker inspect --format '{{ json .Config.Labels }}' <containerID>

5. Pulling and running an Alpine container

Next, we demonstrate how to pull an image and launch a lightweight Alpine container:

reader, err := cli.ImagePull(ctx, "alpine", image.PullOptions{})
io.Copy(os.Stdout, reader)

resp, _ := cli.ContainerCreate(ctx, &container.Config{
    Image: "alpine",
    Cmd:   []string{"sleep", "60"},
    Tty:   true,
}, nil, nil, nil, "alpine-demo")
cli.ContainerStart(ctx, resp.ID, container.StartOptions{})

In your shell, this equates to:

docker pull alpine
# create a sleeping container
docker run -d --name alpine-demo alpine sleep 60

6. Executing commands inside the container

You can exec into a running container programmatically:

execConfig := container.ExecOptions{ Cmd: []string{"ls", "-la", "/"}, AttachStdout: true }
execID, _ := cli.ContainerExecCreate(ctx, containerID, execConfig)
attachResp, _ := cli.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
stdcopy.StdCopy(os.Stdout, os.Stderr, attachResp.Reader)

Which maps directly to CLI:

docker exec alpine-demo ls -la /

Repeat for system info or file operations:

docker exec alpine-demo sh -c "cat /etc/os-release && uname -a"

and

docker exec alpine-demo sh -c "echo 'Hello!' > /tmp/hello.txt && cat /tmp/hello.txt"

7. Cleanup

After testing, the Go code stops and removes the container:

cli.ContainerStop(ctx, containerID, container.StopOptions{})
cli.ContainerRemove(ctx, containerID, container.RemoveOptions{})

Or via shell:

docker stop alpine-demo
docker rm alpine-demo

Full Code

Below is the full code, combining all the steps above into a single executable:

package main

import (
	"context"
	"fmt"
	"io"
	"os"
	"sort"

	"github.com/docker/docker/api/types/container"
	"github.com/docker/docker/api/types/image"
	"github.com/docker/docker/client"
	"github.com/docker/docker/pkg/stdcopy"
)

func main() {
	ctx := context.Background()
	cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
	if err != nil {
		panic(err)
	}
	defer cli.Close()

	// List all containers
	containers, err := cli.ContainerList(ctx, container.ListOptions{All: true})
	if err != nil {
		panic(err)
	}

	// Group containers by compose service
	composeServices := groupContainersByComposeService(containers)

	// Print the containers grouped by service
	fmt.Println("Containers")
	fmt.Println("=====================================")

	// Sort service names for consistent output
	var serviceNames []string
	for service := range composeServices {
		serviceNames = append(serviceNames, service)
	}
	sort.Strings(serviceNames)

	for _, service := range serviceNames {
		containers := composeServices[service]
		fmt.Printf("\nService: %s (%d containers)\n", service, len(containers))
		fmt.Println("-----------------------------------")

		// Get the project filepath and config files if they exist (should be same for all containers in a service)
		if service != "non-compose" && len(containers) > 0 {
			// Get project working directory
			if path, exists := containers[0].Labels["com.docker.compose.project.working_dir"]; exists {
				fmt.Printf(" Project path: %s\n", path)
			}
			// Get compose config files
			if configFiles, exists := containers[0].Labels["com.docker.compose.project.config_files"]; exists {
				fmt.Printf(" Config files: %s\n", configFiles)
			}
		}

		for _, c := range containers {
			fmt.Printf(" - %s (ID: %.12s)\n", c.Names[0], c.ID)
			fmt.Printf("   Status: %s\n", c.Status)
			fmt.Printf("   Image: %s\n", c.Image)
		}
	}
	fmt.Println()
	fmt.Println()

	// Pull Alpine image
	fmt.Println("Pulling Alpine image...")
	reader, err := cli.ImagePull(ctx, "docker.io/library/alpine", image.PullOptions{})
	if err != nil {
		panic(err)
	}
	io.Copy(os.Stdout, reader)

	// Create container with Alpine image - just create with a sleep command
	// so it stays alive for us to execute commands in it
	containerName := "alpine-demo"
	fmt.Println("\nCreating container:", containerName)
	resp, err := cli.ContainerCreate(ctx, &container.Config{
		Image: "alpine",
		Cmd:   []string{"sleep", "60"}, // Container will stay alive for 60 seconds
		Tty:   true,
	}, nil, nil, nil, containerName)
	if err != nil {
		panic(err)
	}
	containerID := resp.ID
	fmt.Printf("Container created with ID: %.12s\n", containerID)

	// Start container
	fmt.Println("Starting container...")
	if err := cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil {
		panic(err)
	}
	fmt.Println("Container started successfully")

	// Now let's execute various commands in the running container
	fmt.Println("\nExecuting commands in the running container:")

	// Execute ls -la / command
	fmt.Println("\n1. Directory listing (ls -la /)")
	fmt.Println("=====================================")
	execConfig := container.ExecOptions{
		Cmd:          []string{"ls", "-la", "/"},
		AttachStdout: true,
		AttachStderr: true,
		Tty:          false,
	}
	execID, err := cli.ContainerExecCreate(ctx, containerID, execConfig)
	if err != nil {
		panic(err)
	}

	// Attach to the exec instance to get its output
	execAttachResp, err := cli.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
	if err != nil {
		panic(err)
	}
	defer execAttachResp.Close()

	// Copy the output to stdout
	stdcopy.StdCopy(os.Stdout, os.Stderr, execAttachResp.Reader)

	// Execute command to show system info
	fmt.Println("\n2. System Information")
	fmt.Println("=====================================")
	execConfig = container.ExecOptions{
		Cmd:          []string{"sh", "-c", "cat /etc/os-release && echo '' && uname -a"},
		AttachStdout: true,
		AttachStderr: true,
		Tty:          false,
	}
	execID, err = cli.ContainerExecCreate(ctx, containerID, execConfig)
	if err != nil {
		panic(err)
	}

	// Attach to the exec instance
	execAttachResp, err = cli.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
	if err != nil {
		panic(err)
	}
	defer execAttachResp.Close()

	// Copy the output to stdout
	stdcopy.StdCopy(os.Stdout, os.Stderr, execAttachResp.Reader)

	// Execute a command to create a file and then read it
	fmt.Println("\n3. Creating and reading a file")
	fmt.Println("=====================================")
	execConfig = container.ExecOptions{
		Cmd:          []string{"sh", "-c", "echo 'Hello from Alpine container!' > /tmp/hello.txt && cat /tmp/hello.txt"},
		AttachStdout: true,
		AttachStderr: true,
		Tty:          false,
	}
	execID, err = cli.ContainerExecCreate(ctx, containerID, execConfig)
	if err != nil {
		panic(err)
	}

	// Attach to the exec instance
	execAttachResp, err = cli.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
	if err != nil {
		panic(err)
	}
	defer execAttachResp.Close()

	// Copy the output to stdout
	stdcopy.StdCopy(os.Stdout, os.Stderr, execAttachResp.Reader)

	// Stop and remove the container when we're done
	fmt.Println("\nStopping and removing container...")
	err = cli.ContainerStop(ctx, containerID, container.StopOptions{})
	if err != nil {
		panic(err)
	}

	err = cli.ContainerRemove(ctx, containerID, container.RemoveOptions{})
	if err != nil {
		panic(err)
	}
	fmt.Println("Container stopped and removed successfully")
}

// groupContainersByComposeService groups containers by their Docker Compose service name
func groupContainersByComposeService(containers []container.Summary) map[string][]container.Summary {
	serviceMap := make(map[string][]container.Summary)
	for _, c := range containers {
		// Look for the Docker Compose service label
		serviceName, exists := c.Labels["com.docker.compose.service"]
		if !exists {
			// If the container isn't part of a compose service, group it under "non-compose"
			serviceName = "non-compose"
		}
		serviceMap[serviceName] = append(serviceMap[serviceName], c)
	}
	return serviceMap
}
Join Discord Community