Binary Go yang statis sudah bisa disalin ke server mana pun, jadi mengapa repot membungkusnya lagi ke container? Jawabannya pada operasional, bukan portabilitas binary: Kubernetes menjadikan container satuan penjadwalan, konfigurasi berpindah bersama image, dan lingkungan uji bisa identik byte demi byte dengan production. Container image bukan mesin virtual — ia tumpukan layer berkas yang dieksekusi kernel Linux yang sama. Chapter ini membahas anatomi Dockerfile aplikasi Go, pilihan image dasar (scratch, alpine, distroless), dan pemeriksaan kesiapan sebelum aplikasi menerima trafik.
Image adalah Tumpukan Layer #
Setiap perintah Dockerfile (COPY, RUN) menghasilkan satu layer yang ditumpuk di atas layer sebelumnya. Dua image yang berbagi layer awal hanya menyimpan selisihnya — image berbasis sama hemat penyimpanan dan cepat didistribusikan. Ukuran image ditentukan image dasar (FROM), dan di sinilah aplikasi Go menang besar: binary statis tidak butuh runtime apa pun, sehingga image dasar bisa paling minimal. Perbandingan konseptualnya:
| Image dasar | Isi | Ukuran dasar (konseptual) |
|---|---|---|
golang:1.27 | Toolchain + build cache | ± 800 MB ke atas |
alpine | musl libc + package manager | ± 5 MB |
distroless/static | glibc minimal + CA cert, tanpa shell | ± 2 MB |
scratch | Kosong total | 0 MB |
Dockerfile Pertama #
Dockerfile adalah resep pembuatan image. Versi paling sederhana untuk sapa-server terlihat seperti ini:
FROM golang:1.27
WORKDIR /app
COPY . .
RUN go build -o sapa-server .
EXPOSE 8080
ENTRYPOINT ["/app/sapa-server"]Enam perintah tersebut membangun image yang berfungsi, tetapi membawa seluruh toolchain Go, source, dan build cache ke dalam image production — ukurannya ratusan megabyte padahal aplikasinya 2 MB. Perintah-perintahnya sendiri layak dihafal:
| Perintah | Peran |
|---|---|
FROM | Image dasar tumpukan layer |
WORKDIR | Direktori kerja untuk perintah selanjutnya |
COPY | Menyalin berkas dari konteks build ke image |
RUN | Mengeksekusi perintah saat build image |
EXPOSE | Dokumentasi port yang didengar aplikasi |
ENTRYPOINT | Perintah yang dijalankan saat container start |
Dua perintah terakhir sering tertukar. ENTRYPOINT menetapkan executable tetap — docker run sapa --flag menambahkan --flag ke belakang executable. CMD memberikan argumen bawaan yang masih bisa diganti seluruhnya ketika user menjalankan container dengan argumen lain. Konvensi umum: ENTRYPOINT untuk binary aplikasi, CMD untuk nilai bawaan yang bisa ditimpa.
Scratch, Alpine, dan Distroless #
Pilihan image runtime menentukan ukuran akhir dan perilaku aplikasi. scratch adalah titik nol: hanya binary statis kita — tanpa shell, tanpa apk. alpine menambah musl libc dan package manager, berguna bila aplikasi butuh alat tambahan. distroless menawarkan jalan tengah: glibc, CA certificate bawaan, tzdata, tetapi tanpa shell sehingga permukaan serangan tetap kecil.
Aplikasi tidak boleh berjalan sebagai root di dalam container. Direktif USER nonroot membuat proses berjalan sebagai user tanpa hak — sama prinsipnya dengan User=sapa pada systemd. Container root terasa aman karena terisolasi, tetapi container escape dan volume mount yang salah menjadikan proses root di dalamnya berbahaya.
Terakhir, variabel environment masuk lewat docker run -e APP_PORT=8080 ... atau direktif ENV di Dockerfile. ENV tercetak di dalam image (cocok untuk bawaan), sedangkan -e diberikan saat runtime (cocok untuk nilai per lingkungan) — pola yang sama dengan chapter Environment Production
.
Preflight Sebelum Menerima Trafik #
Satu jebakan klasik aplikasi ber-container: developer binding ke 127.0.0.1 karena itulah bawaan laptop, lalu trafik dari luar container tidak pernah sampai. Network container punya namespace sendiri — localhost di dalamnya merujuk ke container itu sendiri, bukan host. Program container-demo.go pada bagian bawah halaman memeriksa kesiapan sebelum aplikasi menyala, persis seperti health check yang menolak container tak layak:
package main
import (
"fmt"
"net"
"os"
"strconv"
)
// cek menyimpan satu hasil pemeriksaan kesiapan container.
type cek struct {
nama string
hasil string
lulus bool
}
func main() {
semua := []cek{
cekAlamat(os.Getenv("APP_ADDR")),
cekUser(),
cekTerisi("APP_LOG_LEVEL", os.Getenv("APP_LOG_LEVEL")),
}
fmt.Println("preflight container:")
gagal := 0
for _, c := range semua {
penanda := "[OK]"
if !c.lulus {
penanda = "[GAGAL]"
gagal++
}
fmt.Printf(" %-8s %s: %s\n", penanda, c.nama, c.hasil)
}
if gagal > 0 {
fmt.Printf("%d pemeriksaan gagal; aplikasi menolak start\n", gagal)
os.Exit(1)
}
fmt.Println("semua pemeriksaan lulus; aplikasi siap menerima trafik")
}
// cekAlamat memastikan APP_ADDR bisa dijangkau dari luar container.
func cekAlamat(alamat string) cek {
host, portStr, err := net.SplitHostPort(alamat)
if err != nil {
return cek{"APP_ADDR=" + alamat, "tidak bisa diurai: " + err.Error(), false}
}
if host == "127.0.0.1" || host == "localhost" {
return cek{"APP_ADDR=" + alamat, "host loopback tidak terjangkau dari luar container", false}
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 {
return cek{"APP_ADDR=" + alamat, "port tidak valid", false}
}
return cek{"APP_ADDR=" + alamat, "host " + host + ", port " + portStr, true}
}
// cekUser memastikan proses tidak berjalan sebagai root.
func cekUser() cek {
uid := os.Getuid()
if uid == 0 {
return cek{"uid=" + fmt.Sprint(uid), "proses berjalan sebagai root", false}
}
return cek{"uid=" + fmt.Sprint(uid), "bukan root", true}
}
// cekTerisi memastikan variabel wajib memiliki nilai.
func cekTerisi(nama, nilai string) cek {
if nilai == "" {
return cek{nama, "kosong", false}
}
return cek{nama, "terisi (" + nilai + ")", true}
}Output yang Dihasilkan:
preflight container:
[OK] APP_ADDR=0.0.0.0:8080: host 0.0.0.0, port 8080
[OK] uid=1001: bukan root
[OK] APP_LOG_LEVEL: terisi (info)
semua pemeriksaan lulus; aplikasi siap menerima trafikMari kita telaah bagian penting program di atas:
- Baris 17–21 (slice
semua): tiga pemeriksaan disusun sebagai data — menambah pemeriksaan baru cukup menambah satu baris, tanpa mengubah logika pencetakan. - Baris 30–34 (
penanda): status[OK]/[GAGAL]dicetak dengan lebar tetap%-8ssehingga kolom hasil sejajar. - Baris 35–38 (hitung gagal +
os.Exit(1)): satu pemeriksaan gagal sudah cukup menolak start — container ditandai gagal oleh orchestrator, bukan berjalan setengah hidup. - Baris 48–51 (loopback): alamat
127.0.0.1/localhostditolak dengan pesan penyebabnya; host0.0.0.0berarti semua interface di dalam container. - Baris 60–66 (
cekUser):os.Getuid()mengembalikan uid proses;0berarti root dan langsung gagal.
Bukti kegagalan alamat loopback — binary dibangun dulu supaya exit code terlihat (pola yang sama dengan chapter Environment Production ):
go build -o container-demo container-demo.go
APP_ADDR=127.0.0.1:8080 APP_LOG_LEVEL=info ./container-demo; echo "exit=$?"Output yang Dihasilkan:
preflight container:
[GAGAL] APP_ADDR=127.0.0.1:8080: host loopback tidak terjangkau dari luar container
[OK] uid=1001: bukan root
[OK] APP_LOG_LEVEL: terisi (info)
1 pemeriksaan gagal; aplikasi menolak start
exit=1Image naif dari Dockerfile pertama kita berfungsi, tetapi membawa ratusan megabyte toolchain yang tidak dipakai saat runtime. Chapter Docker Multi-Stage Build memisahkan dapur dari piring hasilnya.
Referensi #
- Dockerfile reference — dokumentasi resmi seluruh direktif Dockerfile.
- Dokumentasi package net
— referensi resmi
net.SplitHostPortyang dipakai pemeriksaan alamat. - Dokumentasi package os
— referensi resmi
os.Getuid,os.Getenv, danos.Exit.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
container-demo.go
Unduh berkaspackage main
import (
"fmt"
"net"
"os"
"strconv"
)
// cek menyimpan satu hasil pemeriksaan kesiapan container.
type cek struct {
nama string
hasil string
lulus bool
}
func main() {
semua := []cek{
cekAlamat(os.Getenv("APP_ADDR")),
cekUser(),
cekTerisi("APP_LOG_LEVEL", os.Getenv("APP_LOG_LEVEL")),
}
fmt.Println("preflight container:")
gagal := 0
for _, c := range semua {
penanda := "[OK]"
if !c.lulus {
penanda = "[GAGAL]"
gagal++
}
fmt.Printf(" %-8s %s: %s\n", penanda, c.nama, c.hasil)
}
if gagal > 0 {
fmt.Printf("%d pemeriksaan gagal; aplikasi menolak start\n", gagal)
os.Exit(1)
}
fmt.Println("semua pemeriksaan lulus; aplikasi siap menerima trafik")
}
// cekAlamat memastikan APP_ADDR bisa dijangkau dari luar container.
func cekAlamat(alamat string) cek {
host, portStr, err := net.SplitHostPort(alamat)
if err != nil {
return cek{"APP_ADDR=" + alamat, "tidak bisa diurai: " + err.Error(), false}
}
if host == "127.0.0.1" || host == "localhost" {
return cek{"APP_ADDR=" + alamat, "host loopback tidak terjangkau dari luar container", false}
}
port, err := strconv.Atoi(portStr)
if err != nil || port < 1 {
return cek{"APP_ADDR=" + alamat, "port tidak valid", false}
}
return cek{"APP_ADDR=" + alamat, "host " + host + ", port " + portStr, true}
}
// cekUser memastikan proses tidak berjalan sebagai root.
func cekUser() cek {
uid := os.Getuid()
if uid == 0 {
return cek{"uid=" + fmt.Sprint(uid), "proses berjalan sebagai root", false}
}
return cek{"uid=" + fmt.Sprint(uid), "bukan root", true}
}
// cekTerisi memastikan variabel wajib memiliki nilai.
func cekTerisi(nama, nilai string) cek {
if nilai == "" {
return cek{nama, "kosong", false}
}
return cek{nama, "terisi (" + nilai + ")", true}
}