Docker Build Cache: Deploy PaaS Lebih Cepat dari Builder Dingin

Foto oleh AgainErick via Wikimedia Commons (CC BY-SA 4.0)
Laptop Anda masih menyimpan layer dari build sebelumnya di builder lokal, jadi sebagian besar instruksi dipakai ulang. Builder PaaS atau CI dibuat untuk deploy lalu dihapus, sehingga layer cache-nya kosong dan semua instruksi dijalankan. Solusinya adalah mengekspor cache ke registry dengan cache-to dan mengimpornya dengan cache-from, supaya builder baru berangkat dari build terakhir Anda.
Salin manifest dependency dan lockfile lebih dulu, jalankan install, baru salin sisa source. Panduan Docker sendiri menyarankan langkah mahal diletakkan di bagian awal, karena satu perubahan memaksa rebuild pada semua langkah sesudahnya. Dengan urutan itu, mengubah satu komponen tidak lagi membatalkan layer install.
Tidak dengan sendirinya. Cache mount adalah direktori di builder, bukan layer dari image, jadi ia tidak ikut ter-push bersama image dan tidak diekspor oleh cache-to. Garbage collection bawaan BuildKit juga mendaftarkan exec.cachemount sebagai record ephemeral yang dibersihkan. Di builder ephemeral ia membantu di dalam satu build, bukan antar deploy.
min adalah default dan hanya menyimpan layer yang diekspor ke image hasil akhir. max menyimpan semua layer, termasuk layer dari langkah antara. Untuk multi-stage build, max biasanya yang Anda butuhkan, karena install dependency yang mahal justru berada di stage yang tidak pernah ikut dikirim.
Baca build output dan cari langkah pertama yang tidak dicetak dengan tanda CACHED di depannya. Input langkah itulah yang berubah, dan semua yang di bawahnya memang akan dijalankan ulang. Ubah satu hal dalam satu waktu lalu build ulang, dan pakai progress plain ketika output ringkas menyembunyikan langkah yang perlu Anda lihat.

