[Go Tour ] 2. Optimize Dockerfile

thaonx

Thao Nguyen

Posted on December 16, 2023

[Go Tour ] 2. Optimize Dockerfile

Simple Dockerfile


FROM golang:1.21.3-alpine

WORKDIR /app

COPY . .

RUN go build -v -o /usr/local/bin/myapp

CMD ["myapp"]

Enter fullscreen mode Exit fullscreen mode

1. Identified Issues

1.1. Image size:
The golang:1.21.3-alpine base image carries a lot of stuff that make the image unnecessarily big. Since Golang is a compiled language, having the compiler and its dependencies in the final image is redundant.
Taking a simple Hello World sample for a instance, the image size amounts to 327MB.

1.2. Security Concerns:
We cannot not entirely ensure what is up in the golang:1.21.3-alpine image.

1.3. Inefficiency:
Despite Docker's layer caching, the COPY . . instruction invalidates the cache for all the subsequent changes. Consequently, any modification will result in downloading all the dependencies and full recompilation. the simple Hello World sample image would approximately take 10 seconds to build.

```
#9 [5/5] RUN go build -v -o /usr/local/bin/myapp
#9 0.503 go: downloading gorm.io/gorm v1.25.5
#9 0.505 go: downloading gorm.io/driver/sqlite v1.5.4
#9 0.723 go: downloading github.com/mattn/go-sqlite3 v1.14.17
#9 0.913 go: downloading github.com/jinzhu/now v1.1.5
#9 0.913 go: downloading github.com/jinzhu/inflection v1.0.0
#9 1.182 internal/goos
#9 1.182 internal/itoa
#9 1.182 internal/godebugs
...
#9 8.840 gotour/example
#9 DONE 9.6s
```
Enter fullscreen mode Exit fullscreen mode

2. Optimizations

2.1. Use a multi-stage build to reduce the image size and improve security.

#----- Builder stage -----#
FROM golang:1.21.3-alpine as builder

WORKDIR /app

# download dependencies
RUN --mount=type=bind,source=go.mod,target=go.mod \
    --mount=type=bind,source=go.sum,target=go.sum \
    go mod download -x

# compile go binary
RUN --mount=type=bind,source=.,target=.\
    CGO_ENABLED=0 \
    go build -o /usr/local/bin/myapp ./

#----- Runtime stage -----#
FROM gcr.io/distroless/static-debian11 as runner
COPY --from=builder /usr/local/bin/myapp /usr/local/bin/myapp
EXPOSE 8080
CMD ["myapp"]

#----- Dev stage -----#
FROM runner as dev

#----- Prod stage -----#
FROM runner as prod
Enter fullscreen mode Exit fullscreen mode

First, we split the Dockerfile into two main stages: builder and runner. The builder is responsible for compiling the Go binary and the runner is responsible for running the binary. The runner uses the distroless/static-debian11 image as its base image which is a minimal image that contains only the necessary dependencies to run a statically compiled binary.
The image size is now reduced to 13.64MB.

Second, we seperate the download of dependencies and the compilation of the binary into two separate steps. Most of the time, the dependencies do not change as often as the source code. Therefore, we can only invalidate the docker layer cache when the dependencies change. This will save us a lot of time when building the image.

...
# download dependencies
RUN --mount=type=bind,source=go.mod,target=go.mod \
    --mount=type=bind,source=go.sum,target=go.sum \
    go mod download -x

# compile go binary
RUN --mount=type=bind,source=.,target=.\
    CGO_ENABLED=0 \
    go build -o /usr/local/bin/myapp ./
...
Enter fullscreen mode Exit fullscreen mode

2.2. Use docker dedicated RUN cache

However, a drawback arises when changing dependencies (go.mod), as Docker tends to redownload all dependencies and recompile the binary, leading to inefficiency.
In prior article:



I introduced how go buildkit caches the downloaded dependencies and compiled binaries. To capitalize on this feature, we can utilize Docker's dedicated RUN cache. The Dockerfile is as follows:

# download dependencies
-RUN --mount=type=bind,source=go.mod,target=go.mod \
+RUN \
+   --mount=type=cache,target=/go/pkg/mod \
+   --mount=type=bind,source=go.mod,target=go.mod \
    --mount=type=bind,source=go.sum,target=go.sum \
    go mod download -x

# compile go binary
-RUN --mount=type=bind,source=.,target=.\
+RUN \
+   --mount=type=cache,target=/go/pkg/mod \
+   --mount=type=cache,target=/root/.cache/go-build \
+   --mount=type=bind,source=.,target=.\
    CGO_ENABLED=0 \
    go build -v -o /usr/local/bin/myapp ./
Enter fullscreen mode Exit fullscreen mode

By adding --mount=type=cache,target=/go/pkg/mod and --mount=type=cache,target=/root/.cache/go-build to the RUN command, we can use docker dedicated RUN cache to cache the dependencies and the compiled binary. Everytimes docker BuilderKit run the RUN command, it will mount the cache to the specified target. This will leverage the artifacts from the previous build and save us a lot of time.
For instance, I made a change into go.mod and repository package and re-build the image. The output is as follows:

#9 [builder 3/4] RUN     --mount=type=cache,target=/go/pkg/mod     --mount=type=bind,source=go.mod,target=go.mod     --mount=type=bind,source=go.sum,target=go.sum     go mod download -x
#9 DONE 0.3s

#10 [builder 4/4] RUN     --mount=type=cache,target=/go/pkg/mod     --mount=type=cache,target=/root/.cache/go-build     --mount=type=bind,source=.,target=.     CGO_ENABLED=0     go build  -v -o /usr/local/bin/myapp ./
#10 0.556 gotour/example/internal/repository
#10 0.587 gotour/example/internal/service
#10 0.597 gotour/example
#10 DONE 1.0s
Enter fullscreen mode Exit fullscreen mode

This log discloses that the docker BuilderKit re-executed go mod download command but it was exceptionally fast as the go mod reused the downloaded dependencies from the cache. Similarly, the go build command was also re-executed and remarkably swift, as it only recompiled three packages (repository, service and main) instead of the whole project along with all Go standard libraries.

2.3. Multi platforms build (Bonus)

We can also use docker multi platform build to build the image for different platforms. For instance, we can build the image for linux/amd64 and linux/arm64 platforms.

#----- Builder stage -----#
-FROM golang:1.21.3-alpine as builder
+FROM --platform=${BUILDPLATFORM} golang:1.21.3-alpine as builder
+ARG TARGETOS
+ARG TARGETARCH

RUN \
   --mount=type=cache,target=/go/pkg/mod \
   --mount=type=cache,target=/root/.cache/go-build \
   --mount=type=bind,source=.,target=.\
    CGO_ENABLED=0 \
+   GOOS=${TARGETOS} \
+   GOARCH=${TARGETARCH} \
    go build -v -o /app/bin/main .

Enter fullscreen mode Exit fullscreen mode

3. Conclusion

In this section, we have explored how to optimize the Dockerfile for a golang application. Key takeaways include leveraging Docker's multi-stage build to diminish image size (from >300Mb to 13Mb) and enhance security. Additionally, We delved into utilizing Docker's dedicated RUN cache to speed up the build process. However, this technique is primarily effective on the Local Development Environment. For the CI/CD pipeline, we need a more advanced technique to enable the cache across separate builds.

In the upcoming section, we will delve into configuring hot-reload in local development environment for a Golang application.

Thank you for reading.

💖 💪 🙅 🚩
thaonx
Thao Nguyen

Posted on December 16, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related