Multi-Stage Build untuk Image yang Lebih Kecil

Salah satu kebiasaan yang paling sering saya lihat di Dockerfile pemula (dan jujur, dulu saya juga begitu) adalah menaruh semuanya dalam satu FROM. Compiler, dependency development, source code mentah, sampai file build sementara, semuanya ikut terbawa ke image final yang jalan di production. Hasilnya image jadi gemuk, deploy lebih lambat, attack surface lebih besar karena banyak tool yang sebenarnya tidak dibutuhkan saat runtime tapi tetap ada di dalam container. Multi-stage build adalah jawaban Docker untuk masalah ini, dan begitu saya mulai pakai secara konsisten di proyek Laravel maupun layanan AI/LLM yang saya tangani, ukuran image bisa turun drastis tanpa mengorbankan apa pun dari sisi fungsionalitas.

Kenapa Ini Penting

Image Docker yang besar bukan cuma soal estetika angka di docker images. Setiap MB ekstra berarti waktu pull yang lebih lama saat deploy, terutama kalau infrastruktur Anda melakukan rolling update atau scaling horizontal di banyak node. Image gemuk juga sering berarti permukaan serangan yang lebih luas, karena ikut membawa package manager, compiler, atau library development yang sebetulnya tidak pernah dipanggil saat aplikasi jalan. Di dunia sistem yang menyentuh data sensitif seperti klinik atau rumah sakit, prinsip minimal footprint ini bukan sekadar optimisasi, tapi bagian dari hygiene keamanan dasar.

Konsep Dasar: Build Stage vs Runtime Stage

Ide intinya sederhana: Anda boleh punya beberapa FROM dalam satu Dockerfile, dan setiap blok itu disebut stage. Stage awal biasanya berisi semua tool berat untuk compile atau build asset, sementara stage terakhir hanya menyalin hasil akhirnya saja. Image yang benar-benar dibangun dan dipakai adalah stage terakhir, jadi semua lapisan dari stage sebelumnya otomatis tidak ikut terbawa.

Sebagai ilustrasi untuk aplikasi Laravel dengan asset frontend Vue:

FROM node:20 AS frontend-builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY resources resources
COPY vite.config.js ./
RUN npm run build

FROM composer:2 AS vendor-builder
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts

FROM php:8.3-fpm-alpine AS runtime
WORKDIR /var/www/html
COPY --from=vendor-builder /app/vendor vendor
COPY --from=frontend-builder /app/public/build public/build
COPY . .
CMD ["php-fpm"]

Perhatikan tiga hal di sini. Stage frontend-builder membawa Node.js lengkap dengan npm dan seluruh node_modules, tapi itu semua raib begitu kita pindah ke stage runtime. Stage vendor-builder membawa Composer dan tool build PHP, tapi lagi-lagi tidak ikut ke image akhir. Yang benar-benar disalin ke stage runtime cuma hasil jadi: folder vendor dan folder build hasil kompilasi asset. Base image runtime pun sengaja dipilih varian minimal seperti alpine, bukan varian penuh yang membawa banyak tool tambahan.

Hal-Hal yang Sering Terlewat

Beberapa catatan praktis yang menurut saya penting dipahami sebelum menerapkan pola ini di proyek nyata.

  • Urutan COPY memengaruhi cache. Copy file dependency (package.json, composer.json) lebih dulu sebelum copy source code penuh, supaya layer install dependency bisa memanfaatkan Docker layer cache selama file dependency itu sendiri tidak berubah.
  • Named stage lebih mudah dirawat daripada index angka. Pakai AS nama-stage lalu rujuk dengan --from=nama-stage, bukan --from=0, karena Dockerfile akan lebih mudah dibaca dan diubah urutan stage-nya di kemudian hari.
  • Anda bisa punya stage khusus untuk testing. Misalnya stage terpisah yang menjalankan composer install tanpa flag --no-dev plus tools testing, dipakai di pipeline CI, tapi tidak pernah jadi target build image production.
  • Jangan lupa .dockerignore. Multi-stage build menyelamatkan Anda dari toolchain yang tidak perlu, tapi kalau context build masih menyertakan folder .git, node_modules lokal, atau file .env, itu tetap jadi beban dan risiko tersendiri saat proses build berjalan.
  • Base image runtime tetap perlu dipilih dengan sadar. Varian alpine memang kecil, tapi ada kasus tertentu di mana ekstensi PHP atau library C tertentu lebih mudah dikompilasi di base image glibc seperti slim. Ukuran image kecil tidak boleh mengorbankan stabilitas.

Takeaway

Multi-stage build pada dasarnya adalah cara memisahkan tanggung jawab: satu stage untuk membangun, satu stage lagi untuk menjalankan. Prinsip ini sejalan dengan filosofi container pada umumnya, yaitu setiap image sebaiknya membawa persis apa yang dibutuhkan saat runtime, tidak lebih. Kalau Dockerfile Anda saat ini masih satu blok panjang dengan banyak RUN apt-get install untuk build tool, coba mulai pisahkan jadi minimal dua stage: satu untuk build, satu untuk runtime. Selisih ukuran image yang Anda dapatkan biasanya langsung terasa, dan efeknya berlanjut ke waktu deploy yang lebih cepat serta permukaan keamanan yang lebih kecil untuk dijaga.