Golang #

Golang sering disebut bahasa paling “container-friendly”. Klaim ini tidak salah — Go mampu menghasilkan single static binary tanpa dependency runtime. Tapi klaim ini juga sering diam-diam disalahpahami: banyak image Go di production tetap 200-400 MB, padahal potensi aslinya adalah 5-15 MB. Kesenjangan antara potensi dan realita ini bukan karena Go gagal, tapi karena Dockerfile-nya gagal mengelola boundary antara build dan runtime.

Artikel ini membahas secara mendetail, realistis, dan berorientasi production bagaimana membangun Docker image Go yang kecil, aman, dan operasional. Kita akan lihat kenapa image Go mudah membengkak, strategi yang benar untuk mengatasinya, dan kapan harus berhenti optimasi karena sudah cukup.

1. Realita Ukuran Image Go #

Sebelum masuk strategi, mari kita lihat angka realistis di production. Ini bukan angka teoritis — ini yang biasanya muncul di tim yang baru mulai optimasi.

Setup Ukuran Image
golang:latest + runtime default 800-900 MB
golang:alpine runtime 300-400 MB
Multi-stage + alpine 20-40 MB
Multi-stage + distroless 10-25 MB
Static binary + scratch 5-15 MB

Insight penting: Selisih antara “image Go yang asal” (800 MB) dan “image Go yang optimal” (5-15 MB) adalah dua order of magnitude. Ini bukan optimasi 10% — ini perbedaan fundamental. Jika image Go kamu > 100 MB, hampir pasti ada yang salah dengan Dockerfile-nya: compiler bocor ke runtime, dependency tidak di-strip, atau base image terlalu besar.

Cara audit cepat:

docker history myapp:latest

Lihat layer mana yang paling besar. Biasanya culprit-nya adalah base image atau dependency build yang ikut terbawa.

2. Kenapa Go Bisa Sangat Kecil? #

Untuk menghargai image Go yang optimal, kamu perlu paham fitur Go yang memungkinkan hal itu.

Single Static Binary #

Tidak seperti Java (butuh JRE), Node.js (butuh runtime + node_modules), atau Python (butuh interpreter + packages), Go meng-compile aplikasi menjadi satu executable yang berisi semua kode aplikasi. Tidak ada runtime terpisah yang harus di-include.

Static Linking Opsional #

Secara default Go men-link binary ke glibc (di Linux). Tapi kamu bisa compile dengan CGO_ENABLED=0 untuk membuat fully static binary yang tidak butuh library OS apapun. Ini memungkinkan image sekecil scratch (0 byte base image, hanya binary kamu).

Tidak Ada Dependency Tree Runtime #

Tidak ada node_modules, tidak ada site-packages, tidak ada vendor/. Binary Go berisi semuanya.

Compiler Ada di Toolchain, Bukan di Output #

Go punya toolchain yang besar (go, compile, link, asm), tapi semua itu tidak masuk ke binary hasil compile. Binary hasil compile adalah produk akhir yang bersih.

3. Prinsip Utama: Boundary Build vs Runtime #

Runtime image Go idealnya hanya berisi binary aplikasi + CA certificates (untuk HTTPS) + time zone data (opsional).

Titik. Tidak ada compiler, tidak ada source code, tidak ada build cache, tidak ada package manager. Kontras dengan image yang sering kita lihat:

// ANTI-PATTERN: semua fase dicampur
FROM golang:latest
WORKDIR /app
COPY . .
RUN go build -o app
CMD ["./app"]
// Image sekarang punya: go toolchain, source code, build cache, GOPATH

Image seperti ini berfungsi di development, tapi untuk production ia membawa ribuan file yang tidak relevan dengan runtime. Ukuran membengkak, attack surface melebar, dan setiap deployment menarik lebih banyak data dari yang sebenarnya dibutuhkan.

4. Strategi Multi-Stage Build #

Multi-stage build adalah fondasi yang tidak bisa dihindari. Tanpa multi-stage, image Go akan selalu membawa tool build ke runtime. Ada tiga strategi utama yang umum dipakai, dari yang paling umum hingga yang paling ekstrem.

4.1 Alpine Runtime — Default Pragmatis #

Pendekatan paling umum di industri. Stage build pakai golang:alpine (yang sudah kecil), stage runtime pakai alpine (yang lebih kecil lagi).

# Build stage
FROM golang:1.22-alpine AS builder

WORKDIR /app

# Cache dependency
COPY go.mod go.sum ./
RUN go mod download

# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o app

# Runtime stage
FROM alpine:3.19

RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/app .

