Go Docker SDK, Raw Terminal Ctrl+C handling

January 10, 2021 3 By addshore

I spent my weekend in working on a new project called dockerit. It’s a simple wrapper around Docker written in Go and making use of the Docker SDK.

One of the biggest sticking points for me, being fairly new with the Golang world, was trying to pass stdin stdout and stderr between the container and host terminal correctly, while also having good performance and doing the expected things (like Ctrl+C to cancel).

The full code for setting up and, interacting with and removing my container can be found here. The main steps are broken down below.

Breakdown

Firstly a Docker client is needed.

I highly recommend using client.WithAPIVersionNegotiation() so that your code will work out of the box with multiple Docker versions.

cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())

Then we need to make a container to work with. Here using the “ubuntu” image, and all of the options needed to correctly pass through stdin, stdout, stderr.

cont, err := cli.ContainerCreate( context.Background(), &container.Config{ Image: "ubuntu", AttachStderr:true, AttachStdin: true, Tty: true, AttachStdout:true, OpenStdin: true, Labels: labels, }, &container.HostConfig{ AutoRemove: true, }, nil, nil, "", );
Code language: JavaScript (javascript)

We can then attach to the container before we start it. The io.Copy calls deal with data coming from the container to the host. Stdin will be handled a different way.

waiter, err := cli.ContainerAttach(context.Background(), cont.ID, types.ContainerAttachOptions{ Stderr: true, Stdout: true, Stdin: true, Stream: true, }) go io.Copy(os.Stdout, waiter.Reader) go io.Copy(os.Stderr, waiter.Reader)
Code language: JavaScript (javascript)

Next we want to start the container.

err = cli.ContainerStart(context.Background(), cont.ID, types.ContainerStartOptions{})

And then to the fun. This code section is essentially split into two.

The first bit sets the terminal to Raw mode using MakeRaw.

The second bit starts reading from Stdin, byte by byte, looking for the a Ctrl+C press. If found it will start removing the container. If not found in the byte then the byte is sent to the container waiter that we created earlier when attaching to the container.

fd := int(os.Stdin.Fd()) var oldState *terminal.State if terminal.IsTerminal(fd) { oldState, err = terminal.MakeRaw(fd) if err != nil { // TODO handle error? } go func() { for { consoleReader := bufio.NewReaderSize(os.Stdin, 1) input, _ := consoleReader.ReadByte() // Ctrl-C = 3 if input == 3 { cli.ContainerRemove( context.Background(), cont.ID, types.ContainerRemoveOptions{ Force: true, } ) } waiter.Conn.Write([]byte{input}) } }() }
Code language: JavaScript (javascript)

So now the container is running, and waiting to end naturally, or for Ctrl+C to be fired and the container stopped. We don’t want to stop execution, so we must wait for the container!

statusCh, errCh := cli.ContainerWait(context.Background(), cont.ID, container.WaitConditionNotRunning) select { case err := <-errCh: if err != nil { panic(err) } case <-statusCh: }
Code language: PHP (php)

And once the container is no longer running, we need to restore the terminal to its previous state.

if terminal.IsTerminal(fd) { terminal.Restore(fd, oldState) }

And for good measure, make sure that the container has stopped.

cli.ContainerRemove( context.Background(), cont.ID, types.ContainerRemoveOptions{ Force: true, } )
Code language: CSS (css)

You can probably do some of this cleanup using defer, but I skipped that in this example for clarity.