Đâ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. 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=0bắ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ì.

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.

Thử ba mươi giây

docker images ten-anh-cua-ban --format '{{.Size}}'

Nếu con số lớn hơn 50 MB cho một dịch vụ Go, gần như chắc chắn 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.

Ngày mai: cấu hình ứng dụng — cờ dòng lệnh, biến môi trường, hay tệp.