Go + Docker: Multi-stage Build tối ưu — Giảm dung lượng image từ 800MB xuống 21MB

Khi bắt đầu đóng gói ứng dụng Go bằng Docker, hầu hết lập trình viên đều gặp chung một tình huống: chỉ cần dùng base image golang là Docker image của ứng dụng đã nặng tới 800MB – 1GB, trong khi bản binary thực sự chỉ nặng vài chục MB. Sự chênh lệch này đến từ đâu, và làm sao đưa image về đúng kích thước cần thiết? Câu trả lời nằm ở kỹ thuật multi-stage build — chuẩn mực bất thành văn khi đóng gói ứng dụng Go lên production. Bài viết này sẽ hướng dẫn chi tiết: từ Dockerfile một stage đầy bất cập, chuyển sang multi-stage, tối ưu bằng BuildKit cache, cho đến chạy container với healthcheck trong docker-compose.

Vì sao Docker image Go mặc định lại nặng?

Base image golang:1.24 nặng khoảng 800MB vì nó chứa toàn bộ toolchain cần cho việc biên dịch: compiler, linker, toàn bộ source code của standard library, git, và hàng loạt công cụ build khác. Đây là những thứ chỉ cần ở giai đoạn build, không cần khi container chạy. Nhưng nếu bạn viết Dockerfile theo cách “một stage” — dùng image golang làm cả môi trường build lẫn runtime — thì toàn bộ toolchain sẽ đi theo container mãi mãi.

Vì sao điều này tệ? Thứ nhất, image nặng làm chậm quá trình pull/push trên mọi môi trường, đặc biệt là server production có băng thông thấp. Thứ hai, image càng nặng thì bề mặt tấn công càng lớn: trong image chứa compiler, git và source code — nếu kẻ tấn công chiếm được container, chúng có sẵn công cụ để leo thang đặc quyền. Thứ ba, mỗi layer trong Docker image được giữ nguyên vĩnh viễn, nên dung lượng lãng phí không bao giờ tự biến mất. Trong khi đó, binary Go có một đặc tính quý giá: khi build với CGO_ENABLED=0, nó là static binary — không phụ thuộc bất kỳ thư viện hệ thống nào, chạy được trên mọi distro, thậm chí cả image rỗng không có gì.

Dockerfile một stage — vấn đề điển hình

Hãy bắt đầu với Dockerfile đơn giản nhất mà nhiều người mới viết:

FROM golang:1.24

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN go build -o server ./cmd/server

EXPOSE 8080
CMD ["./server"]

Build xong, kiểm tra kích thước image và binary bên trong:

docker build -t goapp-single .
docker images goapp-single
# REPOSITORY     TAG       IMAGE ID       CREATED          SIZE
# goapp-single   latest    a1b2c3d4e5f6   10 seconds ago   851MB

docker run --rm goapp-single ls -lh /app/server
# -rwxr-xr-x 1 root root 12M /app/server

Binary chỉ nặng 12MB nhưng image nặng 851MB — hơn 98% dung lượng là rác không cần thiết ở runtime. Đây chính là vấn đề mà multi-stage build giải quyết triệt để.

Multi-stage build — giải pháp chuẩn

Cơ chế của multi-stage build rất đơn giản: Dockerfile có thể chứa nhiều mệnh đề FROM, mỗi mệnh đề bắt đầu một stage mới. Chỉ stage cuối cùng mới được giữ lại trong image kết quả — các stage trung gian bị loại bỏ hoàn toàn. Nhờ đó, ta dùng stage đầu (builder) với image golang đầy đủ toolchain để biên dịch, rồi chỉ COPY --from binary sang stage runtime gọn nhẹ:

# Stage 1: builder — biên dịch binary
FROM golang:1.24 AS builder

WORKDIR /src

# Copy go.mod/go.sum trước để tận dụng layer cache (xem phần BuildKit)
COPY go.mod go.sum ./
RUN go mod download

COPY . .

# CGO_ENABLED=0: build binary tĩnh, không phụ thuộc glibc
# -s -w: loại bỏ symbol table và DWARF debug info để giảm kích thước
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server

# Stage 2: runtime — chỉ chứa binary
FROM alpine:3.20

RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /out/server ./server

