Containers are standalone software packages that bundle an application with all its dependencies, tools, libraries, runtime, configuration files, and anything else necessary to run the application.

Containers abstract away the application from any of the environments that they will run on. That means, containerized apps run consistently across environments from dev to staging to production.

Containers are created from images. The container image is the actual package that contains everything needed to run the application. And, a running instance of an image is referred to as a container.

Docker is a software platform that lets you build, ship, and run containers. You can read more about Docker and Containers in general from the official documentation.

In this article, you’ll learn how to build a docker image for a Go application. We’ll start with a simple image, then we’ll learn how to attach a volume to the docker image. Finally, we’ll build an optimized image using docker’s multi-stage builds that’s only 12.8MB in size.

Creating a Simple Golang App

Let’s create a simple Go app that we’ll containerize. Fire up your terminal and type the following command to create a Go project -

$ mkdir go-docker

We’ll use Go modules for dependency management. Change to the root directory of the project and initialize Go modules like so -

$ cd go-docker
$ go mod init github.com/callicoder/go-docker

We’ll be creating a simple Hello world server. Create a new file called hello_server.go -

$ touch hello_server.go

Following are the contents of the hello_server.go file -

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/gorilla/mux"
	"gopkg.in/natefinch/lumberjack.v2"
)

func handler(w http.ResponseWriter, r *http.Request) {
	query := r.URL.Query()
	name := query.Get("name")
	if name == "" {
		name = "Guest"
	}
	log.Printf("Received request for %s\n", name)
	w.Write([]byte(fmt.Sprintf("Hello, %s\n", name)))
}

