Hồi mới đem con API Go lên VPS deploy, mỗi lần docker push là mỗi lần mình đi pha ấm trà. Image 1.2GB, mạng ở trọ hơi chậm nên pull về gần chục phút, CI build cũng đội thời gian lên gấp ba. Sau khi chuyển qua Docker multi-stage build, image còn 15MB, push vài giây là xong, deploy nhanh tới mức mình tưởng pipeline bị treo.
Bài này mình kể lại cách làm, kèm mấy chỗ dễ sập mà mình từng sập.
Vì sao image lại phình to?
FROM golang:1.23 kéo theo cả compiler, git, gcc, header C, apt cache, module cache. Binary cuối cùng chỉ tầm 12MB nhưng mình khiêng nguyên cái toolchain vào production.
Nặng thì chậm, mà còn có chuyện khác khó chịu hơn: mã nguồn, file test, cả .git nằm luôn trong image. Ai đó kubectl exec vào container là đọc được code. Bề mặt tấn công rộng ra theo từng package cài thừa — mỗi CVE trong image base là một lần bạn phải đi vá.
Multi-stage build hoạt động ra sao?
Dockerfile có nhiều dòng FROM. Stage đầu dùng image nặng để build. Stage sau dùng image tối giản và chỉ COPY --from=builder đúng file cần chạy. Stage build không được đưa vào image cuối, nên toàn bộ compiler, cache, source đều ở lại ngoài.
# syntax=docker/dockerfile:1.7
FROM golang:1.23-alpine AS builder
WORKDIR /src
# copy trước phần khai báo dependency để tận dụng layer cache
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /out/api /api
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/api"]
Hai chi tiết quan trọng trong đoạn trên:
COPY go.mod go.sumđứng riêng trướcCOPY . .. Sửa một dòng code thì Docker chỉ chạy lại bước build, không phải tải lại toàn bộ module. Đảo thứ tự này là mất cache mỗi lần commit — build từ 20 giây thành 3 phút.CGO_ENABLED=0cho ra binary tĩnh, không phụ thuộc libc, nên chạy được trên image trống.-ldflags="-s -w"bỏ bảng symbol và debug info, thường giảm thêm 25-30% kích thước.
Ba thứ hay quên khi lên image tối giản
Chứng chỉ CA. Image trống không có /etc/ssl/certs. Gọi HTTPS ra ngoài sẽ chết với lỗi x509: certificate signed by unknown authority, mà lỗi này chỉ hiện lúc runtime. Distroless đã kèm sẵn CA nên mình chọn nó thay vì scratch.
Múi giờ. Không có tzdata thì time.LoadLocation("Asia/Ho_Chi_Minh") trả lỗi, log ghi sai giờ, cron lệch. Cách gọn nhất: nhúng tzdata vào binary bằng tag -tags timetzdata, khỏi phải copy file.
User không phải root. Mặc định container chạy root. Chuyển sang user thường (distroless/static:nonroot đã có sẵn uid 65532) là một dòng, nhưng chặn được kha khá kịch bản xấu.
Đo lại bằng số thật
Sau khi đổi, mình so sánh trên cùng một service:
golang:1.23build thẳng ra image chạy: ~1.2GB (on disk), compressed ~320MBgolang:1.23-alpine+ alpine runtime: ~50MB- distroless static + binary tĩnh 12MB: ~15MB
Thời gian pull từ registry về VPS đi từ ~9 phút xuống dưới 5 giây. Riêng chuyện này đã đủ khiến rollback lúc sự cố bớt đau.
Muốn soi image đang có gì thì dùng docker history <image> để xem từng layer, hoặc dive <image> để duyệt cây file và tìm thẳng thư mục phình to.
Vài bẫy khác mình từng dính
Đừng dùng tag latest cho image base. Ghim theo digest (golang:1.23-alpine@sha256:...) để build hôm nay và build tháng sau ra cùng kết quả — nếu không, một ngày đẹp trời base image cập nhật và app bạn đi bụi mà chẳng ai đổi dòng code nào.
Đừng copy cả thư mục vào context. Thêm .dockerignore với .git, node_modules, testdata, file .env. Context nhỏ thì build nhanh, và không có chuyện secret lỡ nằm trong layer.
Với dự án dùng build kit, bật cache mount cho package manager sẽ nhanh hơn hẳn: RUN --mount=type=cache,target=/go/pkg/mod go mod download. Cache này nằm ngoài image cuối, chỉ dùng lúc build.
Chốt
Multi-stage build không phải trick gì cao siêu, nó chỉ là nguyên tắc: thứ không cần chạy thì đừng nằm trong image production. Image nhỏ giúp deploy nhanh, rollback nhanh, ít lỗ hổng phải vá, và CI cũng rẻ hơn.
Câu hỏi để bạn tự soi lại: image production của bạn đang nặng bao nhiêu, và trong đó có bao nhiêu phần là thứ chưa bao giờ thực sự chạy lúc runtime?
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| Multi-stage build | Nhiều stage FROM trong một Dockerfile; chỉ artifact từ stage trước được copy sang stage cuối |
| Distroless | Image nền chỉ có runtime tối thiểu (CA certs, tzdata, user), không shell, không package manager |
| Layer cache | Docker tái sử dụng layer không đổi; đổi input của lệnh nào thì lệnh đó và các lệnh sau bị chạy lại |
| CGO_ENABLED=0 | Tắt CGO, sinh binary tĩnh, không phụ thuộc thư viện C của hệ điều hành |
-ldflags="-s -w" |
Bỏ bảng symbol và thông tin debug khi link, giảm kích thước binary |
| Digest pinning | Ghim image base theo sha256 thay vì tag, để build có thể lặp lại y hệt |
| Build context | Tập file gửi cho Docker daemon khi build; kiểm soát bằng .dockerignore |