[Go Tour ] 2. Optimize Dockerfile
Tags: go, docker, dockerfile, optimize
Published: 2023-12-16 05:27:31

## Simple Dockerfile

```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.
<u>Taking a simple Hello World sample for a instance, the image size amounts to <b> 327MB. </b></u>

**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.

```dockerfile
#----- 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.
<u>The image size is now reduced to <b> 13.64MB. </b>
</u>
**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: {% embed https://dev.to/thaonx/go-tours-1-exploring-the-go-build-command-omd %}
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:

```diff
# 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:

```terminal
#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.

```diff
#----- 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.