2026-07-12 · 10 min
Docker Multi-Stage Build untuk Optimasi Ukuran Image
Awal tahun ini saya diminta membereskan pipeline deploy sebuah layanan Node.js internal yang push-nya selalu terasa lambat. Setelah dicek, biang keroknya bukan jaringan: image production-nya menyentuh kisaran 1,1 GB. Isinya? Toolchain build lengkap, node_modules versi dev, cache npm, sampai source TypeScript yang sebenarnya tidak dipakai saat runtime. Setiap deploy artinya push dan pull gigabytean data yang 80%-nya sampah.
Solusinya bukan sihir. Multi-stage build sudah ada di Docker sejak lama, tapi masih banyak tim yang belum memakainya dengan benar. Tulisan ini membahas konsepnya, contoh Dockerfile before/after yang konkret, cara menata layer agar caching bekerja, dan kapan melangkah ke distroless untuk image seramping mungkin.
Kenapa image bisa membengkak
Sebelum ke solusi, pahami dulu dari mana lemaknya. Pada single-stage build, semua yang Anda lakukan di dalam Dockerfile menumpuk di image final:
- Toolchain build: compiler,
build-essential, header library — dibutuhkan saat build, tidak saat runtime. - Dev dependency: linter, test runner, type definition. Tidak relevan di production.
- Artefak sementara: cache package manager, file intermediate, source code sebelum di-bundle.
- Base image gemuk: memilih
node:20(varian penuh berbasis Debian) alih-alih varian slim, langsung menambah ratusan MB sebelum kode Anda masuk.
Semua itu terbawa karena single-stage tidak punya cara memisahkan “yang dipakai untuk membangun” dari “yang dipakai untuk menjalankan”. Multi-stage build menyelesaikan tepat masalah ini.
Konsep multi-stage build
Multi-stage build adalah satu Dockerfile dengan lebih dari satu instruksi FROM. Setiap FROM memulai stage baru dengan filesystem-nya sendiri. Anda bisa menamai stage (AS builder) dan menyalin artefak dari satu stage ke stage lain lewat COPY --from=.
Kuncinya: image final hanya berisi isi stage terakhir. Semua stage sebelumnya dipakai untuk kerja berat lalu dibuang. Compiler, dev dependency, dan file sementara tinggal di stage build dan tidak pernah ikut ke image production.
Alurnya sederhana:
- Stage build: pakai base image lengkap dengan toolchain, install semua dependency, jalankan proses build.
- Stage runtime: pakai base image minimal,
COPY --fromhanya artefak jadi yang benar-benar dibutuhkan untuk menjalankan aplikasi.
Before: single-stage yang gemuk
Ini pola yang paling sering saya temui — semuanya dijejalkan ke satu stage:
# Dockerfile (before) — single stage, ~1.1 GB
FROM node:20
WORKDIR /app
# Copy semuanya sekaligus (ini juga bikin cache buruk)
COPY . .
# Install SEMUA dependency, termasuk devDependencies
RUN npm install
# Build TypeScript -> JavaScript
RUN npm run build
EXPOSE 3000
CMD ["node", "dist/server.js"]
Masalahnya berlapis:
node:20penuh membawa banyak paket OS yang tidak dipakai runtime.npm installmemasang devDependencies (TypeScript, ESLint, dll) yang hanya perlu saat build.- Source TypeScript, config, dan cache npm ikut nangkring di image final.
COPY . .di awal membuat perubahan kode sekecil apa pun meng-invalidate layernpm install.
After: multi-stage yang ramping
Sekarang versi multi-stage. Perhatikan dua FROM dan COPY --from=builder:
# Dockerfile (after) — multi-stage, ~180 MB
# ---- Stage 1: build ----
FROM node:20-slim AS builder
WORKDIR /app
# Copy manifest dulu supaya layer install bisa di-cache
COPY package.json package-lock.json ./
RUN npm ci
# Baru copy source, lalu build
COPY . .
RUN npm run build
# Pangkas devDependencies setelah build selesai
RUN npm prune --omit=dev
# ---- Stage 2: runtime ----
FROM node:20-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
# Hanya salin artefak yang diperlukan runtime
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Jalankan sebagai non-root user
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Yang berubah dan kenapa itu penting:
node:20-slimsebagai base memangkas ratusan MB dari varian penuh, tapi tetap punya runtime Node yang matang.npm ci(bukannpm install) memakai lockfile secara deterministik — build lebih konsisten dan cepat.npm prune --omit=devmembuang devDependencies setelah build, jadinode_modulesyang tersalin ke runtime hanya berisi dependency production.- Stage runtime hanya menyalin
dist,node_modulesyang sudah dipangkas, danpackage.json. Source TypeScript dan toolchain build tidak ikut. USER nodemenjalankan proses sebagai non-root — praktik keamanan dasar yang mudah dilupakan.
Angka ukuran di komentar (1,1 GB → 180 MB) adalah gambaran umum dari kasus yang saya tangani; hasil persisnya jelas bergantung pada dependency proyek Anda. Yang konsisten: penurunan biasanya signifikan begitu toolchain build dan devDependencies dikeluarkan dari image final.
Layer caching: urutan itu segalanya
Docker membangun image lapis demi lapis dan meng-cache tiap layer. Kalau satu layer cache-nya invalid, semua layer sesudahnya ikut di-rebuild. Karena itu urutan instruksi menentukan seberapa sering Anda membayar ongkos rebuild yang mahal.
Prinsipnya: letakkan yang jarang berubah di atas, yang sering berubah di bawah. Dependency jarang berubah dibanding source code, jadi manifest dependency di-copy dan di-install lebih dulu:
# BENAR: manifest dulu, source belakangan
COPY package.json package-lock.json ./
RUN npm ci # layer ini di-cache selama lockfile tidak berubah
COPY . . # perubahan kode hanya invalidate dari sini ke bawah
RUN npm run build
Bandingkan dengan pola yang salah:
# SALAH: copy semua dulu -> tiap ganti 1 baris kode, install ulang
COPY . .
RUN npm ci # cache invalid tiap ada perubahan file apa pun
RUN npm run build
Pada versi salah, mengubah satu baris kode membuat npm ci dijalankan ulang dari nol setiap build. Pada versi benar, npm ci diambil dari cache selama package-lock.json tidak berubah.
Manfaatkan .dockerignore
COPY . . juga menyalin hal-hal yang tidak perlu (dan bisa merusak cache) kalau tidak difilter. Tambahkan .dockerignore:
node_modules
dist
.git
.env
*.log
coverage
Dockerfile
.dockerignore
Tanpa ini, node_modules lokal atau folder .git bisa ikut ke konteks build, memperlambat build dan kadang menabrak instalasi yang bersih di dalam container.
BuildKit cache mount untuk CI
Kalau pakai BuildKit (default di Docker modern), Anda bisa cache direktori package manager antar-build tanpa menaruhnya di layer final:
# syntax=docker/dockerfile:1
FROM node:20-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
Cache mount ini mempercepat instalasi berulang di CI tanpa membuat image lebih besar, karena isinya tidak masuk ke layer.
Distroless: menekan lebih jauh
Setelah multi-stage dan base slim, langkah berikutnya untuk yang butuh image sekecil dan seaman mungkin adalah distroless. Image distroless (dari Google) tidak punya shell, package manager, maupun utilitas OS umum — hanya runtime bahasa dan dependency aplikasi.
Dua keuntungan utamanya:
- Ukuran lebih kecil: tidak ada paket OS yang tidak dipakai.
- Attack surface lebih sempit: tanpa shell dan tanpa
apt/apk, banyak teknik eksploitasi pasca-breach jadi jauh lebih sulit dijalankan di dalam container.
Contoh runtime stage dengan distroless untuk Node:
# ---- Stage runtime dengan distroless ----
FROM gcr.io/distroless/nodejs20-debian12 AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./package.json
# Distroless nodejs sudah menetapkan entrypoint "node",
# jadi CMD cukup berisi argumen path aplikasi.
CMD ["dist/server.js"]
Untuk bahasa yang menghasilkan binary statis (Go, Rust), distroless makin bersinar karena Anda bahkan bisa memakai gcr.io/distroless/static yang isinya nyaris cuma binary Anda:
# ---- Contoh Go: static binary + distroless static ----
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server ./cmd/server
FROM gcr.io/distroless/static-debian12 AS runtime
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Konsekuensi distroless: debugging
Tidak ada makan siang gratis. Karena distroless tanpa shell, Anda tidak bisa docker exec -it <container> sh untuk mengintip ke dalam. Beberapa cara mengatasinya:
- Varian
:debug: image distroless punya tag:debugyang menyertakan BusyBox shell. Pakai untuk investigasi, jangan untuk production reguler. - Ephemeral debug container:
kubectl debug(ataudocker debug) menempelkan container berisi tooling ke pod/container yang bermasalah tanpa mengubah image aslinya. - Logging dan observability yang matang: karena mengintip manual jadi mahal, andalkan log terstruktur dan metrik yang baik. Pendekatan ini nyambung dengan yang saya bahas di Observability Stack Grafana + Loki + Tempo.
Kalau tim belum siap dengan perubahan cara debug ini, node:20-slim atau Alpine sudah memberi mayoritas manfaat pengecilan dengan friksi operasional yang jauh lebih rendah. Distroless adalah optimasi lanjutan, bukan wajib.
Perbandingan pilihan base image runtime
Sebagai kerangka memilih, ini gambaran umum trade-off tiap opsi base image runtime:
| Base image | Ukuran (relatif) | Ada shell? | Kemudahan debug | Attack surface |
|---|---|---|---|---|
node:20 (penuh) | Paling besar | Ya | Paling mudah | Paling luas |
node:20-slim | Sedang | Ya | Mudah | Sedang |
node:20-alpine | Kecil | Ya (ash) | Cukup mudah | Sempit |
distroless/nodejs20 | Paling kecil | Tidak | Sulit | Paling sempit |
Untuk kebanyakan tim, slim atau alpine adalah titik keseimbangan yang sehat: image sudah jauh lebih kecil, tapi masih ada shell untuk debug saat insiden. Naik ke distroless saat Anda memang mengejar ukuran minimum dan sudah punya alur debug alternatif.
Pitfall yang sering terjadi
1. Copy node_modules dev ke runtime. Kalau lupa npm prune --omit=dev (atau npm ci --omit=dev di stage terpisah), Anda menyalin devDependencies ke image final dan usaha multi-stage jadi setengah sia-sia.
2. Lupa .dockerignore. node_modules lokal ikut ke konteks build, membuat build lambat dan berisiko menabrak instalasi bersih di container.
3. Menaruh COPY . . sebelum install dependency. Ini pembunuh cache paling umum. Selalu copy manifest dependency lebih dulu.
4. Base image runtime tidak sinkron dengan build. Kalau build di Debian tapi runtime di Alpine (musl vs glibc), binary native tertentu bisa gagal jalan. Samakan basis OS antar-stage untuk dependency yang punya komponen native.
5. Menjalankan sebagai root. Multi-stage tidak otomatis membuat container aman. Tambahkan USER node/USER nonroot di stage runtime.
6. Tidak menandai stage yang tak terpakai. Untuk build target selektif, gunakan docker build --target builder. Berguna saat ingin membangun sampai stage tertentu untuk debugging, tanpa membuat image final.
Penutup
Multi-stage build adalah salah satu perubahan dengan rasio dampak-terhadap-usaha yang paling tinggi di dunia Docker. Dengan memisahkan stage build dari stage runtime, Anda membuang compiler, dev dependency, dan artefak sementara dari image production — dan penurunan ukurannya biasanya terasa langsung di kecepatan push, pull, serta cold start.
Urutannya: mulai dari multi-stage dengan base slim, benahi layer caching (manifest dulu, source belakangan) dan .dockerignore, lalu pangkas devDependencies. Baru setelah itu pertimbangkan distroless kalau Anda mengejar ukuran minimum dan attack surface terkecil — dengan catatan tim sudah siap dengan cara debug tanpa shell.
Image yang ramping bukan sekadar soal estetika angka. Ia berarti deploy lebih cepat, biaya registry dan bandwidth lebih hemat, dan permukaan serangan yang lebih kecil. Ketiganya terbayar setiap kali Anda merilis.
Ditulis oleh Reza Pradipta