Once upon a time, in a land far, far away, there was a Go developer named Upix. Upix had a simple Go app that he wanted to turn into a Docker container. He build a simple ReST API to show the current date in UTC+7.
Here is the code:
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func now(w http.ResponseWriter, req *http.Request) {
var local, _ = time.LoadLocation("Asia/Jakarta")
fmt.Fprintf(w, "Now : %v\n", time.Now().In(local))
}
func main() {
http.HandleFunc("/now", now)
fmt.Println("server run on 8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Upix began by creating a new file in his Go app directory called “Dockerfile”. In this file, he wrote the following:
FROM golang:latest
COPY . /go/src/app
WORKDIR /go/src/app
RUN go build -o main .
CMD ["/go/src/app/main"]
This Dockerfile told Docker to use the latest version of the official Go image as the base image for the container. It then copied all the files in the current directory (the Go app) into the container, set the working directory to the app directory, and ran the go build command to build the app. Finally, it set the default command for the container to run the built app.
Next, Upix opened a terminal and navigated to his Go app directory. Then, he ran the following command to build the Docker image:
$ docker build -t jakarta-now .
This command told Docker to build an image with the name “jakarta-now” based on the instructions in the Dockerfile in the current directory (denoted by the .).
After a few minutes, the Docker image was built successfully. Upix was now ready to run the container. He ran the following command:
$ docker run -p 8080:8080 jakarta-now
This command told Docker to run the “jakarta-now” image as a container, and to map the container’s port 8080 to the host’s port 8080.
Upix opened a web browser and navigated to “localhost:8080/now”, and to his delight, he saw the “Now : 2023-01-08 09:31:01.616292115 +0700 WIB” message displayed on the page!
Upix was happy with the result, but seeing the docker image size is big he is wondering how to make it smaller and efficient docker image so if there any update the download size is small.
After doing some research he founds workaround to solve the issue:
- Make binary output smaller
- Use minimal base image in docker images such as alpine or scratch. He try using scratch due the current application that he built is very simple.
- Use multiple build stage in dockerfile
Upix modified the Dockerfile
to reduce image size.
FROM golang:latest as builder
COPY . /go/src/app
WORKDIR /go/src/app
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o main .
FROM scratch
COPY --from=builder /go/src/app/main /main
CMD ["/main"]
He use multiple build stage in docker image, as builder using golang:latest
and runner using raw scratch. Another trick is using -ldflags="-s -w"
when build go apps to reduce output binary, he can make smaller image by using upx but he avoid to use it because building image takes more time if using upx. After the build application completed the binary location in the builder
with path /go/src/app/main
. The final step is copy from builder to the runner image.
Upix create the docker image with another tag jakarta-now-small
$ docker build -t jakarta-now-small .
The result is amazing
❯ docker image list
REPOSITORY TAG IMAGE ID CREATED SIZE
jakarta-now-small latest ff2c7caebd56 34 seconds ago 4.89MB
jakarta-now latest 07d16a449e6a 17 minutes ago 1.01GB
But after trying to run docker image he got fatal error
❯ docker run -p 8080:8080 jakarta-now-small
server run on 8080
2023/01/08 02:48:55 http: panic serving 172.17.0.1:54118: time: missing Location in call to Time.In
goroutine 18 [running]:
net/http.(*conn).serve.func1()
/usr/local/go/src/net/http/server.go:1850 +0xbf
2023/01/08 02:48:55 http: panic serving 172.17.0.1:54118: time: missing Location in call to Time.In
goroutine 18 [running]:
net/http.(*conn).serve.func1()
/usr/local/go/src/net/http/server.go:1850 +0xbf
panic({0x626340, 0x6db150})
/usr/local/go/src/runtime/panic.go:890 +0x262
time.Time.In(...)
/usr/local/go/src/time/time.go:1122
main.now({0x6ddca8?, 0xc0001200e0?}, 0x4a44d3?)
/go/src/app/main.go:12 +0x15a
net/http.HandlerFunc.ServeHTTP(0xc000093af0?, {0x6ddca8?, 0xc0001200e0?}, 0x0?)
/usr/local/go/src/net/http/server.go:2109 +0x2f
net/http.(*ServeMux).ServeHTTP(0x0?, {0x6ddca8, 0xc0001200e0}, 0xc00011c000)
/usr/local/go/src/net/http/server.go:2487 +0x149
net/http.serverHandler.ServeHTTP({0xc0000a0ea0?}, {0x6ddca8, 0xc0001200e0}, 0xc00011c000)
/usr/local/go/src/net/http/server.go:2947 +0x30c
net/http.(*conn).serve(0xc0000b6aa0, {0x6de080, 0xc0000a0db0})
/usr/local/go/src/net/http/server.go:1991 +0x607
created by net/http.(*Server).Serve
/usr/local/go/src/net/http/server.go:3102 +0x4db
Ah, he forgot that in scratch
image don’t have a time zone data. If he using alpine it simply run apk add tzdata
in the Dockerfile runner, but unfortunately he use scratch
base image.
After reading and ask his mentor, he found out that starting from Go 1.15, time/tzdata is available in go standard library, he just need to import the package in the main package. So the final main.go file become like this
package main
import (
"fmt"
"log"
"net/http"
"time"
_ "time/tzdata"
)
func now(w http.ResponseWriter, req *http.Request) {
var local, _ = time.LoadLocation("Asia/Jakarta")
fmt.Fprintf(w, "Now : %v\n", time.Now().In(local))
}
func main() {
http.HandleFunc("/now", now)
fmt.Println("server run on 8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}
Yeah, after rebuilding dockerfile, the application worked properly.
❯ docker run -p 8080:8080 jakarta-now-small
server run on 8080
❯ curl localhost:8080/now
Now : 2023-01-08 10:00:34.281244452 +0700 WIB
And so, Upix lived happily ever after, having successfully created a Docker container for his simple “jakarta-now” Go app. The end.