func main() {
	// Create Server and Route Handlers
	r := mux.NewRouter()

	r.HandleFunc("/", handler)

	srv := &http.Server{
		Handler:      r,
		Addr:         ":8080",
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	// Configure Logging
	LOG_FILE_LOCATION := os.Getenv("LOG_FILE_LOCATION")
	if LOG_FILE_LOCATION != "" {
		log.SetOutput(&lumberjack.Logger{
			Filename:   LOG_FILE_LOCATION,
			MaxSize:    500, // megabytes
			MaxBackups: 3,
			MaxAge:     28,   //days
			Compress:   true, // disabled by default
		})
	}

	// Start Server
	go func() {
		log.Println("Starting Server")
		if err := srv.ListenAndServe(); err != nil {
			log.Fatal(err)
		}
	}()

	// Graceful Shutdown
	waitForShutdown(srv)
}

func waitForShutdown(srv *http.Server) {
	interruptChan := make(chan os.Signal, 1)
	signal.Notify(interruptChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

	// Block until we receive our signal.
	<-interruptChan

	// Create a deadline to wait for.
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
	defer cancel()
	srv.Shutdown(ctx)

	log.Println("Shutting down")
	os.Exit(0)
}

The server uses gorilla mux to create HTTP routes. It listens for connections on port 8080.

Building and Running the app locally

Let’s first build and run our application locally. Please type the following command to build the app -

$ go build

The build command will produce an executable file named go-docker. You can run the binary executable like so -

$ ./go-docker
2018/12/22 19:16:02 Starting Server

Our hello server is now running. Try interacting with the hello server using curl -

$ curl http://localhost:8080
Hello, Guest

$ curl http://localhost:8080?name=Rajeev
Hello, Rajeev

Defining the Docker image using a Dockerfile

Let’s define the Docker image for our Go application. Create a new file called Dockerfile inside the root directory of your project with the following contents -

# Dockerfile References: https://docs.docker.com/engine/reference/builder/

# Start from the latest golang base image
FROM golang:latest

# Add Maintainer Info
LABEL maintainer="Rajeev Singh <rajeevhub@gmail.com>"

# Set the Current Working Directory inside the container
WORKDIR /app

# Copy go mod and sum files
COPY go.mod go.sum ./

# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download

# Copy the source from the current directory to the Working Directory inside the container
COPY . .

# Build the Go app
RUN go build -o main .

# Expose port 8080 to the outside world
EXPOSE 8080

# Command to run the executable
CMD ["./main"]

Building and Running the Docker image

Now that we have the Dockerfile defined, let’s build and run the docker image -

  • Building the image

    $ docker build -t go-docker .
    

    You can list all the available images by typing the following command -

    $ docker image ls
    REPOSITORY                    TAG                            IMAGE ID            CREATED             SIZE
    go-docker                     latest                         ed03a0732734        21 seconds ago      830MB    
    golang                        latest                           2422e4d43e15        4 days ago          814MB
    
  • Running the Docker image

    Type the following command to run the docker image -

    $ docker run -d -p 8080:8080 go-docker
    fff93d13a4849accd965d5d342b7f6bf55ba50b7b2202b16f4188c076e667563
    
  • Finding Running containers

    You can list all the running containers like so -

    $ docker container ls
    CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS                    NAMES
    fff93d13a484        go-docker           "go-docker"   13 seconds ago      Up 12 seconds       0.0.0.0:8080->8080/tcp   hardcore_kare
    
  • Interacting with the app running inside the container

    Finally, Let’s interact with our Go application that is running inside the container -

    $ curl http://localhost:8080?name=Rajeev
    Hello, Rajeev
    
  • Stopping the container

    To stop the container, type the following command with the container id -

    $ docker container stop fff93d13a484
    fff93d13a484
    

Attaching a Volume to the Docker Container

Let’s see another example of Dockerfile. This time, we’ll attach a volume to the container that will be used to store all the logs generated by the application. A volume is used to share directories from the host OS with the container or persist data generated from the container on the Host os.

Dockerfile.volume

In the following Dockerfile, we declare a volume at path /app/logs. The container will write log files to /app/logs/app.log. When we run the docker image, we can mount a directory of the Host OS to this volume. Once we do that, we’ll be able to access all the log files from the mounted directory of the Host OS.

# Dockerfile References: https://docs.docker.com/engine/reference/builder/

# Start from the latest golang base image
FROM golang:latest

# Add Maintainer Info
LABEL maintainer="Rajeev Singh <callicoder@gmail.com>"

# Set the Current Working Directory inside the container
WORKDIR /app

# Build Args
ARG LOG_DIR=/app/logs

# Create Log Directory
RUN mkdir -p ${LOG_DIR}

# Environment Variables
ENV LOG_FILE_LOCATION=${LOG_DIR}/app.log 

# Copy go mod and sum files
COPY go.mod go.sum ./

# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download

# Copy the source from the current directory to the Working Directory inside the container
COPY . .

# Build the Go app
RUN go build -o main .

# This container exposes port 8080 to the outside world
EXPOSE 8080

# Declare volumes to mount
VOLUME [${LOG_DIR}]

# Run the binary program produced by `go install`
CMD ["./main"]

Let’s build the image by typing the following command -

$ docker build -t go-docker-volume -f Dockerfile.volume .

Let’s now run the image. Notice how we mount a directory of the Host OS to the volume specified by the docker container -

$ mkdir -p ~/logs/go-docker
$ docker run -d -p 8080:8080 -v ~/logs/go-docker:/app/logs go-docker-volume
0c5d2b21ec3ea66f63f56b79725008ce2d229e0b6d07491aaa5b97a32fda6cb9

That’s it. You can now access your application’s logs from the ~/logs/go-docker directory -

$ tail -200f ~/logs/go-docker/app.log
2018/12/22 14:13:27 Starting Server

Building an Optimized Docker image for Go applications using Multi-stage builds

The docker images that we built in the previous sections are quite big. If you type docker image ls, you can see the size of all the images -

$ docker image ls
REPOSITORY                    TAG                            IMAGE ID            CREATED             SIZE
go-docker-volume              latest                         f7b09f7e8a5a        9 minutes ago       830MB
go-docker                     latest                         ed03a0732734        14 minutes ago      830MB
golang                        latest                           2422e4d43e15        4 days ago          814MB

The golang:latest image that we’re using as our base is 814MB in size, and our application images are 830MBs in size.

To reduce the size of the docker image, we can use a multi-stage build. The first stage of the multi-stage build will use the golang:latest image and build our application. The second stage will use a very lightweight Alpine linux image and will only contain the binary executable built by the first stage.

This way, our final image will be very small because It won’t have all the Golang runtime. It will only contain the things needed to run the binary executable -

Dockerfile.multistage

# Dockerfile References: https://docs.docker.com/engine/reference/builder/

# Start from the latest golang base image
FROM golang:latest as builder

# Add Maintainer Info
LABEL maintainer="Rajeev Singh <rajeevhub@gmail.com>"

# Set the Current Working Directory inside the container
WORKDIR /app

# Copy go mod and sum files
COPY go.mod go.sum ./

# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download

# Copy the source from the current directory to the Working Directory inside the container
COPY . .

# Build the Go app
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .


######## Start a new stage from scratch #######
FROM alpine:latest  

RUN apk --no-cache add ca-certificates

WORKDIR /root/

# Copy the Pre-built binary file from the previous stage
COPY --from=builder /app/main .

# Expose port 8080 to the outside world
EXPOSE 8080

# Command to run the executable
CMD ["./main"] 

Type the following command to build the above image -

$ docker build -t go-docker-optimized -f Dockerfile.multistage .

Now let’s see the size of the image -

$ docker image ls
REPOSITORY                    TAG                            IMAGE ID            CREATED             SIZE
go-docker-volume              latest                         f7b09f7e8a5a        9 minutes ago       830MB
go-docker                     latest                         ed03a0732734        14 minutes ago      830MB
go-docker-optimized           latest                         f2117958dff4        3 hours ago         12.8MB
golang                        latest                           2422e4d43e15        4 days ago          814MB

Wow! Our optimized image is only 12.8MB in size. That’s awesome!

Conclusion

In this article, you learned how to build a docker image for your Go application. We started with a simple image, then we learned how to attach a volume to the image. Finally, we learned how to build an optimized image for our Go application.

You can find the complete source code for the Go app and all the Dockerfiles in the Github Repository.

I hope you enjoyed the article. Thanks for reading. See you in the next post.

Footnotes