When deploying Go applications in containers, image size matters. A smaller image means faster deployments, reduced storage costs, and improved security through a smaller attack surface. In this post, we’ll explore different approaches to create the smallest possible Docker image for a Go application.

Understanding Attack Surface Link to heading

Before diving into the implementation, it’s important to understand what attack surface is in container security. The attack surface refers to all the potential points where an attacker could exploit vulnerabilities in your container. This includes:

  1. Software Components: Every piece of software in your container (binaries, libraries, tools) that could contain vulnerabilities
  2. Network Services: Open ports and services that could be targeted
  3. System Utilities: Tools like shells, package managers, or debugging utilities that could be misused
  4. Configuration Files: System configurations that could be manipulated
  5. User Permissions: Access levels and capabilities that could be escalated

A smaller attack surface means:

  • Fewer components that could contain vulnerabilities
  • Less code that needs to be audited and maintained
  • Reduced chance of misconfiguration
  • Fewer potential entry points for attackers

This is why minimal container images are generally more secure - they contain only the absolute essentials needed to run your application.

Our Test Application Link to heading

Let’s start with a simple Go application that we’ll use for our tests:

// main.go
package main

import (
    "fmt"
    "net/http"
)

func main() {
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello from the smallest Go container!")
    })
    
    fmt.Println("Server starting on port 8080...")
    http.ListenAndServe(":8080", nil)
}

// go.mod
module main

go 1.21

Approach 1: Using Alpine Linux Link to heading

Alpine Linux is a popular choice for minimal Docker images. Let’s see how small we can make our image using it:

# Dockerfile.alpine
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod main.go ./

RUN CGO_ENABLED=0 GOOS=linux go build -o main .

FROM alpine:3.19

WORKDIR /app
COPY --from=builder /app/main .

EXPOSE 8080
CMD ["./main"]

Build and check size of the image:

docker build -t go-alpine -f Dockerfile.alpine

docker image ls go-alpine:latest    
REPOSITORY           TAG         IMAGE ID      CREATED        SIZE
localhost/go-alpine  latest      090e90b432c1  9 minutes ago  14.5 MB

Approach 2: Using Distroless Link to heading

Google’s Distroless images provide a minimal runtime environment:

# Dockerfile.distroless
FROM golang:1.21 AS builder

WORKDIR /app
COPY go.mod main.go ./

RUN CGO_ENABLED=0 GOOS=linux go build -o main .

FROM gcr.io/distroless/static-debian12

WORKDIR /app
COPY --from=builder /app/main .

EXPOSE 8080
CMD ["./main"]

Build and check size of the image:

docker build -t go-distroless -f Dockerfile.distroless

docker image ls go-distroless:latest
REPOSITORY               TAG         IMAGE ID      CREATED        SIZE
localhost/go-distroless  latest      632388527122  7 minutes ago  9.49 MB

Approach 3: Using Scratch Link to heading

The scratch image is the most minimal base image possible, but requires some additional setup:

# Dockerfile.scratch
FROM golang:1.21 AS builder

WORKDIR /app
COPY go.mod main.go ./

RUN CGO_ENABLED=0 GOOS=linux go build -o main .

FROM scratch

COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

WORKDIR /app
COPY --from=builder /app/main .

EXPOSE 8080
CMD ["./main"]

Build and check size of the image:

docker build -t go-scratch -f Dockerfile.scratch

docker image ls go-scratch:latest
REPOSITORY            TAG         IMAGE ID      CREATED        SIZE
localhost/go-scratch  latest      6c8ff3dbc5f1  2 minutes ago  6.67 MB

Results Comparison Link to heading

Let’s compare the sizes of our different images:

Base ImageSizeNotes
Alpine~15MBIncludes basic shell and package manager
Distroless~10MBMinimal runtime environment
Scratch~7MBAbsolute minimum, requires manual setup

Key Optimizations Link to heading

  1. Multi-stage builds: Separate build and runtime environments
  2. CGO_ENABLED=0: Disable CGO for static compilation
  3. GOOS=linux: Target Linux explicitly
  4. Minimal base images: Choose the smallest suitable base image
  5. Layer optimization: Combine RUN commands to reduce layers
  6. Use .dockerignore: Exclude unnecessary files from builds
  7. Enable BuildKit: Leverage better caching and build performance

Security Considerations Link to heading

While smaller images are generally more secure, there are some trade-offs:

  1. Scratch images:

    • Pros: Minimal attack surface
    • Cons: No shell for debugging, requires manual setup of certificates
  2. Distroless images:

    • Pros: Minimal but includes essential security features
    • Cons: Slightly larger than scratch
  3. Alpine images:

    • Pros: Includes shell for debugging
    • Cons: Larger attack surface

Conclusion Link to heading

Creating minimal Docker images for Go applications is straightforward thanks to Go’s static compilation capabilities. While the scratch image provides the smallest possible size, the distroless image often provides a better balance between size and usability.

Remember that the smallest image isn’t always the best choice - consider your specific needs for debugging, security, and maintenance when choosing your base image.

Further Reading Link to heading