🐳 How to Manage Remote Docker Containers Using Go SDK and SSH Tunnel

Nov 17, 2024

☁️ Introduction

In my previous post, I compared several Go web frameworks for developing a budget planning application. I ended up building a simple version for myself, and realized I needed to deploy it somehow. Previously, I deployed my personal applications in different ways:

  • Terraform - enterprise-grade infrastructure as code; good for work, but overkill for personal projects.

  • GitHub Actions - overkill for personal projects that don't require CI/CD.

  • Manual deployment over SSH - suitable for small personal projects but not scalable, as it requires repeated server setup and dependency installation.

For my budget tracking application, I had been using manual SSH deployments. However, I decided to explore automation options. In this article, I will explain how to control Docker on a remote machine via Go SDK and SSH, without exposing the Docker API externally.

☁️ Introduction

πŸ’Ž Local Docker Go API

Docker provides an SDK that allows you to control it via the Go programming language. Before we explore controlling a remote Docker instance, let’s first see how to control Docker locally using the Go SDK.

1package main
2
3import (
4	"context"
5	"io"
6	"log"
7	"os"
8
9	"github.com/docker/docker/api/types/container"
10	"github.com/docker/docker/api/types/image"
11	"github.com/docker/docker/client"
12)
13
14func pullImage(cli *client.Client, imageName string) error {
15	log.Printf("pulling the image: image = %s\n", imageName)
16	reader, err := cli.ImagePull(context.Background(), imageName, image.PullOptions{})
17	if err != nil {
18		return err
19	}
20	defer reader.Close()
21
22	io.Copy(os.Stdout, reader)
23	return nil
24}
25
26func startContainer(cli *client.Client, imageName string) (string, error) {
27	log.Printf("creating a container: image = %s\n", imageName)
28	resp, err := cli.ContainerCreate(context.Background(), &container.Config{
29		Image: imageName,
30	}, nil, nil, nil, "")
31	if err != nil {
32		return "", err
33	}
34
35	log.Println("Starting the container...")
36	if err := cli.ContainerStart(context.Background(), resp.ID, container.StartOptions{}); err != nil {
37		return "", err
38	}
39
40	return resp.ID, nil
41}
42
43func main() {
44	cli, err := client.NewClientWithOpts(client.FromEnv)
45	if err != nil {
46		log.Fatal(err)
47	}
48	cli.NegotiateAPIVersion(context.Background())
49
50	imageName := "hello-world:latest"
51
52	if err := pullImage(cli, imageName); err != nil {
53		log.Fatal(err)
54	}
55
56	containerID, err := startContainer(cli, imageName)
57	if err != nil {
58		log.Fatal(err)
59	}
60
61	log.Printf("container started: containerID = %s\n", containerID)
62}

The code above runs a Docker container with the Hello World image on the local machine. However, this code only manages Docker on the local machine and cannot handle remote Docker instances.

🧐 Docker API Access

There are two ways to access the Docker API:

  • Docker REST API - Requires explicit enabling, which allows managing Docker remotely.

  • Docker Socket API - Enabled by default but not accessible remotely.

The first method requires extra configuration and poses a security risk by adding an additional attack vector to the server. Given that I already have SSH configured on my server, there is a third way to access Docker:

  • Docker Socket API via SSH tunnel - No additional Docker configuration required; just use an SSH tunnel to connect to the Docker socket.

😎 Remote Docker Go API over SSH Tunnel

To access Docker on a remote machine where SSH is already configured, you just need to create a tunnel to the Docker socket and manage Docker as if it were a local instance using the Go SDK.