EXPOSE 8080
USER nonroot:nonroot
CMD ["./app"]

Ukuran tipikal: 20-40 MB.

Catatan penting:

  • alpine menggunakan musl libc, tapi karena kita pakai CGO_ENABLED=0, binary kita tidak bergantung pada libc apapun. musl/glibc tidak relevan.
  • ca-certificates di-install di runtime image agar HTTPS request bisa verifikasi certificate. Tanpa ini, panggilan ke HTTPS akan gagal.
  • USER nonroot:nonroot membuat container jalan sebagai user non-root.

Kapan pakai: Default untuk production service Go. Trade-off ukuran vs debugging能力 masih seimbang — alpine punya shell dan package manager, jadi kamu masih bisa docker exec -it container sh untuk debugging.

4.2 Distroless Runtime — Production Mature #

Distroless image hanya membawa runtime essentials — glibc, CA certs, dan /etc/passwd untuk user nonroot. Tidak ada shell, tidak ada package manager, tidak ada utility OS.

# Build stage
FROM golang:1.22-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-s -w" -o app

# Runtime stage — distroless
FROM gcr.io/distroless/base-debian12

WORKDIR /app
COPY --from=builder /app/app .

USER nonroot:nonroot
EXPOSE 8080
CMD ["./app"]

Ukuran tipikal: 10-25 MB.

Kapan pakai: Production high-maturity, security-first. Cocok untuk aplikasi yang observability-nya sudah solid (log ke STDOUT, metrics, tracing).

Trade-off yang harus dipahami:

  • Tidak bisa docker exec -it container sh — debugging interaktif tidak memungkinkan.
  • HEALTHCHECK harus berbasis HTTP (tidak bisa pakai shell command).
  • Logging harus ke STDOUT secara native — tidak ada log file yang bisa di-tail.
  • Semua observability harus lewat log aggregator, metrics endpoint, dan tracing.

4.3 Scratch — Ekstrem Tapi Murni #

scratch adalah base image kosong — 0 byte. Image akhirmu hanya berisi binary yang kamu copy ke dalamnya.

FROM golang:1.22-alpine AS builder

WORKDIR /app

COPY go.mod go.sum ./
RUN go mod download
COPY . .

# Build static binary
RUN CGO_ENABLED=0 GOOS=linux \
    go build -ldflags="-s -w" -o app

# Runtime stage — kosong
FROM scratch
COPY --from=builder /app/app /app
USER nonroot:nonroot
EXPOSE 8080
CMD ["/app"]

Ukuran tipikal: 5-15 MB.

Kapan pakai: Aplikasi yang sangat statis, tidak butuh HTTPS eksternal (atau embed CA cert di binary), tidak butuh DNS, tidak butuh time zone. Ini kasus yang sempit — biasanya CLI tools, agent, atau service internal.

Catatan penting:

  • scratch tidak punya /etc/passwdUSER nonroot akan error kecuali kamu juga copy file passwd.
  • scratch tidak punya /etc/ssl/certs/ca-certificates.crt — HTTPS call ke external akan gagal.
  • scratch tidak punya DNS resolver — scratch bahkan tidak bisa resolve hostname.

Solusi untuk scratch:

# Tambahkan CA certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

# Atau build dengan cert di-embed
// certdata.go
//go:embed certs/ca-certificates.crt
var CACerts []byte

Kebanyakan orang lebih memilih distroless daripada scratch untuk service production. Distroless sudah punya ca-certificates, /etc/passwd dengan user nonroot, dan glibc — semua yang biasanya dibutuhkan tanpa overhead yang signifikan.

5. CGO: Musuh Diam-Diam Image Kecil #

CGO memungkinkan Go memanggil C code. Masalahnya: kalau kamu pakai CGO, binary Go akan bergantung pada libc (C standard library), dan libc harus ada di runtime image.

# Cek apakah binary kamu bergantung pada C library
ldd myapp
# Output:
#   not a dynamic executable  ← bagus, static
#   libc.musl-x86_64.so.1     ← bergantung pada musl
#   libc.so.6                 ← bergantung pada glibc

Solusi default: matikan CGO.

ENV CGO_ENABLED=0

Dengan CGO_ENABLED=0, Go membuat static binary yang tidak bergantung pada libc apapun. Ini membuat image sekecil scratch jadi mungkin.

Kapan CGO harus ON? Ketika aplikasimu:

  • Pakai SQLite via mattn/go-sqlite3 (CGO wrapper).
  • Pakai image processing via library C seperti imaging, bimg (yang binding ke libvips).
  • Pakai library system call yang butuh C wrapper.

