Pipeline toko kini berwarna hijau dari hulu ke hilir: workflow pada Chapter Build, Test, dan Publish menjalankan test dengan sidecar postgres, membangun image, mendorongnya ke ghcr.io/budi/toko/backend, dan menahan deploy sampai manusia memberi persetujuan. Namun satu bilangan yang dititipkan penutup chapter itu masih menyakitkan setiap kali Budi membuka tab Actions: run di cabang baru masih memakan hampir delapan menit penuh — type=gha yang terpasang di job build ternyata baru menyimpan sebagian jawabannya. Penyebabnya bukan Dockerfile yang buruk, karena urutan instruksi backend sudah mengikuti aturan emas dari Chapter Build Cache; penyebabnya adalah dua sifat yang berkolusi — runner GitHub-hosted adalah VM sekali pakai yang selalu dimulai dari nol, dan cache gha membawa kuota serta ikatan cabang yang membuat banyak run tetap membayar seluruh biaya dari awal. Chapter ini melengkapi sisa jawabannya: memindahkan simpanan cache ke tempat yang dirancang bertahan lintas run dan lintas mesin — registry — membedah mode=min versus mode=max yang menentukan nasib stage dependensi, lalu menutup dengan dua urusan yang sering dilupakan: perawatan simpanan dan risiko cache poisoning.
Runner Sekali Pakai, Cache Sekali Pakai #
Runner GitHub-hosted yang dipakai workflow toko adalah VM yang disiapkan untuk satu job lalu dibongkar begitu job itu selesai. Seluruh isi disknya — termasuk state BuildKit tempat rekaman cache disimpan — ikut lenyap bersama VM. Run berikutnya menerima VM yang benar-benar baru: image basis harus ditarik ulang dari registry, npm ci harus dijalankan ulang dari nol, dan tidak ada satu baris CACHED pun yang bisa diharapkan. Efeknya seperti menebang pohon setiap selesai panen: simpanan yang seharusnya menopang panen berikutnya tidak pernah sempat terbentuk.
Di laptop Budi, iterasi 1.8 detik dari Chapter Build Cache bertahan karena disk laptop bertahan — rekaman cache kemarin masih hadir hari ini. Di CI, angka yang setara tidak pernah muncul; setiap run membayar biaya penuh delapan menit berapa pun kecilnya suntingan. Dengan belasan run per hari dari berbagai pull request, delapan menit itu menjelma menjadi berjam-jam waktu tunggu yang tidak memberi nilai apa pun kepada siapa pun. Seluruh optimasi yang kita bangun pada section image build nyaris terhapus oleh satu fakta sederhana tentang tempat pipeline berjalan.
type=gha yang terpasang sejak Chapter GitHub Actions memang meredakan sebagian derita — tetapi sifatnya membatasi jangkauannya. Cache gha terikat cabang: pull request dari cabang baru selalu memulai simpanan sendiri, dan cabang adalah tempat sebagian besar run terjadi. Kuotanya 10GB dibagi untuk seluruh repositori — setiap workflow, setiap PR, setiap dependensi yang ikut menumpang — sehingga eviksi LRU kerap memangkas rekaman toko justru menjelang dipakai lagi. Dan simpanannya hanya terbaca oleh runner GitHub; begitu sebagian build berpindah tempat, simpanan itu tertinggal. Jawaban yang tuntas harus hidup di luar kuota dan di luar cabang: di registry, di samping image yang dilahirkannya.
Jalan keluarnya bukan membuat runner persisten — menyimpan state antar build justru menambah permukaan risiko yang kita pelajari pada Chapter Supply Chain Security, dan runner ephemeral tetap pilihan yang lebih sehat. Jalan keluarnya adalah memindahkan simpanan cache ke tempat yang dirancang bertahan dan bisa dijangkau dari mesin mana pun: registry. Diagram berikut merangkum pergantian peran yang terjadi — runner tidak lagi memegang simpanan, registry yang memegangnya:
Mari kita telaah alur pada diagram tersebut: run pertama tetap membayar build penuh karena simpanan belum ada, lalu mengekspor hasilnya ke registry sebagai investasi. Run kedua — meski berjalan di VM yang sama sekali berbeda — mengimpor rekaman itu sebelum eksekusi dimulai, sehingga langkah-langkah yang bahan kuncinya tidak berubah dilayani sebagai CACHED. Mulai run kedua, kecepatan yang kita kenal dari laptop kembali hadir di CI.
Cache di Registry: –cache-from dan –cache-to #
Registry sudah lama menjadi rumah image toko, dan buildx memakai mekanisme penyimpanan yang sama untuk cache. Rekaman hasil tiap instruksi dibungkus sebagai manifest khusus lalu didorong ke satu tag yang kita tunjuk sendiri — kebiasaan yang lazim memakai tag buildcache. Manifest itu bukan image yang bisa dijalankan; isinya adalah peta layer yang siap dilayani sebagai CACHED bagi build mana pun yang mengimpornya. Perintah berikut adalah bentuk CLI lengkapnya, bentuk yang secara harfiah dijalankan workflow:
$ docker buildx build \
--cache-from type=registry,ref=ghcr.io/budi/toko:buildcache \
--cache-to type=registry,ref=ghcr.io/budi/toko:buildcache,mode=max \
-t ghcr.io/budi/toko:main \
--push .Mari kita telaah tiga flag yang bekerja di dalamnya:
--cache-from type=registry,ref=ghcr.io/budi/toko:buildcache— sebelum eksekusi dimulai, buildx menarik manifest dari tagbuildcachedan menjadikannya simpanan semu untuk build ini; instruksi yang cache key-nya cocok dengan rekaman lama langsung dilayaniCACHED. Bila tag itu belum ada — misalnya pada run pertama setelah fitur ini dinyalakan — flag ini dibiarkan tanpa efek dan build berjalan normal tanpa galat.--cache-to type=registry,ref=ghcr.io/budi/toko:buildcache— arah sebaliknya: setelah build selesai, hasil tiap instruksi diekspor sebagai rekaman cache dan didorong menimpa manifest lama pada tag itu. Run berikutnya, di runner mana pun, mewarisi hasil run ini.mode=max— parameter cakupan pada--cache-to: rekaman yang diekspor mencakup seluruh stage Dockerfile, termasuk stage antara tempat dependensi dipasang. Bedah penuh mode ini menyusul pada bagian khusus di bawah, karena perbedaannya paling terasa justru pada Dockerfile multi-stage.
Run pertama setelah flag dipasang tetap memakan waktu penuh — saat itu tag buildcache belum berisi apa-apa dan build bertugas mengisinya. Investasi sekali itu dilunasi mulai run kedua, dan setiap run sesudahnya sekaligus menjadi investasi bagi run berikutnya. Simpanan cache menjadi milik bersama lintas cabang: cache key BuildKit memuat checksum berkas sumber, sehingga rekaman dari cabang lain aman dipakai selama bahan kuncinya memang cocok.
Varian build-push-action #
Job build toko tidak memanggil CLI secara langsung; job memakai docker/build-push-action, dan action itu menerima dua input yang memetakan flag di atas satu per satu:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build dan push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/budi/toko:main
cache-from: type=registry,ref=ghcr.io/budi/toko:buildcache
cache-to: type=registry,ref=ghcr.io/budi/toko:buildcache,mode=maxMari kita telaah tiga bagian yang membedakan blok ini dari workflow lama:
cache-fromdancache-to— nama inputnya memakai tanda hubung, nilainya identik dengan flag CLI; keduanya tetap menunjuk tagbuildcacheyang sama, sehingga CLI dan action bisa dipakai bergantian tanpa memecah simpanan.permissions: packages: write— GITHUB_TOKEN bawaan harus diberi hak tulis paket supaya boleh mendorong image sekaligus manifest cache ke GHCR; scope sesempat ini adalah kebiasaan token minimal dari Chapter Supply Chain Security.tags: ghcr.io/budi/toko:main— pipeline pada tahap ini masih mendorong tagmainsecara langsung; disiplin “tag sha lebih dulu, tag cantik belakangan” baru dipasang pada chapter berikutnya bersama gerbang scan.
type=gha: Cache di Layanan GitHub Actions #
GitHub menyediakan layanan cache internal yang dipakai berbagai action untuk dependensi build, dan buildx bisa menumpang layanan yang sama lewat type=gha. Pilihan ini memiliki tiga sifat yang membedakannya dari cache registry. Pertama, token otomatis: runner GitHub-hosted menyuntikkan kredensial layanan cache ke proses build tanpa satu baris konfigurasi, sehingga tidak perlu langkah login tambahan. Kedua, kuota 10GB per repositori dengan eviksi LRU — begitu simpanan melewati batas, rekaman yang paling lama tidak terpakai dihapus lebih dahulu. Ketiga, cache terikat cabang: rekaman yang ditulis dari cabang fitur hanya terbaca dari cabang itu, sedangkan rekaman yang ditulis dari default branch terbaca oleh semua cabang; pull request dari cabang baru memulai simpanan sendiri dan menyatu ke simpanan umum begitu merge.
- name: Build dan push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/budi/toko:main
cache-from: type=gha
cache-to: type=gha,mode=maxBlok di atas adalah seluruh konfigurasinya — dua baris input yang menggantikan pasangan type=registry pada contoh sebelumnya. Perhatikan bahwa mode=max tetap berlaku dengan cara yang persis sama; parameter cakupan adalah milik cache-to, bukan milik lokasi simpanan. Kesederhanaan seperti ini membuat type=gha menjadi pilihan default yang wajar untuk project yang seluruh pipeline-nya hidup di GitHub.
Memilih Lokasi Simpanan #
Tiga lokasi simpanan kini tersedia, dan masing-masing menempati titik yang berbeda pada pertukaran kecepatan, batas, dan jangkauan:
| Tipe cache | Lokasi simpan | Kecepatan | Batas | Cocok untuk |
|---|---|---|---|---|
local | disk builder | tercepat — layer sudah di mesin | hilang begitu runner dihancurkan | laptop; runner self-hosted yang disknya bertahan |
type=gha | layanan cache GitHub Actions | cepat — lewat jaringan internal GitHub | kuota 10GB per repositori, eviksi LRU, terikat cabang | workflow yang seluruhnya hidup di GitHub |
type=registry | registry, di balik tag buildcache | paling lambat — layer bolak-balik lewat jaringan registry | kuota penyimpanan registry; hidup selama tag tidak dihapus | cache lintas platform CI, simpanan besar, pipeline yang berpindah runner |
Toko memilih type=registry dengan alasan yang jujur pada baris terakhir tabel: simpanan dependensi toko berukuran besar, dan tim ingin simpanan yang tetap terpakai bila suatu hari sebagian build berpindah — misalnya ke runner self-hosted atau platform CI lain. Project yang lebih ramping dengan pipeline yang sepenuhnya di GitHub akan lebih bahagia dengan type=gha yang tidak menuntut perawatan apa pun. Yang tidak boleh dipilih adalah berharap cache local bertahan di runner ephemeral — harapan yang bertentangan langsung dengan cara kerja runner itu sendiri.
mode=min versus mode=max #
Parameter mode pada cache-to menentukan bagian mana dari build yang diekspor, dan perbedaannya nyaris tak terasa pada Dockerfile satu stage serta sangat terasa pada multi-stage. Dockerfile toko — warisan Chapter Multi-Stage Build — memiliki tiga stage:
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]Stage deps dan build adalah stage antara: hasilnya disalin lewat COPY --from lalu stage itu dibuang dari image akhir. Dua mode menangani stage antara dengan cara yang berlawanan:
| Mode | Yang diekspor ke cache | Akibat pada run berikutnya | Ukuran cache |
|---|---|---|---|
min (bawaan) | hanya layer yang menjadi image final — stage runtime | RUN npm ci di stage deps dieksekusi ulang penuh | kecil |
max | seluruh stage, termasuk deps dan build | RUN npm ci dilayani CACHED | besar |
Default-nya adalah mode=min: hanya layer dari stage yang menjadi image akhir yang masuk simpanan. Konsekuensinya fatal bagi tujuan kita — layer npm ci, justru layer termahal yang ingin kita simpan, hidup di stage deps yang tidak diekspor, sehingga setiap run berikutnya tetap memasang dependensi dari nol. mode=max memerintahkan buildx mengekspor rekaman seluruh stage, stage antara termasuk, dengan COPY --from beserta hasilnya. Harganya adalah ukuran simpanan yang lebih besar — untuk toko, ratusan megabyte ekstra di registry adalah pembayaran yang murah dibanding tiga setengah menit npm ci pada setiap run.
Dari Delapan Menit ke Sembilan Puluh Detik #
Bukti akhirnya tampak pada run kedua setelah cache registry mode=max aktif — skenario suntingan kode kecil di cabang main, kasus yang paling sering terjadi:
$ docker buildx build \
--cache-from type=registry,ref=ghcr.io/budi/toko:buildcache \
--cache-to type=registry,ref=ghcr.io/budi/toko:buildcache,mode=max \
-t ghcr.io/budi/toko:main \
--push .Output yang Dihasilkan (dipotong):
[+] Building 92.4s (19/19) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 512B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 96B 0.0s
=> [auth] budi/toko:pull token for ghcr.io 0.7s
=> [internal] load metadata for docker.io/library/node:22-alpine 1.0s
=> [internal] load build context 0.2s
=> => transferring context: 1.87MB 0.1s
=> importing cache manifest from ghcr.io/budi/toko:buildcache 2.1s
=> [deps 1/4] FROM docker.io/library/node:22-alpine 0.0s
=> => resolve docker.io/library/node:22-alpine 1.0s
=> CACHED [deps 2/4] WORKDIR /app 0.0s
=> CACHED [deps 3/4] COPY package.json package-lock.json ./ 0.0s
=> CACHED [deps 4/4] RUN npm ci 0.0s
=> CACHED [build 1/3] COPY --from=deps /app/node_modules ./node_modules 0.0s
=> [build 2/3] COPY . . 0.2s
=> [build 3/3] RUN npm run build 11.8s
=> CACHED [runtime 1/4] FROM docker.io/library/node:22-alpine 0.0s
=> CACHED [runtime 2/4] WORKDIR /app 0.0s
=> CACHED [runtime 3/4] COPY --from=deps /app/node_modules ./node_modules 0.0s
=> CACHED [runtime 4/4] COPY --from=build /app/dist ./dist 0.0s
=> exporting to image 0.3s
=> => exporting layers 0.1s
=> => writing image sha256:c81f4e2b9d6a3c0f5e8b2d7a4c9f1e6b3d8a5c2f7e4b1d9a6c3f0e5b8d2a7c4f 0.0s
=> => naming to ghcr.io/budi/toko:main 0.0s
=> exporting cache 9.4s
=> => preparing build cache for export 0.2sMari kita telaah baris-baris kunci pada output tersebut:
importing cache manifest from ghcr.io/budi/toko:buildcacheselama 2.1s — biaya mengimpor peta cache dari registry; jauh lebih murah daripada mengunduh layer, dan itulah seluruh “sihir” yang dibutuhkan run ini.CACHED [deps 4/4] RUN npm ci— layer termahal dilayani dari simpanan; suntingan kode tidak menyentuh checksumpackage-lock.jsonsehingga rantai cache stagedepsutuh, persis mekanisme invalidasi yang kita bedah di Chapter Build Cache.[build 3/3] RUN npm run buildselama 11.8s — satu-satunya langkah berat yang benar-benar dieksekusi ulang: kode berubah membuatCOPY . .gugur, dannpm run buildyang menempel di bawahnya ikut dieksekusi kembali.exporting cacheselama 9.4s — run ini sekaligus mengekspor rekaman baru, termasuk layer build yang baru, sebagai warisan bagi run berikutnya.- Total 92.4 detik — dari delapan menit menjadi sekitar satu setengah menit; sisa waktunya habis untuk tarif langkah internal, transfer context 1.87MB, dan ekspor.
Efek kumulatifnya terbaca dalam satu tabel:
| Langkah | Run tanpa cache | Run dengan cache registry mode=max |
|---|---|---|
| Tarik basis dan context | 55s | 18s |
| Impor cache dari registry | — | 2s |
npm ci (stage deps) | 3m40s | CACHED |
npm run build | 2m05s | 12s |
| Ekspor image dan cache | 1m20s | 40s |
| Total | sekitar 8 menit | sekitar 1,5 menit |
Seluruh angka pada tabel di atas bersifat ilustratif untuk proyek seukuran toko — bukan hasil pengukuran tercatat; hasil nyata bergantung pada ukuran dependensi, kecepatan jaringan runner, dan berapa banyak berkas yang berubah. Arah perubahannya yang penting: pemasangan dependensi berpindah dari kolom “selalu dibayar” menjadi kolom “hampir tidak pernah dibayar”.
Merawat Simpanan Cache #
Simpanan cache registry tumbuh diam-diam. Setiap run menimpa manifest pada tag buildcache, tetapi blob lama tidak ikut terhapus dari penyimpanan registry — layer yang sudah tidak dirujuk manifest mana pun tetap menunggu di kuota. Kebiasaan perawatan yang sederhana: sebulan sekali hapus tag buildcache dari antarmuka GHCR atau lewat skrip kecil yang memakai API registry, lalu biarkan run berikutnya mengisi ulang simpanan dari nol. Rotasi ini juga membatasi umur rekaman cache menjadi satu bulan — sifat yang akan kita hargai pada bagian berikutnya.
type=gha merawat dirinya sendiri: eviksi LRU memangkas rekaman tertua begitu kuota 10GB terlampaui, dan scope cabang menyapu bersih simpanan cabang yang sudah dihapus. Pekerjaan manual yang tersisa hanya memantau pemakaian cache pada halaman Actions repositori, dan memutuskan kapan simpanan yang membesar layak di-reset dengan menghapusnya dari daftar cache. Kedua jenis perawatan bermuara pada prinsip yang sama: cache adalah simpanan yang boleh hilang kapan pun tanpa merusak apa pun — build berikutnya cukup membayar harga pengisian ulang.
Cache Poisoning: Ketika Simpanan Menjadi Pintu Masuk #
Cache pada dasarnya adalah kode orang lain yang kita jalankan ulang tanpa memeriksa isinya. Skenario penyerangannya bersifat teoretis, tetapi arsitekturnya nyata: bila kredensial push ke registry bocor atau akun penyimpan cache disusupi, penyerang dapat mendorong manifest buildcache palsu yang layer-layernya memuat berkas racun. Build berikutnya mengimpor manifest itu sebagai simpanan tepercaya, dan layer berbahaya menyusup ke dalam image tanpa pernah tampil satu baris pun di Dockerfile — pembaca Dockerfile tidak akan menemukan jejaknya karena jejak itu memang tidak ada di Dockerfile.
Skenario ini belum pernah terjadi pada toko, dan kemungkinan besarnya tidak akan pernah — tetapi kepercayaan terhadap registry harus selalu dibayar dengan kontrol akses yang bisa diverifikasi. Ada pula lapis pertahanan yang bekerja setelah fakta: image hasil build tetap melewati pemindaian kerentanan, dan gerbang pemindaian di CI adalah alat yang paling tepat untuk menangkap isi asing apa pun yang menyusup lewat pintu manapun. Membangun gerbang itulah agenda chapter berikutnya.
Pipeline toko kini menuntaskan delapan menit menjadi sekitar sembilan puluh detik, dan kecepatan itu bukan lagi alasan menunda merge. Namun di workflow yang sama masih bersembunyi satu langkah yang tidak pernah bisa berkata “tidak”: langkah scan yang diwariskan dari pipeline lama hanya mencetak tabel temuan lalu keluar dengan status sukses — hijau tak peduli berapa banyak kerentanan kritis yang ditemukannya. Laporan yang tidak bisa menggagalkan apa pun hanyalah hiasan; keamanan menuntut gerbang. Chapter Image Scanning di CI/CD mengubah pemindaian dari penonton menjadi penjaga pintu: kebijakan exit-code yang menutup jalur rilis sebelum image bermasalah sempat menerima nama yang manis.
Referensi #
- Build cache — mekanisme cache key BuildKit, pemicu invalidasi, dan pilihan lokasi simpanan cache.
- Docker di GitHub Actions — integrasi buildx dengan workflow GitHub Actions termasuk opsi cache.
- Caching dependencies to speed up workflows — kuota 10GB, eviksi LRU, dan scope cache per cabang pada layanan cache GitHub Actions.