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.
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.
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).
CGO_ENABLED=0 buộc Go dùng bản thuần Go. Đá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
Thử ba mươi giây
go build -ldflags "-X main.PhienBan=$(git describe --tags --always) -s -w" -o app
./app --version
Hai phút thiết lập, và từ đó mọi binary bạn triển khai đều tự nói được nó là commit nào. Khi có sự cố, đó là câu hỏi đầu tiên cần trả lời.
Ngày mai: đóng gói Go vào Docker — từ 1,34 GB xuống 11,7 MB.