Solusi untuk CGO: Gunakan base image yang punya libc — alpine (musl) atau debian-slim/distroless/cc (glibc). Tapi ingat, image ini lebih besar dari scratch/distroless/base.

6. Optimasi Binary Size #

Binary Go hasil build default masih punya debug symbol dan DWARF info yang menambah ukuran tapi tidak dibutuhkan runtime. Stripping membantu mengecilkan binary hingga 20-30%.

Strip dengan ldflags #

go build -ldflags="-s -w" -o app
  • -s — omit symbol table dan debug info.
  • -w — omit DWARF debugging information.

Kombinasi ini tidak mempengaruhi fungsionalitas runtime, hanya menghilangkan informasi debugging. Untuk production, ini hampir selalu aman dipakai. Untuk development, kamu mungkin ingin keep informasi debug.

Strip dengan upx (Opsional) #

upx (Ultimate Packer for eXecutables) bisa mengompresi binary lebih lanjut, hingga 30-50% lebih kecil.

# Di build stage
RUN apk add --no-cache upx
RUN go build -ldflags="-s -w" -o app \
 && upx --best --lzma app

Trade-off UPX:

  • Binary lebih kecil.
  • Startup time naik karena binary harus di-decompress saat start (biasanya <100ms).
  • Beberapa sistem monitoring mungkin salah deteksi binary yang di-UPX sebagai malware.

Rekomendasi: UPX hanya untuk tool CLI atau agent yang startup time tidak kritis. Untuk service production, -ldflags="-s -w" sudah cukup.

7. Build Cache Strategy #

Go module cache (go mod download) sangat efektif untuk Docker layer cache jika dipakai dengan benar.

// ✓ Pola yang memaksimalkan cache
FROM golang:1.22-alpine AS builder
WORKDIR /app

# Layer 1: dependency file (jarang berubah)
COPY go.mod go.sum ./
RUN go mod download

# Layer 2: source code (sering berubah)
COPY . .

# Layer 3: build
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o app

Cara kerjanya:

  • Selama go.mod/go.sum tidak berubah, go mod download tidak dijalankan ulang.
  • Selama source code tidak berubah, build tidak dijalankan ulang.
  • Hanya source code yang berubah = hanya layer 2 dan 3 yang di-rebuild.

Trik tambahan — fake entrypoint untuk cache dependency:

# Force cache untuk compile dependency
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /tmp/app ./...

# Build yang sebenarnya (lebih cepat karena dependency sudah di-cache)
RUN go build -o app ./cmd/server

Ini adalah pattern lama yang kadang masih dipakai untuk project besar, tapi dengan Docker build cache modern, biasanya tidak perlu.

8. Binary Reproducibility #

Untuk production, build yang reproducible penting untuk audit dan rollback. Beberapa cara mencapainya:

Tag image dengan git commit hash:

docker build -t myapp:$(git rev-parse --short HEAD) .

Pastikan go.sum selalu di-commit. go.sum berisi checksum semua dependency, sehingga go mod download akan selalu mengambil versi yang sama.

Hindari go get tanpa versi eksplisit di go.mod. Selalu tulis go.mod secara eksplisit, dan biarkan go mod tidy yang mengelola go.sum.

9. Security Hardening #

9.1 Non-Root User Selalu Wajib #

Default container jalan sebagai root. Tambahkan USER di Dockerfile.

USER nonroot:nonroot

Untuk distroless, user nonroot sudah built-in dengan UID 65532. Untuk alpine, kamu bisa pakai user nobody atau buat custom user.

Membuat custom user di alpine:

RUN addgroup -g 1001 -S appgroup \
 && adduser -u 1001 -S appuser -G appgroup
USER appuser

UID tetap (>= 1000) penting untuk konsistensi permission pada volume mount.

9.2 Vulnerability Scanning #

Image Go kecil lebih mudah di-scan, tapi tetap harus di-scan. Tools:

  • trivy image myapp:latest
  • grype myapp:latest
  • docker scout cves myapp:latest

Integrasikan ke CI:

- name: Build
  run: docker build -t myapp:${{ github.sha }} .
- name: Scan
  run: trivy image --exit-code 1 --severity CRITICAL myapp:${{ github.sha }}

9.3 Read-Only Filesystem #

Untuk hardening ekstra, jalankan container dengan read-only root filesystem:

docker run --read-only --tmpfs /tmp myapp

Aplikasi Go yang well-written akan bekerja dengan read-only root, karena dia tidak menulis ke filesystem (semua lewat env var, log ke STDOUT, dsb).

