Multi-Stage Build Docker: Memperkecil Image Next.js Nyata
Belajar multi-stage build Docker dari Dockerfile nyata: stage bernama, COPY --from, user non-root, --target, dan kenapa hanya stage akhir yang dikirim.
Oleh Matthews WongDitinjau pada
Poin utama
Multi-stage build Docker adalah Dockerfile dengan beberapa instruksi FROM: setiap FROM memulai stage baru, dan COPY --from hanya membawa file yang Anda pilih dari satu stage ke stage berikutnya. Perkakas build tertinggal di stage awal, dan hanya stage terakhir yang menjadi image yang dijalankan, sehingga ukurannya lebih kecil dan tidak membawa source code maupun dev dependency.
Setelah pelajaran ini, Anda bisa
- Memecah Dockerfile menjadi beberapa stage bernama dan hanya menyalin hasil build ke stage terakhir.
- Menjelaskan apa yang dibawa output standalone Next.js, dan kenapa public serta .next/static harus disalin sendiri.
- Menjalankan image akhir sebagai user non-root, dan mem-build stage yang tidak pernah dikirim dengan --target.
Ikuti diagramnya langkah demi langkah
Tekan Putar, atau melangkah dengan panah. Setiap langkah juga ditulis di bawah diagram.
Cara multi-stage build hanya mengirim yang dijalankan
Image single-stage mengirim semua yang dipakai saat build. Dengan stage deps, builder, dan runner, node_modules pindah ke builder, tiga folder hasil build pindah ke runner, dan hanya runner yang dikirim.
Tombol: ← → langkah · Home / End · K putar atau jeda
Di kiri, image single-stage: satu FROM mem-build sekaligus mengirim semuanya. Di kanan, aplikasi yang sama dalam tiga stage. Setiap FROM memulai stage baru, dan hanya runner yang dikirim.
Dengan satu stage, semua yang dibutuhkan saat build ikut terkirim: source, dev dependency, cache unduhan npm, dan build cache Next. Tidak satu pun dibutuhkan untuk menjalankan aplikasi.
Stage deps menginstall paket. Builder menyalin node_modules lewat COPY --from=deps, jadi perubahan source tidak membuat layer npm ci di-build ulang.
RUN --mount=type=cache,target=/root/.npm npm ci COPY --from=deps /app/node_modules ./node_modulesBuilder menyalin source, termasuk public/, lalu menjalankan next build. Dengan output: standalone, Next.js menulis server.js minimal dan hanya node_modules yang terlacak dipakai.
COPY . . RUN npm run buildTebak langkah berikutnya Runner mulai dari nol. Apa yang diambilnya dari builder? (Seluruh builder, termasuk source · Hanya tiga folder hasil build · node_modules dan source code)Runner mulai dari base image yang bersih dan menyalin tiga folder dari builder. Source code, node_modules lengkap, dan perkakas build tertinggal.
COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/staticSebelum CMD, runner membuat system user dengan uid 1001 lalu beralih ke user itu, jadi proses server tidak berjalan sebagai root di dalam container.
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs USER nextjsTebak langkah berikutnya Dari tiga stage itu, mana yang di-push dan dijalankan? (Hanya runner · Ketiganya, ditumpuk jadi satu image)Hanya runner yang di-push dan dijalankan. Tanpa source dan dev dependency, image-nya lebih kecil dari image single-stage, walaupun base image tetap menentukan batas bawahnya.
Apa itu multi-stage build di Docker?
Multi-stage build adalah Dockerfile dengan lebih dari satu instruksi FROM. Setiap FROM memulai stage baru di atas base image yang bersih, dan stage bisa diberi nama dengan AS, misalnya FROM node:20-alpine AS builder. Stage tanpa nama diberi nomor mulai dari 0, tetapi nama tetap benar walaupun urutannya berubah, jadi pakailah nama.
Baca selengkapnya (1)Tutup
Stage berikutnya mengambil file dari stage sebelumnya dengan COPY --from ditambah nama stage, misalnya COPY --from=deps, dan hanya path yang disebut yang ikut. Dokumentasi Docker merangkumnya dalam satu kalimat: Anda menyalin artefak dari satu stage ke stage lain dan meninggalkan semua yang tidak Anda inginkan di image akhir. Stage yang ditinggalkan boleh seberat apa pun, karena tidak ada yang mengunduhnya.
Kenapa multi-stage build membuat image lebih kecil?
Mem-build aplikasi butuh jauh lebih banyak hal daripada menjalankannya: compiler, paket tipe, linter, seluruh source code, dan cache unduhan npm. Di image single-stage semuanya ikut terkirim, karena image yang mem-build aplikasi adalah image yang Anda jalankan. Situs ini membagi pekerjaannya menjadi tiga: deps menginstall paket, builder meng-compile, dan runner hanya menerima tiga folder, tidak lebih.
Baca selengkapnya (2)Tutup
Tiga folder itu berasal dari output standalone Next.js. Dengan output standalone, next build melacak file apa saja yang dimuat setiap halaman, lalu menulis server.js minimal beserta node_modules yang dibutuhkan saja ke .next/standalone. Server itu tidak menyalin public dan .next/static secara default, itulah sebabnya runner menyalin keduanya sendiri.
Multi-stage membuang perkakas build, tetapi base image yang menentukan batas bawahnya. Pada aplikasi uji yang kecil, image multi-stage hanya lebih kecil puluhan megabyte, karena node:22-alpine sendiri sudah mengisi sebagian besar image. Pilih base image dengan sengaja, lalu ukur dengan docker image ls, jangan percaya angka orang lain begitu saja.
Bagaimana stage akhir berjalan sebagai user non-root?
Runner membuat system group dan user dengan id 1001, menyalin hasil build dengan --chown=nextjs:nodejs, lalu beralih dengan USER nextjs. Sejak baris itu, semua instruksi berikutnya di stage tersebut dan server yang berjalan memakai user itu. Panduan best practice Docker singkat saja soal ini: jika sebuah service bisa berjalan tanpa hak istimewa, pakai USER untuk beralih ke user non-root.
Baca selengkapnya (1)Tutup
Urutannya penting. COPY tanpa --chown membuat file dimiliki uid dan gid 0, yaitu root. Itu tidak masalah untuk public, yang hanya dibaca server, tetapi apa pun yang harus ditulis aplikasi perlu dimiliki user runtime sebelum USER berlaku.
Kenapa Dockerfile ini punya stage yang tidak pernah dikirim?
docker build --target diikuti nama stage akan mem-build sampai stage itu lalu berhenti, dan BuildKit hanya mem-build stage yang dibutuhkan target tersebut. Situs ini memakainya untuk cache-export: stage yang dibangun FROM scratch, image kosong, yang hanya berisi cache kompilasi Next. CI mem-build-nya dengan target: cache-export dan local exporter, yang menulis file ke folder alih-alih membuat image, lalu menyerahkannya ke actions/cache untuk run berikutnya.
Baca selengkapnya (1)Tutup
Akui juga biayanya. Dockerfile menyebut stage ini memakai ulang semua layer builder, jadi biayanya hampir nol. Pada 2026-10-03 BuildKit ternyata sudah membuang layer-layer itu: step ini menjalankan ulang npm ci dan seluruh build, lalu gagal saat mengekspor cache sebesar 572 MB, dan kegagalan itu membuat deploy dilewati. Sekarang step ini bersifat best-effort, dengan continue-on-error.
Konfigurasi nyata di balik pelajaran ini
Kutipan dari repositori yang membangun dan men-deploy situs ini, apa adanya.
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
# This carries .next/cache in with it — see the negation in .dockerignore. That
# cache is what makes `next build` incremental instead of compiling ~1,486 pages
# from cold on every deploy.
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
# Typechecking and linting are disabled in next.config.ts and run exactly once,
# in CI's `verify` job, which gates this build through `needs: verify`.
RUN npm run buildStage builder. Stage ini mulai dari base image baru, mengambil node_modules dari deps, lalu seluruh source. Komentar terakhir adalah trade-off yang disengaja: type checking hanya berjalan sekali di CI, jadi next build di lokal tidak lagi menangkap error tipe. next.config.ts menyebut syarat keluarnya: jika job verify tidak lagi menjadi gerbang build, dua setelan itu harus dihapus dalam perubahan yang sama.
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]Bagian akhir runner, satu-satunya stage yang dikirim. Hanya tiga salinan: public tanpa --chown karena hanya dibaca, lalu server standalone dan file statis yang dimiliki user aplikasi. Di atas baris ini stage tersebut menyetel HOSTNAME=0.0.0.0, sehingga server.js mendengarkan di semua interface dalam container dan port mapping Docker bisa menjangkaunya.
# Nothing but Next's compilation cache, so CI can pull it back out of the build
# and hand it to actions/cache for the next run. Without this the cache is a
# one-way street: the restore would warm the build context, the build would run
# inside Docker, and the runner's copy would never be updated — a cache step
# that looks like an optimisation and silently does nothing.
#
# This is a separate target, never part of the runner image. Building it reuses
# every layer from `builder`, so it costs close to nothing.
FROM scratch AS cache-export
COPY --from=builder /app/.next/cache /Stage yang ada hanya untuk diekspor. Posisinya sengaja sebelum runner: dalam sebuah uji coba, build tanpa --target menghasilkan stage terakhir, jadi stage khusus CI yang diletakkan paling akhir akan menjadi image yang dikirim. Baris terakhir komentarnya adalah bagian yang optimis; insiden di atas menunjukkan kapan itu tidak berlaku.
- name: Extract Next.js build cache
id: extract-next-cache
continue-on-error: true
uses: docker/build-push-action@v6
with:
context: .
target: cache-export
outputs: type=local,dest=/tmp/next-cache
cache-from: type=ghaCara CI mem-build stage itu. target memilih stage-nya, dan outputs: type=local menulis file-nya ke folder di runner alih-alih mem-push image. continue-on-error adalah pelajaran dari 2026-10-03: image sudah di-push pada titik ini, jadi ekspor cache yang gagal tidak boleh menahan deploy.
Kesalahan yang benar-benar membuang waktu
Halaman tanpa style: public dan .next/static terlupa
Jika hanya .next/standalone yang disalin, container tetap start, health check lolos, dan setiap halaman tampil sebagai teks polos, karena setiap request stylesheet berbalas 404. server.js dari standalone tidak menyalin public atau .next/static. Salin keduanya, dan letakkan static di ./.next/static, bukan di sebelahnya.
COPY --from tanpa --chown, lalu USER
File yang disalin tanpa --chown dimiliki root. Setelah USER beralih ke akun tanpa hak istimewa, aplikasi bisa membacanya tetapi tidak bisa menulis di sebelahnya, dan error-nya baru muncul saat runtime. Berikan path yang perlu ditulis kepada user runtime di COPY yang sama, atau buat path itu sebelum USER.
Menyalin seluruh node_modules ke runner
Dockerfile setengah jadi yang umum membuang source tetapi tetap menyalin node_modules dari builder, sehingga semua dev dependency ikut terkirim lagi. Dengan output standalone hal itu tidak perlu: hasil tracing sudah memasukkan modul yang dipakai server.js ke .next/standalone.
Stage khusus CI yang ternyata tidak gratis
Target pembantu hanya murah selama build cache masih menyimpan layer yang dibutuhkannya. Saat cache itu hilang, mem-build target tersebut menjalankan ulang semua stage sebelumnya. Jadikan step seperti ini best-effort jika berjalan setelah image di-push, dan jangan pernah meletakkannya paling akhir di Dockerfile.
| Single stage | Multi-stage | |
|---|---|---|
| Baris FROM | Satu | Satu per stage |
| Yang dikirim | Semua sisa proses build | Hanya yang disalin stage terakhir |
| Source dan dev deps di production | Ya | Tidak |
| Output tambahan untuk CI | Tidak bisa | Target terpisah, di-build dengan --target |
| Yang harus benar | Sedikit | Setiap path dan pemilik di COPY --from |
Lakukan di komputer Anda sendiri
Anda hanya butuh Docker dan Node. Build server TypeScript kecil yang sama dengan dua cara, bandingkan ukurannya, lalu ekspor satu stage ke folder.
Buat file proyeknya, lalu jalankan npm install sekali untuk membuat package-lock.json.
// package.json { "name": "devops-lab", "version": "1.0.0", "private": true, "scripts": { "build": "tsc" }, "devDependencies": { "@types/node": "22.7.5", "typescript": "5.6.3" } } // tsconfig.json { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "outDir": "dist", "strict": true }, "include": ["src"] } // src/server.ts import { createServer } from "node:http"; createServer((req, res) => res.end("hello\n")).listen(3000, "0.0.0.0"); # .dockerignore node_modules dist out .gitTulis Dockerfile multi-stage: deps, builder, stage khusus ekspor, dan runner yang berjalan sebagai uid 1001.
# syntax=docker/dockerfile:1 FROM node:22-alpine AS deps WORKDIR /app COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci FROM node:22-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build FROM scratch AS dist-export COPY --from=builder /app/dist / FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 app && adduser --system --uid 1001 -G app app COPY --from=builder --chown=app:app /app/dist ./dist USER app EXPOSE 3000 CMD ["node", "dist/server.js"]Simpan aplikasi yang sama sebagai single stage di Dockerfile.single.
FROM node:22-alpine WORKDIR /app COPY . . RUN npm ci && npm run build CMD ["node", "dist/server.js"]Build keduanya dan bandingkan kolom ukurannya. Lalu periksa user yang dipakai runner.
docker build -t devops-lab:v1 . docker build -f Dockerfile.single -t devops-lab:single . docker image ls devops-lab docker run --rm devops-lab:v1 idBuild stage ekspor saja ke sebuah folder. Tidak ada image yang dibuat; folder out hanya berisi server.js.
docker build --target dist-export --output type=local,dest=./out . ls out
Tiga pertanyaan sebelum lanjut
Pilih jawaban untuk melihat kenapa benar atau salah. Tidak ada yang disimpan atau dinilai, jadi tebak saja dengan bebas.
Jawab dalam hati, lalu cek
1.Apakah source code TypeScript situs ini ada di dalam image yang dikirim?Lihat jawabannya
Jawaban:Tidak. Hanya runner yang dikirim, dan runner hanya menyalin public, .next/standalone, dan .next/static dari builder. Source-nya tertinggal di stage builder, yang tidak pernah di-pull siapa pun.
2.Container berjalan dan health check lolos, tetapi semua halaman tampil tanpa style. Apa yang terlupa di Dockerfile?Lihat jawabannya
Jawaban:Menyalin .next/static, dan biasanya public juga. server.js dari standalone tidak menyalinnya secara default, jadi setiap file CSS dan JavaScript ber-hash berbalas 404.
3.Apa yang dihasilkan FROM scratch AS cache-export, dan kenapa letaknya sebelum runner?Lihat jawabannya
Jawaban:Filesystem yang hanya berisi .next/cache, yang diekspor CI ke folder dengan --target dan local exporter. Letaknya sebelum runner karena build tanpa --target menghasilkan stage terakhir, dan stage itu harus menjadi image yang dikirim.
Bahasan mendalam terkait di situs ini
Sumber
Sumber primer yang diperiksa pada tanggal riset. Ikuti tautannya untuk versi terbaru.