USER app
EXPOSE 8080
CMD ["./server"]

Giải thích các flag quan trọng:

  • CGO_ENABLED=0 — vô hiệu hoá CGO để tạo static binary. Nếu bật CGO, binary sẽ link với glibc của image builder, và khi chạy trên Alpine (dùng musl libc) sẽ lỗi exec format error hoặc lỗi thiếu thư viện.
  • GOOS=linux — build đúng nền tảng đích, tránh tình trạng build trên Mac ra binary Darwin rồi không chạy được trên server Linux.
  • -ldflags="-s -w"-s bỏ symbol table, -w bỏ DWARF debug info. Giảm binary khoảng 20-30%, không ảnh hưởng chức năng, chỉ mất khả năng debug bằng dlv.
  • USER app — chạy container với user không có đặc quyền root. Đây là nguyên tắc bảo mật cơ bản: nếu ứng dụng bị exploit, kẻ tấn công không có quyền root trong container.

Build lại và so sánh:

docker build -t goapp-multi .
docker images goapp-multi
# REPOSITORY     TAG       IMAGE ID       CREATED          SIZE
# goapp-multi    latest    f6e5d4c3b2a1   8 seconds ago    21.4MB

docker run -d -p 8080:8080 goapp-multi
docker ps
docker exec -it CONTAINER_ID sh
# /app $ ./server

Image giảm từ 851MB xuống còn 21.4MB — gần 40 lần. Toàn bộ toolchain, source code và cache build đã bị loại khỏi image cuối.

Alpine, Distroless hay Scratch — chọn base image cho runtime

Ở stage runtime, bạn có ba lựa chọn phổ biến. Mỗi loại đánh đổi giữa độ gọn nhẹ và tiện lợi:

Tiêu chíAlpineDistrolessScratch
Kích thước base~3MB~2MB0 byte
Shell + package managerCó (sh, apk)KhôngKhông
Debug trong containerDễ — exec vào shellKhóRất khó
ca-certificatesCài qua apkCó sẵnPhải COPY thủ công
Healthcheckwget của busyboxwget tĩnh kèm sẵnTự viết hoặc bỏ qua
Phù hợpDev, dự án nhỏProductionBinary tĩnh thuần

Khuyến nghị thực tế: dự án cần debug nhanh, đội ngũ chưa quen thì dùng Alpine; production yêu cầu bảo mật cao thì dùng Distroless (gcr.io/distroless/static-debian12) vì không có shell nên không thể leo thang kiểu “exec vào container gõ lệnh”; còn Scratch dành cho binary tĩnh thuần khi bạn chấp nhận tự lo mọi thứ.

Một lưu ý quan trọng: nếu ứng dụng gọi API bên ngoài qua HTTPS (gọi Stripe, gọi webhook…), image phải có ca-certificates, nếu không mọi kết nối TLS sẽ báo x509: certificate signed by unknown authority. Với Alpine dùng apk add --no-cache ca-certificates; với Scratch phải COPY từ builder stage:

# Với Scratch: copy CA certificates từ builder
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /out/server /server
ENTRYPOINT ["/server"]

Tận dụng BuildKit để build nhanh hơn

Multi-stage giúp image gọn, nhưng thời gian build cũng quan trọng không kém, nhất là trong CI/CD. Với Docker truyền thống, mỗi lần source code thay đổi, lệnh go build phải tải lại toàn bộ module — rất chậm. BuildKit (mặc định từ Docker 23.0) giải quyết bằng cache mount: thư mục cache được giữ lại giữa các lần build mà không nằm trong image:

# syntax=docker/dockerfile:1
FROM golang:1.24 AS builder
WORKDIR /src

RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    CGO_ENABLED=0 go build -ldflags="-s -w" -o /out/server ./cmd/server

Điểm mấu chốt: --mount=type=cache tạo một volume tạm chỉ tồn tại trong lúc build, không được ghi vào layer nào của image cuối. Lần build đầu vẫn tải module, nhưng các lần sau chỉ biên dịch phần thay đổi — thời gian build trong pipeline giảm từ vài phút xuống còn vài chục giây. Ngoài ra, thứ tự COPY go.mod go.sum trước rồi mới COPY . . cũng là một mẹo cache quan trọng: nếu chỉ có source thay đổi mà go.mod không đổi, layer go mod download được tái sử dụng nguyên vẹn.

