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:

dockerfile
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:

bash
$ docker images katalog

Output yang Dihasilkan:

text
REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
katalog      single   9b2f8a4d1c56   8 minutes ago   851MB

Mari kita telaah angka di atas:

  1. 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.
  2. Aplikasi aslinya hanya butuh dua benda: binary server sekitar 4.8MB dan runtime pustaka sistem yang mendasar. Sisanya — lebih dari 846MB — adalah koper alat yang tidak pernah dibuka lagi.
  3. Masalah ini bukan keputusan golang:1.24 yang salah — kita memang butuh toolchain untuk go 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:

bash
$ tree katalog

Output yang Dihasilkan:

text
katalog
├── Dockerfile
├── go.mod
└── main.go
go
package 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))
}
text
module katalog

go 1.24

Mari kita telaah kedua berkas di atas:

  1. main.go — seluruh layanan dalam satu berkas: struct Produk, potongan data dua produk, satu handler untuk rute /produk yang membalas JSON, dan listener di port 8080. Semuanya dari pustaka standar Go, tanpa dependensi eksternal — go.mod pun cukup dua baris.
  2. go.mod — identitas modul katalog dan versi bahasa 1.24. Tanpa dependensi eksternal, kita tidak membutuhkan go mod download atau go.sum; seluruh kebutuhan build adalah main.go dan go.mod itu sendiri.
  3. 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:

dockerfile
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:

  1. FROM golang:1.24-alpine AS builder — stage pertama diberi nama builder lewat klausa AS. Basisnya sengaja ditukar ke varian alpine dari golang yang lebih ramping — ukuran stage perantara tidak ikut ke image final, jadi kriteria pemilihannya cukup “nyaman untuk bekerja”.
  2. WORKDIR /src — direktori kerja stage builder; sumber dan hasil kompilasi hidup di sini hanya selama build.
  3. COPY *.go go.mod ./ — hanya berkas yang benar-benar dibutuhkan kompilasi yang masuk stage ini.
  4. RUN go build -o /app/server — kompiler bekerja dan melahirkan binary di /app/server. Setelah baris ini selesai, seluruh toolchain kehilangan tugasnya.
  5. 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-layer golang tidak pernah menjadi bagian tumpukan kedua.
  6. COPY --from=builder /app/server /usr/local/bin/ — satu-satunya jembatan antar stage: menyalin binary dari hasil stage builder ke dalam stage final. Bentuknya seperti COPY biasa, tetapi sumbernya adalah path di dalam stage lain, bukan di build context.
  7. USER nobody dan CMD ["server"] — container berjalan sebagai pengguna tanpa privilese bawaan alpine, dan binary ditemukan di PATH karena 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:

bash
$ docker build -t katalog:1.0 .

Output yang Dihasilkan:

text
[+] 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.0s

Mari kita telaah sebelas langkah pada output di atas:

  1. Satu pembacaan definisi — load build definition memuat Dockerfile 292 byte yang kini memuat dua stage.
  2. Dua pemeriksaan metadata — satu untuk golang:1.24-alpine, satu untuk alpine: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.
  3. [1/7] FROM golang:1.24-alpine selama 8.7s — basis builder diunduh satu kali; setelah itu ia hidup di penyimpanan lokal dan cache untuk build-build berikutnya.
  4. [4/7] RUN go build selama 9.4s — kerja berat kompilasi tetap ada dan durasi sebesar itu memang wajar; yang berubah hanyalah ke mana hasilnya dibawa.
  5. [5/7] FROM alpine:3.20 selama 0.0s — basis kedua sudah tersimpan lokal sejak Section Docker Image (image ID 2676beef0bef); resolve 0.8 detik hanya memastikan digest-nya masih sah.
  6. [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/7] USER nobody lalu ekspor — metadata pengguna dicatat, tumpukan stage kedua dirakit menjadi image, dan tag katalog:1.0 ditempelkan. 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:

bash
$ docker images katalog

Output yang Dihasilkan:

text
REPOSITORY   TAG      IMAGE ID       CREATED         SIZE
katalog      1.0      c4a92f1e7b83   2 minutes ago   12.6MB
katalog      single   9b2f8a4d1c56   8 minutes ago   851MB

Mari kita telaah barisan baru di atas:

  1. 12.6MB — penyusutan 67 kali lipat dari 851MB. Aritmatikanya transparan: binary 4.8MB di atas alpine:3.20 seukuran 7.8MB, dan keduanya memang seluruh isi image — tidak ada lagi toolchain, tidak ada kode sumber, tidak ada layer yang menganggur.
  2. 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.
  3. Binary Go bersifat mandiri — program ini hanya memakai pustaka standar sehingga hasil go build tidak 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:

bash
$ docker run -d --name katalog -p 8080:8080 katalog:1.0
$ curl localhost:8080/produk

Output yang Dihasilkan:

text
[{"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 runtimeUkuranIsiKapan pas dipakai
alpine:3.207.8MBmusl libc, busybox, apkBinary dinamis; butuh shell dan utilitas ringan
scratch0Bkosong absolutBinary statis murni; image minimum mutlak
distrolesssekitar 2-25MB sesuai varianlibc dan CA certificate, tanpa shellProduksi 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:

dockerfile
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:

  1. FROM builder AS tester — stage tester mewarisi seluruh hasil stage builder (kompiler, sumber, dan binary di /app/server), lalu menambahkan satu langkah: menjalankan pengujian.
  2. 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.
  3. 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:

bash
$ docker build --target tester -t katalog:test .

Output yang Dihasilkan:

text
[+] 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.0s

Mari 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 #