Build your Go image
Estimated reading time: 18 minutes
- Build images
- Run your image as a container
- Use containers for development
- Run your tests
- Configure CI/CD
- Deploy your app
Prerequisites
- Some understanding of Go and its toolchain. This is not a tutorial on Go. If you are new to the language, the Go website is a good starting point, so go (pun intended) check it out.
- Some awareness of basic Docker concepts. If unsure, work through the orientation and setup in Get started Part 1.
Overview
Now that we have a good overview of containers and the Docker platform, let’s take a look at building our first image. An image includes everything you need to run an application – the code or binary, runtime, dependencies, and any other file system objects required.
To complete this tutorial, you need the following:
- Go version 1.16 or later. You might want to download and install Go first.
- Docker running locally. Follow the instructions to download and install Docker.
- An IDE or a text editor to edit files. We recommend using Visual Studio Code.
Meet the example application
To avoid losing focus on Docker’s features, the sample application is a minimal HTTP server that has only three features:
- It responds with a text message containing a heart symbol (“<3”) on requests to
/
. - It responds with
{"Status" : "OK"}
JSON to the health check request on requests to/ping
. - The port it listens on is configurable using the environment variable
HTTP_PORT
. The default value is8080
.
Thus, it somewhat mimics enough basic properties of a REST microservice to be useful for our learning of Docker.
The source code for the application is in the olliefr/docker-gs-ping GitHub repo. Please feel free to clone or fork it.
For our present study, we clone it to our local machine:
$ git clone https://github.com/olliefr/docker-gs-ping
The application’s main.go
file is fairly straightforward, if you are familiar with Go:
package main
import (
"net/http"
"os"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
e := echo.New()
e.Use(middleware.Logger())
e.Use(middleware.Recover())
e.GET("/", func(c echo.Context) error {
return c.HTML(http.StatusOK, "Hello, Docker! <3")
})
e.GET("/ping", func(c echo.Context) error {
return c.JSON(http.StatusOK, struct{ Status string }{Status: "OK"})
})
httpPort := os.Getenv("HTTP_PORT")
if httpPort == "" {
httpPort = "8080"
}
e.Logger.Fatal(e.Start(":" + httpPort))
}
Smoke test the application
Let’s start our application and make sure it’s running properly. Open your terminal and navigate to the directory into which you cloned the project’s repo. From now on, we’ll refer to this directory as the working directory.
$ go run main.go
This should compile and start the server as a foreground application, outputting the banner, as illustrated in the next figure.
____ __
/ __/___/ / ___
/ _// __/ _ \/ _ \
/___/\__/_//_/\___/ v4.2.2
High performance, minimalist Go web framework
https://echo.labstack.com
____________________________________O/_______
O\
⇨ http server started on [::]:8080
Let’s run a quick smoke test on the application. In a new terminal, run a request using curl
. Alternatively, you can use your favourite web browser as well.
$ curl http://localhost:8080/
Hello, Docker! <3
So, the application responds with a greeting, just as the first business requirement says it should. Great.
Having established that the server is running and is accessible, let’s proceed to “dockerizing” it.
Create a Dockerfile for the application
A Dockerfile is a text document that contains the instructions to assemble a
Docker image. When we tell Docker to build our image by executing the docker build
command, Docker reads these instructions, executes them, and creates a Docker
image as a result.
Let’s walk through the process of creating a Dockerfile for our application. In
the root of your project, create a file named Dockerfile
and open this file in
your text editor.
What to name your Dockerfile?
The default filename to use for a Dockerfile is
Dockerfile
(without a file- extension). Using the default name allows you to run thedocker build
command without having to specify additional command flags.Some projects may need distinct Dockerfiles for specific purposes. A common convention is to name these
Dockerfile.<something>
or<something>.Dockerfile
. Such Dockerfiles can then be used through the--file
(or-f
shorthand) option on thedocker build
command. Refer to the “Specify a Dockerfile” section in thedocker build
reference to learn about the--file
option.We recommend using the default (
Dockerfile
) for your project’s primary Dockerfile, which is what we’ll use for most examples in this guide.
The first line to add to a Dockerfile is a # syntax
parser directive.
While optional, this directive instructs the Docker builder what syntax to use
when parsing the Dockerfile, and allows older Docker versions with BuildKit enabled
to upgrade the parser before starting the build. Parser directives
must appear before any other comment, whitespace, or Dockerfile instruction in
your Dockerfile, and should be the first line in Dockerfiles.
# syntax=docker/dockerfile:1
We recommend using docker/dockerfile:1
, which always points to the latest release
of the version 1 syntax. BuildKit automatically checks for updates of the syntax
before building, making sure you are using the most current version.
Next, we need to add a line in our Dockerfile that tells Docker what base image we would like to use for our application.
# syntax=docker/dockerfile:1
FROM golang:1.16-alpine
Docker images can be inherited from other images. Therefore, instead of creating our own base image, we’ll use the official Go image that already has all the tools and packages to compile and run a Go application. You can think of this in the same way you would think about class inheritance in object oriented programming or functional composition in functional programming.
When we have used that FROM
command, we told Docker to include in our image all the functionality from the golang:1.16-alpine
image. All of our consequent commands would build on top of that “base” image.
Note
If you want to learn more about creating your own base images, see creating base images section of the guide.
To make things easier when running the rest of our commands, let’s create a directory inside the image that we are building. This also instructs Docker to use this directory as the default destination for all subsequent commands. This way we do not have to type out full file paths but can use relative paths based on this directory.
WORKDIR /app
Usually the very first thing you do once you’ve downloaded a project written in Go is to install the modules necessary to compile it.
But before we can run go mod download
inside our image, we need to get our go.mod
and go.sum
files copied into it. We use the COPY
command to do this.
In its simplest form, the COPY
command takes two parameters. The first parameter tells Docker what file you would like to copy into the image. The second parameter tells Docker where you want that file to be copied to.
We’ll copy the go.mod
and go.sum
file into our working directory /app
which, owing to our use of WORKDIR
, is the current directory (.
) inside the image.
COPY go.mod ./
COPY go.sum ./
Now that we have the module files inside the Docker image that we are building, we can use the RUN
command to execute the command go mod download
there as well. This works exactly the same as if we were running go
locally on our machine, but this time these Go modules will be installed into the app directory inside our image.
RUN go mod download
At this point, we have an image that is based on Go environment version 1.16 (or a later minor version, since we had specified 1.16
as our tag in the FROM
command) and we have installed our dependencies.
The next thing we need to do is to copy our source code into the image. We’ll use the COPY
command just like we did with our module files before.
COPY *.go ./
This COPY
command uses a wildcard to copy all files with .go
extension located in the current directory on the host (the directory where the Dockerfile
is located) into the current directory inside the image.
Now, we would like to compile our application. To that end, we use the familiar RUN
command:
RUN go build -o /docker-gs-ping
This should be familiar. The result of that command will be a static application binary named docker-gs-ping
and located in the root of the filesystem of the image that we are building. We could have put the binary into any other place we desire inside that image, the root directory has no special meaning in this regard. It’s just convenient to use it to keep the file paths short for improved readability.
Now, all that is left to do is to tell Docker what command to execute when our image is used to start a container.
We do this with the CMD
command:
CMD [ "/docker-gs-ping" ]
Here’s the complete Dockerfile
:
# syntax=docker/dockerfile:1
FROM golang:1.16-alpine
WORKDIR /app
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY *.go ./
RUN go build -o /docker-gs-ping
EXPOSE 8080
CMD [ "/docker-gs-ping" ]
The Dockerfile
may also contain comments. They always begin with a #
symbol and make no difference to Docker. The comments are there for the convenience of humans tasked to maintain the Dockerfile
:
# syntax=docker/dockerfile:1
# Alpine is chosen for its small footprint
# compared to Ubuntu
FROM golang:1.16-alpine
WORKDIR /app
# Download necessary Go modules
COPY go.mod ./
COPY go.sum ./
RUN go mod download
# ... the rest of the Dockerfile is ...
# ... omitted from this example ...
Build the image
Now that we’ve created our Dockerfile
, let’s build an image from it. The docker build
command creates Docker images from the Dockerfile
and a “context”. A build context is the set of files located in the specified path or URL. The Docker build process can access any of the files located in the context.
The build command optionally takes a --tag
flag. This flag is used to label the image with a string value, which is easy for humans to read and recognise. If you do not pass a --tag
, Docker will use latest
as the default value.
Let’s build our first Docker image!
$ docker build --tag docker-gs-ping .
[+] Building 3.6s (12/12) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 38B 0.0s
=> [internal] load .dockerignore 0.1s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/golang:1.16-alpine 3.0s
=> [1/7] FROM docker.io/library/golang:1.16-alpine@sha256:49c07aa83790aca732250c2258b59 0.0s
=> => resolve docker.io/library/golang:1.16-alpine@sha256:49c07aa83790aca732250c2258b59 0.0s
=> [internal] load build context 0.1s
=> => transferring context: 114B 0.0s
=> CACHED [2/7] WORKDIR /app 0.0s
=> CACHED [3/7] COPY go.mod . 0.0s
=> CACHED [4/7] COPY go.sum . 0.0s
=> CACHED [5/7] RUN go mod download 0.0s
=> CACHED [6/7] COPY *.go . 0.0s
=> CACHED [7/7] RUN go build -o /docker-gs-ping 0.0s
=> exporting to image 0.1s
=> => exporting layers 0.0s
=> => writing image sha256:336a3f164d0f079f2e42cd1d38f24ab9110d47d481f1db7f2a0b0d2859ec 0.0s
=> => naming to docker.io/library/docker-gs-ping 0.0s
Use 'docker scan' to run Snyk tests against images to find vulnerabilities and learn how to fix them
Your exact output will vary, but provided there aren’t any errors, you should see the FINISHED
line in the build output. This means Docker has successfully built our image and assigned a docker-gs-ping
tag to it.
View local images
To see the list of images we have on our local machine, we have two options. One is to use the CLI and the other is to use Docker Desktop. Since we are currently working in the terminal, let’s take a look at listing images with the CLI.
To list images, simply run the images
command:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-gs-ping latest 336a3f164d0f 39 minutes ago 540MB
postgres 13.2 c5ec7353d87d 7 weeks ago 314MB
Your exact output may vary, but you should see docker-gs-ping
image with the latest
tag.
Tag images
An image name is made up of slash-separated name components. Name components may contain lowercase letters, digits and separators. A separator is defined as a period, one or two underscores, or one or more dashes. A name component may not start or end with a separator.
An image is made up of a manifest and a list of layers. In simple terms, a “tag” points to a combination of these artifacts. You can have multiple tags for the image and, in fact, most images have multiple tags. Let’s create a second tag for the image we had built and take a look at its layers.
To create a new tag for our image, run the following command.
$ docker tag docker-gs-ping:latest docker-gs-ping:v1.0
The Docker tag
command creates a new tag for the image. It does not create a new image. The tag points to the same image and is just another way to reference the image.
Now run the docker images
command to see the updated list of local images:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-gs-ping latest 336a3f164d0f 43 minutes ago 540MB
docker-gs-ping v1.0 336a3f164d0f 43 minutes ago 540MB
postgres 13.2 c5ec7353d87d 7 weeks ago 314MB
You can see that we have two images that start with docker-gs-ping
. We know they are the same image because if you look at the IMAGE ID
column, you can see that the values are the same for the two images. This value is a unique identifier Docker uses internally to identify the image.
Let’s remove the tag that we had just created. To do this, we’ll use the rmi
command, which stands for “remove image”:
$ docker rmi docker-gs-ping:v1.0
Untagged: docker-gs-ping:v1.0
Notice that the response from Docker tells us that the image has not been removed but only “untagged”. Verify this by running the images command:
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-gs-ping latest 336a3f164d0f 45 minutes ago 540MB
postgres 13.2 c5ec7353d87d 7 weeks ago 314MB
The tag v1.0
has been removed but we still have the docker-gs-ping:latest
tag available on our machine, so the image is there.
Multi-stage builds
You may have noticed that our docker-gs-ping
image stands at 540MB, which you may think is a lot. You may also be wondering whether our dockerized application still needs the full suite of Go tools, including the compiler, after the application binary had been compiled.
These are legit concerns. Both can be solved by using multi-stage builds. The following example is provided with little explanation because this would derail us from our current concerns, but please feel free to explore on your own later. The main idea is that we use one image to produce some artefacts, which are then placed into another, much smaller image, containing only the parts necessary for running the artefacts that we’d built.
The Dockerfile.multistage
in the sample application’s repo has the following content:
# syntax=docker/dockerfile:1
##
## Build
##
FROM golang:1.16-buster AS build
WORKDIR /app
COPY go.mod ./
COPY go.sum ./
RUN go mod download
COPY *.go ./
RUN go build -o /docker-gs-ping
##
## Deploy
##
FROM gcr.io/distroless/base-debian10
WORKDIR /
COPY --from=build /docker-gs-ping /docker-gs-ping
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/docker-gs-ping"]
Since we have two dockerfiles now, we have to tell Docker that we want to build using our new Dockerfile. We also tag the new image with multistage
but this word has no special meaning, we only do so that we could compare this new image to the one we’ve built previously, that is the one we tagged with latest
:
$ docker build -t docker-gs-ping:multistage -f Dockerfile.multistage .
Comparing the sizes of docker-gs-ping:multistage
and docker-gs-ping:latest
we see an order-of-magnitude difference!
$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
docker-gs-ping multistage e3fdde09f172 About a minute ago 27.1MB
docker-gs-ping latest 336a3f164d0f About an hour ago 540MB
This is due to the fact that the “distroless” base image that we have used to deploy our Go application is very barebones and is meant for lean deployments of static binaries.
For more information on multi-stage builds, please feel free to check out other parts of Docker documentation. This is, however, not essential for our progress here, so we’ll leave it at that.
Next steps
In this module, we took a look at setting up our example Go application that we will use for much of the rest of the tutorial. We also created a Dockerfile
that we used to build our Docker image. Then, we took a look at tagging our images and removing images and tags. In the next module, we’ll take a look at how to:
Feedback
Help us improve this topic by providing your feedback. Let us know what you think by creating an issue in the Docker Docs GitHub repository. Alternatively, create a PR to suggest updates.