Cú sốc đầu đời: Lỗi “exec format error”
Kịch bản này chắc chắn nhiều anh em dev dùng MacBook chip M-series từng nếm trải. Code ở local chạy cực mượt. Lệnh docker build và push lên Docker Hub diễn ra trơn tru không một vết gợn.
Bi kịch chỉ bắt đầu khi bạn SSH vào VPS Ubuntu (như gói droplet 2 vCPU của DigitalOcean hay EC2 x86 của AWS). Bạn hăm hở pull image về, gõ lệnh docker run, rồi bỗng thấy container văng ngay lập tức với một dòng log cụt lủn:
standard_init_linux.go:228: exec user process caused: exec format error
Bạn tá hỏa kiểm tra lại code, chmod 777 cho file script, hay lục tung Dockerfile tìm lỗi syntax. Mọi thứ trông vẫn hoàn hảo. Thực chất, code không sai một dòng nào. Thủ phạm chính là sự lệch pha về kiến trúc CPU (CPU Architecture) giữa hai chiếc máy tính.
Giải mã nguyên nhân: Chuyện gì vừa xảy ra?
Mặc định, lệnh docker build chỉ biên dịch ứng dụng theo tập lệnh vi xử lý của chính cỗ máy bạn đang ngồi gõ phím:
- MacBook Apple Silicon (M1 đến M4): Dùng kiến trúc
linux/arm64. - Đa số Cloud VPS phổ thông (Intel Xeon, AMD EPYC): Chạy kiến trúc
linux/amd64(x86_64).
Mã nhị phân dành cho chip ARM hoàn toàn vô nghĩa với CPU x86_64. Khi kernel Linux trên server cố nạp file thực thi ARM vào bộ nhớ, nó không thể hiểu cấu trúc tập lệnh này. Kết quả: kernel ném ngay lỗi exec format error và hạ màn container.
Hai giải pháp tình thế và những hạn chế khó chịu
1. Build trực tiếp trên server đích qua CI/CD runner
Thay vì build local, bạn đẩy Git rồi để GitHub Actions hay GitLab Runner trên server x86 tự build. Cách này giải quyết được vấn đề môi trường. Thế nhưng, nó có cái giá khá đắt.
Một pipeline build image Node.js hay Golang ngốn sạch 100% CPU của con VPS 2 vCPU production. Server giật lag, thời gian build kéo dài 7-10 phút. Chưa kể, nếu ngày mai công ty chuyển sang AWS Graviton (dùng chip ARM64 để tiết kiệm 20% chi phí), bạn lại phải cặm cụi sửa lại toàn bộ kịch bản CI/CD từ đầu.
2. Ép kiến trúc bằng cờ –platform
Docker Desktop cho phép bạn ép kiến trúc thông qua trình giả lập QEMU:
docker build --platform linux/amd64 -t my-app:latest .
Máy Mac của bạn giờ đây giả lập môi trường x86 để cho ra đúng image server cần. Dù vậy, đây vẫn chỉ là giải pháp chắp vá.
Lệnh này chỉ tạo đúng một image duy nhất. Khi bạn push lên Docker Hub, bản AMD64 sẽ đè bẹp bản ARM64. Một dev khác trong team kéo image đó về máy Mac chạy thử sẽ thấy container chạy ì ạch vì phải gánh tầng giả lập QEMU, đôi khi phát sinh lỗi cache khó hiểu.
Giải pháp chuẩn chỉ: Dùng Docker Buildx
Docker Buildx là plugin thế hệ mới tích hợp sẵn BuildKit. Nó cho phép bạn đóng gói đồng thời cả hai bản linux/amd64 và linux/arm64 chỉ trong một command duy nhất.
Điểm tinh tế nằm ở chỗ Docker sẽ gom cả hai image con này vào một Manifest List (danh mục chỉ mục OCI chung 1 tag). Khi bạn kéo image về trên máy Mac, Docker daemon tự động nhặt bản ARM64. Khi server Linux x86 pull cùng tag đó, Docker daemon lại tự tải đúng bản AMD64. Cả hai thế giới đều vận hành mượt mà.
Bước 1: Chuẩn bị môi trường và QEMU
Nếu dùng Docker Desktop trên macOS hay Windows, Buildx đã sẵn sàng phục vụ. Bạn kiểm tra nhanh:
docker buildx version
Nếu bạn setup trên server Linux (Ubuntu/Debian) đóng vai trò runner, hãy nạp các module binfmt của QEMU để hệ điều hành biết cách xử lý giả lập đa kiến trúc:
docker run --privileged --rm tonistiigi/binfmt --install all
Bước 2: Khởi tạo Builder Instance mới
Builder mặc định gắn liền với Docker daemon cũ không hỗ trợ xuất manifest list trực tiếp lên remote registry. Chúng ta cần một builder chuyên dụng chạy qua container driver:
# Tạo builder mới mang tên multi-builder
docker buildx create --name multi-builder --driver docker-container --bootstrap --use
# Kiểm tra danh sách nền tảng được hỗ trợ
docker buildx inspect --bootstrap
Màn hình terminal sẽ in ra danh sách dài các platform khả dụng: linux/amd64, linux/arm64, linux/riscv64, linux/ppc64le, linux/s390x, linux/386, linux/arm/v7, linux/arm/v6.
Bước 3: Tối ưu Dockerfile cho đa nền tảng
Base image trong Dockerfile cần hỗ trợ multi-arch. May mắn là hầu hết các image chính quy như node:alpine, python:slim hay golang:alpine đều đã làm sẵn điều này.
# Ví dụ Dockerfile chuẩn hóa cho ứng dụng Node.js
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]
Bước 4: Build và push trực tiếp lên Registry
Storage truyền thống của Docker local chưa thể chứa manifest list đa nền tảng dưới cùng một tag tên. Vì vậy, Buildx yêu cầu bạn truyền cờ --push để đẩy thẳng sản phẩm lên registry:
# Đăng nhập registry trước
docker login
# Build đồng thời amd64 và arm64, sau đó push ngay
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t username/my-app:1.0.0 \
-t username/my-app:latest \
--push .
Lưu ý thực tế: Do phải giả lập kiến trúc khác thông qua QEMU, thời gian build có thể lâu hơn bình thường khoảng 1.5 đến 2 lần. Hãy kiên nhẫn chờ vài phút để tiến trình hoàn tất.
Bước 5: Kiểm tra lại Manifest trên Docker Hub
Đừng vội tin lời quảng cáo. Hãy dùng lệnh sau để kiểm tra cấu trúc image vừa đẩy lên:
docker buildx imagetools inspect username/my-app:latest
CLI sẽ trả về chuỗi JSON chứa manifest list. Để đọc cấu trúc JSON này dễ dàng và đỡ rối mắt hơn, bạn có thể copy vào công cụ định dạng online như json-formatter tại toolcraft.app/vi/tools/developer/json-formatter. Khi mở rộng các object, bạn sẽ thấy 2 block rõ ràng: một block ứng với architecture: amd64 và một block ứng với architecture: arm64.
Từ giờ, bất kể bạn gõ lệnh ở máy Mac dev hay server production:
docker run -d -p 3000:3000 username/my-app:latest
Mỗi máy chủ sẽ tự khắc nhận đúng gói nhị phân tương thích tuyệt đối với phần cứng của mình. Lỗi exec format error chính thức đi vào dĩ vãng.