A story about Go, Docker and time zones

A story about Go, Docker and time zones

Featured on Hashnode

Yesterday I received a requirement for one of my Go services to convert some data from a specific timezone to UTC. As the process goes, I made the changes, tested it out locally on my machine, and pushed the code to the Github repo. After the changes got deployed to the test environment, I checked if the service is working correctly and found this error:

2020/11/06 08:29:33 error occurred during import and sync execution:
 unknown time zone Europe/Sarajevo

Everything is better with an example

Consider the code below.

package main

import (
    "fmt"
    "os"
    "time"
)

func main() {

    fmt.Println("Local time: ", time.Now())
    fmt.Println("UTC time: ", time.Now().UTC())

    //load timezone
    tz, err := time.LoadLocation("Europe/Sarajevo")
    if err != nil {
        fmt.Println(err)
        os.Exit(-1)
    }

    fmt.Println("Sarajevo time: ", time.Now().In(tz))
}

If you run this locally, it should work. If you run this in some Go playground, it will show the results. It might not be the results you expected, but that's because the instance on which the code is run might have different time settings. In any case, you should get some results.

Now, let's build and run the app inside a Docker container, with a nice multi-stage build. Below is a Dockerfile I used.

FROM golang:1.14-alpine
WORKDIR /app
ADD . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp

FROM scratch as final
COPY --from=0 /app/myapp .
CMD ["/myapp"]

The output:

Local time:  2020-11-07 17:55:09.1324588 +0000 UTC m=+0.000137401
UTC time:  2020-11-07 17:55:09.1326111 +0000 UTC
unknown time zone Europe/Sarajevo

Note

As you might have seen, I've used Go 1.14 in my examples. You might get different errors based on the image you use for building the app. For example, I have also tried this with the golang:alpine image, and this was the output:

Local time:  2020-11-07 08:29:59.037473424 +0000 UTC m=+0.000692888
UTC time:  2020-11-07 08:29:59.037545751 +0000 UTC
open /usr/local/go/lib/time/zoneinfo.zip: no such file or directory

Wait, whaaat??

It seems that either the file cannot be found or that the used time zone is not in the available zoneinfo.zip file. This problem stems from using the scratch image in my Docker multi-stage build. We all love the scratch image because of its size, but because it is an image for a bare-bone container, it doesn't have all dependencies you expect to have when running an app within it. In my case, there is no time zone information that I needed.

How to fix it?

First, let's see where our Go app retrieves the time zone information from. Time to peek into the time package. You can find here info on all environments, but below you can check where the time zone info is in Unix systems.

/src/time/zoneinfo_unix.go:21

// Many systems use /usr/share/zoneinfo, Solaris 2 has
// /usr/share/lib/zoneinfo, IRIX 6 has /usr/lib/locale/TZ.
var zoneSources = []string{
    "/usr/share/zoneinfo/",
    "/usr/share/lib/zoneinfo/",
    "/usr/lib/locale/TZ/",
    runtime.GOROOT() + "/lib/time/zoneinfo.zip",
}

For this approach, there are two things to do to get the time zone info data available at runtime.

  • Copy the time zone info from the build image
  • Update the ZONEINFO environment variable with the correct path to the zoneinfo.zip file
FROM golang:1.14-alpine
WORKDIR /app
ADD . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp

FROM scratch
COPY --from=0 /app/myapp .
COPY --from=0 /usr/local/go/lib/time/zoneinfo.zip /
ENV ZONEINFO=/zoneinfo.zip
CMD ["/myapp"]

The output after building and running the Docker container:

Local time:  2020-11-07 18:51:30.03363 +0000 UTC m=+0.000164101
UTC time:  2020-11-07 18:51:30.0338934 +0000 UTC
Sarajevo time:  2020-11-07 19:51:30.0340067 +0100 CET

Other solutions

The solution above works well, especially if you have some firewall restrictions that could prevent the solutions below to work properly.

Solution 1

Basically, you can download the time zone info data with the apk tool in the build step and copy the data in the final step.

FROM golang:1.14-alpine
RUN apk --no-cache add tzdata
WORKDIR /app
ADD . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp

FROM scratch
COPY --from=0 /app/myapp .
COPY --from=0 /usr/share/zoneinfo /usr/share/zoneinfo
CMD ["/myapp"]

Or...

Solution 2

...you can download it directly in the final step, add read privileges and set the ZONEINFO environment variable. This solution is my least favorite, but it works.

FROM golang:1.14-alpine
WORKDIR /app
ADD . .
RUN CGO_ENABLED=0 GOOS=linux go build -o myapp

FROM scratch
COPY --from=0 /app/myapp .
ADD https://github.com/golang/go/raw/master/lib/time/zoneinfo.zip /zoneinfo.zip
RUN chmod +r /zoneinfo.zip
ENV ZONEINFO /zoneinfo.zip
CMD ["/myapp"]

Conclusion

As you've seen, the curse of it works on my machine strikes again. It is reasonable to think that even the presented code should work in every environment. As it turns out, it doesn't. The lesson to learn here is to test your code in various runtime environments, to make sure it is working properly. Also, you learn a lot from failures. During the investigation of the presented errors, I learned more about how Go and Docker work together when a specific situation is presented. I'm sure there are a lot more different cases like these, and I can't wait to run into them.

Cheers!