Docker Multi-Stage Builds — image Go từ 1GB xuống 20MB

Phong Hy

Hồi mới học Docker, mình build image Go xong thấy nó nặng tận 1GB. Deploy lên server chậm rì, tốn bandwidth, mà trong image toàn những thứ không cần thiết: Go compiler, hệ thống package, với đống cache.

Rồi mình biết đến multi-stage builds — và từ đó đến giờ chưa bao giờ viết Dockerfile kiểu cũ nữa.


Vấn đề: Image "béo" đến từ đâu?

Hồi đó Dockerfile của mình đơn giản thế này:

FROM golang:1.23
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server .
CMD ["./server"]

Image nặng 800MB–1.2GB. Trong đó chứa cả Go toolchain, compiler, thư viện hệ thống, source code — những thứ runtime không cần. Bản chất, ứng dụng Go chỉ cần static binary để chạy, chẳng cần gì thêm.


Multi-stage builds — giải pháp đơn giản mà hiệu quả

Ý tưởng: dùng nhiều lệnh FROM trong một Dockerfile. Stage đầu (builder) chứa toàn bộ công cụ build. Stage cuối (runtime) chỉ copy đúng binary vào base image nhẹ nhất có thể.

# === STAGE 1: Builder ===
FROM golang:1.23 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server .

# === STAGE 2: Runtime ===
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

Image lúc này chỉ còn 20–30MB — giảm 95–97%. Deploy nhanh hơn, lưu trữ rẻ hơn, bảo mật hơn.


Tại sao Go lại hợp với multi-stage đến vậy?

Vì Go compile thành static binary. Khi bạn tắt CGO (CGO_ENABLED=0) và strip debug info (-ldflags="-s -w"), binary Go hoàn toàn tự thân — không phụ thuộc thư viện C hay shared library nào. Nó chỉ cần kernel Linux là chạy được.

Đây là lý do Go là ngôn ngữ số một cho container: bạn có thể dùng base image cực kỳ nhỏ.

So sánh base image

Base Image Size Bảo mật Khi nào dùng
golang:1.23 ~800MB Thấp Chỉ dùng để build
alpine:3.20 ~5MB Trung bình Đa số trường hợp
distroless ~15MB Cao (không shell) Production cần security
scratch 0MB Cao nhất Binary không cần gì thêm

Ví dụ với scratch image:

FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
CMD ["/server"]

Image chỉ 15–20MB — chỉ gồm binary và kernel syscalls.


Kinh nghiệm thực tế trong production

1. Cache layer cho go mod

Đặt go mod download trước khi copy source code:

COPY go.mod go.sum ./
RUN go mod download    # ← cache layer này nếu go.mod không đổi
COPY . .               # ← chỉ build lại khi code thay đổi

Docker cache từng layer — nếu không thay đổi go.mod, layer này được reuse, build nhanh hơn hẳn.

2. Distroless images

Google cung cấp image distroless — chỉ có binary + runtime tối thiểu, không có shell, không package manager, không lỗ hổng leo thang đặc quyền:

FROM gcr.io/distroless/base-debian12:latest
COPY --from=builder /app/server /server
CMD ["/server"]

Cực kỳ an toàn cho production — CVE scan hầu như zero.

3. Dockerignore

Đừng quên .dockerignore để context nhẹ:

.git/
node_modules/
*.md
.env
*.test.go

Build nhanh hơn 30–50% chỉ nhờ cái này.

4. Build nhiều binary

Một Dockerfile build cả server lẫn worker:

FROM golang:1.23 AS builder
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
RUN CGO_ENABLED=0 go build -o /worker ./cmd/worker

FROM alpine:3.20
COPY --from=builder /server /server
COPY --from=builder /worker /worker

Kết luận

Multi-stage builds là kỹ thuật đơn giản nhưng cực kỳ hiệu quả. Với Go, bạn có thể đưa image từ 1GB xuống còn 20MB — con số biết nói.

Những lợi ích bạn nhận được:

  • Deploy nhanh hơn — image nhỏ, pull và start trong giây lát
  • Bảo mật hơn — bề mặt tấn công giảm mạnh
  • Tiết kiệm chi phí — ít storage, ít bandwidth, ít thời gian CI/CD
  • Dễ maintain — một Dockerfile cho cả build và runtime

Nếu bạn chưa chuyển sang multi-stage, hãy thử ngay hôm nay — chỉ mất 5 phút sửa Dockerfile mà lợi ích kéo dài mãi.