Một số lệnh hữu ích khi làm việc với multi-stage: docker build --target builder -t goapp-builder . để build riêng stage builder phục vụ debug; docker history IMAGE_ID để xem từng layer chiếm bao nhiêu dung lượng; và docker scout cves IMAGE_ID để quét lỗ hổng bảo mật trong image trước khi deploy.

.dockerignore — giảm build context và tránh lộ secret

Build context là toàn bộ thư mục dự án được gửi lên Docker daemon trước khi build. Nếu dự án có thư mục .git vài trăm MB, mỗi lần build đều phải gửi cả đống đó. File .dockerignore hoạt động như .gitignore, loại bỏ file không cần thiết khỏi context:

.git
.gitignore
Dockerfile
.dockerignore
README.md
*.md
bin/
dist/
coverage.out
.env
.env.*

Ngoài việc build nhanh hơn, .dockerignore còn là lớp bảo vệ quan trọng: file .env chứa API key, database credential — nếu nằm trong build context, chúng có thể bị nhúng vào image layer và lộ ra ngoài khi ai đó pull image hoặc quét bằng công cụ như Trivy. Luôn loại trừ file nhạy cảm khỏi context.

Chạy với docker-compose và healthcheck

Sau khi có image gọn nhẹ, bước tiếp theo là đưa vào docker-compose kèm healthcheck — để Docker (hoặc nền tảng orchestration như Dokploy, Swarm) biết container đã sẵn sàng nhận traffic hay chưa. Nguyên tắc chi tiết về healthcheck đã được trình bày trong bài Docker HEALTHCHECK: kiểm tra sức khỏe container và tự động phục hồi:

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    image: ghcr.io/codetot/goapp:latest
    ports:
      - "8080:8080"
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

Lưu ý: ứng dụng cần expose endpoint /healthz trả về HTTP 200. Nếu runtime là Distroless, dùng wget tĩnh kèm sẵn trong image; nếu là Scratch (không có gì cả), bạn phải tự viết một binary healthcheck nhỏ hoặc dựa vào cơ chế restart của nền tảng.

Kinh nghiệm triển khai tại Code Tốt

Trong quá trình xây dựng CI/CD cho các dự án WordPress và Laravel tại Code Tốt, chúng tôi đóng gói hàng loạt service phụ trợ viết bằng Go — từ tool đồng bộ dữ liệu đến webhook receiver. Trước đây mỗi image nặng khoảng 800MB, khiến việc pull trên server production mất tới 40 giây. Sau khi áp dụng đồng bộ multi-stage build kết hợp Distroless và BuildKit cache, image chỉ còn khoảng 20MB, thời gian pull giảm xuống dưới 3 giây, và chu kỳ deploy qua Docker Compose trên VPS ngắn lại rõ rệt. Đội ngũ còn phát hiện thêm một lợi ích phụ: image nhỏ giúp quét bảo mật bằng Trivy nhanh hơn và số lượng CVE phải xử lý giảm hẳn, vì không còn compiler và công cụ build trong image runtime.

Kết luận

Multi-stage build là kỹ thuật bắt buộc phải nắm khi đóng gói ứng dụng Go bằng Docker. Tóm tắt những điểm chính: tách builder stage khỏi runtime stage, build static binary với CGO_ENABLED=0 kèm -ldflags="-s -w", chọn base image phù hợp (Alpine cho dev, Distroless cho production, Scratch cho binary thuần), tận dụng BuildKit cache mount để build nhanh trong CI/CD, và luôn có .dockerignore để giảm context lẫn tránh lộ secret. Chỉ với vài thay đổi nhỏ trong Dockerfile, image của bạn có thể giảm từ 800MB xuống còn khoảng 20MB — tiết kiệm băng thông, tăng tốc deploy và thu nhỏ bề mặt tấn công.

Bài viết liên quan

Khoi Pro (Nguyễn Minh Khôi)

Founder of Code Tốt (codetot.vn). WordPress developer and performance optimization specialist. Author at Chuyên DEV (chuyendev.com). Personal site: khoipro.com.