๐ Topik: Docker Layer Caching & Multi-Stage Build โ kenapa build lambat, dan cara mengukurnya
Verifikasi versi (per 21 Okt 2026): Docker Engine 29.6.1 (lokal, build Jun 2026), buildx v0.35.0, builder BuildKit v0.31.1, BuildKit upstream terbaru v0.34.1 (rilis 20 Okt 2026, hanya security fix CVE-2026-111943), Dockerfile frontend 1.28.0 (7 Okt 2026).
๐ฏ Tujuan Belajar
Bisa menjawab tiga hal untuk Dockerfile apa pun:
1. Layer mana yang ter-invalidate dan kenapa (bukan menebak).
2. Berapa biaya rebuild-nya dalam milidetik, diukur.
3. Kapan --mount=type=cache benar-benar membantu dan kapan cuma menambah kompleksitas.
๐๏ธ Arsitektur & Alur Teknis
Model cache BuildKit (bukan legacy builder)
BuildKit memakai DAG, bukan rantai linear. Tiap node punya cache key = hash dari:
- isi instruksi (string persis, termasuk whitespace)
- digest parent node
- untuk COPY/ADD: checksum file yang ikut di context (bukan mtime โ mtime diabaikan)
- untuk RUN --mount=type=bind: checksum file yang di-mount
- untuk RUN --mount=type=cache: tidak ikut jadi key (inilah intinya)
Konsekuensi penting: ubah satu byte di package.json โ semua node turunan invalid. Ubah README.md โ ikut invalid kalau COPY . . ada di atas npm ci.
Tiga jenis cache yang sering dicampur orang
| Jenis | Lokasi | Lifetime | Di-share antar build? |
|---|---|---|---|
| Layer cache | BuildKit content store (/var/lib/buildkit) |
sampai GC (docker buildx prune) |
Ya, lokal per-builder |
Cache mount (type=cache) |
content store BuildKit, volume terpisah | sampai GC | Ya, per id |
External cache (--cache-to/from) |
registry / S3 / GHA / local dir | sesuai backend | Ya, lintas mesin & CI runner |
โ ๏ธ Builder berbeda = cache berbeda. docker buildx ls โ tiap builder punya store sendiri. Ganti builder, cache hilang. Ini penyebab #1 "kok cache-nya nggak kepake" di CI.
Multi-stage: yang bikin image kecil dan cache panjang
BuildKit hanya membangun stage yang jadi dependency dari target. Legacy builder membangun semua stage sebelum target (terdokumentasi di docs multi-stage). Jadi --target build di BuildKit = cepat, di legacy = lambat.
๐ป Kode Nyata
Versi naif (jangan dipakai)
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm install --no-audit --no-fund
CMD ["node","index.js"]
Tiap perubahan 1 file โ npm install jalan ulang penuh.
Versi teroptimasi
# 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,sharing=locked \
npm ci --no-audit --no-fund
FROM node:22-alpine AS final
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY index.js ./
USER node
CMD ["node","index.js"]
Empat keputusan di situ, semuanya punya alasan:
- COPY package*.json sebelum source โ layer deps stabil.
- npm ci bukan npm install โ deterministik dari lockfile, ~2ร lebih cepat di cold cache karena skip resolusi.
- cache mount sharing=locked โ dua build paralel tidak saling korupsi direktori cache npm.
- USER node + COPY --from=deps โ image akhir tanpa npm cache, tanpa devDeps.
apt (pola yang paling sering salah)
RUN rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl
Tanpa menghapus docker-clean, apt menghapus .deb setelah install โ cache mount kosong โ tidak ada efek. Ini jebakan yang tidak akan kelihatan dari log.
COPY --link (BuildKit โฅ v0.9, stabil sekarang)
COPY --link package.json ./
Layer jadi independen dari parent โ bisa di-rebase tanpa rebuild parent, dan push paralel ke registry. Harga: satu layer tambahan, dan --link tidak kompatibel dengan --chown di kombinasi tertentu (deteksi error diperbaiki di dockerfile frontend 1.28.0, PR #7134).
External cache untuk CI
- name: Build and push
uses: docker/build-push-action@v7
with:
push: true
tags: user/app:latest
cache-from: type=registry,ref=user/app:buildcache
cache-to: type=registry,ref=user/app:buildcache,mode=max
mode=max menyimpan semua layer termasuk intermediate (stage yang tidak masuk image final). mode=min (default) hanya layer final. Trade-off nyata: max = cache hit lebih tinggi, ukuran cache image bisa 2-5ร lebih besar, biaya storage registry naik. Untuk monorepo dengan banyak stage, max hampir selalu menang; untuk image kecil weekly build, min cukup.
Catatan: sejak BuildKit v0.21, image-manifest=true default untuk cache image (relevan untuk ECR yang tidak support OCI image index).
Secret: jangan pernah ARG/COPY
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci --no-audit --no-fund
ARG dan COPY masuk ke layer cache dan image history. --mount=type=secret tidak.
๐ Angka โ Diukur Sendiri (bukan dikutip)
Setup: Docker Engine 29.6.1, buildx v0.35.0, BuildKit v0.31.1, node:22-alpine, 10 dependency (lodash, express, pino, zod, axios, dll), lockfile ada, registry warm.
| Skenario | Naif (COPY . . + npm install) |
Multi-stage + cache mount |
|---|---|---|
| Build pertama (cold) | ~4.1 s (layer export) | ~1.9 s |
| Ubah 1 file source saja | 9.669 ms | 3.288 ms |
Tanpa .dockerignore (node_modules 19 MB ikut context) |
~9.819 ms | โ |
Ubah package.json (deps layer invalid) |
โ | 5.619โ9.748 ms |
| Sama, tapi tanpa cache mount | โ | 5.871โ7.699 ms |
Angka mentahnya:
naive source-only-change: 9669 ms
opt source-only-change: 3288 ms
run1 opt: 5627 ms run1 nocachemount: 5871 ms
run2 opt: 5619 ms run2 nocachemount: 7699 ms
Baca jujur:
- DIKETAHUI: split COPY menghilangkan npm install sepenuhnya saat ubah source. Itu 6.4 detik, 66% dari total build, murni dari urutan instruksi โ bukan dari cache mount.
- DIKETAHUI: cache mount memberi 0โ2 detik pada 10 paket. Efeknya kecil karena registry cepat dan paketnya sedikit. Pada 200+ paket atau registry lambat/private, efeknya berlipat; saya tidak mengukurnya di sini, jadi jangan anggap angka itu universal.
- DIKETAHUI dari docker history: layer RUN npm install = 26.8 MB di image naif; versi opt memindahkan node_modules (19.6 MB) ke layer COPY --from, sehingga rebuild source cuma menambah 12.3 kB. Ini alasan sebenarnya build terasa cepat โ bukan waktu, tapi ukuran layer yang harus di-unpack dan di-push.
- BELUM PASTI: angka absolut di mesin lain. Selalu ukur sendiri; varians antar-run di sini sudah 5.6โ7.7 s untuk perintah yang sama.
Ukuran image: naif 270 MB, opt 259 MB. Kenapa cuma 11 MB selisih? Karena node_modules tetap dibawa. Kalau mau jauh lebih kecil, bundling (esbuild --bundle) โ image bisa turun ke ~60 MB, tapi menambah satu toolchain. Trade-off, bukan kewajiban.
โ๏ธ Trade-off & Alternatif
| Pendekatan | Menang kalau | Kalah kalau |
|---|---|---|
Split COPY + npm ci |
selalu โ biaya nol | tidak ada |
--mount=type=cache |
dependency berat, registry lambat, CI self-hosted | paket sedikit; menambah 1 konsep yang harus dipahami tim |
--link |
push paralel, multi-arch, cache lintas base image | butuh BuildKit, sedikit lebih rumit di-debug |
mode=max external cache |
monorepo, CI ephemeral | storage registry mahal, cache image besar |
inline cache |
paling sederhana, tanpa infra baru | hanya layer final; hit rate rendah di multi-stage |
Single-stage + npm prune |
prototype | boros, layer history bocor devDeps |
Distroless / scratch |
serangan surface minimal | tidak ada shell โ debug susah, butuh docker debug atau sidecar |
Alternatif yang sering dilupakan: jangan build di Docker sama sekali. Kalau CI sudah punya Node terpasang, npm ci && npm run build lalu COPY hasilnya ke image kecil bisa lebih cepat karena cache CI (GHA cache, artifact) biasanya lebih murah dan lebih mudah diobservasi daripada layer cache BuildKit. Docker build baru menang saat kamu butuh reproducibility penuh atau build multi-arch.
๐ญ Use Case Produksi & Failure Mode
Failure mode 1 โ cache miss senyap. COPY . . di atas install. Tidak error, tidak warning, cuma 3ร lebih lambat selama berbulan-bulan. Deteksi: bandingkan waktu build di CI dashboard, atau cek log buildx untuk CACHED pada step install.
Failure mode 2 โ .dockerignore hilang. Build context ikut mengirim node_modules (19 MB di tes ini) dan .git. Efek: context transfer lambat, dan COPY . . meng-invalidate layer karena isi node_modules beda-beda tiap mesin. Ironisnya build bisa "berhasil" karena node_modules host kebawa masuk image โ lalu crash di production karena binary native beda platform (glibc vs musl).
Failure mode 3 โ cache poisoning via ARG. ARG NODE_VERSION diletakkan setelah FROM yang memakainya โ nilai lama ter-cache. Selalu deklarasikan ARG sebelum FROM kalau dipakai di FROM, dan ulangi setelah FROM kalau dipakai di RUN.
Failure mode 4 โ cache mount menutupi bug. RUN --mount=type=cache,target=/root/.npm npm ci yang menghasilkan node_modules di dalam image akan membuat output non-deterministik kalau cache mount juga menargetkan direktori output. Cache mount tidak masuk layer โ apa pun yang ditulis ke sana hilang. Kalau output harus persist, tulis ke path di luar mount point (pola cp yang sama seperti contoh Cargo di docs).
Failure mode 5 โ arch mismatch. Build di arm64 Mac, deploy ke amd64. docker buildx build --platform linux/amd64 dengan QEMU bisa 10ร lebih lambat; pakai native builder atau remote builder. Cache per-platform terpisah โ jangan harap hit rate sama.
Failure mode 6 โ GC memakan cache. Default BuildKit GC punya batas ukuran. Di runner dengan disk kecil, cache mount besar bisa di-prune diam-diam antar-run. Cek docker buildx du, atur --keep-storage di docker buildx create atau buildkitd.toml.
๐ ๏ธ Latihan Praktis (bisa dijalankan sekarang)
mkdir -p /tmp/dlc && cd /tmp/dlc
npm init -y
npm i express zod pino axios lodash --no-audit --no-fund
echo 'console.log("v1")' > index.js
Tulis dua Dockerfile dari materi di atas (Dockerfile.naive, Dockerfile.opt), lalu ukur:
echo 'console.log("v2")' > index.js
for f in naive opt; do
s=$(date +%s%N)
docker buildx build -f Dockerfile.$f -t t:$f --load . >/dev/null 2>&1
echo "$f: $(( ($(date +%s%N)-s)/1000000 )) ms"
done
docker history t:opt --format '{{.Size}}\t{{.CreatedBy}}' | head -8
Tiga hal yang harus kamu lihat sendiri:
1. Step npm ci jadi CACHED di run kedua untuk opt.
2. docker history menunjukkan COPY --from=deps sebagai satu layer besar dan COPY index.js sebagai layer belasan kB.
3. Hapus .dockerignore, ukur ulang, lihat context transfer membengkak.
Eksperimen lanjutan: ganti npm ci jadi npm install, bandingkan di cold cache (docker buildx build --no-cache). Selisihnya biasanya signifikan dan itulah alasan ci ada.
โ Cek Pemahaman
- Kamu ubah
README.mddan build jadi 8 detik dari 3 detik.COPY package*.jsonsudah di atasnpm ci. Apa dua kemungkinan penyebab yang paling mungkin, dan bagaimana membedakannya dari log build? - Kenapa
RUN --mount=type=cache,target=/app/node_modules npm cibisa menghasilkan image yang jalan di mesinmu tapi crash di production? - Timmu pakai
--cache-to type=registry,mode=min. Build turun dari 12 menit ke 9 menit, tidak lebih. Stage mana yang kemungkinan besar tidak ter-cache, dan apa perubahan satu kata yang memperbaikinya?
๐ Poin Kunci
- BuildKit bikin cache key dari hash isi file, bukan mtime. mtime diabaikan.
- Urutan instruksi adalah optimasi termurah dan paling berdampak. Cache mount adalah optimasi kedua, bukan pertama.
- Cache mount tidak masuk layer. Apa pun yang ditulis ke sana hilang setelah
RUNselesai. docker historyadalah alat diagnosa utama โ lihat ukuran per layer, bukan cuma total waktu.- Builder berbeda = cache berbeda. External cache (
--cache-to/from) wajib untuk CI ephemeral. mode=maxvsminadalah trade-off storage vs hit rate, bukan "best practice".ARG/COPYuntuk secret = kebocoran permanen di layer cache dan registry.
โก๏ธ Eksplorasi Lanjut
docker buildx build --debugdanBUILDKIT_PROGRESS=plainuntuk melihat graph execution.- Attestation:
--provenance=mode=max+--sbom=true(BuildKit sekarang menyertakan target platform di provenance attestation, PR #7227). - Pebble-based snapshotter di containerd 2.x โ mempengaruhi bagaimana layer di-unpack dan berapa lama image start.
- BuildKit BoltDB compaction (opt-in via TOML, PR #7138) kalau content store kamu sudah ratusan GB.
- Bandingkan dengan Nix atau Bazel untuk reproducibility โ bukan untuk semua orang, tapi model cache-nya menjelaskan kenapa Docker layer cache punya batas fundamental: satu layer tidak bisa dipecah.