Docker BuildKit Cache Mounts: Pangkas Waktu Build CI Setengahnya

Foto oleh CDEGlobal via Openverse (CC BY 2.0)
Cache mount adalah direktori yang dideklarasikan dengan RUN --mount=type=cache,target=path yang dipertahankan BuildKit lintas build, terpisah dari layer cache image biasa. Berbeda dari layer normal, isinya tidak pernah ikut terbakar ke dalam image final, jadi aman dipakai untuk cache package manager besar seperti npm, pip, atau download module Go tanpa menambah ukuran image.
Tanpa cache mount, perubahan pada package.json atau requirements.txt membatalkan semua layer Docker setelahnya, memaksa instalasi dingin total yang mengunduh ulang semua dependensi. Cache mount mengikat cache download package manager ke direktori persisten yang dikelola BuildKit, bukan ke layer cache, sehingga hanya package yang benar-benar baru atau berubah yang diambil ulang.
Tidak. Runner yang di-hosting GitHub mulai dengan disk kosong di setiap job, jadi cache mount tanpa export yang dikonfigurasi akan berperilaku seperti cache dingin di setiap run. Kamu perlu menyetel cache-from: type=gha dan cache-to: type=gha,mode=max pada docker/build-push-action agar cache tersimpan di layanan Actions cache GitHub antar run workflow.
Gunakan cache backend registry, bukan backend gha yang khusus GitHub. Jalankan docker buildx build dengan --cache-to type=registry,ref=nama-image:buildcache,mode=max dan --cache-from menunjuk ke tag yang sama. Cara ini bekerja sama persis di GitLab CI, Bitbucket Pipelines, Jenkins, atau runner mana pun yang punya akses push registry, karena hanya bergantung pada container registry, bukan API vendor CI tertentu.
mode=min, nilai default, hanya mengekspor layer yang berakhir di image final. mode=max mengekspor setiap layer perantara, termasuk isi di balik direktori --mount=type=cache dari stage build sebelumnya. Cache mount baru benar-benar bertahan antar run CI kalau kamu memakai mode=max — mode=min diam-diam menghapusnya.

