โ† semua materi
15:00 Teknis ยท 21 Oct 2026 ยท 15:06

๐Ÿ“š 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

  1. Kamu ubah README.md dan build jadi 8 detik dari 3 detik. COPY package*.json sudah di atas npm ci. Apa dua kemungkinan penyebab yang paling mungkin, dan bagaimana membedakannya dari log build?
  2. Kenapa RUN --mount=type=cache,target=/app/node_modules npm ci bisa menghasilkan image yang jalan di mesinmu tapi crash di production?
  3. 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


โžก๏ธ Eksplorasi Lanjut