Penutup chapter sebelumnya menjanjikan seorang Raspberry Pi, dan sekarang giliran mesin kecil itu masuk cerita. Siti memasang Docker Engine di Raspberry Pi 4 miliknya untuk menjalankan myapp sebagai layanan pinggiran kantor, dan ia meminta image myapp:1.6 yang baru saja Budi perbaiki dengan secret mount. Kedengarannya seperti tugas lima menit: kirim image, jalankan, selesai. Kenyataannya berakhir di satu baris galat yang membingungkan generasi demi generasi: exec format error. Chapter ini membongkar akar masalahnya — image yang kita bangun selama ini diam-diam hanya mengenal satu arsitektur prosesor — lalu menyiapkan tiga jalan keluarnya: emulasi QEMU, cross-compile, dan manifest list multi-platform.
exec format error #
Budi mengemas image-nya menjadi arsip, persis ritual Chapter Menyimpan dan Memuat Image:
$ docker save -o myapp-1.6.tar myapp:1.6Siti memuatnya di Raspberry Pi lalu mencoba menjalankan:
$ docker load -i myapp-1.6.tarOutput yang Dihasilkan:
Loaded image: myapp:1.6$ docker run --rm -p 8000:8000 myapp:1.6Output yang Dihasilkan:
docker: Error response from daemon: failed to create task for container: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "/usr/local/bin/node": exec format error.Mari kita telaah apa yang sebenarnya gagal:
- Image termuat tanpa masalah —
docker loadtidak memeriksa arsitektur; layer-layer image disimpan apa adanya, jadi kegagalan baru muncul saat container dinyalakan. /usr/local/bin/nodeadalah biner ELF x86-64 — imagenode:22-alpineyang Budi pakai selama ini adalah varian untuk prosesor 64-bit keluarga x86; instruksi mesinnya ditulis untuk arsitektur tersebut.- Kernel Raspberry Pi berbicara bahasa lain — kernel aarch64 di Pi memahami instruksi ARM 64-bit dan menolak biner x86-64 dengan galat
exec format error; pesan itu datang dari kernel, bukan dari Docker, karena Docker memang hanya meminta kernel menjalankan program tersebut. - Arah sebaliknya sama buruknya — image arm64 yang dijalankan di laptop amd64 akan gagal dengan pesan yang serupa; kecocokan arsitektur adalah syarat dua arah.
Cara cepat memastikan bahasa apa yang dipakai masing-masing mesin adalah bertanya langsung kepada daemon:
$ docker version --format '{{.Server.Os}}/{{.Server.Arch}}'Output yang Dihasilkan:
linux/amd64Di Raspberry Pi Siti, perintah yang sama menjawab linux/arm64. Nilai gabungan inilah yang disebut platform, dan seluruh urusan chapter ini berkisar pada satu pertanyaan: bagaimana membangun image yang platformnya berbeda dari platform mesin build.
Anatomi Platform: os, arch, dan variant #
Platform ditulis dalam bentuk os/architecture dengan tambahan variant untuk kasus tertentu. Tiga bentuk yang paling sering kita temui:
| Platform | Arti | Contoh mesin |
|---|---|---|
linux/amd64 | Linux pada prosesor x86 64-bit | laptop dan server Intel/AMD, mayoritas cloud |
linux/arm64 | Linux pada prosesor ARM 64-bit | Raspberry Pi 4/5, Apple Silicon, AWS Graviton |
linux/arm/v7 | Linux pada prosesor ARM 32-bit varian v7 | Raspberry Pi generasi lama dengan OS 32-bit |
Mari kita telaah tiga kolom penyusunnya:
os— sistem operasi yang kernel-nya berbicara format biner tersebut; selama ini kita hanya bersinggungan denganlinux, tetapiwindowsdandarwinpun punya tempatnya di ekosistem image.architecture— keluarga instruksi mesin; inilah penentu utamaexec format erroratau tidaknya.variant— pembeda halus di dalam satu arsitektur, paling sering dipakai keluarga ARM 32-bit (v5,v6,v7) yang berbagi namaarmtetapi tidak saling kompatibel; ARM 64-bit umumnya tidak memerlukan variant.
Satu catatan perilaku penting: docker build tanpa flag --platform selalu membangun untuk platform mesin build — itulah sebabnya seluruh image kita otomatis menjadi linux/amd64 di laptop Budi tanpa pernah kita minta. Docker Desktop menampilkan peringatan dan menyalakan emulasi otomatis ketika image yang dijalankan tidak cocok dengan host, tetapi Engine telanjang di Raspberry Pi Siti tidak punya belas kasihan seperti itu.
Build Lintas Platform dengan QEMU #
Jalan keluar pertama: biarkan laptop Budi tetap membangun image arm64 dengan cara menirukan prosesor ARM. Tugas penerjemahan instruksi dipegang QEMU, dan pemasangannya di Linux dilakukan lewat mekanisme binfmt_misc kernel — Docker Desktop sudah membawanya, sedangkan Engine Linux perlu satu perintah persiapan memakai image tonistiigi/binfmt:
$ docker run --privileged --rm tonistiigi/binfmt --install arm64Output yang Dihasilkan:
installing: arm64Handler QEMU untuk aarch64 kini terdaftar di kernel laptop Budi, sehingga kernel sanggup menjalankan biner ARM 64-bit seolah biner itu asli. Setelah itu, build-nya sendiri hanya menambah satu flag:
$ cd myapp
$ docker build --platform linux/arm64 -t myapp:arm .Output yang Dihasilkan:
[+] Building 47.3s (9/9) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 264B 0.0s
=> [internal] load metadata for docker.io/library/node:22-alpine 0.8s
=> [1/6] FROM docker.io/library/node:22-alpine 4.8s
=> => resolve docker.io/library/node:22-alpine 0.8s
=> [2/6] WORKDIR /app 0.0s
=> [3/6] COPY package.json package-lock.json ./ 0.1s
=> [4/6] RUN --mount=type=cache,target=/root/.npm npm ci 41.2s
=> [5/6] COPY . . 0.1s
=> [6/6] EXPOSE 8000 0.0s
=> exporting to image 0.5s
=> => exporting layers 0.3s
=> => writing image sha256:1a79716e978d70d1151b22377362f870292e4275979494246e15839804530223 0.1s
=> => naming to docker.io/library/myapp:arm 0.0sMari kita telaah apa yang terjadi di balik angka-angkanya:
[1/6] FROMmengunduh varian arm64 — tagnode:22-alpineyang sama menunjuk manifest list di Docker Hub; dengan--platform linux/arm64, BuildKit memilih varian layer untuk ARM 64-bit, itulah mengapa langkah ini kembali mengunduh meski varian amd64 sudah tersimpan lokal.RUN npm cimelonjak menjadi 41,2 detik — bandingkan dengan 9,6 detik pada build native di Chapter Cache Mount; seluruh proses node berjalan di dalam QEMU yang menerjemahkan tiap instruksi ARM ke x86 satu per satu, dan bahkan cache mount yang menyajikan tarball tanpa unduhan pun tidak mampu menolong biaya penerjemahan ini.- Total 47,3 detik — bukan hanya
RUN; setiap langkah yang mengeksekusi biner arm64 ikut memperlambat, meski langkah-langkah metadata sepertiWORKDIRtetap instan karena tidak mengeksekusi biner image. - Perbandingan tiga angka yang menjelaskan segalanya — build native di laptop Budi: 11,2 detik; build native di Raspberry Pi Siti (ia akhirnya mencoba membangun sendiri di mesinnya): 28,7 detik; build emulasi di laptop: 47,3 detik. Mesin kecil itu dua kali lebih lambat dari laptop, tetapi tetap mengalahkan emulasi — lapisan penerjemahan QEMU lebih mahal daripada perangkat keras yang perlahan.
Image myapp:arm hasil build ini akhirnya berjalan mulus di Raspberry Pi — curl dari laptop ke Pi mengembalikan sapaan JSON myapp seperti biasa. QEMU menyelesaikan masalah Siti, tetapi dengan harga 41 detik per perubahan kode, jalur ini tidak menyenangkan untuk siklus develop-build-test yang rapat.
ARG Otomatis BuildKit #
Sebelum melihat jalan keluar yang lebih cepat, kita perlukan satu piranti yang disediakan BuildKit secara otomatis: sekumpulan ARG yang terisi sendiri mengikuti konteks platform. Nilainya bisa dibaca di mana pun dalam Dockerfile tanpa perlu --build-arg:
| ARG otomatis | Nilai saat build linux/arm64 di host linux/amd64 | Arti |
|---|---|---|
TARGETPLATFORM | linux/arm64 | platform tujuan build |
TARGETOS | linux | sistem operasi tujuan |
TARGETARCH | arm64 | arsitektur tujuan |
TARGETVARIANT | kosong | variant arsitektur tujuan |
BUILDPLATFORM | linux/amd64 | platform mesin yang menjalankan build |
BUILDARCH | amd64 | arsitektur mesin build |
Pasangan TARGET* dan BUILD* inilah fondasi pola yang mengalahkan QEMU: instruksi FROM boleh memaksa stage berjalan di platform asli mesin build (BUILDPLATFORM), sementara target arsitektur tetap dibaca lewat TARGETARCH dan diteruskan ke compiler.
Cross-Compile: Kompilasi Natif untuk Arsitektur Lain #
Bahasa kompilasi seperti Go punya keunggulan yang tidak dimiliki Node.js: compilernya sendiri mampu menghasilkan biner untuk arsitektur lain tanpa berjalan di arsitektur itu — proses yang disebut cross-compile. Kompiler Go berjalan natif di amd64, tetapi menghasilkan biner arm64; tidak ada satu instruksi pun yang diterjemahkan QEMU. Layanan katalog milik tim kita, yang Dockerfile multi-stage-nya kita rakit pada Chapter Multi-Stage Build, cukup diubah dua baris untuk memakai pola ini:
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
ARG TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY *.go ./
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" -o /app/server .
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/server /usr/local/bin/server
USER nobody
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]Mari kita telaah baris-baris kuncinya:
FROM --platform=$BUILDPLATFORM golang:1.24-alpine— stage builder dipaksa berjalan di platform mesin build; compiler Go yang dieksekusi adalah biner amd64 natif yang berlari kencang, bukan biner arm64 yang diterjemahkan QEMU.ARG TARGETARCH— deklarasi ini membuka keran ARG otomatis BuildKit; nilainya terisiarm64ketika build diminta untuk arm64, danamd64ketika tidak diminta apa-apa.GOARCH=$TARGETARCH— variabel environment standar compiler Go yang menentukan arsitektur hasil kompilasi; inilah satu-satunya tempat target platform benar-benar menyeberang ke dalam proses build.CGO_ENABLED=0— mematikan jembatan ke compiler C; tanpa ini, cross-compile butuh toolchain C untuk arsitektur target, dan binary Go berhenti menjadi file statis yang bisa ditempatkan dialpine:3.20begitu saja.- Dua cache mount dari Chapter Cache Mount —
/go/pkg/moddan/root/.cache/go-buildmembuatgo mod downloaddango buildhangat meski layer runtuh; pola lama dari chapter itu tetap berlaku penuh di sini.
Hasilnya terasa langsung di durasi build:
$ cd katalog
$ docker build --platform linux/arm64 -t katalog:arm .Output yang Dihasilkan:
[+] Building 24.1s (18/18) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 512B 0.0s
=> [1/7] FROM docker.io/library/golang:1.24-alpine 4.2s
=> [2/7] WORKDIR /src 0.0s
=> [3/7] COPY go.mod go.sum ./ 0.1s
=> [4/7] RUN --mount=type=cache,target=/go/pkg/mod go mod download 3.2s
=> [5/7] COPY *.go ./ 0.0s
=> [6/7] RUN --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 G 12.9s
=> [7/7] FROM docker.io/library/alpine:3.20 0.0s
=> exporting to image 0.4s
=> => exporting layers 0.2s
=> => writing image sha256:cdfb727cbf6a1b6bbfe6bd82adf9b34db02c90b9408e2229c02f7e2c54acb62a 0.1s
=> => naming to docker.io/library/katalog:arm 0.0sAngka 24,1 detik untuk image arm64 itu nyaris identik dengan build amd64 native — kompilasi berjalan natif, jadi satu-satunya perbedaan adalah beberapa detik unduhan layer varian. Bandingkan dengan 47,3 detik myapp lewat QEMU: untuk bahasa kompilasi, cross-compile menang telak.
Manifest List: Satu Tag, Banyak Arsitektur #
Kedua jalur di atas masih menghasilkan satu image untuk satu platform, sehingga Siti dan Budi harus memilah tag myapp:arm versus myapp:1.6. Solusi pamungkasnya adalah manifest list: sebuah tag yang menunjuk daftar image untuk beberapa platform sekaligus, dan setiap mesin yang menarik tag itu otomatis menerima image yang cocok untuk arsitekturnya. Perintahnya memakai buildx dengan beberapa platform sekaligus dan hasilnya langsung didorong ke registry kantor:
$ docker buildx build --platform linux/amd64,linux/arm64 \
-t registry.example.com/katalog:1.0 --push .Output yang Dihasilkan:
[+] Building 52.4s (26/26) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 512B 0.0s
=> [linux/amd64 1/7] FROM docker.io/library/golang:1.24-alpine 0.0s
=> [linux/arm64 1/7] FROM docker.io/library/golang:1.24-alpine 0.0s
...
=> [linux/amd64 6/7] RUN --mount=type=cache,target=/go/pkg/mod CGO 13.1s
=> [linux/arm64 6/7] RUN --mount=type=cache,target=/go/pkg/mod CGO 14.0s
...
=> exporting manifest list sha256:20989f097f8c74f4c24414f27d29d7b42810cee6e52f9b12b92348fc64b7309f 0.2s
=> => naming to registry.example.com/katalog:1.0 0.0s
=> pushing manifest list 1.8sAlur lengkapnya tergambar pada diagram berikut:
Diagram di atas memperlihatkan pembagian kerjanya: satu Dockerfile dieksekusi BuildKit dua kali — sekali untuk linux/amd64 dan sekali untuk linux/arm64, keduanya lewat cross-compile natif — lalu kedua image digabungkan menjadi satu manifest list bertag katalog:1.0 di registry. Registry kemudian bertugas sebagai pintu pintar: mesin yang menarik tag itu menerima image sesuai arsitekturnya tanpa perlu tahu bahwa varian lain ada. Isi manifest list itu bisa kita baca langsung:
$ docker manifest inspect registry.example.com/katalog:1.0Output yang Dihasilkan:
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.list.v2+json",
"manifests": [
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 739,
"digest": "sha256:b17d9ca14d8aef7c0afd860762ec4583e8d68b551832def948cbb67b783fd16d",
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"size": 739,
"digest": "sha256:6ef5691575dad14630d581311eef01a748bf799488911226231b2a462c0cfce8",
"platform": {
"architecture": "arm64",
"os": "linux"
}
}
]
}Mari kita telaah isi manifest list di atas:
- Dua entri
manifests— satu untuklinux/amd64dan satu untuklinux/arm64, masing-masing dengan digest manifest image-nya sendiri; struktur ini persis keluarga manifest list yang kita kenal pada Chapter Memahami Image Tag sebagai penunjuk banyak-ke-satu. - Digest manifest list itu sendiri — nilai
sha256:20989f09...yang tercetak pada output build; menarik tag tanpa platform berarti meminta manifest list, lalu memilih entri yang cocok. docker run registry.example.com/katalog:1.0di mesin mana pun — laptop Budi menerima varian amd64, Raspberry Pi Siti menerima varian arm64, keduanya berjalan native tanpa satu baris konfigurasi tambahan.
Dengan tiga jalur ini, myapp dan katalog kini bisa berjalan dari laptop sampai Raspberry Pi tanpa drama exec format error. Namun perhatikan satu hal yang diam-diam ikut berubah sepanjang section ini: belum ada satu pun pembahasan tentang seberapa besar image yang kita produksi. Image bengkak menaikkan biaya unduhan, memenuhi disk, dan memperluas permukaan serangan — dan justru di situlah pemborosan proyek nyata paling sering terjadi. Saatnya mengangkat timbangan.
Referensi #
- Multi-Platform Builds — panduan resmi build lintas arsitektur dengan QEMU dan cross-compile.
- Dockerfile Reference
— penjelasan
FROM --platformdan ARG otomatisTARGETPLATFORMbeserta saudaranya. - docker buildx build
— flag
--platform,--push, dan--loadpada builder modern.