Hình dung một người viết thư song ngữ. Có hai cách để họ soạn một lá thư bằng thứ tiếng mình không sống cùng. Cách một: dọn hẳn sang nước đó ở, làm mọi việc trong một môi trường xa lạ, chậm chạp vì cái gì cũng phải qua một lớp phiên dịch. Cách hai: ngồi yên ở nhà, làm việc với tốc độ bình thường của mình, và chỉ viết phần đầu ra bằng thứ tiếng đích — vì họ vốn thạo cả hai, nên xuất ra bản tiếng nào cũng nhanh như nhau. Dựng ảnh Docker cho một kiến trúc khác đúng là hai cách đó. Máy lập trình viên là Mac 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à hai cách ấy chênh nhau 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 TARGETOS và TARGETARCH 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.

Muốn biết ngay builder của mình dựng được cho kiến trúc nào và Dockerfile có đang lặng lẽ mô phỏng không, soi hai chỗ:

docker buildx inspect --bootstrap | grep -i platforms
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.

Mẫu số chung

Bài học đầu tiên, đọc thẳng từ 3,4 giây so với 42,7: hãy làm việc ở môi trường nhanh của mình rồi chỉ nhắm đầu ra sang đích, đừng lôi cả quy trình vào một môi trường xa lạ chậm chạp. Biên dịch chéo giữ trình biên dịch chạy nguyên tốc độ máy và chỉ đổi thứ nó xuất ra, nên dựng cho arm64 hay amd64 gần như tốn như nhau (3,8 so với 3,4 giây); mô phỏng thì kéo toàn bộ bước build qua QEMU và trả giá 12,6 lần. Cùng nguyên lý "dịch ở biên, đừng di dời cả phép tính" ở khắp nơi: sinh mã lúc biên dịch thay vì thông dịch lúc chạy, đẩy chuyển đổi định dạng ra đúng tầng vào/ra thay vì rải khắp lõi, giữ i18n ở tầng trình bày chứ không nhúng vào logic nghiệp vụ. Nguyên tắc: chi phí để phục vụ một đích lạ nên sống gọn trong bước tạo-đầu-ra, không được lan vào và làm chậm cả đường ống — và mỗi khi thấy một việc "chậm vì môi trường", hỏi xem cái chậm đó có thể dồn về đúng một bước cuối hay không.

Điều thứ hai, một ý niệm về cấu trúc đáng nhớ: "một cái tên" có thể thật ra là một tấm chỉ mục trỏ tới nhiều thứ cụ thể, và người tiêu thụ tự chọn đúng cái của mình. registry/da:v1 nhìn như một image, nhưng cái đẩy lên là một index trỏ tới nhiều manifest; lúc docker pull, daemon đọc index, chọn đúng kiến trúc máy, và chỉ tải phần đó — mỗi máy nhận một thứ khác nhau từ cùng một tên. Cùng mẫu "một tên, nhiều hiện thân, phân giải theo người dùng" ở khắp nơi: universal binary gói cả hai kiến trúc, Accept-header cho content negotiation trả HTML hay JSON tuỳ người hỏi, một tên DNS trỏ IP khác nhau theo vùng, một gói phát hành wheel riêng cho từng nền. Nó gọn vì tính song song và tính chọn lọc đi kèm: dựng các kiến trúc song song nên tổng thời gian bằng cái chậm nhất chứ không phải tổng của tất cả, và mỗi máy chỉ tải phần của mình dù registry giữ hết. Nguyên tắc: khi một định danh cần phục vụ nhiều ngữ cảnh, hãy để nó trỏ tới một tập biến thể và phân giải ở điểm tiêu thụ — người dùng cầm một cái tên ổn định, còn cái cụ thể thì được chọn đúng lúc, đúng chỗ.

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.