Foto oleh AgainErick via Wikimedia Commons (CC BY-SA 4.0)
Ringkasan Utama
Builder PaaS atau CI dibuat baru pada setiap deploy, jadi layer cache Docker mulai kosong kecuali Anda mengekspornya. Susun Dockerfile agar dependency terpasang sebelum source disalin, jaga build context tetap kecil lewat file dockerignore, dan dorong registry-backed cache dengan cache-to serta cache-from supaya builder dingin berikutnya mulai dalam keadaan hangat.
Saya mengubah satu string di komponen footer lalu push. Build log di platform melakukan hal yang sama seperti biasa: menarik base image, menjalankan install dependency penuh, meng-compile seluruh aplikasi, baru menyalakan container. Tidak ada bagian dari satu string itu yang menuntut install ulang apa pun, dan deploy-nya tetap selama deploy sebelumnya.
Tulisan ini membahas kenapa itu terjadi khusus di builder milik platform, dan apa yang perlu diubah supaya perubahan kecil hanya membayar deploy kecil: urutan Dockerfile, ukuran build context, apa yang bertahan dan tidak bertahan dari sebuah cache mount, serta satu pasang flag yang membuat builder baru mulai hangat. Semua flag, nilai default, dan nama mode di bawah dicek ke dokumentasi Docker, bukan diandalkan dari ingatan.
Aturan cache Docker sudah umum diketahui, tetapi itu baru separuh cerita. Dokumentasinya menyebutkan dengan lugas: perubahan apa pun pada perintah instruksi RUN membatalkan layer itu, perubahan pada file yang disalin lewat COPY atau ADD membatalkan layer miliknya, dan begitu satu layer batal, semua layer setelahnya ikut batal. Itulah aturan yang biasa kita optimalkan di laptop, tempat layer dari build sebelumnya masih tersimpan di builder lokal.
PaaS atau CI runner tidak bekerja seperti itu. Builder-nya dibuat untuk deploy Anda lalu dibuang setelahnya, jadi tidak ada layer lama yang bisa dibatalkan — memang tidak ada apa-apa di sana. Semua instruksi dijalankan. Itu sebabnya Dockerfile yang rebuild dalam hitungan detik di mesin Anda butuh menit di platform, dan kenapa cepat di lokal bukan bukti apa pun. Di builder ephemeral, cache bukan sesuatu yang Anda simpan, melainkan sesuatu yang Anda kirim masuk dan keluar dengan sengaja.
Ini kemenangan tunggal terbesar dan biayanya hanya perhatian. Panduan optimasi Docker cukup satu kalimat: karena sebuah perubahan memaksa rebuild pada langkah-langkah sesudahnya, usahakan langkah yang mahal muncul di bagian awal Dockerfile. Di image Node atau Next.js, langkah mahal itu adalah install dependency, sedangkan yang berubah lima puluh kali sehari adalah source Anda.
# Wrong: the source copy sits above the install, so the install layer's
# inputs are every file in the repository. Editing one component
# reinstalls the entire dependency tree on every deploy.
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/main.js"]
# Right: the install layer has exactly two inputs. A source edit
# invalidates the COPY below it and nothing above it.
FROM node:22-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/main.js"]Versi yang salah menyalin semuanya lebih dulu, baru install. Input layer COPY itu mencakup seluruh file di repository, jadi mengubah satu komponen mengubah layer tersebut, yang membatalkan install di bawahnya, lalu package manager kembali me-resolve dan mengunduh seluruh pohon dependency hanya karena perubahan teks. Versi yang benar menyalin manifest dan lockfile saja, install, baru menyalin source. Sekarang layer install hanya punya dua input dan dipakai ulang sampai ada dependency yang benar-benar berubah.
Perlu tepat soal arti berubah di sini. Layer dikunci pada file yang disalin ke dalamnya, bukan pada niat Anda, jadi memformat ulang manifest tanpa menyentuh satu dependency pun tetap membatalkan install. Itu bukan bug yang perlu diakali — justru itu alasan COPY dua file layak ditulis sebagai baris tambahan.
Salin lockfile dengan nama eksplisit, bukan dengan glob. Pola seperti package bintang titik json cocok dengan lockfile hari ini, tetapi juga cocok dengan apa pun berawalan package yang ditambahkan orang tahun depan, sehingga input layer install melebar diam-diam tanpa disadari. Dua nama file adalah kontrak yang bisa dibaca sekilas.
Setiap path yang sampai ke builder adalah path yang bisa membatalkan langkah copy, dan secara default seluruh direktori ikut terkirim. Dokumentasinya menjelaskan build context sebagai kumpulan file yang bisa diakses build Anda, diproses secara rekursif sehingga semua isi direktori lokal ikut, dan menjelaskan file dockerignore sebagai penyaring yang membuang file yang cocok dari context sebelum dikirim ke builder. Tanpa itu, empat hal berikut rutin merusak cache karena alasan yang sama sekali tidak berhubungan dengan kode Anda:
# .dockerignore — every line is a path that can no longer invalidate a
# COPY layer for a reason that has nothing to do with your code.
.git
.gitignore
node_modules
.next
dist
build
coverage
npm-debug.log
.env
.env.local
.DS_Store
# The Dockerfile and this file may be listed here too, but the docs note
# they are still sent to the builder, because the build needs them.Ada biaya kedua yang baru terasa di platform. Build context diunggah ke builder sebelum build dimulai, jadi node_modules yang lolos adalah masalah cache sekaligus transfer yang Anda bayar pada setiap deploy. Di laptop, transfer itu cuma pembacaan filesystem dan tidak pernah Anda sadari.
Multi-stage build biasanya dijual lewat argumen ukuran image, dan bagian itu memang benar. Anda memakai beberapa statement FROM, lalu menyalin artefak tertentu dari satu stage ke stage lain dan meninggalkan semua yang tidak Anda inginkan di image akhir, sehingga runtime image berangkat tanpa compiler, dev dependency, maupun source.
Separuh yang penting untuk kecepatan deploy justru berbeda: stage yang tidak dikirim siapa pun tetap harus dibangun seseorang. Builder stage tidak memakan apa pun saat runtime, tetapi menagih install penuh pada setiap deploy kalau layer-nya tidak ter-cache. Jadi perlakukan build stage dengan disiplin urutan yang sama seperti file satu stage — manifest, install, lalu source — dan beri nama stage dengan AS, karena copy dari stage bernama tetap benar ketika ada orang menyisipkan stage di atasnya, sedangkan copy dari stage nomor nol tidak.

