Optimizing Docker Images with Multi-Stage Builds and Distroless Approach
Tags: docker, devops, beginners, go
Published: 2024-01-07 00:20:06

When we transitioned our application from Node.js (Express) to Go, we managed to reduce the Docker image size from 2.8 GB to 400 MB, which initially satisfied us. However, aiming for more efficient use of resources and faster deployment, we realized there was still room for further reduction in the image size, so we decided to try a new approach.

This might be familiar territory for engineers with DevOps experience, but for beginners, I'll share the improvements we made.

## Original Dockerfile
Our original Dockerfile used a `golang:1.21.0` base image and included steps for downloading Go modules, building the application, and executing it. However, this approach meant that all files needed for the development environment were included in the image, resulting in a larger final image size.

```dockerfile
# Base the image on the official Go image
FROM golang:1.21.0

# Create the application directory
WORKDIR /app

# Enable Go modules
ENV GO111MODULE=on

# Copy and download dependencies
COPY go.mod .
COPY go.sum .
RUN go mod download

# Copy the application source
COPY . .

# Build the application
RUN go build -o main .

# Expose the port
EXPOSE 8081

# Execute the application command
CMD ["./main"]
```

## Improved Dockerfile
In the improved Dockerfile, we introduced multi-stage builds. Multi-stage builds define multiple build stages in a single Dockerfile, and only the necessary files are included in the final image. In the first stage, the application is built, and in the next stage, only the built executable file is copied to a lightweight `alpine` image. This reduces the final image size significantly by including only the essential files.

```dockerfile
# Base the image on the official Go image
FROM golang:1.21.0-alpine as builder

# Create the application directory
WORKDIR /app

# Enable Go modules
ENV GO111MODULE=on

# Copy and download dependencies
COPY go.mod .
COPY go.sum .
RUN go mod download

# Copy the application source
COPY . .

# Build the application
RUN go build -o main .

# Execution stage
FROM alpine:3.19

WORKDIR /root/

# Copy the built binary
COPY --from=builder /app/main .

# Expose the port
EXPOSE 8081

# Execute the application command
CMD ["./main"]
```

## Results Comparison
Using the original Dockerfile, the image size was about 400 MB. However, after applying the multi-stage build, the image size was reduced to just 10 MB. Without multi-stage build but using `golang:1.21.0-alpine` as the base image, the size was about 160 MB.

## About Distroless Images
For this project, we chose the Alpine image with a shell for debugging purposes, but we also considered using Google's Distroless images. Distroless images are known for being very lightweight and secure environments, containing only the minimal files required, with shells and unnecessary packages removed.

When using Distroless images, applications written in Go need statically linked binaries that do not depend on external C libraries. This is because Distroless provides a minimal environment without external libraries and shells. Therefore, it's necessary to set `CGO_ENABLED=0` to disable CGO (the interface between C and Go languages), creating a standalone binary that includes all dependencies.

The Dockerfile using Distroless would look like this:

```dockerfile
# Build stage
FROM golang:1.21.0 as builder

# Set the application directory
WORKDIR /app

# Enable Go modules
ENV GO111MODULE=on

# Copy and download dependencies
COPY go.mod .
COPY go.sum .
RUN go mod download

# Copy the application source
COPY . .

# Build the application
RUN CGO_ENABLED=0 go build -o main .

# Execution stage
FROM gcr.io/distroless/base-debian10

# Copy the built binary
COPY --from=builder /app/main /

# Execute the application
CMD ["/main"]
```

## Conclusion
In summary, our journey in optimizing Docker images for a Go application taught us valuable lessons about the efficiency and security in containerized environments. By implementing multi-stage builds, we were able to drastically reduce the image size, thus enhancing deployment speed and minimizing resource usage. This approach not only contributes to a more streamlined and cost-effective deployment process but also aligns well with the principles of modern DevOps practices.

Furthermore, exploring options like the Distroless images opened our eyes to the importance of security in Docker environments. While we opted for Alpine due to our immediate need for a shell for debugging, Distroless presents a compelling alternative for production environments, where security and minimalism are paramount.

This exercise stands as a testament to the continuous evolution in the field of software development and deployment. It highlights the importance of staying adaptable and always being on the lookout for better, more efficient solutions. As technology progresses, so do the tools and practices, enabling us to build and deploy applications that are not only powerful but also efficient and secure.

For both seasoned DevOps professionals and beginners, these findings underscore the significance of Docker image optimization and the impact it can have on the overall software delivery lifecycle. As we move forward, it's clear that embracing such optimizations will be key in driving operational excellence and delivering software at the pace and scale demanded by modern digital landscapes.