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:

bash
$ docker save -o myapp-1.6.tar myapp:1.6

Siti memuatnya di Raspberry Pi lalu mencoba menjalankan:

bash
$ docker load -i myapp-1.6.tar

Output yang Dihasilkan:

text
Loaded image: myapp:1.6
bash
$ docker run --rm -p 8000:8000 myapp:1.6

Output yang Dihasilkan:

text
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:

  1. Image termuat tanpa masalah — docker load tidak memeriksa arsitektur; layer-layer image disimpan apa adanya, jadi kegagalan baru muncul saat container dinyalakan.
  2. /usr/local/bin/node adalah biner ELF x86-64 — image node:22-alpine yang Budi pakai selama ini adalah varian untuk prosesor 64-bit keluarga x86; instruksi mesinnya ditulis untuk arsitektur tersebut.
  3. 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.
  4. 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:

bash
$ docker version --format '{{.Server.Os}}/{{.Server.Arch}}'

Output yang Dihasilkan:

text
linux/amd64

Di 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:

PlatformArtiContoh mesin
linux/amd64Linux pada prosesor x86 64-bitlaptop dan server Intel/AMD, mayoritas cloud
linux/arm64Linux pada prosesor ARM 64-bitRaspberry Pi 4/5, Apple Silicon, AWS Graviton
linux/arm/v7Linux pada prosesor ARM 32-bit varian v7Raspberry Pi generasi lama dengan OS 32-bit

Mari kita telaah tiga kolom penyusunnya:

  1. os — sistem operasi yang kernel-nya berbicara format biner tersebut; selama ini kita hanya bersinggungan dengan linux, tetapi windows dan darwin pun punya tempatnya di ekosistem image.
  2. architecture — keluarga instruksi mesin; inilah penentu utama exec format error atau tidaknya.
  3. variant — pembeda halus di dalam satu arsitektur, paling sering dipakai keluarga ARM 32-bit (v5, v6, v7) yang berbagi nama arm tetapi 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:

bash
$ docker run --privileged --rm tonistiigi/binfmt --install arm64

Output yang Dihasilkan:

text
installing: arm64

Handler 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:

bash
$ cd myapp
$ docker build --platform linux/arm64 -t myapp:arm .

Output yang Dihasilkan:

text
[+] 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.0s

Mari kita telaah apa yang terjadi di balik angka-angkanya:

  1. [1/6] FROM mengunduh varian arm64 — tag node:22-alpine yang 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.
  2. RUN npm ci melonjak 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.
  3. Total 47,3 detik — bukan hanya RUN; setiap langkah yang mengeksekusi biner arm64 ikut memperlambat, meski langkah-langkah metadata seperti WORKDIR tetap instan karena tidak mengeksekusi biner image.
  4. 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 otomatisNilai saat build linux/arm64 di host linux/amd64Arti
TARGETPLATFORMlinux/arm64platform tujuan build
TARGETOSlinuxsistem operasi tujuan
TARGETARCHarm64arsitektur tujuan
TARGETVARIANTkosongvariant arsitektur tujuan
BUILDPLATFORMlinux/amd64platform mesin yang menjalankan build
BUILDARCHamd64arsitektur 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:

dockerfile
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:

  1. 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.
  2. ARG TARGETARCH — deklarasi ini membuka keran ARG otomatis BuildKit; nilainya terisi arm64 ketika build diminta untuk arm64, dan amd64 ketika tidak diminta apa-apa.
  3. 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.
  4. 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 di alpine:3.20 begitu saja.
  5. Dua cache mount dari Chapter Cache Mount — /go/pkg/mod dan /root/.cache/go-build membuat go mod download dan go build hangat meski layer runtuh; pola lama dari chapter itu tetap berlaku penuh di sini.

Hasilnya terasa langsung di durasi build:

bash
$ cd katalog
$ docker build --platform linux/arm64 -t katalog:arm .

Output yang Dihasilkan:

text
[+] 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.0s

Angka 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:

bash
$ docker buildx build --platform linux/amd64,linux/arm64 \
    -t registry.example.com/katalog:1.0 --push .

Output yang Dihasilkan:

text
[+] 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.8s

Alur 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:

bash
$ docker manifest inspect registry.example.com/katalog:1.0

Output yang Dihasilkan:

json
{
  "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:

  1. Dua entri manifests — satu untuk linux/amd64 dan satu untuk linux/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.
  2. 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.
  3. docker run registry.example.com/katalog:1.0 di 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 #