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ỗiexec format errorhoặ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"—-sbỏ symbol table,-wbỏ 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ằngdlv.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í | Alpine | Distroless | Scratch |
|---|---|---|---|
| Kích thước base | ~3MB | ~2MB | 0 byte |
| Shell + package manager | Có (sh, apk) | Không | Không |
| Debug trong container | Dễ — exec vào shell | Khó | Rất khó |
| ca-certificates | Cài qua apk | Có sẵn | Phải COPY thủ công |
| Healthcheck | wget của busybox | wget tĩnh kèm sẵn | Tự viết hoặc bỏ qua |
| Phù hợp | Dev, dự án nhỏ | Production | Binary 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
- Xây dựng CLI tool với Go và Cobra: Flag parsing, Viper và Build Binary — viết một CLI tool hoàn chỉnh bằng Go, từ khởi tạo dự án đến build binary.
- Docker HEALTHCHECK: Hướng dẫn kiểm tra sức khỏe container và tự động phục hồi — hiểu sâu về healthcheck và các pattern phục hồi container.
- Triển khai Laravel với Docker Compose trên VPS bằng Dokploy — đưa image Docker lên production với nền tảng self-hosted.