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:

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

  1. FROM golang:1.27 AS build: memulai stage pertama dan memberi nama build supaya bisa dirujuk stage berikutnya. Seluruh compiler hanya hidup di stage ini.
  2. COPY go.mod go.sum ./ sebelum COPY . .: urutan ini disengaja dan menjadi kunci performa — penjelasannya di bagian berikutnya.
  3. RUN go mod download: mengunduh dependensi ke module cache dalam image. Karena layer ini hanya bergantung go.mod/go.sum, hasilnya terpakai ulang selama dependensi tidak berubah.
  4. ARG VERSION=dev: mendeklarasikan argumen build bernama VERSION dengan nilai bawaan dev — nilai riil diberikan lewat --build-arg saat build rilis.
  5. RUN CGO_ENABLED=0 go build ...: memakai seluruh kendali dari chapter Build Flags — linking statis untuk image minimal, -trimpath dan -s -w untuk binary bersih, serta -X main.version=${VERSION} untuk menanam versi.
  6. FROM gcr.io/distroless/static-debian12:nonroot: memulai ulang dari image dasar baru — seluruh layer stage build tidak ikut. Varian nonroot sudah menyertakan CA certificate (penting untuk HTTPS) dan user non-root.
  7. COPY --from=build --chmod=0755 /out/sapa-server /app/sapa-server: menyalin hanya satu berkas binary dari stage build; flag --chmod memastikan bit executable ikut terpasang.
  8. USER nonroot:nonroot dan EXPOSE 8080: proses berjalan tanpa hak istimewa, port terdokumentasi — kelanjutan disiplin dari chapter Environment Production .
  9. 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:

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

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

  1. Baris 10–16 (langkahRilis): setiap langkah menyimpan kunci — input yang dibandingkan Docker (hash berkas untuk COPY, teks perintah untuk RUN).
  2. Baris 19–31 (cacheMenentukan): sekali sebuah layer MISS, flag missBerantai menjadikan seluruh layer setelahnya MISS — Docker tidak bisa memakai layer lama yang dibangun di atas layer yang sudah berubah.
  3. Baris 38–41 (skenario): tabel kasus sehari-hari — skenario kedua adalah alasan COPY go.mod go.sum dipisah dari COPY . .: mengubah main.go tidak sampai menebang layer go 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:

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

text
.git
.env
*.md
Dockerfile
docker-compose*

Dengan resep lengkap, image dibangun dan dijalankan dengan dua perintah ini:

bash
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.3

Contoh tampilan proses build-nya (ilustratif — angka dan lama bergantung pada mesin, versi image dasar, dan isi source):

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

Ukuran akhirnya yang membuat multi-stage terasa — contoh tampilan (ilustratif):

text
REPOSITORY   TAG       IMAGE ID       SIZE
sapa         v1.2.3    4f2a9d1c8e77   9.3MB

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

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

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

multistage-demo.go

Unduh berkas
go
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()
	}
}