1package main
2
3import (
4	"context"
5	"fmt"
6	"io"
7	"log"
8	"net"
9	"os"
10
11	"github.com/docker/docker/api/types/container"
12	"github.com/docker/docker/api/types/image"
13	"github.com/docker/docker/client"
14	"golang.org/x/crypto/ssh"
15)
16
17func createSSHTunnel(user, privateKey string, serverAddr, localAddr string) error {
18	signer, err := ssh.ParsePrivateKey([]byte(privateKey))
19	if err != nil {
20		return fmt.Errorf("failed to parse private key: %w", err)
21	}
22
23	sshConfig := &ssh.ClientConfig{
24		User: user,
25		Auth: []ssh.AuthMethod{
26			ssh.PublicKeys(signer),
27		},
28		HostKeyCallback: ssh.InsecureIgnoreHostKey(),
29	}
30
31	sshConn, err := ssh.Dial("tcp", serverAddr, sshConfig)
32	if err != nil {
33		return fmt.Errorf("failed to establish SSH connection: %w", err)
34	}
35
36	listener, err := net.Listen("tcp", localAddr)
37	if err != nil {
38		return fmt.Errorf("failed to listen on local address: %w", err)
39	}
40
41	go func() {
42		for {
43			localConn, err := listener.Accept()
44			if err != nil {
45				log.Printf("failed to accept local connection: %s", err)
46				continue
47			}
48
49			remoteConn, err := sshConn.Dial("unix", "/var/run/docker.sock")
50			if err != nil {
51				log.Printf("failed to dial remote Docker socket: %s", err)
52				localConn.Close()
53				continue
54			}
55
56			go func() {
57				defer localConn.Close()
58				defer remoteConn.Close()
59				io.Copy(localConn, remoteConn)
60			}()
61
62			go func() {
63				defer localConn.Close()
64				defer remoteConn.Close()
65				io.Copy(remoteConn, localConn)
66			}()
67		}
68	}()
69
70	return nil
71}
72
73func pullImage(cli *client.Client, imageName string) error {
74	log.Printf("pulling the image: image = %s\n", imageName)
75	reader, err := cli.ImagePull(context.Background(), imageName, image.PullOptions{})
76	if err != nil {
77		return err
78	}
79	defer reader.Close()
80
81	io.Copy(os.Stdout, reader)
82	return nil
83}
84
85func startContainer(cli *client.Client, imageName string) (string, error) {
86	log.Printf("creating a container: image = %s\n", imageName)
87	resp, err := cli.ContainerCreate(context.Background(), &container.Config{
88		Image: imageName,
89	}, nil, nil, nil, "")
90	if err != nil {
91		return "", err
92	}
93
94	log.Println("Starting the container...")
95	if err := cli.ContainerStart(context.Background(), resp.ID, container.StartOptions{}); err != nil {
96		return "", err
97	}
98
99	return resp.ID, nil
100}
101
102func main() {
103	serverAddr := os.Getenv("DOCKER_HOST")
104	localAddr := "localhost:2375"
105	user := os.Getenv("SSH_USER")
106	privateKey := os.Getenv("SSH_PRIVATE_KEY")
107
108	err := createSSHTunnel(user, privateKey, serverAddr, localAddr)
109	if err != nil {
110		log.Fatalf("Failed to create SSH tunnel: %s", err)
111	}
112
113	cli, err := client.NewClientWithOpts(client.WithHost("tcp://localhost:2375"))
114	if err != nil {
115		log.Fatalf("Failed to create Docker client: %s", err)
116	}
117
118	imageName := "hello-world:latest"
119
120	if err := pullImage(cli, imageName); err != nil {
121		log.Fatal(err)
122	}
123
124	containerID, err := startContainer(cli, imageName)
125	if err != nil {
126		log.Fatal(err)
127	}
128
129	log.Printf("container started: containerID = %s\n", containerID)
130}

The above code shows how to run a Docker container locally. By setting up an SSH tunnel, this same code can also manage Docker containers on a remote machine.

πŸŽ‰ Conclusions

In this article, I demonstrated two ways to manage Docker using the Go SDK:

  • Local Docker management via Go SDK

  • Remote Docker management via Go SDK over an SSH tunnel

The second method, using an SSH tunnel, requires no additional Docker configuration, provided you already have SSH access to the server. This method allows me to deploy my budget tracking application through the Docker API directly, without manually SSHing into the server.

You can find the examples from this article in the GitHub repository.

πŸŽ‰ Conclusions