Dockerfile sederhana pada chapter sebelumnya menghasilkan image yang berfungsi tetapi membawa seluruh toolchain Go, source, dan cache build — ratusan megabyte untuk aplikasi 2 MB. Dockerfile multi-stage memecahnya dengan tegas: stage pertama adalah dapur berisi compiler, stage kedua piring bersih berisi binary hasilnya, dan hanya stage terakhir menjadi image final. Hasilnya image beberapa megabyte yang identik antar build, tanpa shell, compiler, dan source. Chapter ini membedah Dockerfile multi-stage lengkap untuk sapa-server, urutan perintah yang menentukan kecepatan build harian, dan cara menyuntik versi rilis ke dalam image.
Dockerfile Multi-Stage #
Berkas Dockerfile pada bagian bawah halaman adalah resep lengkap dua stage — identik dengan blok berikut:
# syntax=docker/dockerfile:1
# Stage 1: builder dengan toolchain Go penuh.
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.version=${VERSION}" \
-o /out/sapa-server .
# Stage 2: image runtime minimal tanpa shell dan tanpa compiler.
FROM gcr.io/distroless/static-debian12:nonroot
WORKDIR /app
COPY --from=build --chmod=0755 /out/sapa-server /app/sapa-server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app/sapa-server"]Mari kita telaah perintah pentingnya satu per satu:
FROM golang:1.27 AS build: memulai stage pertama dan memberi namabuildsupaya bisa dirujuk stage berikutnya. Seluruh compiler hanya hidup di stage ini.COPY go.mod go.sum ./sebelumCOPY . .: urutan ini disengaja dan menjadi kunci performa — penjelasannya di bagian berikutnya.RUN go mod download: mengunduh dependensi ke module cache dalam image. Karena layer ini hanya bergantunggo.mod/go.sum, hasilnya terpakai ulang selama dependensi tidak berubah.ARG VERSION=dev: mendeklarasikan argumen build bernamaVERSIONdengan nilai bawaandev— nilai riil diberikan lewat--build-argsaat build rilis.RUN CGO_ENABLED=0 go build ...: memakai seluruh kendali dari chapter Build Flags — linking statis untuk image minimal,-trimpathdan-s -wuntuk binary bersih, serta-X main.version=${VERSION}untuk menanam versi.FROM gcr.io/distroless/static-debian12:nonroot: memulai ulang dari image dasar baru — seluruh layer stagebuildtidak ikut. Variannonrootsudah menyertakan CA certificate (penting untuk HTTPS) dan user non-root.COPY --from=build --chmod=0755 /out/sapa-server /app/sapa-server: menyalin hanya satu berkas binary dari stagebuild; flag--chmodmemastikan bit executable ikut terpasang.USER nonroot:nonrootdanEXPOSE 8080: proses berjalan tanpa hak istimewa, port terdokumentasi — kelanjutan disiplin dari chapter Environment Production .ENTRYPOINT: container menjalankan binary aplikasi begitu start, tanpa shell pembungkus.
Urutan Perintah Menentukan Kecepatan Build #
Docker membangun setiap perintah menjadi layer dan menyimpan hasilnya di cache. Saat build berikutnya, Docker membandingkan input tiap layer: bila input sama, layer diambil dari cache (HIT); bila berbeda, layer dan seluruh layer setelahnya dibangun ulang (MISS). Konsekuensinya: perintah yang jarang berubah harus ditulis lebih dulu. Program multistage-demo.go pada bagian bawah halaman menyimulasikan aturan ini:
package main
import "fmt"
// langkah merepresentasikan satu perintah pada stage build Dockerfile.
type langkah struct {
nama string // deskripsi perintah
kunci string // input yang menentukan status cache layer
}
// langkahRilis adalah urutan perintah stage build pada Dockerfile multi-stage.
var langkahRilis = []langkah{
{"FROM golang:1.27 AS build", "digest image dasar"},
{"COPY go.mod go.sum", "isi go.mod + go.sum"},
{"RUN go mod download", "daftar modul dari go.mod"},
{"COPY . .", "seluruh isi direktori"},
{"RUN go build", "source + flag build"},
}
// cacheMenentukan menghitung status cache tiap langkah dari layer yang berubah.
// Begitu satu layer MISS, seluruh layer setelahnya ikut dibangun ulang.
func cacheMenentukan(berubah map[string]bool) []string {
var status []string
missBerantai := false
for _, l := range langkahRilis {
if missBerantai || berubah[l.kunci] {
missBerantai = true
status = append(status, "MISS (build ulang)")
} else {
status = append(status, "HIT (dari cache)")
}
}
return status
}
func main() {
skenario := []struct {
nama string
berubah map[string]bool
}{
{"tidak ada berkas berubah", nil},
{"hanya main.go berubah", map[string]bool{"seluruh isi direktori": true}},
{"go.mod berubah (dependensi baru)", map[string]bool{"isi go.mod + go.sum": true}},
}
for _, s := range skenario {
fmt.Println("skenario:", s.nama)
status := cacheMenentukan(s.berubah)
for i, l := range langkahRilis {
fmt.Printf(" %-28s %s\n", l.nama, status[i])
}
fmt.Println()
}
}Output yang Dihasilkan:
skenario: tidak ada berkas berubah
FROM golang:1.27 AS build HIT (dari cache)
COPY go.mod go.sum HIT (dari cache)
RUN go mod download HIT (dari cache)
COPY . . HIT (dari cache)
RUN go build HIT (dari cache)
skenario: hanya main.go berubah
FROM golang:1.27 AS build HIT (dari cache)
COPY go.mod go.sum HIT (dari cache)
RUN go mod download HIT (dari cache)
COPY . . MISS (build ulang)
RUN go build MISS (build ulang)
skenario: go.mod berubah (dependensi baru)
FROM golang:1.27 AS build HIT (dari cache)
COPY go.mod go.sum MISS (build ulang)
RUN go mod download MISS (build ulang)
COPY . . MISS (build ulang)
RUN go build MISS (build ulang)Simulasi tersebut mengikuti aturan invalidasi cache Docker yang sesungguhnya. Mari kita telaah programnya:
- Baris 10–16 (
langkahRilis): setiap langkah menyimpankunci— input yang dibandingkan Docker (hash berkas untukCOPY, teks perintah untukRUN). - Baris 19–31 (
cacheMenentukan): sekali sebuah layer MISS, flagmissBerantaimenjadikan seluruh layer setelahnya MISS — Docker tidak bisa memakai layer lama yang dibangun di atas layer yang sudah berubah. - Baris 38–41 (skenario): tabel kasus sehari-hari — skenario kedua adalah alasan
COPY go.mod go.sumdipisah dariCOPY . .: mengubahmain.gotidak sampai menebang layergo mod download.
Skenario kedua terjadi puluhan kali sehari saat development, dan penghematannya nyata: layer dependensi tetap HIT sehingga build hanya mengompilasi ulang source tanpa mengunduh modul. BuildKit (frontend # syntax=docker/dockerfile:1) menambahkan cache mount yang bertahan antar build meski layer invalid:
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -o /out/sapa-server .Versi, Konteks, dan Menjalankan Build #
Nomor versi diberikan lewat --build-arg sehingga satu Dockerfile menghasilkan image dev maupun rilis tanpa mengubah source. Konteks build (direktori yang dikirim ke builder) dibersihkan lewat .dockerignore agar COPY . . tidak membawa berkas yang tidak perlu:
.git
.env
*.md
Dockerfile
docker-compose*Dengan resep lengkap, image dibangun dan dijalankan dengan dua perintah ini:
docker build --build-arg VERSION=1.2.3 -t sapa:v1.2.3 .
docker run --rm -p 8080:8080 \
-e APP_PORT=8080 -e APP_LOG_LEVEL=info \
sapa:v1.2.3Contoh tampilan proses build-nya (ilustratif — angka dan lama bergantung pada mesin, versi image dasar, dan isi source):
[+] Building 18.4s (12/12) FINISHED
=> [internal] load build definition from Dockerfile
=> [internal] load .dockerignore
=> [build 1/5] FROM golang:1.27
=> [build 2/5] WORKDIR /src
=> [build 3/5] COPY go.mod go.sum ./
=> [build 4/5] RUN go mod download
=> [build 5/5] RUN CGO_ENABLED=0 go build ...
=> [runtime 1/2] FROM gcr.io/distroless/static-debian12:nonroot
=> [runtime 2/2] COPY --from=build /out/sapa-server /app/sapa-server
=> exporting to image
=> => naming to docker.io/library/sapa:v1.2.3Ukuran akhirnya yang membuat multi-stage terasa — contoh tampilan (ilustratif):
REPOSITORY TAG IMAGE ID SIZE
sapa v1.2.3 4f2a9d1c8e77 9.3MBImage final ± 9 MB berisi binary, CA certificate, dan metadata — bukan ± 800 MB toolchain yang dibuang begitu stage pertama selesai. Bandingkan dengan image naif satu stage yang membawa seluruhnya ke production.
Terakhir, aplikasi multi-container (aplikasi + database + reverse proxy) didefinisikan dalam satu berkas compose.yaml dan dikelola dengan Docker Compose:
services:
sapa:
build:
context: .
args:
VERSION: "1.2.3"
environment:
APP_PORT: "8080"
APP_LOG_LEVEL: info
ports:
- "8080:8080"Image kecil dan dapat direproduksi kini siap didistribusikan. Masalah berikutnya: bagaimana proses berhenti saat container dimatikan tanpa memutus request yang sedang berjalan — chapter Graceful Shutdown di Production menjawabnya.
Referensi #
- Multi-stage builds — dokumentasi resmi pola multi-stage beserta contohnya.
- Dockerfile reference
— rujukan direktif
FROM ... AS,COPY --from,ARG, dan--mount. - Docker Compose — dokumentasi resmi definisi layanan multi-container.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
multistage-demo.go
Unduh berkaspackage main
import "fmt"
// langkah merepresentasikan satu perintah pada stage build Dockerfile.
type langkah struct {
nama string // deskripsi perintah
kunci string // input yang menentukan status cache layer
}
// langkahRilis adalah urutan perintah stage build pada Dockerfile multi-stage.
var langkahRilis = []langkah{
{"FROM golang:1.27 AS build", "digest image dasar"},
{"COPY go.mod go.sum", "isi go.mod + go.sum"},
{"RUN go mod download", "daftar modul dari go.mod"},
{"COPY . .", "seluruh isi direktori"},
{"RUN go build", "source + flag build"},
}
// cacheMenentukan menghitung status cache tiap langkah dari layer yang berubah.
// Begitu satu layer MISS, seluruh layer setelahnya ikut dibangun ulang.
func cacheMenentukan(berubah map[string]bool) []string {
var status []string
missBerantai := false
for _, l := range langkahRilis {
if missBerantai || berubah[l.kunci] {
missBerantai = true
status = append(status, "MISS (build ulang)")
} else {
status = append(status, "HIT (dari cache)")
}
}
return status
}
func main() {
skenario := []struct {
nama string
berubah map[string]bool
}{
{"tidak ada berkas berubah", nil},
{"hanya main.go berubah", map[string]bool{"seluruh isi direktori": true}},
{"go.mod berubah (dependensi baru)", map[string]bool{"isi go.mod + go.sum": true}},
}
for _, s := range skenario {
fmt.Println("skenario:", s.nama)
status := cacheMenentukan(s.berubah)
for i, l := range langkahRilis {
fmt.Printf(" %-28s %s\n", l.nama, status[i])
}
fmt.Println()
}
}