9.4 Drop Capabilities #

Linux capabilities yang tidak dibutuhkan harus di-drop. Docker default sudah drop beberapa, tapi untuk hardening maksimal:

docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE myapp

10. Production-Grade Logging #

Container production tidak boleh menulis log ke file. Aplikasi Go harus log ke os.Stdout dan os.Stderr.

// ✓ Pakai standard library
log.SetOutput(os.Stdout)
log.Println("request received")

// ✓ Pakai structured logger (zap, zerolog, slog)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request received", "path", r.URL.Path, "status", 200)

JSON structured log lebih mudah di-parse oleh log aggregator (Loki, ELK, Datadog, dll).

Catatan untuk 12-factor apps: Aplikasi 12-factor harus treat log sebagai event stream. Menulis log ke file di container违反了 ini dan membuat log hilang saat container di-restart. Selalu log ke STDOUT/STDERR.

11. Healthcheck yang Tepat #

Untuk aplikasi Go, healthcheck harus HTTP-based (cek endpoint /health atau /ready) dan harus membedakan antara liveness (apakah perlu restart) dan readiness (apakah siap terima traffic).

// Contoh minimal
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
    // Cek dependency (DB, cache, dll)
    if !db.Ping() {
        w.WriteHeader(http.StatusServiceUnavailable)
        return
    }
    w.WriteHeader(http.StatusOK)
})

Di Dockerfile (untuk alpine, bukan distroless):

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --quiet --tries=1 --spider http://localhost:8080/health || exit 1

Untuk distroless, HEALTHCHECK di Dockerfile sulit — pindahkan ke orchestrator (Kubernetes liveness/readiness probe).

12. Signal Handling #

Container production harus bisa shutdown dengan rapi saat menerima SIGTERM. Aplikasi Go secara default sudah handle SIGTERM dengan benar (proses exit), tapi ada beberapa hal yang harus dipastikan:

// ✓ Setup context yang listen ke signal
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

// Server.Shutdown dengan context
go func() {
    <-ctx.Done()
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    if err := server.Shutdown(shutdownCtx); err != nil {
        log.Printf("HTTP server Shutdown: %v", err)
    }
}()

Pastikan juga:

  • Exec form di CMD/ENTRYPOINT (bukan shell form), agar signal sampai langsung ke binary.
  • Grace period yang cukup (30 detik biasanya cukup) untuk drain connection.
  • Database connection di-close dengan benar.

13. Kapan Pakai Strategi Mana #

Pemilihan strategi tergantung pada konteks aplikasimu. Tidak ada pilihan yang universal benar.

Kondisi Pilihan Alasan
API production umum distroless Ukuran kecil, aman, observability diasumsikan solid
Service internal / dev-friendly alpine Butuh shell untuk debugging, ukuran masih kecil
CLI / agent / sidecar scratch Paling kecil, startup cepat, dependency minimal
Aplikasi dengan CGO alpine atau distroless/cc Butuh libc
Serverless (Lambda, Cloud Run) scratch atau distroless Cold start time penting
High-security environment distroless atau scratch Minimal attack surface

Rule of thumb: Mulai dengan alpine untuk development, evaluasi distroless untuk staging, dan scratch hanya untuk CLI tools atau service yang sangat performance-sensitive.

14. Anti-Pattern yang Harus Dihindari #

✗ Copy Seluruh Repo di Awal #

// ✗ Cache invalid setiap perubahan
FROM golang:1.22
WORKDIR /app
COPY . .
RUN go build -o app

Solusi: Copy go.mod dan go.sum dulu, download dependency, baru copy source.

✗ Jangan Strip Binary #

// ✗ Binary 30% lebih besar
RUN go build -o app

Solusi: Selalu pakai -ldflags="-s -w".

✗ Build dengan CGO Tanpa Sadar #

// CGO default ON, binary bergantung pada glibc
FROM golang:1.22
RUN go build -o app

Solusi: Selalu set CGO_ENABLED=0 kecuali kamu benar-benar butuh CGO.

✗ Base Image latest #

// ✗ Build tidak deterministik
FROM golang:latest

Solusi: Pin tag spesifik: golang:1.22.5-alpine3.20.

✗ Image Final Pakai golang:alpine #

// ✗ Image ~400 MB karena bawa tool build
FROM golang:1.22-alpine
COPY --from=builder /app/app .

Solusi: Runtime stage pakai alpine, distroless, atau scratch — bukan golang:*.

✗ Container Jalan sebagai Root #

// ✗ Risiko privilege escalation
FROM alpine
COPY --from=builder /app/app /app
CMD ["/app"]

