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:
- Software Components: Every piece of software in your container (binaries, libraries, tools) that could contain vulnerabilities
- Network Services: Open ports and services that could be targeted
- System Utilities: Tools like shells, package managers, or debugging utilities that could be misused
- Configuration Files: System configurations that could be manipulated
- 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 Image | Size | Notes |
|---|---|---|
| Alpine | ~15MB | Includes basic shell and package manager |
| Distroless | ~10MB | Minimal runtime environment |
| Scratch | ~7MB | Absolute minimum, requires manual setup |
Key Optimizations Link to heading
- Multi-stage builds: Separate build and runtime environments
- CGO_ENABLED=0: Disable CGO for static compilation
- GOOS=linux: Target Linux explicitly
- Minimal base images: Choose the smallest suitable base image
- Layer optimization: Combine RUN commands to reduce layers
- Use
.dockerignore: Exclude unnecessary files from builds - 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:
Scratch images:
- Pros: Minimal attack surface
- Cons: No shell for debugging, requires manual setup of certificates
Distroless images:
- Pros: Minimal but includes essential security features
- Cons: Slightly larger than scratch
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.