Dwi Wahyudi
Senior Software Engineer (Ruby, Golang, Java)
In this article, we’re going to dockerize a Golang web application, with multi-stage build.
Overview
With the age of containerization nowadays especially with Docker, we’ve come to realization that using container gives us plenty of advantages, from using reproducible builds to easily manifest and document our builds process. Sharing docker images between developers is a norm nowadays. With Docker we can package an application to be run in an isolated container, because of its isolation, developers need to be strict and clear when exposing something from and to container, increasing its security. Docker also has a mechanism to cache locally the images and steps needed to build the app.
Unlike some other programming languages, Golang code are compiled to small binaries, to run it, we don’t need any interpreter or VM, resulting in very small and compact Docker images. In order to do so we need to do multi-stage build process, which taking binaries from a build environment and copying it to the runnable environment.
Getting Started
The first thing to do is to install Docker. https://docs.docker.com/get-docker/
Let’s make a Golang app to demonstrate it.
mkdir golang-dockerize
cd golang-dockerize
go mod init golang-dockerize
go get -u github.com/gin-gonic/
Our Golang App
Here’s the main function, a simple web server:
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
log.Println("test dockerizing golang web-app")
router := gin.Default()
router.GET("/ping", ping)
router.Run(":7878")
}
func ping(c *gin.Context) {
c.String(http.StatusOK, "pong")
}
The Dockerfile Definition
And here’s the dockerfile.
- On the build stage, we’re getting a base docker image
golang:1.15.6-alpine
to build our golang app.FROM golang:1.15.6-alpine AS build-env
- Setting
/app
as working directory.WORKDIR ${APP_PATH}
- Copy both go.mod and go.sum from the host machine to the working directory (inside the container), then from inside the container, run some go mod commands to download the libraries. We do this because we need to cache the library downloads for our Golang app. If we use plenty amount of libraries, this will save a lots of bandwidth. This cache will be invalidated if either go.mod and go.sum changes (we will redownload the whole libraries again inside the container).
go mod download
is a command to download all libraries specified ingo.mod
.COPY go.mod ${APP_PATH}
COPY go.sum ${APP_PATH}
RUN go mod download
RUN go mod verify
- After downloading the libraries/dependencies, we build the main.go file into an executable binary file.
RUN CGO_ENABLED=0 GOOS=linux go build main.go
- On the run stage, we’re getting a
scratch
image. This image is blank/empty. Our executable binary file needs to be a statically compiled Linux binary file in order for it to run correctly.FROM scratch
- We then copy the executable binary file from build phase, we don’t need to copy the source code or download the libraries anymore. The
main
binary file is ready to be executed.COPY --from=build-env ${APP_PATH} .
- Inside that scratch image, we then execute the binary file.
CMD ["./main"]
# Build
FROM golang:1.15.6-alpine AS build-env
ENV APP_PATH="/app"
WORKDIR ${APP_PATH}
COPY go.mod ${APP_PATH}
COPY go.sum ${APP_PATH}
RUN go mod download
RUN go mod verify
COPY . ${APP_PATH}
RUN CGO_ENABLED=0 GOOS=linux go build main.go
# Run
FROM scratch
ENV APP_PATH="/app"
WORKDIR ${APP_PATH}
COPY --from=build-env ${APP_PATH} .
CMD ["./main"]
Building the Dockerfile
Let’s build this dockerfile to be an image, name it with hello-docker
, the command would be like: docker build . -t hello-docker
.
At first time, we’re going to download images needed to build the application. We’re going to notice that at this first time build, RUN go mod download
will download libraries/dependencies needed by our Golang application, this time, our simple Golang web server only needs gin
library, it would be fast, but in real-world project we would need to download lots of 3rd party libraries, this would take sometimes (depends on our internet connectivity) and some bandwidths.
That’s why we need to cache such things. As long as those go.mod
and go.sum
code aren’t updated, those steps RUN go mod download
and RUN go mod verify
will be cached. The same thing applies to RUN CGO_ENABLED=0 GOOS=linux go build main.go
and all of commands on Run stage, as long as main.go
isn’t updated, those commands will be cached as well.
Sending build context to Docker daemon 18.43kB
Step 1/14 : FROM golang:1.15.6-alpine AS build-env
---> 1463476d8605
Step 2/14 : ENV APP_PATH="/app"
---> Using cache
---> fd858173cd7f
Step 3/14 : WORKDIR ${APP_PATH}
---> Using cache
---> 7807d51240e8
Step 4/14 : COPY go.mod ${APP_PATH}
---> Using cache
---> 8bd747e1a8a7
Step 5/14 : COPY go.sum ${APP_PATH}
---> Using cache
---> d83d58cffb7f
Step 6/14 : RUN go mod download
---> Using cache
---> 2f373c0ee8a5
Step 7/14 : RUN go mod verify
---> Using cache
---> 46fdf738f6fe
Step 8/14 : COPY . ${APP_PATH}
---> Using cache
---> 1c52d8ee33bb
Step 9/14 : RUN CGO_ENABLED=0 GOOS=linux go build main.go
---> Using cache
---> 678bb200ca0d
Step 10/14 : FROM scratch
--->
Step 11/14 : ENV APP_PATH="/app"
---> Using cache
---> dd0c08ee10f8
Step 12/14 : WORKDIR ${APP_PATH}
---> Using cache
---> 0d9e040f7465
Step 13/14 : COPY --from=build-env ${APP_PATH} .
---> Using cache
---> 1021d04cefae
Step 14/14 : CMD ["./main"]
---> Using cache
---> 60b18c8f958c
Successfully built 60b18c8f958c
Successfully tagged hello-docker:latest
The resulted image will be around 15MB, it is that huge because we use gin
web framework. If we use the built-in net/http
, we will get around 2MB. We include gin
here just to demonstrate the use of go mod download
.
Run the Container
Once successfully built, we can then run that image: docker run --rm -d -p 9191:7878 hello-docker
.
Here we specify that we want Docker to run hello-docker
, -d
specifies that we want to run it as a daemon/background process, --rm
means to cleanup the container’s file system after the container exits, -p 9191:7878
means we want to map the port of 7878
(this is the port for the web server specified by above main.go file
) inside the container to host machine with port 9191
, so we can access it from our host machine with localhost:9191
.
Open a new terminal and run curl localhost:9191/ping
, we will get pong
.
We can check running container with docker container ls
. The output would be like this.
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
42c56a99d9b3 hello-docker "./main" 2 minutes ago Up 2 minutes 0.0.0.0:9191->7878/tcp hopeful_moore
We didn’t give it a name, so Docker will just pick a random name for us. Let’s stop this container: docker stop 42c56a99d9b3
, and let’s give it a name.
docker run --rm -d -p 9191:7878 --name hello-docker hello-docker
In the future article, we’ll be covering docker volume.
Here’s the code for this article: https://github.com/dwahyudi/golang-dockerize