[Go Tour ] 2. Optimize Dockerfile
Thao Nguyen
Posted on December 16, 2023
Simple Dockerfile
FROM golang:1.21.3-alpine
WORKDIR /app
COPY . .
RUN go build -v -o /usr/local/bin/myapp
CMD ["myapp"]
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
```
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
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 ./
...
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 ./
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
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 .
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.
Posted on December 16, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.