Membangun Image
Dockerfile, Multi-stage & Entrypoint
Setelah memahami apa itu image, kini kita merakitnya sendiri: menulis Dockerfile yang cepat di-cache, memangkasnya dari ratusan MB jadi belasan MB dengan multi-stage, dan memastikan binary Go-mu berhenti dengan rapi saat di-deploy ulang.
Di Chapter 1 kita melihat image sebagai tumpukan layer beku. Chapter ini adalah satu busur “rakit image yang benar untuk service Go”: mulai dari Dockerfile dasar yang sadar cache, lalu perkecil drastis dengan multi-stage build dan base distroless, dan tutup dengan ENTRYPOINT/CMD yang membuat proses di dalam container berhenti bersih saat orchestrator menghentikannya. Ketiganya membangun satu artefak: image produksi Go yang kecil, aman, dan berperilaku benar.
Dockerfile: Resep Image
FROM sampai CMD, build context, dan .dockerignore
Dockerfile adalah resep deklaratif: deretan instruksi yang Docker eksekusi dari atas ke bawah untuk merakit sebuah image.
Setiap instruksi menambah satu layer (ingat Chapter 1), dan urutannya bukan sekadar gaya, ia menentukan seberapa sering cache build kamu meleset. Mari kenali instruksi inti lebih dulu, lalu bahas kenapa urutan itu penting.
| Instruksi | Fungsi |
|---|---|
FROM | Image dasar tempat semuanya ditumpuk |
WORKDIR | Set direktori kerja (dan membuatnya bila perlu) |
COPY | Salin file dari build context ke image |
RUN | Jalankan perintah saat build (compile, install) |
ENV | Set variabel environment di image |
EXPOSE | Dokumentasi port yang didengarkan (tidak membuka port) |
CMD | Perintah default saat container dijalankan |
Saat kamu menjalankan docker build ., titik di akhir itu adalah build context: seluruh isi folder tersebut di-paket dan dikirim ke daemon Docker sebelum build dimulai. Kalau folder itu berisi .git, node_modules, atau binary lokal berukuran ratusan MB, semuanya ikut terkirim, build jadi lambat dan ada risiko file rahasia (seperti .env) bocor masuk ke image.
flowchart LR A[Folder proyek] -->|paket build context| B[Docker daemon] B -->|baca Dockerfile| C[Rakit layer] C --> D[Image jadi] E[.dockerignore] -.->|saring sebelum kirim| A
Build context dikirim ke daemon. .dockerignore menyaring file sebelum apa pun dikirim, jadi konteks tetap ramping.
Solusinya .dockerignore: daftar pola file yang tidak ikut dikirim sebagai build context. Polanya mirip .gitignore, tapi tujuannya berbeda.
.gitignore mencegah file masuk riwayat Git; .dockerignore mencegah file masuk build context dan image. Sintaks pola-nya mirip, tapi keduanya independen, kamu tetap perlu .dockerignore sendiri meski sudah punya .gitignore.
.dockerignore.git node_modules *.env .env dist/ tmp/ skincare-backend # binary hasil build lokal Dockerfile .dockerignore
Sekarang resep paling sederhana untuk API Go kita, satu tahap (multi-stage menyusul di section berikutnya). Perhatikan urutan COPY-nya.
DockerfileFROM golang:1.26 WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN go build -o /skincare-api ./cmd/server EXPOSE 8080 CMD ["/skincare-api"]
Kenapa go.mod dan go.sum disalin lebih dulu, terpisah dari COPY . .? Karena cache. Docker meng-cache setiap layer berdasarkan input instruksinya. Layer RUN go mod download hanya akan di-build ulang bila go.mod/go.sum berubah. Selama dependency tetap, mengubah satu file handler di internal/product/ tidak membatalkan cache download dependency, build berikutnya lompat langsung ke go build. Kalau kamu menulis COPY . . sebelum go mod download, perubahan kode apa pun akan membuang cache dependency dan mengunduh ulang semua modul tiap build.
Susun instruksi dari yang paling stabil (base image, dependency) ke yang paling sering berubah (kode aplikasi). Semakin tinggi sebuah layer di-cache awet, semakin banyak build di bawahnya yang ikut cepat.
EXPOSE 8080 tidak benar-benar membuka port ke host; ia hanya menandai port yang dipakai container. Yang memetakan port ke host adalah flag -p saat docker run (dibahas di Chapter 1).
Resep di atas berfungsi, tapi ada satu masalah serius: image yang dihasilkan gemuk. Mari perbaiki itu.
Multi-stage Build untuk Go
Image kecil: pisahkan tahap compile dan runtime
Multi-stage build memisahkan tahap meng-compile dari tahap menjalankan, sehingga toolchain Go yang berat tidak pernah ikut ke image produksi.
Image single-stage tadi berfungsi, tapi gemuk: berbasis golang:1.26 ia bisa mencapai sekitar satu gigabyte karena membawa compiler Go, git, header C, dan ratusan MB tooling yang hanya berguna saat build, sama sekali tidak dibutuhkan untuk menjalankan binary yang sudah jadi. Membawa semua itu ke produksi berarti image besar (lambat di-pull, lambat deploy) dan permukaan serangan yang luas (makin banyak paket, makin banyak CVE potensial).
Ide multi-stage: pakai satu stage golang untuk compile, lalu mulai stage runtime baru dari image super-minimal dan COPY --from hanya binary-nya. Semua isi stage build (compiler, source, cache) ditinggal dan tidak ikut ke image akhir. Hasilnya dramatis: image yang tadinya sekitar 1 GB bisa turun ke kisaran belasan MB.
Di React kamu menjalankan build (Vite, webpack) lalu hanya men-deploy folder dist; Node, dependency dev, dan source map dibuang. Multi-stage Go persis ide yang sama: build dengan toolchain penuh, lalu kirim hanya binary hasilnya ke runtime.
flowchart LR
subgraph S1[Stage build: golang:1.26]
A[go mod download] --> B[go build CGO_ENABLED=0]
B --> C[/skincare-api binary/]
end
C -->|COPY --from=build| D
subgraph S2[Stage runtime: distroless static]
D[/api/] --> E[Image kecil, non-root]
endDua tahap, satu artifact. Hanya binary yang menyeberang dari stage build ke stage runtime.
Berikut Dockerfile multi-stage untuk skincare-api kita. Modul Go memakai github.com/kamu/skincare-backend.
Dockerfile# --- build --- FROM golang:1.26 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/api ./cmd/server # --- runtime --- FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /app/api /api USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/api"]
Dua detail krusial di stage build. CGO_ENABLED=0 mematikan linking ke pustaka C, sehingga Go menghasilkan binary statis yang tidak bergantung pada glibc apa pun, syarat agar bisa berjalan di image kosong seperti distroless static atau scratch. Flag -ldflags="-s -w" membuang tabel simbol dan info debug, memangkas ukuran binary tanpa mengubah perilakunya.
Untuk runtime kita pakai gcr.io/distroless/static-debian12 dengan tag :nonroot. Distroless adalah image yang hanya berisi runtime minimal: tidak ada shell, tidak ada package manager, tidak ada bin/sh. Image static distroless sendiri hanya sekitar 2 MB; ditambah binary Go-mu, image akhir tipikalnya berkisar 12 sampai 15 MB, dibanding hampir 1 GB versi single-stage. USER nonroot:nonroot membuat proses berjalan sebagai user tak-berhak, sehingga andai ada celah di aplikasi, penyerang tidak otomatis jadi root di dalam container.
Memilih base image runtime
Distroless bukan satu-satunya pilihan. Tiga base yang paling sering dipakai untuk biner Go statis berbeda pada satu sumbu: seberapa banyak yang ikut, dan apakah ada shell untuk debugging.
scratch (~0 MB)
Benar-benar kosong, hanya binary-mu. Terkecil dan paling aman, tapi tanpa CA cert dan timezone, jadi panggilan HTTPS keluar bisa gagal kecuali kamu menyalin cert sendiri.
distroless static (~2 MB)
Kosong tapi sudah berisi CA cert, timezone, user nonroot, dan /tmp. Pilihan default yang sehat untuk biner Go statis di produksi.
alpine (~6 MB)
Distro Linux mini lengkap dengan /bin/sh dan apk. Sedikit lebih besar, tapi punya shell untuk docker exec saat debugging mendesak.
Selalu pin tag base image (mis. golang:1.26, bukan golang:latest) agar build reprodusibel. Untuk jaminan penuh, pin sampai digest: FROM gcr.io/distroless/static-debian12@sha256:.... Tag bisa digeser pemiliknya kapan saja; digest adalah byte yang persis sama selamanya.
Karena tidak ada bin/sh, kamu tidak bisa docker exec lalu masuk ke shell untuk debugging, dan HEALTHCHECK harus exec-form yang memanggil binary-mu langsung, bukan perintah shell. Bila kamu butuh shell ringan, alpine adalah kompromi yang masuk akal.
Mari buktikan pemangkasan ukurannya dengan tanganmu sendiri.
Jalankan docker build -t skincare-api:dev . di folder proyek. BuildKit akan menjalankan stage build, lalu menyalin hanya binary ke stage runtime distroless.
docker images skincare-api menampilkan kolom SIZE; untuk biner Go statis di distroless, angkanya tipikal belasan MB, bukan ratusan.
Build sekali lagi versi single-stage golang:1.26 di tag berbeda dan bandingkan SIZE-nya; selisihnya yang membuat pull di CI dan deploy ke server jauh lebih cepat.
Image sudah kecil dan aman. Tapi ada satu pertanyaan yang belum dijawab: saat container dijalankan, proses apa yang menjadi PID 1, dan apa ia berhenti dengan benar?
CMD vs ENTRYPOINT
Menentukan proses default container dengan benar
CMD dan ENTRYPOINT sama-sama menetapkan proses default, tapi keduanya menjawab pertanyaan yang berbeda: “apa yang dijalankan” versus “ini memang program apa”.
Sebuah image perlu tahu satu hal saat docker run dipanggil tanpa argumen: perintah apa yang menjadi proses pertama di dalam container. Di Go, jawabannya hampir selalu binary tunggal hasil build, misalnya server API skincare. Dua instruksi mengatur ini, dan memilih yang tepat menentukan apakah container kamu mau berhenti dengan rapi saat di-deploy ulang.
- Memberi perintah default yang mudah ditimpa dari
docker run image arg. - Cocok untuk image serba-guna: default jalankan server, tapi pengguna bebas mengganti dengan
shatau perintah lain. - Argumen di
docker runmengganti seluruh CMD.
- Menetapkan program yang selalu jalan, container “adalah” binary itu.
- Cocok saat image punya satu tujuan jelas: ini server, titik.
- Argumen di
docker runmenjadi argumen tambahan untuk ENTRYPOINT, bukan menggantinya.
Pola paling kokoh untuk service Go: pakai ENTRYPOINT untuk binary dan CMD untuk argumen default. ENTRYPOINT mengunci “ini server”, sementara CMD memberi flag default yang masih bisa diganti tanpa menyentuh binary-nya.
DockerfileENTRYPOINT ["/api"] CMD ["--http-addr=:8080"]
Dengan kombinasi di atas, docker run img menjalankan /api --http-addr=:8080. Sementara docker run img --http-addr=:9090 menjalankan /api --http-addr=:9090: binary tetap, hanya argumennya yang ditimpa. Inilah yang membuat satu image bisa dipakai untuk port berbeda tanpa rebuild.
Yang krusial adalah perbedaan exec form dan shell form. Exec form (array JSON, ["/api"]) menjalankan binary secara langsung sebagai PID 1. Shell form (string, "/api") diam-diam dibungkus menjadi /bin/sh -c "/api", sehingga PID 1 adalah sh, dan binary Go kamu hanya anak proses.
flowchart TB
subgraph exec["Exec form: ENTRYPOINT ["/api"]"]
K1[Kernel] -->|SIGTERM| P1["PID 1 = /api"]
P1 -->|graceful shutdown| OK[Tutup koneksi, drain]
end
subgraph shell["Shell form: ENTRYPOINT "/api""]
K2[Kernel] -->|SIGTERM| S1["PID 1 = /bin/sh"]
S1 -.->|sinyal tidak diteruskan| P2["/api anak proses"]
P2 -.->|tidak tahu mau stop| KILL["dipaksa SIGKILL setelah timeout"]
endJalur sinyal SIGTERM. Hanya exec form yang menjadikan binary Go sebagai PID 1, sehingga ia menerima sinyal stop dan bisa shutdown bersih.
Kenapa ini penting di produksi? Saat orchestrator (Compose, Kubernetes, ECS) ingin menghentikan container, misalnya selama rolling deploy versi baru, ia mengirim SIGTERM ke PID 1, menunggu beberapa detik, lalu SIGKILL paksa bila belum mati. Server Go yang baik menangkap SIGTERM untuk menghentikan terima request baru, menyelesaikan request yang sedang jalan, lalu menutup koneksi database. Itu semua hanya terjadi bila SIGTERM benar-benar sampai ke binary kamu, dan itulah yang membedakan deploy mulus tanpa request terputus dari deploy yang memutus transaksi checkout pelanggan di tengah jalan.
cmd/server/main.goctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("listen: %v", err) } }() <-ctx.Done() // SIGTERM tiba di sini (hanya jika binary PID 1) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = srv.Shutdown(shutdownCtx)
Tulis ENTRYPOINT "/api" (string) dan PID 1 menjadi /bin/sh, bukan binary kamu; sh tidak meneruskan SIGTERM, jadi signal.NotifyContext di Go tidak pernah terpicu dan container dibunuh paksa setelah timeout, memutus request yang sedang berjalan.
Pakai bentuk exec untuk ENTRYPOINT/CMD: ["/api"], bukan /api. Selain meneruskan sinyal dengan benar, ia juga menghindari binary distroless yang tidak punya /bin/sh sama sekali (shell form akan langsung gagal di sana).
Di proyek Node, npm start memetakan ke satu perintah default di package.json; ENTRYPOINT + CMD adalah versi container dari ide itu, tapi dengan konsekuensi sinyal yang nyata, karena proses ini benar-benar menjadi PID 1 di namespace-nya sendiri.
Ringkasan
Image Go yang kecil, aman, dan berperilaku benar
Chapter ini mengubah biner Go menjadi image produksi: resep yang sadar cache, ramping lewat multi-stage, dan berhenti dengan rapi lewat ENTRYPOINT exec-form.
Kita mulai dari Dockerfile sebagai resep berlapis, dengan dua trik kunci: .dockerignore menjaga build context ramping dan bebas secret, dan urutan COPY go.mod go.sum sebelum COPY . . menjaga cache dependency tetap awet. Lalu multi-stage memisahkan toolchain build dari runtime, memangkas image dari sekitar 1 GB ke belasan MB lewat CGO_ENABLED=0 plus base distroless static non-root. Terakhir, ENTRYPOINT exec-form memastikan binary Go menjadi PID 1, sehingga SIGTERM sampai dan graceful shutdown bekerja saat deploy ulang.
Yang Wajib Menempel
- Tiap instruksi Dockerfile menambah layer; urutkan dari yang jarang berubah ke yang sering agar cache awet.
COPY go.mod go.sumlalugo mod downloadsebelumCOPY . .mencegah download ulang dependency tiap kali kode berubah..dockerignoremenyaring build context: cegah.env,.git, dan binary lokal bocor ke image.- Multi-stage +
CGO_ENABLED=0menghasilkan biner statis yang muat di distroless static (~2 MB), image akhir belasan MB, bukan ratusan. - Pin tag base image, idealnya sampai digest
@sha256:, agar build benar-benar reproducible. - Pakai ENTRYPOINT exec-form (
["/api"]) agar binary jadi PID 1 dan SIGTERM memicu graceful shutdown; shell form mematikannya.
Image sudah jadi dan berperilaku benar. Tapi image yang sama harus bisa jalan beda config di tiap lingkungan, menyambung ke service lain, dan menyimpan data yang bertahan. Itu fokus Chapter 3: environment variable, networking, dan volume.