Cache sudah menuntaskan separuh misi section ini: build myapp kini berjalan 1.8 detik per iterasi. Separuh sisanya — image yang ramping — tidak bisa diselesaikan dengan urutan instruksi, karena biaya terbesarnya bukan aplikasi melainkan basis yang kita pilih: selama myapp berjalan di atas node:22-alpine, image 278MB adalah harga mati minimalnya. Kabar baiknya, Docker memiliki teknik untuk memisahkan “dapur” dari “hidangan”: bangun di atas image yang gemuk penuh alat, lalu kemas hasil akhirnya ke image lain yang ramping. Teknik multi-stage build ini paling dramatis pada bahasa yang menghasilkan binary mandiri seperti Go, dan Budi kebetulan baru membuka proyek kedua bernama katalog — layanan daftar produk kecil yang ditulis dengan Go — yang akan menjadi kelinci percobaan kita.
Masalah: Compiler Ikut Terkirim #
Berikut Dockerfile pertama yang Budi tulis untuk katalog — satu stage, pola yang sama dengan myapp:
FROM golang:1.24
WORKDIR /src
COPY *.go go.mod ./
RUN go build -o /app/server
CMD ["/app/server"]Mari kita telaah keempat instruksi di atas: basis golang:1.24 varian penuh membawa toolchain Go lengkap — kompiler, linker, dokumentasi, dan tool pengembangan lainnya; WORKDIR /src menyiapkan direktori kerja; COPY *.go go.mod ./ memasukkan sumber program; dan RUN go build menghasilkan binary server. Build ini berhasil tanpa keluhan, tetapi hasilnya membuat Siti — yang ditugasi menaruh image ke server — mengangkat alis:
$ docker images katalogOutput yang Dihasilkan:
REPOSITORY TAG IMAGE ID CREATED SIZE
katalog single 9b2f8a4d1c56 8 minutes ago 851MBMari kita telaah angka di atas:
- 851MB untuk satu layanan kecil. Seluruh toolchain Go ikut terkirim ke setiap server yang menjalankan image ini — padahal compiler sudah selesai bekerja saat build berakhir, dan server produksi tidak akan pernah mengompilasi apa pun.
- Aplikasi aslinya hanya butuh dua benda: binary
serversekitar 4.8MB dan runtime pustaka sistem yang mendasar. Sisanya — lebih dari 846MB — adalah koper alat yang tidak pernah dibuka lagi. - Masalah ini bukan keputusan
golang:1.24yang salah — kita memang butuh toolchain untukgo build. Masalahnya adalah satu stage yang dipaksa merangkap dua peran: dapur pengolahan dan piring sajian.
Proyek katalog: main.go dan go.mod #
Sebelum memperbaikinya, mari kita lihat seluruh isi proyek yang akan kita rakit ulang:
$ tree katalogOutput yang Dihasilkan:
katalog
├── Dockerfile
├── go.mod
└── main.gopackage main
import (
"encoding/json"
"log"
"net/http"
)
type Produk struct {
Kode string `json:"kode"`
Nama string `json:"nama"`
}
var produk = []Produk{
{Kode: "P001", Nama: "Buku Tulis"},
{Kode: "P002", Nama: "Pensil"},
}
func main() {
http.HandleFunc("/produk", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(produk)
})
log.Println("katalog mendengarkan permintaan di port 8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}module katalog
go 1.24Mari kita telaah kedua berkas di atas:
main.go— seluruh layanan dalam satu berkas: structProduk, potongan data dua produk, satu handler untuk rute/produkyang membalas JSON, dan listener di port 8080. Semuanya dari pustaka standar Go, tanpa dependensi eksternal —go.modpun cukup dua baris.go.mod— identitas modulkatalogdan versi bahasa 1.24. Tanpa dependensi eksternal, kita tidak membutuhkango mod downloadataugo.sum; seluruh kebutuhan build adalahmain.godango.moditu sendiri.- Konsekuensi untuk cache — kedua berkas ini berperan seperti
package.json-nya myapp: jarang berubah, layak menempati posisi atas rantai cache sebagaimana aturan emas Chapter Build Cache.
Dockerfile Dua Stage #
Inilah Dockerfile katalog versi multi-stage — perhatikan bahwa FROM sekarang muncul dua kali:
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY *.go go.mod ./
RUN go build -o /app/server
FROM alpine:3.20
COPY --from=builder /app/server /usr/local/bin/
USER nobody
CMD ["server"]Mari kita telaah tujuh baris penting di atas:
FROM golang:1.24-alpine AS builder— stage pertama diberi namabuilderlewat klausaAS. Basisnya sengaja ditukar ke varian alpine darigolangyang lebih ramping — ukuran stage perantara tidak ikut ke image final, jadi kriteria pemilihannya cukup “nyaman untuk bekerja”.WORKDIR /src— direktori kerja stage builder; sumber dan hasil kompilasi hidup di sini hanya selama build.COPY *.go go.mod ./— hanya berkas yang benar-benar dibutuhkan kompilasi yang masuk stage ini.RUN go build -o /app/server— kompiler bekerja dan melahirkan binary di/app/server. Setelah baris ini selesai, seluruh toolchain kehilangan tugasnya.FROM alpine:3.20— tumpukan baru dimulai dari nol: baris ini bukan lanjutan stage builder, melainkan akar stage kedua yang menjadi image final. Inilah pintu keluar “koper alat”: layer-layergolangtidak pernah menjadi bagian tumpukan kedua.COPY --from=builder /app/server /usr/local/bin/— satu-satunya jembatan antar stage: menyalin binary dari hasil stagebuilderke dalam stage final. Bentuknya sepertiCOPYbiasa, tetapi sumbernya adalah path di dalam stage lain, bukan di build context.USER nobodydanCMD ["server"]— container berjalan sebagai pengguna tanpa privilese bawaan alpine, dan binary ditemukan diPATHkarena mendarat di/usr/local/bin. Kedua kebiasaan ini kita kenali dari Chapter WORKDIR dan USER.
Build Sebelas Langkah #
Budi membangun ulang katalog dengan Dockerfile baru dan menandainya katalog:1.0:
$ docker build -t katalog:1.0 .Output yang Dihasilkan:
[+] Building 21.4s (11/11) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 292B 0.0s
=> [internal] load metadata for docker.io/library/golang:1.24-alpine 1.3s
=> [internal] load metadata for docker.io/library/alpine:3.20 0.8s
=> [1/7] FROM docker.io/library/golang:1.24-alpine 8.7s
=> => resolve docker.io/library/golang:1.24-alpine 1.3s
=> [2/7] WORKDIR /src 0.0s
=> [3/7] COPY *.go go.mod ./ 0.1s
=> [4/7] RUN go build -o /app/server 9.4s
=> [5/7] FROM docker.io/library/alpine:3.20 0.0s
=> => resolve docker.io/library/alpine:3.20 0.8s
=> [6/7] COPY --from=builder /app/server /usr/local/bin/ 0.1s
=> [7/7] USER nobody 0.0s
=> exporting to image 0.3s
=> => exporting layers 0.2s
=> => writing image sha256:c4a92f1e7b83d056e19a4c7f2b8e5d1f6a3c09e4b7d2f5a8c1e4b6d9f2a5c8e1b 0.1s
=> => naming to docker.io/library/katalog:1.0 0.0sMari kita telaah sebelas langkah pada output di atas:
- Satu pembacaan definisi —
load build definitionmemuat Dockerfile 292 byte yang kini memuat dua stage. - Dua pemeriksaan metadata — satu untuk
golang:1.24-alpine, satu untukalpine:3.20; BuildKit menyiapkan kedua basis secara paralel. Token registry untuk Docker Hub masih tersimpan dari sesi build myapp, sehingga tidak ada langkah[auth]pada output ini. [1/7] FROM golang:1.24-alpineselama 8.7s — basis builder diunduh satu kali; setelah itu ia hidup di penyimpanan lokal dan cache untuk build-build berikutnya.[4/7] RUN go buildselama 9.4s — kerja berat kompilasi tetap ada dan durasi sebesar itu memang wajar; yang berubah hanyalah ke mana hasilnya dibawa.[5/7] FROM alpine:3.20selama 0.0s — basis kedua sudah tersimpan lokal sejak Section Docker Image (image ID2676beef0bef); resolve 0.8 detik hanya memastikan digest-nya masih sah.[6/7] COPY --from=builder— binary 4.8MB menyeberang dari stage pertama ke stage kedua menjadi satu-satunya layer aplikasi di tumpukan final.[7/7] USER nobodylalu ekspor — metadata pengguna dicatat, tumpukan stage kedua dirakit menjadi image, dan tagkatalog:1.0ditempelkan. Seluruh 11 langkah — 3 langkah internal, 7 langkah Dockerfile, 1 ekspor — selesai dalam 21.4 detik.
Hasil: 851MB Menjadi 12.6MB #
Perbandingan kedua image katalog kini menutup argumen dengan angka:
$ docker images katalogOutput yang Dihasilkan:
REPOSITORY TAG IMAGE ID CREATED SIZE
katalog 1.0 c4a92f1e7b83 2 minutes ago 12.6MB
katalog single 9b2f8a4d1c56 8 minutes ago 851MBMari kita telaah barisan baru di atas:
- 12.6MB — penyusutan 67 kali lipat dari 851MB. Aritmatikanya transparan: binary 4.8MB di atas
alpine:3.20seukuran 7.8MB, dan keduanya memang seluruh isi image — tidak ada lagi toolchain, tidak ada kode sumber, tidak ada layer yang menganggur. - Dampak berlipat di produksi. Image yang 67 kali lebih kecil berarti tarik-unduh lebih cepat di setiap server, biaya penyimpanan registry yang lebih rendah, dan permukaan serangan yang jauh lebih sempit — container tidak membawa kompiler yang bisa disalahgunakan.
- Binary Go bersifat mandiri — program ini hanya memakai pustaka standar sehingga hasil
go buildtidak bergantung pada libc milik basis tertentu; itulah sebabnya binary dari builder alpine bisa langsung berjalan di atas alpine runtime.
Budi membuktikan layanannya benar-benar hidup dalam image mungil itu:
$ docker run -d --name katalog -p 8080:8080 katalog:1.0
$ curl localhost:8080/produkOutput yang Dihasilkan:
[{"kode":"P001","nama":"Buku Tulis"},{"kode":"P002","nama":"Pensil"}]Memilih Base Runtime #
Stage final tidak wajib alpine. Tiga pilihan basis runtime menjadi spektrum yang perlu kita kenali:
| Base runtime | Ukuran | Isi | Kapan pas dipakai |
|---|---|---|---|
alpine:3.20 | 7.8MB | musl libc, busybox, apk | Binary dinamis; butuh shell dan utilitas ringan |
scratch | 0B | kosong absolut | Binary statis murni; image minimum mutlak |
| distroless | sekitar 2-25MB sesuai varian | libc dan CA certificate, tanpa shell | Produksi yang butuh libc standar tanpa utilitas |
Stage Bertingkat: builder, tester, runtime #
Multi-stage tidak berhenti di dua tingkat. Budi menambahkan berkas main_test.go berisi pengujian kecil untuk katalog, lalu menyisipkan satu stage perantaranya:
FROM golang:1.24-alpine AS builder
WORKDIR /src
COPY *.go go.mod ./
RUN go build -o /app/server
FROM builder AS tester
RUN go test ./...
FROM alpine:3.20
COPY --from=tester /app/server /usr/local/bin/
USER nobody
CMD ["server"]Mari kita telaah susunan bertingkat di atas:
FROM builder AS tester— stagetestermewarisi seluruh hasil stagebuilder(kompiler, sumber, dan binary di/app/server), lalu menambahkan satu langkah: menjalankan pengujian.RUN go test ./...— seluruh pengujian dieksekusi selama build; bila satu saja gagal, build gagal, dan image produksi tidak pernah lahir dari kode yang tidak lulus uji.COPY --from=tester, bukan--from=builder— perubahan satu kata ini bukan gaya-gayaan, melainkan kunci gotcha berikut.
Berhenti di Stage Tertentu dengan –target #
Flag --target menghentikan build pada stage yang kita tunjuk — berguna untuk menguji satu tahap atau membangun image bantu:
$ docker build --target tester -t katalog:test .Output yang Dihasilkan:
[+] Building 3.2s (9/9) FINISHED
=> [internal] load build definition from Dockerfile 0.1s
=> => transferring dockerfile: 292B 0.0s
=> [internal] load metadata for docker.io/library/golang:1.24-alpine 0.9s
=> CACHED [1/4] FROM docker.io/library/golang:1.24-alpine 0.0s
=> CACHED [2/4] WORKDIR /src 0.0s
=> CACHED [3/4] COPY *.go go.mod ./ 0.0s
=> CACHED [4/4] RUN go build -o /app/server 0.0s
=> [5/5] RUN go test ./... 2.1s
=> exporting to image 0.2s
=> => exporting layers 0.1s
=> => writing image sha256:e7b3a9c5f1d82406e3b7c9f2a5d8e1b4c7f0a3d6e9b2c5f8a1d4e7b0c3f6a9d2e 0.1s
=> => naming to docker.io/library/katalog:test 0.0sMari kita telaah output di atas: empat langkah pertama dilayani CACHED dari build multi-stage sebelumnya — aturan emas bekerja lintas pemanggilan build, bukan hanya dalam satu Dockerfile — dan hanya RUN go test ./... yang benar-benar dieksekusi, 2.1 detik. Image katalog:test hasilnya adalah snapshot stage tester: basis golang lengkap plus binary, pas untuk menjalankan penyelidikan yang butuh alat Go, jauh dari layak dipakai sebagai image produksi.
Image final katalog kini setipis 12.6MB, dan perbandingan 851MB di sebelahnya menjadi museum. Seluruh sulap ini berjalan di atas satu engine yang sejak awal section bekerja diam-diam di balik docker build: BuildKit yang mengevaluasi DAG stage, menjalankan langkah paralel, dan menyalin hasil antar stage. Engine itu — beserta CLI-nya docker buildx, konsep builder dan driver, build multi-platform, dan fitur frontend seperti heredoc — pantas mendapat chapter perkenalan tersendiri sebelum kita melanjutkan ke trik cache berikutnya.
Referensi #
- Multi-Stage Builds
— pola penulisan stage, klausa
AS, danCOPY --fromsecara resmi. - Dockerfile Reference
— sintaks
FROM ... AS,COPY --from, dan--target. - Dockerfile Best Practices — panduan memilih basis dan menata stage.