Hình dung đóng gói một dịch vụ vào Docker như gửi một chiếc bánh đi xa. Cách vụng về nhất là nhét cả căn bếp vào thùng — lò nướng, bao bột sống, cuốn công thức — rồi giao nguyên thùng đó, chỉ để người nhận có một chiếc bánh. Cách đúng là nướng xong trong bếp, rồi chỉ đặt chiếc bánh vào một cái hộp sạch. Và với Go, cái hộp ấy có thể rỗng tới mức không còn cả dao nĩa — không shell, không ls, không gì — vì chiếc bánh (binary tĩnh) tự nó đã đủ. Đây là chỗ lời hứa ở bài 1 — "binary tĩnh, chép một tệp lên là chạy" — trả công rõ ràng nhất.
Ba cách, ba kích thước
1. golang đầy đủ, build trong ảnh : 1,34 GB
2. nhiều tầng + alpine : 24,1 MB
3. nhiều tầng + scratch : 11,7 MB
Một trăm mười bốn lần giữa đầu và cuối.
Đặt cạnh bài 97 sê-ri Java, đo trên cùng máy: ứng dụng Java nhỏ nhất tôi dựng được — jlink cộng distroless — là 126 MB. Go xuống 11,7 MB vì runtime nằm trong chính binary và không cần JVM.
Cách 1: cái không nên làm
FROM golang:1.23
WORKDIR /app
COPY . .
RUN go build -o svc main.go
CMD ["./svc"]
1,34 GB, chứa toàn bộ toolchain Go, mã nguồn của bạn, và cache build — nguyên căn bếp đi theo chiếc bánh. Trình biên dịch có sẵn trong ảnh cũng là công cụ cho kẻ tấn công nếu họ vào được container.
Cách 2: nhiều tầng + alpine
FROM golang:1.23 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download # tầng này được đệm lại
COPY . .
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /svc main.go
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
COPY --from=build /svc /svc
ENTRYPOINT ["/svc"]
24,1 MB, trong đó binary khoảng 1,4 MB — phần còn lại là alpine.
Chú ý thứ tự COPY: chép go.mod trước rồi go mod download, sau đó mới chép mã nguồn. Sửa mã mà không đổi phụ thuộc thì Docker dùng lại tầng đã tải.
CGO_ENABLED=0 là bắt buộc ở đây: alpine dùng musl, và binary liên kết với glibc sẽ báo no such file or directory — thông báo lỗi gây hiểu nhầm nhất trong Docker, vì tệp rõ ràng có ở đó.
Alpine đáng chọn khi bạn muốn có shell để docker exec vào xem.
Cách 3: scratch
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /svc /svc
ENTRYPOINT ["/svc"]
11,7 MB. scratch là ảnh hoàn toàn rỗng — không có shell, không có ls, không có gì. Cái hộp không một cây dao.
Bốn thứ phải chép vào nếu cần:
Chứng chỉ CA — thiếu là mọi lời gọi HTTPS thất bại với x509: certificate signed by unknown authority.
Dữ liệu múi giờ — thiếu là time.LoadLocation trả lỗi, đúng như bài 50 đã nói. Cách gọn hơn là import _ "time/tzdata" để nhúng thẳng vào binary, khoảng 450KB.
Tệp /etc/passwd nếu bạn chạy bằng người dùng không phải root.
Thư mục tạm nếu chương trình dùng os.TempDir() — scratch không có /tmp.
Đánh đổi: không docker exec vào được. Không có shell nghĩa là chẩn đoán phải qua log và endpoint — nên pprof ở bài 52 càng đáng bật.
Nếu muốn ảnh nhỏ mà vẫn có người dùng không phải root sẵn, gcr.io/distroless/static là lựa chọn giữa, khoảng 2 MB phần nền.
Người dùng không phải root
FROM build AS b
RUN adduser -D -u 10001 ungdung
FROM scratch
COPY --from=b /etc/passwd /etc/passwd
COPY --from=build /svc /svc
USER 10001
ENTRYPOINT ["/svc"]
Hoặc đơn giản hơn với distroless: USER nonroot đã có sẵn.
Tham số môi trường
ENV TZ=Asia/Ho_Chi_Minh
giờ hiện tại: 2026-08-13T10:08:38+07:00
Không đặt TZ thì container chạy UTC — và bài 62 sê-ri Java đã kể chuyện cron chạy sai giờ vì điều này.
Go không có khái niệm tương đương -Xmx: không có heap cố định, bộ thu gom rác tự điều chỉnh theo lượng cấp phát. Nhưng có hai biến đáng biết:
ENV GOMEMLIMIT=400MiB # gợi ý giới hạn bộ nhớ mềm, Go 1.19+
ENV GOMAXPROCS=2 # nếu container bị giới hạn CPU
GOMAXPROCS quan trọng hơn người ta nghĩ: Go đọc số nhân của máy chủ, không đọc giới hạn CPU của container. Container giới hạn 2 nhân trên máy 64 nhân sẽ có GOMAXPROCS=64 và bộ lập lịch làm việc thừa. Thư viện automaxprocs của Uber sửa tự động.
Tín hiệu dừng
ENTRYPOINT ["/svc"] # dạng exec — nhận SIGTERM
CMD /svc # dạng shell — KHÔNG nhận
Dạng shell chạy qua /bin/sh, và tiến trình Go thành con của shell — nó không nhận SIGTERM khi container dừng, nên graceful shutdown không chạy và bạn mất 10 giây chờ SIGKILL.
Luôn dùng dạng exec (mảng JSON). Bài mai sẽ nói phần Go xử lý tín hiệu.
.dockerignore
.git
*.md
Dockerfile*
vendor/
Thiếu tệp này thì COPY . . mang cả .git vào ngữ cảnh build. Với kho mã có lịch sử dài, đó là hàng trăm megabyte đi qua daemon mỗi lần build.
Muốn biết mình có đang gửi cả căn bếp không, kiểm một dòng:
docker images ten-anh-cua-ban --format '{{.Size}}'
Con số lớn hơn 50 MB cho một dịch vụ Go gần như chắc chắn nghĩa là bạn đang dùng ảnh nền quá lớn hoặc build trong ảnh cuối. Chuyển sang mẫu hai tầng ở trên là công việc mười phút.
Mẫu số chung
Hai ý lớn của bài này vượt khỏi Go, và đáng mang theo cho mọi container bạn từng dựng.
Một: gửi sản phẩm, đừng gửi xưởng. Build nhiều tầng — nướng trong tầng builder, chỉ chép binary sang tầng cuối tối giản — là chuẩn mực cho mọi ngôn ngữ biên dịch: Rust COPY binary sang scratch/distroless, C/C++ tương tự, và ngay cả Node cũng build trong ảnh node rồi chép dist sang ảnh slim. Ảnh cuối nhỏ tới đâu được quyết định bởi đúng một câu hỏi: sản phẩm của bạn có cần runtime đi kèm không. Binary native của Go và Rust chạm được tới scratch (cỡ vài MB); còn JVM, Node, Python phải cõng theo runtime — đó là lý do sàn của Java là 126 MB. Chính là trục "gửi-binary hay gửi-runtime" của bài cross-compile, lần này đo bằng kích thước ảnh.
Hai, và tinh tế hơn: runtime không tự thấy giới hạn của container trừ khi được bảo. GOMAXPROCS của Go đọc số nhân máy chủ, không đọc hạn cgroup — và đây đúng là con bọ từng cắn JVM nhiều năm: nó đọc số nhân và bộ nhớ của máy chủ cho tới khi -XX:+UseContainerSupport ra đời; thread pool của Node, của Python cũng từng mù cgroup y hệt. Một container 2 nhân trên máy 64 nhân sẽ lập lịch thừa cho tới khi bạn ghim tay. Cộng cái bẫy tín hiệu PID 1 — dùng dạng shell thì SIGTERM không bao giờ tới tiến trình của bạn — vốn độc lập ngôn ngữ. Sợi chỉ chung: một container là cái hộp giao hàng, không phải cái xưởng — bỏ vào đúng thứ đã hoàn thiện cộng đúng thứ nó cần để chạy, và nhớ cho thứ bên trong biết cái hộp của nó thật ra to bao nhiêu.
Ngày mai: cấu hình ứng dụng — cờ dòng lệnh, biến môi trường, hay tệp.
Bài tập làm thử
Bài 1 (đọc hiểu). Bài viết đo ba cách đóng gói cho ra ba kích thước ảnh rất khác nhau: 1,34 GB, 24,1 MB, 11,7 MB. Giải thích ngắn gọn tại sao cách đầu tiên (build trong ảnh golang đầy đủ, không dùng multi-stage) lại lớn hơn hẳn, theo đúng ẩn dụ bài viết dùng.
Đáp án
Cách đầu tiên (FROM golang:1.23 rồi build và chạy ngay trong cùng ảnh đó) mang theo toàn bộ toolchain Go (trình biên dịch, công cụ build), mã nguồn, và cache build vào ảnh cuối cùng — đúng như ẩn dụ "nhét cả căn bếp vào thùng chỉ để giao một chiếc bánh". Hai cách còn lại dùng kỹ thuật build nhiều tầng (multi-stage): biên dịch trong một tầng "builder" tạm, rồi chỉ chép đúng binary đã build xong sang một ảnh nền tối giản (alpine hoặc scratch), bỏ lại toàn bộ "căn bếp" phía sau.
Bài 2 (sửa lỗi). Dockerfile sau chạy trên alpine nhưng gặp lỗi no such file or directory dù binary đã được copy đúng chỗ. Tìm dòng gây lỗi và sửa, theo đúng bài học bài viết đưa ra.
FROM golang:1.23 AS build
WORKDIR /app
COPY . .
RUN go build -o /svc main.go
FROM alpine:3.20
COPY --from=build /svc /svc
ENTRYPOINT ["/svc"]
Đáp án
Thiếu CGO_ENABLED=0 ở bước build. Vì build mặc định bật CGO khi build cho chính nền tảng, binary sẽ liên kết động với glibc. Nhưng alpine dùng musl chứ không phải glibc, nên khi chạy binary trong ảnh alpine, hệ thống không tìm được thư viện liên kết động cần thiết — và thông báo lỗi no such file or directory (nói về binary, không phải về thư viện thiếu) là "thông báo lỗi gây hiểu nhầm nhất trong Docker". Sửa:
RUN CGO_ENABLED=0 go build -o /svc main.go
Bài 3 (đọc hiểu). Ảnh scratch hoàn toàn rỗng — "không có shell, không có ls, không có gì". Liệt kê đủ bốn thứ mà bài viết nói phải tự chép vào nếu ứng dụng cần chúng, kèm hệ quả nếu thiếu chứng chỉ CA.
Đáp án
Bốn thứ: (1) chứng chỉ CA (/etc/ssl/certs/ca-certificates.crt) — thiếu thì mọi lời gọi HTTPS thất bại với lỗi x509: certificate signed by unknown authority; (2) dữ liệu múi giờ (/usr/share/zoneinfo) — thiếu thì time.LoadLocation trả lỗi (hoặc dùng import _ "time/tzdata" để nhúng thẳng vào binary); (3) tệp /etc/passwd — cần nếu chạy bằng người dùng không phải root; (4) thư mục tạm — cần nếu chương trình dùng os.TempDir(), vì scratch không có sẵn /tmp.
Bài 4 (vận dụng thực tế). Viết đúng dòng ENTRYPOINT hoặc CMD cho một service Go trong Docker sao cho nó nhận được tín hiệu SIGTERM khi container dừng, để graceful shutdown chạy đúng, theo đúng khuyến nghị bài viết.
Đáp án
ENTRYPOINT ["/svc"]
Phải dùng dạng exec (mảng JSON), không phải dạng shell (CMD /svc). Dạng shell chạy qua /bin/sh, khiến tiến trình Go trở thành con của shell đó — tiến trình Go không nhận được SIGTERM trực tiếp khi container dừng, nên graceful shutdown không chạy và Docker phải đợi hết thời gian rồi gửi SIGKILL (mất khoảng 10 giây chờ vô ích). Dạng exec khiến tiến trình Go chạy trực tiếp làm PID 1 và nhận tín hiệu ngay.
Bài 5 (bẫy/đánh đổi). Bài viết cảnh báo GOMAXPROCS của Go đọc số nhân của máy chủ, không đọc giới hạn CPU của container. Giải thích hệ quả cụ thể khi container bị giới hạn 2 nhân chạy trên một máy chủ vật lý có 64 nhân, và nêu cách khắc phục bài viết đề xuất.
Đáp án
Hệ quả: nếu container bị giới hạn (bằng cgroup) chỉ được dùng 2 nhân CPU nhưng chạy trên máy chủ vật lý có 64 nhân, Go mặc định đặt GOMAXPROCS = 64 (đọc từ số nhân thực của máy chủ, không đọc giới hạn cgroup của container). Bộ lập lịch (scheduler) của Go khi đó sẽ tạo và quản lý số luồng hệ điều hành tương ứng với 64, "làm việc thừa" — tốn chi phí chuyển ngữ cảnh (context switch) không cần thiết trên một tài nguyên chỉ thực sự có 2 nhân, làm giảm hiệu năng thực tế.
Cách khắc phục: đặt tường minh ENV GOMAXPROCS=2 trong Dockerfile khớp với giới hạn CPU thật của container, hoặc dùng thư viện automaxprocs của Uber để tự động phát hiện và đặt đúng giá trị này. Bài viết cũng lưu ý đây là con bọ (bug class) từng cắn JVM nhiều năm theo cách y hệt, trước khi có cờ -XX:+UseContainerSupport.