Cache mount memberi satu instruksi RUN sebuah direktori yang hidup di luar layer. Referensi Dockerfile menjelaskannya sebagai cara build container menyimpan direktori cache untuk compiler dan package manager, yang isinya bertahan antar pemanggilan builder tanpa membatalkan instruction cache. Arahkan ke store milik package manager, maka layer install yang terpaksa dibangun ulang pun hanya mengunduh yang benar-benar berubah. Opsi sharing default-nya shared, yang mengizinkan penulis bersamaan; locked menahan penulis kedua sampai penulis pertama melepas mount.
# syntax=docker/dockerfile:1
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# target is npm's own store, so even a rerun of this layer only fetches
# tarballs that actually changed. id keeps it separate from another
# service's cache on the same builder; sharing defaults to shared, and
# locked makes a second build wait rather than write alongside the first.
RUN --mount=type=cache,id=npm,target=/root/.npm,sharing=locked \
npm ci --prefer-offline
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
# Only what this stage copies exists at runtime. The two stages above may
# be as fat as they like — what they must be is cacheable. Naming them
# with AS also means inserting a stage above cannot break these copies.
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN --mount=type=cache,id=npm,target=/root/.npm \
npm ci --omit=dev --prefer-offline
COPY --from=build /app/dist ./dist
CMD ["node", "dist/main.js"]Catatan pentingnya adalah alasan bagian ini bukan bagian terakhir. Cache mount bukan sebuah layer. Ia tidak ikut di image yang Anda push dan tidak ikut di cache yang Anda ekspor — ia tinggal di storage milik builder. Garbage collection bawaan BuildKit memperlakukannya sebagai ephemeral: policy pertama membuang build cache tak terpakai bertipe exec.cachemount, bersama local context dan git checkout, begitu usianya lewat 48 jam dan melewati ambang batas. Di builder yang dihapus setelah deploy selesai, 48 jam itu kelewat murah hati.
Jangan pernah menulis build yang bergantung pada isi direktori cache. Referensi Dockerfile menyatakan tegas bahwa cache mount hanya untuk performa dan build harus tetap jalan dengan isi direktori cache apa pun, karena build lain bisa menimpa file di sana atau garbage collection bisa membersihkannya. Build yang hanya berhasil saat hangat adalah build yang gagal tepat di hari Anda paling membutuhkannya.
Semua di atas membuat rebuild lebih murah. Bagian ini yang membuat builder baru bisa melewati rebuild sama sekali. BuildKit menyimpan hasil build di internal cache miliknya secara otomatis, tetapi dokumentasinya jelas bahwa external cache storage tidak otomatis: semua cache storage backend harus diekspor secara eksplisit dan diimpor secara eksplisit. Dua flag melakukan itu, dan registry backend adalah yang bisa dipakai di mana pun ada container registry.
# Nothing is exported or imported without these two flags. BuildKit's
# internal cache is local to a builder that no longer exists by the time
# the next deploy runs.
docker buildx build \
--push \
-t ghcr.io/acme/api:latest \
--cache-to type=registry,ref=ghcr.io/acme/api:buildcache,mode=max \
--cache-from type=registry,ref=ghcr.io/acme/api:buildcache \
.
# mode defaults to min, which caches only the layers exported into the
# resulting image. On the multi-stage file above, min leaves the deps
# stage — the expensive one — out of the cache entirely. max includes it.
# First run: nothing to import, and the cache image is written on push.
# Every run after that: a fresh builder imports it and skips the install.Parameter mode inilah yang sering terlewat. Default-nya min, yang hanya menyimpan layer yang diekspor ke image hasil akhir; mode=max menyimpan semua layer, termasuk layer dari langkah antara. Untuk multi-stage build, itu bedanya antara mengimpor layer runtime yang toh hanya butuh beberapa detik untuk dibangun ulang, dan mengimpor layer builder stage yang tadinya akan menagih install penuh. Kalau Anda membangun secara multi-stage — dan sebaiknya begitu — Anda ingin max.
Trade-off-nya nyata dan tidak selalu berpihak pada Anda, karena mengimpor cache adalah operasi jaringan: cache image harus ditarik dulu sebelum build bisa membacanya. Dihitung sebagai aritmetika, bukan sebagai hasil pengukuran, kalau layer dependency yang Anda lewati tadinya butuh tiga menit untuk install dan blob cache-nya butuh empat puluh detik untuk ditarik, Anda jelas untung; kalau layer yang dilewati cuma copy empat file selama dua belas detik, Anda justru memperlambat deploy sekaligus menambah tagihan registry. Ekspor cache untuk layer yang mahal dan stabil, dan tak usah repot untuk yang murah.

Semua klaim di atas bisa diuji lewat build output, yang mencetak CACHED di depan langkah yang dipakai ulang. Itu saja diagnostiknya. Cari langkah pertama di log yang tidak bertanda CACHED: input langkah itulah yang berubah, dan semua yang di bawahnya memang akan dijalankan ulang sepintar apa pun sisa Dockerfile Anda.
$ docker buildx build --progress=plain . # durations elided
#5 [deps 2/4] WORKDIR /app CACHED
#6 [deps 3/4] COPY package.json package-lock.json ./ CACHED
#7 [deps 4/4] RUN --mount=type=cache,id=npm,... npm ci CACHED
#8 [build 2/4] COPY --from=deps /app/node_modules ./node_mod CACHED
#9 [build 3/4] COPY . .
#10 [build 4/4] RUN npm run build
# The first line without CACHED is where your change landed. Here that is
# the source COPY, which is exactly right: the install above it was
# reused, and only the compile below it had to rerun. If line #7 had lost
# its CACHED instead, the problem is the Dockerfile, not the backend.Disiplin yang membuat log itu berguna adalah mengubah satu hal dalam satu waktu lalu build ulang, yang terdengar sepele dan justru paling sering dilewati:
Alasan untuk mengukur alih-alih menalar adalah karena cache itu sebuah hash, dan hash tidak punya intuisi. Saya pernah sangat yakin satu layer akan dipakai ulang dan ternyata salah, dua kali, dengan sebab yang sama: ada sesuatu yang saya lupa masih duduk di build context.
Kecepatan deploy di platform bukan fitur yang Anda beli, melainkan sifat dari Dockerfile yang Anda push. Letakkan langkah mahal di atas langkah yang gampang berubah, pangkas context sampai tinggal yang benar-benar dibaca build, pakai cache mount supaya install yang tak terhindarkan jadi murah, dan ekspor registry cache agar builder dingin berikutnya berangkat dari build terakhir Anda. Lalu buktikan di log, karena satu-satunya pendapat yang berlaku adalah kata CACHED.
Sumber