Memecahkan masalah image yang membengkak karena build tooling ikut terbawa: teknik multi-stage build dengan beberapa FROM, menyalin hasil antar stage via COPY --from, membandingkan ukuran image single vs multi-stage, dan kapan memilih Alpine, Distroless, atau scratch.

Setelah di episode 6 sebelumnya kita membuat Dockerfile production-grade: membedah ENV vs ARG, menjalankan proses sebagai non-root dengan USER, dan mengoptimalkan build dengan layer caching serta .dockerignore — pada episode kali ini kita menghadapi masalah terbesar berikutnya: ukuran image. Di episode 6 kita berhasil mengecilkan lapisan-lapisan, tapi ada satu masalah struktural yang belum tersentuh: untuk aplikasi yang butuh dibangun, semua tooling build ikut terpaku di dalam image.
Bayangkan aplikasi Go: untuk mengompilasinya kalian butuh Go SDK (±200 MB), dan hasil binary-nya hanya beberapa megabyte. Dengan pendekatan Dockerfile biasa, image kalian akan membawa SDK, compiler, source code, dan semua perpustakaan build — padahal saat runtime aplikasi hanya butuh binary-nya. Hal yang sama terjadi pada Node.js dengan dependensi native (node-gyp butuh Python + gcc), atau aplikasi Java/Maven yang butuh JDK untuk di-build tapi hanya butuh JRE saat jalan. Ini masalah nyata di produksi: image raksasa berarti unduhan lambat, permukaan serangan keamanan lebih luas, dan biaya penyimpanan registry yang membengkak.
Di episode ini kita akan membedah solusi yang menjadi standar industri: multi-stage builds. Kita akan membangun aplikasi di "stage build" yang penuh tooling, lalu menyalin hanya hasilnya ke "stage runtime" yang mungil — hingga kalian bisa menurunkan ukuran image dari gigabyte menjadi puluhan megabyte, dan sampai pada pertanyaan krusial: kapan memakai Alpine, Distroless, atau bahkan scratch?
Sebelum ada multi-stage, praktik umum adalah menulis satu Dockerfile yang memuat semuanya — tool build dan runtime dalam satu image. Mari lihat betapa borosnya itu. Sebuah image yang dibangun dari golang:1.23 (yang butuh compiler) akan membawa sekitar 800 MB — hanya untuk menjalankan binary yang beratnya 10 MB. Begitu juga node:20 penuh (±1 GB) untuk aplikasi yang sebenarnya sudah di-build menjadi asset statis, atau maven:3.9-eclipse-temurin (±1 GB) untuk mengantarkan sebuah JAR.
docker pull golang:1.23-alpine
docker pull node:20-alpine
docker pull alpine:3.20golang:1.23-alpine 257MB
node:20-alpine 185MB
alpine:3.20 8MBPerhatikan angkanya: hanya base image Go versi Alpine sudah 257 MB, dan itu belum termasuk kode aplikasi dan dependensi. Sekarang ingat lagi pelajaran layer dari episode 5: file yang pernah disalin akan "terkunci" di layer selamanya. Karena itu, kalau kita menyalin source code dan menjalankan go build di image yang sama dengan tempat kita menjalankan binary-nya, semua file antara — source, cache, compiler, toolchain — ikut terbawa ke image final. Image final yang seharusnya berisi binary 10 MB malah menjadi 300+ MB.
Prinsip dasarnya: build tool (SDK, compiler, dev dependencies) tidak boleh ada di image produksi. Image produksi hanya boleh berisi hal yang dibutuhkan saat runtime: binary/aplikasi, runtime minimal, dan konfigurasi. Multi-stage build adalah mekanisme Docker untuk mewujudkan prinsip ini dengan rapi.
Multi-stage build hanyalah Dockerfile dengan lebih dari satu FROM. Setiap FROM memulai "stage" baru — bayangkan stage sebagai ruangan kerja yang terpisah dalam satu proyek bangunan: ruangan pertama adalah tempat merakit komponen (penuh dengan alat), ruangan kedua adalah tempat mengepak hasil jadinya (hanya berisi kotak pengiriman). Docker menjalankan setiap stage, lalu memungkinkan kalian menyalin file antar stage dengan COPY --from=<nama-stage>. Yang menentukan ukuran image final hanyalah stage terakhir — seluruh isi stage sebelumnya dibuang begitu build selesai, kecuali yang disalin.
# Stage 1: build — penuh tooling
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/myapp .
# Stage 2: runtime — mungil
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/myapp ./myapp
CMD ["./myapp"]Di sini, AS builder memberi nama stage pertama. Stage kedua dimulai dari alpine:3.20 (mungil), lalu COPY --from=builder /app/myapp ./myapp menarik binary hasil kompilasi dari stage builder. Hasil akhirnya: image yang hanya berisi Alpine (8 MB) + binary (10 MB) = sekitar 18 MB, bukan 300 MB. Source code, Go SDK, dan cache semuanya dibuang.
Beberapa detail penting:
AS builder, kalian bisa merujuk stage dengan indeks urutannya — COPY --from=0 /app/myapp ./myapp merujuk stage pertama. Nama jauh lebih mudah dibaca, jadi selalu beri nama.--target saat build (docker build --target builder), yang berguna untuk image development.deps (unduh dependensi), stage build (kompilasi), stage runtime (final). Tahap-tahap itu bisa dipakai ulang dan dibangun secara paralel oleh BuildKit.Go adalah kandidat paling sempurna untuk multi-stage karena hasilnya adalah satu binary statis yang tidak membutuhkan runtime apa pun. Perhatikan flag CGO_ENABLED=0 — ini menghasilkan binary statis yang bisa berjalan tanpa library sistem, bahkan di image kosong.
FROM golang:1.23-alpine AS builder
WORKDIR /src
ENV CGO_ENABLED=0 GOOS=linux
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -trimpath -ldflags="-s -w" -o /out/app .
FROM scratch
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]Mari bedah: -ldflags="-s -w" memangkas tabel simbol dan debug info dari binary (mengecilkan beberapa MB lagi). -trimpath menghilangkan path absolut dari metadata binary. Lalu — inilah puncak optimasi — stage runtime memakai scratch, image "kosong total" yang tidak berisi apa pun, bahkan shell dan ls tidak ada. Binary statis Go bisa berjalan langsung di scratch karena tidak bergantung pada library eksternal. Ukuran final: beberapa megabyte, kira-kira ukuran binary itu sendiri.
scratch bukan untuk semua orang — kontainer di dalamnya tidak punya /bin/sh, jadi debugging (docker exec ... sh) tidak mungkin dilakukan. Namun untuk binary statis yang diuji dengan baik, scratch adalah image paling aman dan paling kecil yang bisa kalian punya.
Node.js sedikit berbeda dari Go: aplikasi JavaScript tidak di-kompilasi menjadi binary, sehingga runtime Node.js sendiri tetap dibutuhkan di stage final. Tapi bukan berarti multi-stage tidak membantu. Dua masalah yang dipecahkan multi-stage untuk Node.js:
Dependensi dev. Aplikasi seperti Next.js, React, atau API dengan TypeScript butuh seluruh node_modules (termasuk dependensi development, tooling bundling) saat membangun asset. Di stage build, install lengkap; di stage runtime, hanya salin node_modules produksi (npm ci --omit=dev) dan hasil build.
Dependensi native. Paket dengan node-gyp butuh Python dan compiler untuk di-install. Di stage build, compile; di stage runtime, salin hasilnya.
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/package.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
EXPOSE 3000
USER node
CMD ["npm", "start"]Kuncinya: stage builder menginstal semua dependensi dan membangun aplikasi (npm run build). Stage runtime menyalin hanya package.json, node_modules produksi, dan folder hasil build (.next). Perhatikan bahwa pada stage runtime, node_modules sudah dalam bentuk "produksi" yang dihasilkan npm ci dengan NODE_ENV=production — hasilnya, image final tidak membawa TypeScript, test runner, atau tooling bundling yang mungkin menghabiskan ratusan MB.
Pola yang sama berlaku untuk ekosistem JVM — di sini manfaatnya bahkan lebih terasa karena JDK jauh lebih besar daripada JRE:
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /src
COPY pom.xml ./
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /src/target/*.jar ./app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]Stage pertama memakai image Maven raksasa (±1 GB) yang berisi JDK dan Maven untuk men-download dependensi dan mengepak aplikasi. Stage kedua memakai eclipse-temurin:21-jre-alpine (hanya JRE, ±80 MB) dan menyalin satu file .jar. Image final turun drastis dari lebih dari 1 GB menjadi kurang dari 100 MB.
Untuk benar-benar merasakan dampaknya, mari bandingkan dua cara membangun aplikasi Go yang sama:
FROM golang:1.23-alpine
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /app .
CMD ["/app"]REPOSITORY TAG IMAGE ID SIZE
myapp-single 1.0 5f2a1c9e3b41 312MB
myapp-multi 1.0 8d0c7a4f2e59 14.8MB
alpine 3.20 5c5d0b1a4e92 8.05MB312 MB dibanding 14.8 MB — berkurang 95%. Di produksi, perbedaan ini berarti: pull image jauh lebih cepat saat deployment, storage registry lebih hemat, dan permukaan serangan keamanan menyusut drastis karena image tidak lagi membawa compiler dan source code.
Selain multi-stage, ada beberapa praktik yang saling melengkapi:
--squash: kenapa jarang dipakai. Opsi docker build --squash menggabungkan semua layer menjadi satu. Sekilas menarik — image jadi lebih kecil dan "bersih". Tapi ada alasan kuat mengapa ia tidak populer: (a) ia menghancurkan manfaat layer caching — setiap perubahan memaksa seluruh image dibangun ulang dari nol; (b) buildkit secara historis tidak mendukungnya penuh; (c) semua masalah ukuran yang ingin kalian selesaikan sebenarnya bisa diselesaikan dengan multi-stage + rantai RUN yang benar — dengan cara yang masih mempertahankan caching. Prinsipnya: fix the cause, not the symptom.
Gunakan base image sekecil yang masuk akal. Multi-stage + Alpine/Distroless/scratch. Jangan memakai image penuh di runtime jika tidak ada alasan.
Gunakan versi image yang tepat. node:20-alpine, golang:1.23-alpine — pinned, bukan latest. Image latest adalah target bergerak: base image yang berubah diam-diam bisa merusak reproducibility build kalian (konsep supply chain ini akan kita dalami di episode 26).
Kombinasikan RUN yang saling terkait (pola rantai && dari episode 6) untuk menghindari layer sampah.
Waspadai multi-arch. Image modern di-push sebagai manifest multi-arsitektur — satu tag (node:20-alpine) berisi varian linux/amd64 (server cloud) dan linux/arm64 (Apple Silicon, ARM server). Saat pull, Docker otomatis memilih varian sesuai arsitektur host. Untuk menjaga ini, base image harus resmi dan di-publish multi-arch. Konsekuensinya untuk kalian: saat menguji build, pastikan memakai emulasi atau builder yang sesuai (docker buildx build --platform linux/arm64 — dibahas detail di episode 18). Image Go dengan CGO_ENABLED=0 sangat ramah multi-arch karena binary statis tidak bergantung pada arsitektur library sistem.
Tiga "keluarga" image runtime yang paling sering dipakai, dengan pertukaran yang berbeda:
| Image Runtime | Ukuran | Isi | Ideal Untuk | Trade-off |
|---|---|---|---|---|
alpine:3.20 | ±8 MB | BusyBox + apk + musl libc | Aplikasi yang butuh shell, tools, atau menginstal paket di runtime | Shell ada → permukaan serangan lebih besar; musl bisa bermasalah dengan binary glibc |
distroless (gcr.io/distroless/base) | ±20-30 MB | Runtime + minimal libs, tanpa shell & package manager | Aplikasi runtime yang butuh glibc (Node, Python, Java) | Tidak bisa docker exec ... sh; debugging lebih sulit |
scratch | 0 MB | Kosong total | Binary statis (Go/Rust dengan CGO_ENABLED=0) | Tidak ada apa pun; hanya cocok untuk binary statis |
Aturan praktis:
scratch untuk binary statis — biasanya Go atau Rust dengan CGO dimatikan. Image terkecil dan paling aman. Siapkan strategi debugging (logging, healthcheck) sejak awal karena tidak ada shell.apk dan shell, sangat fleksibel untuk image yang butuh tools tambahan. Kekurangannya: musl libc dan tidak semua binary/package kompatibel — selalu uji aplikasi kalian sebelum berkomitmen ke Alpine.node:20, golang:1.23, ubuntu) di image runtime kecuali ada kebutuhan spesifik — mereka membawa tooling yang hanya berguna saat build.Warning
Perubahan dari glibc (Debian/Ubuntu) ke musl (Alpine) adalah salah satu sumber bug produksi paling misterius: aplikasi berjalan sempurna di laptop, lalu di image Alpine tiba-tiba gagal dengan error seperti "version `GLIBC_2.32' not found". Ini bukan bug aplikasi — ini ketidakcocokan libc. Jika tim kalian belum siap debug hal semacam ini, jalur aman adalah image berbasis Debian Slim atau Distroless (keduanya glibc), baru pindah ke Alpine setelah diverifikasi.
Pada episode 7 ini kalian telah memecahkan masalah image yang membengkak: memahami bahwa build tool tidak boleh ikut ke image produksi, menguasai teknik multi-stage builds dengan beberapa FROM + COPY --from=<stage>, dan melihatnya dalam tiga ekosistem nyata — Go (binary statis ke scratch), Node.js (build asset, runtime ringan), dan Java/Maven (JDK raksasa → JRE ramping). Kalian juga belajar mengapa --squash jarang dipakai (menghancurkan caching), bagaimana multi-arch bekerja, serta kapan memilih Alpine, Distroless, atau scratch — dari ukuran 312 MB menjadi 14.8 MB, berkurang 95%.
Inti yang harus kalian bawa pulang: image final harus berisi sesedikit mungkin — hanya apa yang dibutuhkan saat runtime. Multi-stage adalah alat utamanya, dan pilihan base image runtime adalah keputusan arsitektur yang memengaruhi keamanan dan biaya jangka panjang.
Mulai episode 8, kita bergeser dari bagaimana image dibuat ke bagaimana data dikelola — karena di sinilah banyak orang mulai sadar bahwa kontainer itu stateless: begitu kontainer dihapus, semua data yang ditulis di dalamnya ikut lenyap. Di episode 8 selanjutnya kita akan membedah data persistence & storage: bind mounts, named volumes, tmpfs, cara membackup dan merestorasi volume, dan mengapa database kalian tidak boleh hidup tanpa volume. Sampai jumpa di episode 8!