Foto oleh CDEGlobal via Openverse (CC BY 2.0)
Kebanyakan tim sudah tahu Docker layer caching itu ada, tapi layer caching saja tidak membantu begitu kita menyentuh package.json, go.mod, atau requirements.txt. Begitu file manifest dependensi berubah, Docker langsung membatalkan semua layer setelah langkah COPY, dan RUN npm install berikutnya mulai dari cache yang benar-benar kosong, mengunduh ulang semua package meski 95 persen di antaranya sebenarnya tidak berubah. Cache mount BuildKit menyelesaikan masalah ini secara spesifik. Fitur ini memberi sebuah instruksi RUN sebuah direktori persisten yang tetap ada lintas build, bahkan lintas invalidasi layer cache yang biasanya menghapus semuanya. Artikel ini secara khusus membahas kecepatan build, bukan ukuran image. Kamu tetap perlu final stage yang ramping dan image runtime yang kecil, tapi itu topik terpisah dari seberapa cepat proses build itu sendiri berjalan, dan sudah dibahas di artikel lain di situs ini. Di sini kita hanya mengejar pengurangan menit di pipeline CI.
BuildKit sudah menjadi builder default di Docker Engine dan Docker Desktop sejak cukup lama, tapi tetap perlu dipastikan secara eksplisit, terutama di image CI lama atau runner self-hosted yang mungkin masih memakai builder lama secara default. Ada dua cara mengaktifkannya secara paksa: variabel environment DOCKER_BUILDKIT untuk docker build klasik, atau perintah docker buildx build yang lebih baru, yang memang berbasis BuildKit sepenuhnya dan kini direkomendasikan langsung oleh Docker karena buildx juga membuka kemampuan build multi-platform dan cache exporter yang bisa dipasang bebas.
Menyetel DOCKER_BUILDKIT=1 sebelum memanggil docker build biasa adalah cara tercepat mendapatkan perilaku BuildKit di pipeline yang sudah ada tanpa mengubah perintah apa pun. docker buildx build adalah arah jangka panjangnya: ini plugin CLI yang berbicara langsung ke daemon BuildKit, mendukung flag --cache-to dan --cache-from yang dipakai nanti di artikel ini, dan sudah terpasang secara default di versi Docker Desktop dan Docker Engine yang baru. Jika image CI-mu cukup lama sehingga buildx belum tersedia, DOCKER_BUILDKIT=1 docker build adalah fallback aman yang tetap menghormati --mount=type=cache di Dockerfile.
# Enable BuildKit for a one-off classic build
DOCKER_BUILDKIT=1 docker build -t myapp:latest .
# Or use buildx (BuildKit-native, ships with modern Docker Desktop / Engine)
docker buildx build -t myapp:latest .
# Persist it so you never have to think about it again
echo 'export DOCKER_BUILDKIT=1' >> ~/.zshrcCache mount dideklarasikan per instruksi RUN memakai flag --mount=type=cache, dan perilakunya sama sekali berbeda dari layer biasa berbasis COPY. Alih-alih ikut terbakar ke dalam image, direktori yang dipasang ini didukung oleh storage yang dikelola BuildKit di host atau runner CI dan dipakai ulang lintas build, terlepas dari apakah layer di atasnya berubah atau tidak. Itulah sifat kunci yang membuat ini berguna khusus untuk package manager: cache download milik package manager tidak perlu dibatalkan hanya karena kode sumbermu berubah, hanya perlu dibatalkan ketika versi package yang sesungguhnya berubah, jadi mengikatnya ke layer cache Docker sejak awal memang bukan siklus hidup yang tepat.
Sintaks intinya adalah RUN --mount=type=cache,target=path, diletakkan langsung di instruksi RUN yang butuh direktori persisten tersebut. target adalah satu-satunya opsi wajib; opsi lain seperti id, sharing, uid, gid, mode semuanya sudah punya nilai default yang masuk akal. Untuk proyek Node, memasang cache BuildKit ke direktori cache npm sendiri membuat npm ci hanya perlu mengambil package yang belum pernah diunduh, bahkan setelah package-lock.json berubah total, karena file tarball yang sudah diunduh masih ada di mount tersebut dari build sebelumnya. Menambahkan sharing=locked patut dilakukan untuk cache mount mana pun yang mungkin berjalan bersamaan — misalnya kalau dua build stage atau dua job CI paralel menyentuh cache id yang sama — supaya BuildKit menyerialkan akses alih-alih membiarkan dua penulis merusak direktori yang sama secara bersamaan.
# syntax=docker/dockerfile:1
FROM node:20-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# Cache mount: /root/.npm survives across builds even when package.json
# changes and busts the layer cache above it. "target" is required;
# "sharing=locked" serializes access so parallel builds don't corrupt
# the shared cache directory.
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm ci
FROM node:20-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN --mount=type=cache,target=/root/.npm,sharing=locked \
npm run build
FROM node:20-slim AS runner
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/main.js"]Isi cache mount tidak pernah ikut terbakar ke dalam layer image akhir, jadi aman saja mengarahkan --mount=type=cache ke direktori besar seperti cache node_modules atau module cache Go tanpa khawatir soal ukuran image. Direktori ini hanya ada selama langkah RUN berjalan dan disimpan BuildKit di luar image itu sendiri.
Pola yang sama persis berlaku untuk package manager mana pun yang punya cache di disk sendiri. Untuk image berbasis apt, memasang /var/cache/apt dan /var/lib/apt membuat apt-get install tidak mengunduh ulang paket .deb yang sudah diambil di build sebelumnya — perlu diingat kedua direktori ini biasanya dibersihkan oleh skrip cleanup apt sendiri, jadi pola ini mengasumsikan kamu tidak menjalankan apt-get clean setelahnya di dalam RUN yang sama. Untuk Python, memasang direktori cache pip membuat pip install -r requirements.txt memakai ulang wheel yang sudah pernah dibangun alih-alih membangunnya ulang dari source, yang sangat berpengaruh untuk package dengan native extension. Untuk Go, ada dua cache terpisah yang layak dipasang: module download cache di /go/pkg/mod, dan build cache compiler Go sendiri di /root/.cache/go-build, yang menyimpan package hasil kompilasi antar build bahkan ketika hanya satu file di proyekmu yang berubah.
# syntax=docker/dockerfile:1
FROM golang:1.22 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
# Go's own build cache (distinct from the module cache above)
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -o /out/server .
FROM debian:bookworm-slim
COPY --from=build /out/server /usr/local/bin/server
CMD ["server"]
# --- apt example: keep downloaded .deb files across builds ---
# 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 curl
# --- pip example ---
# RUN --mount=type=cache,target=/root/.cache/pip \
# pip install -r requirements.txtSemua yang dijelaskan di atas langsung berfungsi di mesin developer lokal, karena cache BuildKit tersimpan di disk mesin itu dan otomatis bertahan antar pemanggilan docker build. Runner CI mematahkan asumsi ini. Runner yang di-hosting GitHub, shared runner GitLab, dan sebagian besar infrastruktur CI yang bersifat ephemeral menyalakan virtual machine baru untuk setiap job, dengan disk kosong dan tanpa cache BuildKit dari run sebelumnya. Tanpa konfigurasi tambahan, setiap build CI pada dasarnya berperilaku seperti build pertama yang pernah kamu jalankan secara lokal — dingin, tanpa apa pun yang tercache, termasuk --mount=type=cache. Solusinya adalah mengekspor cache BuildKit secara eksplisit ke tempat yang benar-benar bertahan antar run CI, lalu mengimpornya kembali di run berikutnya.
Cache mount tanpa cache export yang benar-benar berfungsi di CI adalah no-op yang diam-diam. Dockerfile-mu akan tetap ter-build dengan benar dan tidak ada yang mendapat error, hanya saja build itu tidak akan pernah benar-benar lebih cepat, karena tidak ada cache backend persisten di balik mount tersebut pada runner yang ephemeral. Selalu verifikasi langkah export benar-benar berjalan dengan memeriksa log job CI di bagian upload cache, jangan hanya berasumsi --mount=type=cache menanganinya secara otomatis.
build-push-action milik Docker terintegrasi langsung dengan layanan cache GitHub Actions lewat cache backend khusus bernama gha. Menyetel cache-from: type=gha dan cache-to: type=gha,mode=max di action tersebut memberi tahu BuildKit untuk menarik cache dari run sebelumnya lewat cache API GitHub sebelum build, lalu mengunggah kembali cache yang sudah diperbarui setelahnya. Pengaturan mode=max ini penting: mode default mode=min hanya meng-cache layer yang berakhir di image final, sementara mode=max meng-cache setiap layer perantara, termasuk isi di balik direktori --mount=type=cache milikmu, yang justru dibutuhkan supaya pola npm, apt, atau Go di atas benar-benar terbawa antar run workflow.
# .github/workflows/build.yml
name: build
on: [push]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/my-org/myapp:latest
# GitHub Actions cache backend — persists the BuildKit cache
# (including our --mount=type=cache layers) between workflow runs
cache-from: type=gha
cache-to: type=gha,mode=maxRunner GitLab CI sama ephemeral-nya dengan runner GitHub-hosted, dan tidak ada padanan native GitLab untuk backend gha yang tertanam di buildx. Opsi portabel yang bekerja sama persis di GitLab, Bitbucket Pipelines, Jenkins, atau sistem CI lain mana pun adalah cache backend registry: BuildKit mendorong build cache sebagai manifest tersendiri ke container registry yang sudah punya akses push, dengan tag terpisah dari image aplikasi sesungguhnya, lalu menariknya kembali di run berikutnya lewat --cache-from. Karena ini hanya bergantung pada akses registry, bukan API vendor CI tertentu, ini adalah backend yang pas dipakai kapan pun kamu tidak spesifik berada di GitHub Actions.
# Registry-backed cache — portable across any CI runner, not just GitHub Actions
docker buildx build \
--push \
-t registry.example.com/myapp:latest \
--cache-to type=registry,ref=registry.example.com/myapp:buildcache,mode=max \
--cache-from type=registry,ref=registry.example.com/myapp:buildcache \
.
# mode=max caches every intermediate layer, not just the final image —
# this is what lets --mount=type=cache content survive between CI runners
# that each start with a cold, empty local disk.Simpan image cache di tag yang benar-benar terpisah, seperti myapp:buildcache, alih-alih memakai ulang tag release-mu. Manifest cache tidak dimaksudkan untuk dijalankan sebagai container, dan mencampurnya ke riwayat tag release membuat registry-mu lebih sulit diaudit.
Kalau pipeline-mu berjalan eksklusif di GitHub Actions, backend gha lebih sederhana untuk disiapkan karena build-push-action menangani autentikasi secara otomatis — tidak ada langkah push registry terpisah yang perlu dikonfigurasi atau kredensial tambahan di luar yang sudah dimiliki workflow. Backend registry butuh akses push yang dikonfigurasi secara eksplisit, tapi ia ikut berpindah kalau kamu suatu saat ganti penyedia CI, dan bekerja sama persis baik di GitLab CI, runner Jenkins self-hosted, maupun reproduksi lokal dari environment CI. Tim yang menjalankan setup CI multi-cloud atau multi-vendor cenderung menstandarkan pada backend registry justru karena portabilitas itu, bahkan ketika sebagian pipeline mereka kebetulan berjalan di GitHub Actions.
Di job GitLab CI, pola cache registry terlihat sama seperti di lingkungan lain mana pun: jalankan docker buildx build dengan --cache-to type=registry,ref yang mengarah ke tag cache-mu dan --cache-from menunjuk ke tag yang sama, di dalam job yang sudah punya kredensial docker login untuk registry tersebut. Tidak ada plugin khusus GitLab atau layanan tambahan yang dibutuhkan selain Docker-in-Docker atau executor bergaya Kaniko yang mendukung buildx.
Cara realistis memvalidasi semua ini adalah mengukur waktu dua build berturut-turut dari commit yang sama di environment CI sesungguhnya: satu dengan cache export dan import yang sudah disiapkan, satu tanpa itu. Untuk service Node atau Python biasa dengan pohon dependensi menengah, tim umumnya melihat langkah install dependensi turun dari beberapa menit menjadi di bawah tiga puluh detik begitu cache backend benar-benar bekerja, karena langkah install berubah menjadi pencarian cache alih-alih unduhan yang bergantung jaringan. Manfaatnya sebanding dengan seberapa besar pohon dependensimu dan seberapa sering ia berubah dibanding kode aplikasi — monorepo dengan node_modules besar dan lockfile yang jarang berubah mendapat manfaat paling banyak.