Máy lập trình viên là Mac dùng chip Apple (arm64). Máy chủ sản xuất là amd64. Image dựng trên máy này không chạy được ở máy kia — trừ khi bạn dựng cho cả hai.

buildx làm được, và có hai cách làm với chênh lệch rất lớn.

Hai cách dựng cho kiến trúc khác

Cách 1 — mô phỏng. Toàn bộ tầng build chạy trong môi trường amd64 giả lập bằng QEMU:

FROM golang:1.22-alpine AS xay
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -o /ct .

Cách 2 — biên dịch chéo. Tầng build chạy ở kiến trúc của máy, chỉ bảo trình biên dịch sinh mã cho kiến trúc đích:

FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS xay
ARG TARGETOS
ARG TARGETARCH
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /ct .

Hai biến TARGETOSTARGETARCH là biến dựng sẵn của BuildKit — phần 13 đã đo chúng, và nhớ rằng phải khai ARG trong tầng thì mới dùng được.

Đo trên một chương trình Go 1 824 dòng, máy arm64:

Thời gian
Biên dịch chéo → arm64 (kiến trúc của máy) 3,8 s
Biên dịch chéo → amd64 3,4 s
Mô phỏng QEMU → amd64 42,7 s

Chậm hơn 12,6 lần. Và chú ý hai dòng đầu: biên dịch chéo sang kiến trúc khác không tốn thêm gì so với dựng cho chính máy mình — vì trình biên dịch vẫn chạy nguyên tốc độ, chỉ xuất ra mã khác.

Con số đầu tiên của tôi là 4,3 lần, và nó sai

Lần đo đầu tiên cho ra "mô phỏng chậm hơn 4,3 lần". Nghe hợp lý nên suýt viết luôn.

Nhưng vòng lặp đo của tôi có một dòng thay thế chuỗi làm hỏng tệp main.go:

 1787633450.631695
// moc 1787633453.8477068

Mã nguồn không biên dịch được, go build thoát với lỗi, và tôi đang bấm giờ cho một build hỏng — mà thất bại thì luôn nhanh. Vòng lặp đo không kiểm mã thoát nên không có gì báo.

Sau khi sinh lại mã nguồn sạch và thêm kiểm tra returncode, con số thật là 12,6 lần.

Đây là lần thứ hai trong sê-ri tôi dính đúng cái bẫy này — lần trước là Redis báo 72 triệu lệnh mỗi giây khi 959 trên 1 000 lô đã hỏng. Luật rút ra thì luôn giống nhau: mọi vòng lặp đo phải kiểm mã thoát, và một con số bất ngờ theo hướng dễ chịu là dấu hiệu để nghi ngờ chứ không phải để mừng.

Khi nào buộc phải mô phỏng

Biên dịch chéo chỉ làm được nếu công cụ của bạn hỗ trợ. Bảng thực tế:

Biên dịch chéo
Go ✅ có sẵn, chỉ cần GOOS/GOARCH
Rust ✅ với --target, cần thêm linker
Java, .NET ✅ bytecode vốn không phụ thuộc kiến trúc
Node, Python thuần ✅ không có gì để biên dịch
Node/Python có gói native ❌ phải mô phỏng, hoặc dùng wheel/prebuild sẵn
C/C++ với thư viện hệ thống ❌ rất khó, thường phải mô phỏng

Với những trường hợp phải mô phỏng, con số 12,6 lần là cái giá bạn trả. Cách giảm đau thực dụng là chỉ mô phỏng phần bắt buộc: cài gói native trong tầng mô phỏng, còn lại biên dịch chéo.

Ảnh đa kiến trúc trông như thế nào

docker buildx build --platform linux/amd64,linux/arm64 --tag registry/da:v1 --push .
MediaType: application/vnd.oci.image.index.v1+json
Manifests:
  Platform: linux/amd64
  Platform: linux/arm64
  Platform: unknown/unknown

Cái được đẩy lên không phải một image mà là một index trỏ tới nhiều image. Khi ai đó docker pull registry/da:v1, daemon đọc index, chọn manifest khớp kiến trúc máy, và chỉ tải phần đó.

Mục unknown/unknown là manifest chứa thông tin nguồn gốc (provenance) mà BuildKit tự thêm — chủ đề của phần 22.

Cả hai biến thể đều chạy và tự báo đúng kiến trúc của mình:

--platform linux/arm64 : chay tren linux/arm64
--platform linux/amd64 : chay tren linux/amd64

Dòng thứ hai chạy được trên máy arm64 của tôi là nhờ QEMU — cùng cơ chế làm build chậm 12,6 lần ở trên, nhưng ở đây chỉ để chạy thử một lệnh nên không sao.

Giới hạn --load đã biến mất

Lời khuyên phổ biến: --load chỉ nạp được một kiến trúc; muốn nhiều thì phải --push lên registry. Tôi thử:

$ docker buildx build --platform linux/amd64,linux/arm64 --tag da:hai --load .
$ echo $?
0
IMAGE          ID             DISK USAGE   CONTENT SIZE
da:hai         d55077ee9543       37.6MB         12.1MB
├─ linux/amd64 3a3bbc37a775       18.1MB         5.89MB
└─ linux/arm64 5d70709e66e7       19.4MB         6.23MB

Chạy được, và cả hai biến thể đều dùng được. Lý do là Docker Desktop này dùng kho image containerd:

driver-type: io.containerd.snapshotter.v1

Kho image cũ của Docker không lưu được nhiều kiến trúc dưới một tag, kho containerd thì được. Nếu máy bạn chưa bật, --load đa kiến trúc vẫn sẽ báo lỗi — kiểm bằng docker info | grep -i snapshotter.

Dựng cả hai không tốn gấp đôi

chi arm64      : 4,1 s
chi amd64      : 3,6 s
ca hai cung luc: 1,0 s

Hàng cuối nhanh vì hai lần build trước đã điền cache. Nhưng ngay cả khi cache rỗng, BuildKit dựng các kiến trúc song song — đúng cơ chế đã đo ở phần 17 với ba tầng độc lập (11,0 s xuống 3,6 s). Tổng thời gian là của kiến trúc chậm nhất, không phải tổng của tất cả.

Về dung lượng thì có nhân lên: mỗi kiến trúc là một bộ layer riêng — 18,1 MB cho amd64 và 19,4 MB cho arm64 trong ví dụ trên. Registry lưu cả hai, nhưng mỗi máy chỉ tải phần của mình.

Thử ba mươi giây

Xem builder của bạn dựng được cho kiến trúc nào:

docker buildx inspect --bootstrap | grep -i platforms

Và kiểm tra Dockerfile của bạn có đang mô phỏng không:

grep -n 'FROM' Dockerfile

Dòng FROM của tầng build không có --platform=$BUILDPLATFORM nghĩa là tầng đó sẽ chạy dưới mô phỏng khi bạn dựng cho kiến trúc khác — và nếu công cụ của bạn biên dịch chéo được, đó là 12,6 lần thời gian bạn đang trả không cần thiết.

Phần sau bàn về bí mật lúc build: --mount=type=secret so với --build-arg, và cách kiểm chứng bí mật không lọt vào image.