Multi-stage build memisahkan tahap kompilasi dari tahap akhir, sehingga hanya artefak hasil build yang masuk ke image, bukan toolchain dan dependensi pengembangnya. Pada repositori toolkuy, folder node_modules berisi 14.673 berkas dengan total 193 MB, sedangkan direktori public/images berisi 619 berkas dengan total 20 MB. Tanpa pemisahan tahap, 193 MB berkas yang tidak dibutuhkan saat runtime itu ikut terbawa ke dalam image produksi. Dengan multi-stage build, tahap akhir hanya menerima hasil build dan aset statis yang benar-benar disajikan.
TL;DR
- Multi-stage build memakai beberapa perintah FROM dalam satu Dockerfile, dan hanya tahap terakhir yang menjadi image.
- Perintah COPY --from=nama-tahap menyalin artefak antar tahap tanpa membawa dependensi build.
- Pada repo ini node_modules berukuran 193 MB dan 14.673 berkas, semuanya tidak perlu ada di image runtime.
- Flag --target menghentikan build di tahap tertentu, berguna untuk image pengembangan dan pengujian.
- Tanpa .dockerignore yang benar, konteks build bisa membawa seluruh isi direktori termasuk rahasia.
Bagaimana cara kerja multi-stage build sebenarnya?
Setiap perintah FROM di dalam Dockerfile membuka tahap baru dengan filesystem sendiri, dan hanya tahap terakhir yang dipakai sebagai image hasil build. Tahap-tahap sebelumnya tetap tersedia sebagai sumber penyalinan, sehingga perintah COPY --from=builder bisa mengambil satu direktori hasil kompilasi tanpa ikut membawa paket build, cache, dan berkas sumber. Dokumentasi resmi Docker menjelaskan pola ini di halaman multi-stage builds.
Karena tiap tahap punya filesystem terpisah, tidak ada cara bagi berkas dari tahap build untuk bocor ke image akhir kecuali memang disalin secara eksplisit. Itu sebabnya pola ini juga berguna untuk alasan keamanan: kredensial yang dipakai saat mengunduh dependensi private tidak pernah sampai ke lapisan image final, selama kredensial itu hanya hidup di tahap builder.
Kenapa node_modules tidak boleh ikut ke image produksi?
Karena isinya sebagian besar hanya dibutuhkan saat kompilasi, dan pada repo ini ukurannya 193 MB dengan 14.673 berkas. Semakin banyak berkas di dalam image, semakin lama proses penyalinan lapisan, semakin besar permukaan serangan, dan semakin banyak paket yang harus dipindai ketika ada kerentanan dependensi. Paket build seperti kompiler native dan binding platform juga sering terikat ke arsitektur mesin build, sehingga memasukkannya ke image runtime justru menambah risiko gagal jalan di arsitektur lain.
Yang dibutuhkan saat runtime hanyalah keluaran build dan aset statis. Pada proyek ini, direktori public/images saja berisi 619 berkas dengan total 20 MB, dan itulah tipe berkas yang memang harus hadir di image karena akan dikirim ke pengunjung. Memisahkan keduanya membuat ukuran image bisa diprediksi dari ukuran artefak, bukan dari ukuran seluruh rantai dependensi pengembangan.
| Aspek | Satu tahap | Multi-stage |
|---|---|---|
| Isi image akhir | kode sumber, toolchain, node_modules 193 MB | hanya keluaran build dan aset runtime |
| Waktu rebuild saat kode berubah | seluruh lapisan dependensi ikut dihitung ulang | lapisan dependensi di-cache di tahap builder |
| Kredensial saat build | berisiko tertinggal di lapisan image | tetap di tahap builder, tidak ikut tersalin |
| Kemudahan audit | ribuan berkas tak terpakai harus dipindai | permukaan lebih kecil dan jelas |
| Cocok untuk | prototipe cepat di mesin lokal | image produksi dan pipeline rilis |
Seperti apa Dockerfile multi-stage yang benar untuk proyek Node?
Bentuk minimalnya tiga instruksi: satu tahap builder yang memasang dependensi dan menjalankan build, satu perintah COPY --from yang mengambil hasil build, dan satu tahap akhir yang hanya berisi runtime. Kuncinya menyalin berkas manifes dependensi lebih dulu daripada kode sumber, supaya lapisan pemasangan dependensi bisa dipakai ulang selama manifesnya tidak berubah.
# tahap 1: builder
FROM node:22-bookworm-slim AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# tahap 2: runtime
FROM node:22-bookworm-slim AS runtime
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
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["npm", "run", "start"]
Perhatikan bahwa tahap runtime tetap menyalin node_modules, tetapi yang disalin adalah hasil npm ci dengan NODE_ENV=production, bukan direktori 193 MB milik mesin pengembang. Untuk proyek yang benar-benar ingin ramping, tahap runtime bisa memakai pendekatan standalone sehingga hanya dependensi yang dipakai saat proses berjalan yang disertakan. Rujukan sintaks setiap instruksi ada di referensi Dockerfile.
Bagaimana memakai satu Dockerfile untuk development dan produksi?
Dengan flag --target, build bisa dihentikan di tahap tertentu sehingga satu Dockerfile melayani beberapa kebutuhan. Perintah docker build --target builder . menghasilkan image yang berisi toolchain lengkap untuk debugging, sedangkan docker build tanpa flag menghasilkan image runtime yang ramping. Dokumentasi cache build Docker menjelaskan bagaimana setiap tahap diperlakukan sebagai lapisan tersendiri yang bisa dipakai ulang.
Pola ini menghindari dua Dockerfile yang harus dijaga sinkron, sesuatu yang pada repo dengan 214 commit seperti toolkuy berisiko menyimpang tanpa disadari. Ketika satu tahap berubah, hanya tahap itu dan tahap sesudahnya yang dibangun ulang, sementara tahap sebelumnya tetap diambil dari cache.
Apa yang paling sering membuat multi-stage build tidak menghemat apa pun?
Penyebab paling sering adalah .dockerignore yang tidak lengkap. Kalau direktori node_modules lokal ikut terkirim sebagai konteks build, perintah COPY . . akan menimpa hasil npm ci di dalam builder, sehingga image justru membawa dependensi milik mesin pengembang. Pada repo ini, mengirim node_modules berarti mengirim 14.673 berkas dan 193 MB data yang tidak diperlukan ke daemon Docker setiap kali build dijalankan.
Penyebab kedua adalah menyalin seluruh direktori kerja ke tahap akhir, misalnya dengan COPY --from=builder /app /app. Cara itu membatalkan seluruh manfaat pemisahan tahap karena semua berkas antara, termasuk cache dan berkas sumber, ikut kembali masuk. Salin hanya direktori hasil yang memang dibutuhkan runtime.
# .dockerignore minimal untuk proyek Node
node_modules
.git
.next/cache
*.log
.env
.env.*
Bagaimana membuktikan image hasil multi-stage benar-benar lebih kecil?
Ukur sebelum dan sesudah, dengan perintah yang sama dan pada mesin yang sama. docker image ls menampilkan ukuran image akhir, sedangkan docker history menampilkan kontribusi setiap lapisan sehingga bisa dilihat instruksi mana yang menyumbang ukuran terbesar. Tanpa dua angka itu, klaim bahwa multi-stage menghemat ruang hanya dugaan, karena image tetap bisa besar kalau tahap akhirnya menyalin terlalu banyak.
Yang perlu diukur bukan hanya ukuran akhir, tetapi juga waktu build dan waktu push ke registry. Image yang lebih kecil mempercepat pengiriman ke registry dan penarikan di server tujuan, dan pada pipeline yang berjalan berkali-kali sehari dampaknya lebih terasa daripada penghematan disk. Pada repo dengan 214 commit seperti toolkuy, build yang berjalan setiap kali ada perubahan berarti penghematan kecil pun terakumulasi.
Perhatikan juga bahwa ukuran yang dilaporkan docker image ls adalah jumlah lapisan yang dipakai bersama dengan image lain, bukan ukuran unik di disk. Kalau beberapa image memakai base image yang sama, ruang disk yang benar-benar dipakai lebih kecil daripada jumlah angka yang terlihat. Untuk perbandingan yang adil, bandingkan image dengan base yang sama, atau catat juga ukuran base image-nya secara terpisah.
Apakah multi-stage build cocok untuk proyek kecil?
Cocok, tetapi manfaatnya sebanding dengan besarnya dependensi build. Proyek yang hanya berisi beberapa berkas HTML tidak banyak berubah ukurannya, sedangkan proyek Node dengan node_modules 193 MB mendapat penghematan yang signifikan karena seluruh rantai dependensi pengembangan berhenti di tahap builder. Yang penting bukan ukuran proyeknya, melainkan seberapa besar bagian yang hanya dibutuhkan saat kompilasi.
Urutan instruksi juga menentukan seberapa sering cache dipakai ulang. Menyalin package.json dan package-lock.json sebelum menyalin sisa kode membuat lapisan pemasangan dependensi hanya dibangun ulang ketika manifesnya berubah, sehingga perubahan pada satu berkas komponen tidak memaksa npm ci dijalankan lagi. Sebaliknya, menyalin seluruh direktori lebih dulu lalu menjalankan npm ci membuat setiap perubahan kode membatalkan cache dependensi, dan build yang seharusnya hitungan detik berubah menjadi hitungan menit. Untuk reproducibility, base image sebaiknya dipin ke digest, bukan hanya ke tag yang bisa berubah kapan saja. Argumen build juga perlu diperiksa: nilai yang dimasukkan lewat ARG tercatat di riwayat image, jadi jangan pernah memakai ARG untuk kredensial.
Hal yang sama berlaku untuk aset statis dan berkas sumber. Kalau tahap akhir hanya menerima direktori hasil build, seluruh berkas konfigurasi pengembangan, skrip utilitas, dan dokumentasi internal tetap tertinggal di builder dan tidak pernah ikut dikirim ke server produksi. Ini bukan sekadar soal ukuran, tetapi juga soal jejak informasi: berkas yang tidak pernah masuk image tidak bisa dibaca siapa pun yang berhasil masuk ke container. Sebelum menutup pekerjaan, periksa isi image dengan menjalankan shell sementara di dalamnya lalu lihat daftar berkas di direktori kerja. Kalau ada node_modules berisi paket pengujian, berkas contoh, atau direktori cache, berarti pemisahan tahapnya belum benar dan masih ada pekerjaan yang harus dirapikan pada Dockerfile maupun berkas .dockerignore.
FAQ
Apakah multi-stage build memperlambat proses build?
Pada build pertama bisa lebih lambat karena ada dua tahap, tetapi build berikutnya biasanya lebih cepat. Lapisan pemasangan dependensi di tahap builder di-cache dan hanya dibangun ulang ketika berkas manifes dependensi berubah.
Berapa ukuran image yang ideal?
Tidak ada angka tunggal, karena bergantung pada runtime dan aset yang disajikan. Patokannya adalah image hanya berisi yang dipakai saat proses berjalan; pada repo ini berarti keluaran build dan 20 MB aset gambar, bukan 193 MB node_modules.
Apakah node_modules tetap perlu disalin ke tahap akhir?
Untuk banyak setup Node, ya, tetapi hanya versi produksinya. Yang harus dihindari adalah menyalin node_modules dari mesin pengembang, karena isinya mencakup paket pengembangan dan binding native yang tidak diperlukan saat runtime.
Bagaimana kalau saya hanya ingin menguji tahap builder?
Gunakan docker build --target builder . sehingga build berhenti di tahap itu dan image yang dihasilkan bisa dijalankan untuk debugging. Flag --target dijelaskan di dokumentasi multi-stage build.
Di mana membaca soal cache lapisan build?
Penjelasan resmi ada di dokumentasi build cache Docker. Prinsip intinya: instruksi yang isinya tidak berubah memakai cache, dan perubahan pada satu instruksi membatalkan cache instruksi sesudahnya.
💬 Komentar (0)
Belum ada komentar. Jadilah yang pertama! 💬