Solusi: Tambahkan USER nonroot:nonroot atau custom user.

15. Contoh Dockerfile Production-Grade #

Berikut adalah Dockerfile yang menggabungkan semua best practice di atas:

# syntax=docker/dockerfile:1.7

# ==== Stage 1: Build ====
FROM golang:1.22.5-alpine3.20 AS builder

# Build dependency
RUN apk add --no-cache git ca-certificates tzdata

WORKDIR /src

# Cache dependency layer
COPY go.mod go.sum ./
RUN go mod download

# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux \
    go build -trimpath -ldflags="-s -w" \
    -o /out/app ./cmd/server

# ==== Stage 2: Runtime ====
FROM gcr.io/distroless/static-debian12:nonroot

COPY --from=builder /out/app /app

# Default config
ENV APP_PORT=8080 \
    GIN_MODE=release

EXPOSE 8080

USER nonroot:nonroot
ENTRYPOINT ["/app"]

Catatan:

  • distroless/static-debian12 adalah varian statis — tanpa glibc, tanpa busybox. Hanya ca-certificates dan tzdata. Ukuran ~2 MB.
  • -trimpath di build flags — menghilangkan path absolut dari binary, untuk reproducibility.
  • USER nonroot:nonrootdistroless sudah punya user ini built-in.
  • ENTRYPOINT dalam exec form — signal sampai langsung ke binary.

Ukuran akhir: ~10-15 MB untuk binary 5-10 MB.

16. Perbandingan Strategi #

Strategi Ukuran Debugging Keamanan Cocok untuk
golang:alpine runtime 300-400 MB Mudah Rendah Jangan dipakai production
alpine runtime 20-40 MB Mudah Sedang Dev / staging / observability belum solid
distroless 10-25 MB Sulit Tinggi Production default
scratch (static) 5-15 MB Sangat sulit Sangat tinggi CLI / agent / perf-kritis

17. Checklist Review Dockerfile Go #

BASE IMAGE:
  □ Tag eksplisit (golang:1.22.5-alpine3.20, bukan latest)
  □ Runtime stage bukan image golang:*
  □ Base image runtime sekecil mungkin

BUILD:
  □ Multi-stage build
  □ CGO_ENABLED=0 (kecuali benar-benar butuh CGO)
  □ go mod download terpisah dari copy source
  □ go build dengan -ldflags="-s -w"
  □ go build dengan -trimpath (reproducibility)

RUNTIME:
  □ USER nonroot (UID >= 1000 atau user nonroot distroless)
  □ EXPOSE port
  □ ENTRYPOINT dalam exec form
  □ HEALTHCHECK (jika shell ada di image)

UKURAN:
  □ < 50 MB untuk alpine runtime
  □ < 30 MB untuk distroless runtime
  □ < 20 MB untuk scratch static
  □ docker history tidak ada layer aneh yang besar

KEAMANAN:
  □ Tidak ada secret di image
  □ .dockerignore tegas
  □ Image di-scan trivy/grype di CI
  □ Base image up-to-date (CVE patched)

Ringkasan #

  • Go adalah bahasa paling container-friendly, tapi image Go yang optimal hanya terjadi jika Dockerfile mengelola boundary build vs runtime dengan benar.
  • Realita ukuran: 5-15 MB (scratch static), 10-25 MB (distroless), 20-40 MB (alpine), 300-400 MB (golang:alpine runtime — anti-pattern), 800+ MB (golang:latest — anti-pattern).
  • Multi-stage build adalah wajib — compiler dan tool build tidak boleh masuk ke runtime image.
  • CGO_ENABLED=0 adalah default untuk static binary. Hanya ON untuk library C yang memang dibutuhkan.
  • Strip binary dengan -ldflags="-s -w" — kurangi 20-30% ukuran tanpa mengorbankan fungsionalitas runtime.
  • Distroless adalah default production untuk service Go. scratch untuk CLI/agent. alpine untuk dev/staging.
  • USER nonroot wajib — baik di alpine, distroless, maupun scratch.
  • Image kecil butuh observability yang baik — log ke STDOUT, metrics endpoint, healthcheck. Image kecil + observability solid > image besar + debugging manual.
  • Tag image secara eksplisitgolang:1.22.5-alpine3.20, bukan latest. Build reproducibility penting untuk audit dan rollback.
  • Caching strategy — copy go.mod/go.sum dulu, download dependency, baru copy source. Ini memaksimalkan layer cache Docker.

← Sebelumnya: Ukuran Image   Berikutnya: Python →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact