Hãy hình dung một binary Go như bộ đồ phi hành gia mang sẵn bình oxy: thả xuống hành tinh nào nó cũng sống được, vì không phụ thuộc vào việc nơi đó có không khí hay không. Đối lại, một chương trình liên kết động giống người chỉ mang ống thở nối về trạm không gian — nhẹ người hơn, nhưng hễ rời trạm là ngạt. Cả bài này là về cách may bộ đồ tự cấp oxy đó, và về cái dòng lệnh cắt đứt ống thở để binary thật sự tự lập. Bài 1 đã cho thấy cross-compile là một biến môi trường. Bài này về phần còn lại của việc build.
Năm nền tảng từ một máy
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o app
linux/amd64 1.38 MB
linux/arm64 1.44 MB
darwin/arm64 1.48 MB
windows/amd64 1.47 MB
freebsd/amd64 1.35 MB
Chạy trên một máy Linux ARM, không cài toolchain nào thêm — như may sẵn bộ đồ cho năm hành tinh từ cùng một xưởng.
go tool dist list # xem đủ danh sách, khoảng 40 tổ hợp
Đây là thứ khiến công cụ dòng lệnh hay được viết bằng Go: một lần build ra mọi nền tảng, không cần CI đa nền tảng.
-ldflags: nhúng thông tin vào binary
var (
PhienBan = "dev"
Commit = "none"
NgayBuild = "unknown"
)
go build -ldflags "-X main.PhienBan=1.2.3 -X main.Commit=$(git rev-parse --short HEAD)"
phiên bản=1.2.3 commit=abc123 ngày=2026-08-13
-X gán giá trị cho biến chuỗi ở mức package lúc liên kết. Đây là cách chuẩn để binary tự biết mình là bản nào — thứ rất đáng có khi điều tra sự cố.
Chỉ hoạt động với string, và biến phải là biến package (không phải hằng).
-s -w: cắt bảng ký hiệu
không cờ : 2.14 MB
-s -w : 1.44 MB
Giảm 33%. -s bỏ bảng ký hiệu, -w bỏ thông tin gỡ lỗi DWARF.
Cái giá: không dùng được delve để gỡ lỗi, và dấu vết panic mất một phần thông tin. Với binary sản xuất thì đánh đổi này thường đúng; với môi trường phát triển thì đừng dùng.
Chú ý -s -w không ảnh hưởng runtime/pprof — hồ sơ hiệu năng vẫn hoạt động bình thường.
CGO_ENABLED=0: binary thật sự tĩnh
CGO=0: not a dynamic executable
Mặc định CGO_ENABLED=1 khi build cho chính nền tảng đang chạy, và một số gói thư viện chuẩn — net, os/user — dùng thư viện C của hệ thống khi có CGO. Đây chính là cái ống thở nối về trạm.
Hậu quả: binary phụ thuộc libc, và không chạy được trên ảnh scratch hay alpine (alpine dùng musl chứ không phải glibc) — rời trạm là ngạt.
CGO_ENABLED=0 buộc Go dùng bản thuần Go, tức mang bình oxy riêng. Đánh đổi duy nhất đáng kể: phân giải DNS dùng bộ phân giải riêng của Go thay vì của hệ thống, nên nó không đọc một số cấu hình đặc thù trong /etc/nsswitch.conf. Với dịch vụ trong container thì gần như không bao giờ là vấn đề.
Cross-compile thì CGO tự tắt, đó là lý do các binary ở đầu bài đều tĩnh.
Build tag: tách mã theo môi trường
//go:build dev
package main
func init() { println("chỉ có ở bản dev") }
go build -> không có init dev
go build -tags dev -> init CHỈ có ở bản dev
Dòng //go:build phải nằm trước package, và có một dòng trống sau nó.
Ngoài tag tự đặt, Go có tag sẵn theo nền tảng — tệp store_linux.go chỉ biên dịch trên Linux, không cần khai gì.
Dùng cho: mã gỡ lỗi chỉ có ở bản dev, tính năng bật tắt lúc build, cài đặt khác nhau theo hệ điều hành.
Đừng lạm dụng — mã sau build tag không được biên dịch ở bản thường, nên nó dễ mục nát mà không ai biết. Chạy go build -tags ... trong CI cho mọi tổ hợp bạn dùng.
debug.ReadBuildInfo
if bi, ok := debug.ReadBuildInfo(); ok {
for _, s := range bi.Settings {
if s.Key == "vcs.revision" { fmt.Println(s.Value) }
}
}
Từ Go 1.18, binary tự động mang theo thông tin VCS: commit, thời điểm, có thay đổi chưa commit hay không. Không cần -ldflags cho những thứ này nữa.
go version -m ./app # xem thông tin build của một binary bất kỳ
Lệnh này rất hữu ích khi cầm một binary không rõ nguồn gốc: nó in ra phiên bản Go, module, và mọi phụ thuộc kèm phiên bản.
Tăng tốc build
go build ./... # dùng cache, mặc định
go clean -cache # xoá cache (hiếm khi cần)
GOFLAGS=-mod=mod go build
Go cache kết quả biên dịch theo nội dung, nên build lại rất nhanh — bài 1 đo được 1333 ms lần đầu và 48 ms lần sau.
Trong Docker, cache này mất mỗi lần build. Dùng cache mount của BuildKit:
RUN --mount=type=cache,target=/root/.cache/go-build go build -o /svc
Nếu chỉ lấy đúng một thói quen từ bài này, hãy lấy dòng sau — nó khiến mọi binary bạn triển khai đều tự khai được nó là commit nào:
go build -ldflags "-X main.PhienBan=$(git describe --tags --always) -s -w" -o app
./app --version
Hai phút thiết lập, và khi có sự cố, "bản nào đang chạy trên đó" — câu hỏi đầu tiên luôn được hỏi lúc 2 giờ sáng — trả lời được ngay thay vì đoán.
Mẫu số chung
"Bạn giao đi cái gì, và nơi nhận cần sẵn những gì" là một trục thiết kế lớn của việc triển khai phần mềm, và mỗi hệ sinh thái ngồi một chỗ khác nhau — từ gửi mã nguồn, host tự lo runtime tới gửi một binary tự cấp oxy.
- Java đặt cược ngược hẳn với Go: "viết một lần, chạy mọi nơi" — nhưng cái giá là cái trạm không gian (JRE) phải có mặt ở mọi nơi. Tệp
.jarnhỏ xíu, đổi lại host buộc phải có đúng phiên bản JVM. Và GraalVM native-image (đúng bài trước) chính là Java đang cố trở thành một binary tự cấp oxy kiểu Go. - Rust đứng cùng phe Go: biên dịch ra binary gần như tĩnh (target
muslcho tĩnh hoàn toàn), cross- compile được, nén phụ thuộc vào trong. Cùng triết lý "mang theo mọi thứ". - C/C++ là nơi Go toả sáng nhất khi so: cross-compile C nổi tiếng đau đầu — phải có toolchain và sysroot của đích — trong khi Go rút gọn còn một biến môi trường.
- Node/Python thì thuần mô hình trạm không gian: giao mã nguồn, host phải có sẵn trình thông dịch đúng phiên bản (hoặc phải gói lại bằng
pkg/pyinstaller). Gọn khi giao, nhưng "chạy được trên máy tôi" là câu than quen thuộc.
Sợi chỉ chung đáng mang theo: thứ thật sự quyết định là phụ thuộc của bạn được giải quyết lúc build hay lúc chạy. Baked-in lúc build (Go, Rust, native-image) cho bạn một binary lớn hơn nhưng thả đâu cũng chạy — scratch container, máy trống, không cần gì — và bản build tái lập được. Giải quyết lúc chạy (jar cần JVM, script cần trình thông dịch) cho bạn artifact nhỏ xíu nhưng đổi lấy "phải có runtime đúng phiên bản ở mọi nơi", và cả một họ lỗi kiểu libc-lệch hay JRE-sai-bản. Đây đúng là câu hỏi AOT-so-với-JIT của bài native-image, nhưng đặt cho cả gói phần mềm thay vì cho từng hàm — và một khi nhìn ra nó, bạn chọn CGO_ENABLED=0 hay chấp nhận libc không còn là mẹo vặt, mà là quyết định "bộ đồ này có tự mang oxy hay không".
Ngày mai: đóng gói Go vào Docker — từ 1,34 GB xuống 11,7 MB.
Bài tập làm thử
Bài 1 (đọc hiểu). Lệnh sau build ra một binary cho hệ điều hành/kiến trúc nào, và cần cài thêm toolchain gì trên máy build (đang chạy macOS ARM) không?
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app
Đáp án
Build ra binary cho linux/amd64 — chạy được trên máy chủ Linux 64-bit thông thường. Không cần cài thêm toolchain nào: cross-compile trong Go chỉ là đặt hai biến môi trường GOOS và GOARCH, và khi cross-compile thì CGO tự tắt (khớp với CGO_ENABLED=0 ở đây), nên không cần trình biên dịch C cho nền đích. Đây là điểm bài viết ví như "may bộ đồ phi hành gia cho năm hành tinh từ cùng một xưởng".
Bài 2 (sửa lỗi). Đoạn Dockerfile sau muốn chạy binary Go trên ảnh alpine nhưng bị lỗi no such file or directory dù tệp rõ ràng tồn tại. Tìm dòng gây lỗi và sửa.
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
Dòng RUN go build -o /svc main.go thiếu CGO_ENABLED=0. Mặc định CGO_ENABLED=1 khi build cho chính nền tảng đang chạy, nên binary bị liên kết động với glibc. Nhưng alpine dùng musl chứ không phải glibc, nên binary không chạy được — và thông báo lỗi no such file or directory gây hiểu nhầm vì tệp thật sự có ở đó. Sửa:
RUN CGO_ENABLED=0 go build -o /svc main.go
Bài 3 (đọc hiểu). Cho biến toàn cục sau trong main.go, lệnh build nào dưới đây sẽ gán đúng giá trị "1.2.3" vào PhienBan lúc chạy?
var PhienBan = "dev"
# (a)
go build -ldflags "-X PhienBan=1.2.3"
# (b)
go build -ldflags "-X main.PhienBan=1.2.3"
Đáp án
Lệnh (b). Cờ -X yêu cầu chỉ định đầy đủ đường dẫn package cộng tên biến (main.PhienBan), không chỉ tên biến trần. -X chỉ hoạt động với biến string ở mức package (không phải hằng), và gán giá trị lúc liên kết (link time) chứ không phải lúc biên dịch mã nguồn.
Bài 4 (vận dụng thực tế). Bạn cần một binary sản xuất nhỏ gọn, biết chính xác nó được build từ commit Git nào khi debug lúc 2 giờ sáng. Viết một dòng lệnh build kết hợp cắt bảng ký hiệu và nhúng thông tin phiên bản, theo đúng khuyến nghị "chỉ lấy một thói quen" của bài viết.
Đáp án
go build -ldflags "-X main.PhienBan=$(git describe --tags --always) -s -w" -o app
-X main.PhienBan=... nhúng commit/tag vào biến; -s -w cắt bảng ký hiệu và thông tin DWARF (giảm khoảng 33% kích thước) — đánh đổi là mất khả năng dùng delve để gỡ lỗi và dấu vết panic mất một phần thông tin, chấp nhận được cho binary sản xuất. Sau đó ./app --version trả lời ngay câu hỏi "bản nào đang chạy" thay vì phải đoán.
Bài 5 (bẫy/đánh đổi). Bài viết nói CGO_ENABLED=0 có một đánh đổi đáng kể duy nhất liên quan tới phân giải DNS. Đó là gì, và tại sao trong hầu hết dịch vụ chạy container thì đánh đổi này "gần như không bao giờ là vấn đề"?
Đáp án
Khi CGO_ENABLED=0, Go dùng bộ phân giải DNS thuần Go riêng thay vì thư viện C của hệ thống, nên nó không đọc được một số cấu hình đặc thù trong /etc/nsswitch.conf. Điều này gần như không phải vấn đề trong container vì môi trường container thường không có các cấu hình nsswitch.conf phức tạp (như NIS, LDAP tra cứu tên máy) mà chỉ cần phân giải DNS tiêu chuẩn qua /etc/resolv.conf — thứ mà bộ phân giải thuần Go xử lý tốt. Đổi lại, bạn có được một binary tĩnh hoàn toàn, chạy được trên ảnh scratch hay alpine mà không phụ thuộc libc.