Web Artisan
Beranda

Progress belajar

Modul 59 dari 73

0% 0/73 modul selesai

Setelah selesai, tandai modul ini agar progres kursus tetap rapi.

Progress disimpan lokal di browser ini.

Roadmap 8 · Docker, CI/CD, dan AWS

Containerize Go API
dengan Docker

Modul ini mengubah Go API dan worker online shop skincare menjadi image kecil, statis, dan aman yang siap masuk pipeline deployment.

Roadmap 8 · Chapter 1Bahasa: Go 1.26~65 menit baca
01

Kenapa Docker untuk Go API?

Dari binary lokal menuju artefak deployment yang konsisten

Di React kamu mengirim bundle statis ke CDN. Di Laravel kamu mengirim source code plus PHP-FPM, Composer, extension, dan web server. Di Go, kita mengirim satu binary statis di dalam container ukuran beberapa megabita.

Go meng-compile ke binary native tanpa runtime eksternal. Itu membuat Dockerfile Go jauh lebih ringkas daripada setup PHP-FPM atau Node, karena container final tidak perlu membawa compiler Go, source code, package manager, atau toolchain build. Untuk backend skincare, image inilah yang nanti dipakai CI untuk testing, dipush ke registry, dijalankan di staging, lalu dipromosikan ke AWS ECS Fargate tanpa rebuild.

🌉Jembatan: dari PHP-FPM dan node_modules ke satu binary

Image PHP-FPM resmi sekitar 100+ MB karena harus membawa runtime PHP, extension, dan proses FPM. Image Node bisa 150 sampai 1000 MB karena butuh runtime Node plus node_modules. Image Go bisa ~2 sampai 10 MB karena cuma berisi satu binary plus base image tipis.

Docker dipakai bukan karena Go tidak bisa dijalankan langsung, tapi karena deployment butuh artefak yang konsisten dan portabel. Image yang sama bisa dites di laptop, dijalankan di CI, dipush ke ECR, lalu dipakai ECS. Yang berubah antar environment hanya environment variable, bukan binary-nya.

Konsisten

Binary, CA certificate, timezone, dan entrypoint dibungkus dalam satu image yang immutable dan ber-digest.

Kecil

Multi-stage build membuang compiler dan module cache, sehingga image final hanya membawa yang dibutuhkan runtime.

Aman

Final image bisa berjalan sebagai user non-root tanpa shell, sehingga permukaan serangan jauh lebih kecil.

🧾Versi yang dipakai modul ini

Per Juni 2026 kita pakai Go 1.26 (image resmi golang:1.26), base final gcr.io/distroless/static-debian13:nonroot, dan BuildKit modern lewat baris # syntax=docker/dockerfile:1.

02

Mental Model: Image, Layer, dan Container

Image adalah template berlapis, container adalah proses yang berjalan

Kalau kamu terbiasa dengan frontend build, image mirip artefak hasil build, dan container adalah artefak itu saat dijalankan sebagai proses sistem operasi.

Docker image

Template immutable berisi filesystem berlapis, binary, dan metadata cara menjalankan aplikasi. Image diidentifikasi oleh tag (mis. skincare-api:1.4.0) dan digest SHA256 yang unik.

layer

Satu lapisan filesystem yang dihasilkan oleh satu instruksi Dockerfile (mis. COPY, RUN). Layer di-cache dan bisa dipakai ulang antar build selama instruksi dan input-nya tidak berubah.

container

Instance runtime dari image. Di modul ini, container menjalankan proses /app yang listen ke port 8080. Saat container mati, perubahan filesystem-nya hilang kecuali ditulis ke volume.

Bagi developer JS/PHP, perbedaan terbesar ada di image final. Image Node dan PHP biasanya identik antara build-time dan runtime: interpreter yang dipakai untuk menjalankan kode tetap harus ada di production. Go memutus rantai itu. Build butuh compiler 800+ MB, tapi production hanya butuh hasilnya, satu binary.

JS / PHP: runtime ikut ke production
  • Node butuh runtime Node plus node_modules di image final.
  • PHP butuh PHP-FPM, extension, dan sering web server terpisah.
  • Dependency di-install saat build dan tetap ada saat run.
Go: hanya binary ke production
  • Go meng-compile ke satu binary statis Linux.
  • Image final cukup binary plus CA certificate dan timezone.
  • Tidak ada install dependency saat container start.
flowchart LR
  subgraph BUILD["Build-time (dibuang)"]
    SRC["Source code Go"] --> TOOL["golang:1.26 (~800 MB)"]
    TOOL --> COMP["go build"]
  end
  COMP --> BIN["binary /app"]
  subgraph FINAL["Image final (~2 MB)"]
    BIN --> BASE["distroless/static + binary"]
  end
  BASE --> RUN["docker run -> container proses /app"]

Gambar 1. Multi-stage memisahkan dapur build (besar, berisi toolchain) dari ruang makan production (kecil, hanya binary). Stage builder dibuang dari image final.

💡Image sekali, container banyak

Satu image bisa menjalankan banyak container sekaligus. Untuk skincare, image yang sama bisa jalan sebagai container api (listen HTTP) dan container worker (konsumsi antrian), hanya beda argumen entrypoint.

03

Struktur Proyek dan Entrypoint Multi-perintah

Satu binary, beberapa subperintah: serve, worker, migrate, healthcheck

Dockerfile yang baik dimulai dari struktur proyek yang stabil. Sebelum menulis Dockerfile, kita tata dulu agar satu binary bisa menjadi API maupun worker.

Struktur minimum untuk build image API dan worker
  • cmd/
  • app/
  • main.go entry point: dispatch subperintah
  • internal/
  • config/
  • config.go baca env runtime
  • product/
  • handler.go
  • service.go
  • order/
  • handler.go
  • service.go
  • worker/
  • consumer.go konsumsi antrian payment / email
  • go.mod
  • go.sum
  • Dockerfile
  • .dockerignore

Daripada membuat dua binary terpisah (cmd/api dan cmd/worker) yang masing-masing butuh image, kita pakai satu binary dengan subperintah. Pola ini umum di Go dan membuat image final tunggal yang bisa dipakai ulang. Argumen pertama menentukan mode.

cmd/app/main.go
package main import ( "context" "log/slog" "net/http" "os" "os/signal" "syscall" "time" ) func main() { cmd := "serve" if len(os.Args) > 1 { cmd = os.Args[1] } switch cmd { case "serve": runServer() case "worker": runWorker() case "healthcheck": runHealthcheck() default: slog.Error("perintah tidak dikenal", slog.String("cmd", cmd)) os.Exit(2) } } func runServer() { addr := getenv("HTTP_ADDR", ":8080") mux := http.NewServeMux() mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) }) server := &http.Server{ Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second, } // Graceful shutdown saat container menerima SIGTERM dari Docker / ECS. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() go func() { slog.Info("skincare API listening", slog.String("addr", addr)) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { slog.Error("server stopped", slog.Any("error", err)) os.Exit(1) } }() <-ctx.Done() slog.Info("shutting down, draining connections") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := server.Shutdown(shutdownCtx); err != nil { slog.Error("graceful shutdown failed", slog.Any("error", err)) } } func getenv(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback }
🌉Jembatan: dari php artisan dan npm scripts ke subperintah

Di Laravel kamu jalankan php artisan serve dan php artisan queue:work dari satu codebase. Di Node kamu pakai npm run start vs npm run worker. Di Go, satu binary app serve vs app worker memberi efek serupa, tapi tanpa runtime terpisah karena keduanya sudah ter-compile ke binary yang sama.

⚠️Wajib graceful shutdown

Docker dan ECS mengirim SIGTERM lalu menunggu sebelum SIGKILL. Tanpa signal.NotifyContext dan server.Shutdown, request checkout yang sedang jalan bisa terputus saat deploy. Tangani sinyal sejak awal, bukan saat sudah di production.

04

Dockerfile Multi-stage untuk Go

Build dengan golang:1.26, jalankan dengan image final yang kecil

Multi-stage build adalah Dockerfile dengan lebih dari satu FROM. Stage builder boleh besar (berisi compiler), tapi yang masuk image final hanya binary yang kita salin secara eksplisit.

Dockerfile
# syntax=docker/dockerfile:1 FROM golang:1.26 AS builder WORKDIR /src # Layer dependency lebih dulu agar cache efektif (lihat Section 05). COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY . . ENV CGO_ENABLED=0 GOOS=linux GOARCH=amd64 RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go build -trimpath -ldflags="-s -w" -o /app ./cmd/app FROM gcr.io/distroless/static-debian13:nonroot COPY --from=builder /app /app USER nonroot:nonroot EXPOSE 8080 ENTRYPOINT ["/app"] CMD ["serve"]

Baris paling penting adalah COPY --from=builder /app /app. Instruksi itu mengambil satu artefak dari stage builder, lalu meninggalkan source code, module cache, compiler Go, dan package manager di belakang. Stage builder tidak pernah masuk ke image final.

Setiap flag build punya alasan, bukan ritual salin-tempel:

CGO_ENABLED=0

Matikan cgo agar binary jadi statis murni, tidak butuh glibc atau musl. Wajib supaya bisa jalan di scratch dan distroless/static.

GOOS=linux GOARCH=amd64

Target OS dan arsitektur container. Penting saat build dari macOS atau Windows agar binary cocok dengan kernel Linux di server.

-ldflags=“-s -w”

-s buang symbol table, -w buang DWARF debug info. Memangkas ukuran binary sekitar 25 sampai 30 persen. Konsekuensi: tidak bisa pakai debugger di binary itu.

-trimpath

Hilangkan path absolut mesin build dari binary, sehingga build lebih reproducible dan struktur direktori tidak bocor.

🌉Jembatan: dari composer install --no-dev ke multi-stage

Di PHP kamu memisahkan dev dependency dengan composer install --no-dev. Di Go container, padanan kelasnya adalah multi-stage: stage builder memuat seluruh toolchain, lalu image final hanya menerima hasil go build. Bukan sekadar membuang dev dependency, tapi membuang seluruh runtime build.

🧾ENTRYPOINT vs CMD

ENTRYPOINT ["/app"] adalah perintah utama yang selalu jalan. CMD ["serve"] adalah argumen default yang bisa di-override. Jadi docker run img menjalankan app serve, sedangkan docker run img worker menjalankan app worker. Selalu pakai exec form (["..."]) agar binary jadi PID 1 dan menerima SIGTERM langsung.

05

Cache Layer dan BuildKit Cache Mount

Build ulang harus cepat saat hanya kode yang berubah

Urutan COPY di Dockerfile menentukan apakah build berikutnya memakai cache atau mengunduh dependency lagi. Ini perbedaan antara rebuild 1 detik dan 40 detik.

Docker menyimpan cache per instruksi (per layer). Dependency Go berubah lebih jarang daripada file handler atau service. Karena itu go.mod dan go.sum disalin lebih dulu, lalu go mod download dijalankan sebelum source code lain masuk. Selama kedua file itu tidak berubah, layer download dipakai ulang.

Potongan Dockerfile: urutan layer yang benar
COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -o /app ./cmd/app
flowchart TD
  A["Ubah internal/order/service.go"] --> B{"go.mod / go.sum berubah?"}
  B -->|Tidak| C["Layer go mod download REUSE"]
  C --> D["Hanya COPY . . dan go build jalan ulang"]
  B -->|Ya| E["Layer go mod download INVALID"]
  E --> F["Download dependency ulang, lalu build"]

Gambar 2. Perubahan kode biasa tidak membatalkan cache download selama go.mod dan go.sum stabil. Hanya menambah dependency yang memicu download ulang.

⚠️Jebakan: COPY . . terlalu awal

Kalau COPY . . ditaruh sebelum go mod download, satu perubahan kecil di file handler membatalkan cache dan memicu download semua module lagi. Selalu salin manifest dulu, baru source code.

Layer cache punya batas: ia hilang kalau go.mod berubah. BuildKit cache mount lebih kuat karena persist lintas build dan tahan terhadap perubahan go.mod. Cache mount tidak ikut ke image final, hanya hidup saat build.

Potongan Dockerfile: BuildKit cache mount
RUN --mount=type=cache,target=/go/pkg/mod \ go mod download RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ go build -trimpath -ldflags="-s -w" -o /app ./cmd/app
💡Dua cache yang berbeda

/go/pkg/mod adalah cache module download (GOMODCACHE). /root/.cache/go-build adalah cache hasil kompilasi (GOCACHE), sehingga rebuild hanya meng-compile paket yang berubah. Keduanya butuh BuildKit aktif dan baris # syntax=docker/dockerfile:1.

Terakhir, batasi build context dengan .dockerignore. File yang tidak relevan tidak perlu dikirim ke daemon, dan ini juga mencegah .env ikut ter-copy saat COPY . ..

.dockerignore
.git .gitignore *.md Dockerfile* compose*.yaml .dockerignore bin/ dist/ tmp/ *.test *.out coverage.* .env .env.* *.log .idea/ .vscode/
06

Base Image Final: scratch, distroless, Alpine

Pilih base sesuai kebutuhan debug, certificate, dan timezone

Setelah binary jadi, pertanyaan berikutnya: di atas apa ia berjalan? Tiga pilihan umum punya trade-off antara ukuran, keamanan, dan kemudahan debug.

scratch (kosong total)
  • Ukuran nyaris 0, hanya binary.
  • Tidak ada shell, CA cert, timezone, atau user.
  • Kamu harus salin CA dan tzdata manual.
distroless/static (rekomendasi)
  • Sekitar 2 MB plus binary.
  • Sudah bawa ca-certificates, tzdata, /etc/passwd, dan user nonroot (UID 65532).
  • Tetap tanpa shell dan tanpa package manager.

Untuk Go API skincare yang memanggil HTTPS keluar (payment gateway, S3, SMTP), CA certificate wajib ada. Tanpa root CA, request HTTPS gagal dengan error x509: certificate signed by unknown authority. Distroless static sudah membawanya, jadi ia adalah default yang dianjurkan.

ca-certificates

Kumpulan root certificate yang dipakai client TLS untuk memverifikasi server HTTPS. Dibutuhkan kalau aplikasi memanggil API pihak ketiga lewat HTTPS. scratch tidak punya ini.

tzdata

Database timezone yang dibutuhkan kalau kode memakai time.LoadLocation("Asia/Jakarta"). Distroless static sudah bawa. Alternatif: import _ "time/tzdata" agar timezone di-embed ke binary, sehingga base image bebas.

Kalau kamu memilih scratch (ukuran paling kecil), salin certificate dan timezone manual dari stage builder:

Potongan Dockerfile: scratch dengan CA dan timezone
FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo COPY --from=builder /app /app USER 65532:65532 EXPOSE 8080 ENTRYPOINT ["/app"] CMD ["serve"]

Alpine adalah pilihan ketiga, dipakai saat tim masih butuh shell untuk investigasi di staging, atau saat aplikasi butuh cgo dengan musl. Harganya: permukaan serangan lebih besar dan kadang ada edge case resolusi DNS musl.

Potongan Dockerfile: Alpine sebagai kompromi debug
FROM alpine:3.23 RUN apk add --no-cache ca-certificates tzdata \ && addgroup -S app && adduser -S -G app app COPY --from=builder /app /usr/local/bin/app USER app EXPOSE 8080 ENTRYPOINT ["/usr/local/bin/app"] CMD ["serve"]
💡Aturan praktis pemilihan base

Default ke distroless/static (aman, kecil, lengkap). Pakai scratch kalau mengejar image terkecil dan siap salin CA dan tzdata sendiri. Pakai Alpine hanya kalau kamu sungguh butuh shell untuk debug atau butuh cgo.

07

Non-root, EXPOSE, dan Healthcheck

Hardening minimum dan cara container melaporkan kesehatannya

Image yang kecil belum tentu aman. Tiga instruksi berikut menentukan apakah container kamu siap production atau menjadi liability.

USER nonroot

Menjalankan proses sebagai user non-root. Distroless :nonroot memberi UID/GID 65532. Kalau container jebol lewat bug, attacker tidak langsung dapat root. Untuk scratch atau alpine, pakai USER 65532:65532 atau buat user sendiri.

🔒Jangan default root

Root di container memang bukan root penuh di host, tapi tetap memperbesar dampak kalau ada bug file write, RCE, atau rantai escape. Banyak organisasi menolak image yang jalan sebagai root di gate keamanan CI.

EXPOSE 8080 hanya metadata dan dokumentasi, ia tidak benar-benar membuka port. Publikasi nyata dilakukan lewat -p 8080:8080 di docker run atau ports: di Compose. Jangan mengandalkan EXPOSE untuk membuka akses.

🌉Jembatan: EXPOSE bukan app.listen(port)

Di Express kamu menulis app.listen(8080) dan port benar-benar terbuka. EXPOSE di Dockerfile tidak seperti itu, ia hanya catatan niat. Yang sungguh membuka port adalah binding -p saat run. Binary Go tetap perlu listen ke :8080 di kodenya.

Untuk healthcheck, ingat bahwa distroless dan scratch tidak punya shell atau curl. Maka healthcheck harus berupa subperintah di binary Go itu sendiri, bukan curl.

cmd/app/main.go (subperintah healthcheck)
func runHealthcheck() { addr := getenv("HTTP_ADDR", ":8080") url := "http://127.0.0.1" + addr + "/health" client := &http.Client{Timeout: 3 * time.Second} resp, err := client.Get(url) if err != nil { slog.Error("healthcheck gagal", slog.Any("error", err)) os.Exit(1) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { slog.Error("healthcheck status bukan 200", slog.Int("status", resp.StatusCode)) os.Exit(1) } os.Exit(0) }
Potongan Dockerfile: HEALTHCHECK lewat binary sendiri
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \ CMD ["/app", "healthcheck"]
🧾Healthcheck sering lebih praktis di orchestrator

Di Compose dan ECS, healthcheck sering didefinisikan di level orchestrator, bukan di Dockerfile, agar lebih mudah diatur per environment. Subperintah healthcheck di binary tetap berguna karena bisa dipanggil dari kedua tempat tanpa butuh curl.

08

Environment Variables saat Runtime

Image jangan membawa secret, container menerima konfigurasi

Prinsip 12-factor: config hidup di environment, bukan di kode atau image. Satu image yang sama dipromosikan dari dev ke staging ke production tanpa rebuild, yang berubah hanya env-nya.

Dockerfile tidak boleh berisi DATABASE_URL, JWT_SECRET, PAYMENT_SERVER_KEY, atau credential AWS. Nilai yang ter-bake lewat ENV di Dockerfile tertulis permanen di layer image dan terlihat di docker history. ENV hanya untuk nilai default non-sensitif seperti ENV PORT=8080.

ENV di Dockerfile
  • Ter-bake ke image, terlihat di docker history.
  • Hanya untuk default non-sensitif (PORT, GIN_MODE).
  • Jangan untuk secret apa pun.
-e / --env-file saat run
  • Disuntik per container saat runtime.
  • File env tetap di luar image.
  • Cocok untuk config per-environment dan secret.
Terminal
# Override per variabel saat runtime docker run --rm -p 8080:8080 \ -e APP_ENV=local \ -e HTTP_ADDR=:8080 \ -e DATABASE_URL="postgres://postgres:postgres@host.docker.internal:5432/skincare?sslmode=disable" \ skincare-api serve
Terminal
# Muat banyak variabel dari file yang diabaikan git docker run --rm -p 8080:8080 --env-file .env.local skincare-api serve
.env.local
APP_ENV=local HTTP_ADDR=:8080 DATABASE_URL=postgres://postgres:postgres@host.docker.internal:5432/skincare?sslmode=disable JWT_SECRET=local-only-change-me PAYMENT_SERVER_KEY=local-midtrans-key
⚠️Secret saat build pun jangan pakai ARG

Kalau saat build kamu butuh token (mis. akses private repo), jangan pakai ARG atau ENV karena keduanya bocor di docker history. Pakai BuildKit secret: RUN --mount=type=secret,id=.... Saat runtime, secret datang dari secret manager atau orchestrator, bukan dari image.

🌉Jembatan: dari .env Laravel ke env container

Di Laravel .env dibaca otomatis oleh framework. Di Go container, file .env bukan dibaca aplikasi tapi disuntik Docker lewat --env-file, lalu kode membaca via os.Getenv. Di production AWS, .env digantikan oleh Secrets Manager yang nilainya dipetakan ke environment task.

09

Menjalankan Worker dalam Container

Image yang sama, entrypoint berbeda, tanpa port HTTP

Worker skincare (pengirim email, pemroses webhook payment, sinkronisasi inventory) berjalan dari image yang sama persis dengan API. Yang berbeda hanya argumen yang diberikan ke entrypoint.

Karena main.go sudah men-dispatch subperintah, kita tidak butuh Dockerfile kedua. docker run skincare-api worker menimpa CMD ["serve"] default dan menjalankan app worker. Worker tidak membuka port HTTP, jadi tidak perlu -p.

cmd/app/main.go (subperintah worker)
func runWorker() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() slog.Info("skincare worker started") // consumer.Run memblokir sampai ctx dibatalkan (SIGTERM saat deploy). if err := worker.Run(ctx); err != nil { slog.Error("worker stopped", slog.Any("error", err)) os.Exit(1) } slog.Info("worker drained, exiting") }
Terminal
# Container API: listen HTTP di 8080 docker run --rm -p 8080:8080 --env-file .env.local skincare-api serve # Container worker: tanpa port, hanya konsumsi antrian docker run --rm --env-file .env.local skincare-api worker
flowchart LR
  IMG["Image skincare-api (1 binary)"] --> API["container: app serve"]
  IMG --> WRK["container: app worker"]
  API -->|HTTP :8080| LB["Load Balancer / ALB"]
  WRK -->|poll| Q[("Antrian: SQS / Redis")]
  Q -->|payment & email event| WRK
  API --> DB[("PostgreSQL")]
  WRK --> DB

Gambar 3. Satu image melahirkan dua tipe container. API melayani request HTTP, worker mengonsumsi antrian. Keduanya berbagi binary, config, dan akses database yang sama.

🌉Jembatan: dari queue:work ke container worker

Di Laravel kamu menjalankan php artisan queue:work sebagai proses terpisah dari web. Polanya identik di sini: container serve untuk request sinkron, container worker untuk pekerjaan asinkron. Bedanya, di Go keduanya satu binary statis, sehingga deploy worker tidak perlu image atau runtime tambahan.

Worker juga butuh graceful shutdown

Saat ECS scale-in atau deploy, worker menerima SIGTERM. signal.NotifyContext membuat consumer.Run berhenti mengambil pesan baru dan menyelesaikan pesan yang sedang diproses, mencegah job payment terpotong di tengah.

10

Hands-on: Build, Run, dan Inspeksi Image

Bangun image lokal, jalankan API dan worker, lalu ukur hasilnya

Sekarang kita jalankan flow minimum yang menjadi dasar pipeline CI/CD di chapter berikutnya: build, run, cek health, inspeksi ukuran, dan verifikasi non-root.

Pastikan module memakai Go 1.26

go.mod adalah sumber versi module Go untuk proyek skincare.

Build image dengan BuildKit

docker build membaca Dockerfile dan menghasilkan image bernama skincare-api.

Run container API

docker run menjalankan container, memetakan port 8080 host ke 8080 container.

Cek endpoint dan ukuran

curl /health memverifikasi proses jalan, docker image ls memverifikasi image kecil.

go.mod
module github.com/kamu/skincare-backend go 1.26
Terminal
# BuildKit aktif default di Docker modern; bisa dipaksa via env. DOCKER_BUILDKIT=1 docker build -t skincare-api:dev . # Lihat ukuran image final (harapannya hanya beberapa MB). docker image ls skincare-api # Jalankan API. docker run --rm -p 8080:8080 --env-file .env.local skincare-api:dev serve

Di terminal lain, cek health endpoint dan endpoint domain yang nantinya ikut berjalan dalam container yang sama.

Terminal
curl -i http://localhost:8080/health
GET /health Healthcheck ringan, dipakai Docker, ECS, dan ALB target group
GET /v1/products Katalog produk skincare dengan filter dan paginasi
POST /v1/checkout Ubah keranjang jadi order dalam satu transaksi

Verifikasi bahwa container benar-benar berjalan sebagai non-root dengan menjalankan subperintah yang mencetak UID, atau dengan mengintip metadata image.

Terminal
# Inspeksi user default image (harus 65532 / nonroot, bukan root). docker inspect skincare-api:dev --format '{{.Config.User}}' # Jalankan worker dari image yang sama, tanpa port. docker run --rm --env-file .env.local skincare-api:dev worker
Checklist sebelum push ke registry

Image build dari clean checkout, server listen ke :8080, secret masuk via env, container jalan sebagai non-root, healthcheck hijau, dan ukuran image dalam hitungan megabita bukan ratusan megabita.

Output /health yang diharapkan adalah 200 ok. Untuk skincare, endpoint berikutnya (cart, checkout, auth, webhook payment, admin) ikut berjalan dalam container yang sama. Container tidak mengubah desain domain, ia hanya membungkus proses agar siap dikirim ke CI lalu ke AWS.

11

Jebakan Umum dari JS/PHP ke Go Container

Kebanyakan bug container Go datang dari asumsi runtime Node atau PHP

Sebagian besar masalah container Go bukan berasal dari bahasa Go, tapi dari kebiasaan runtime yang terbawa dari Node, React, atau PHP.

Menjalankan dari image golang

Image golang:1.26 (~800 MB) cocok untuk build, bukan final. Final image tidak perlu compiler. Tanpa multi-stage, image kamu membengkak dan tidak aman.

Listen ke localhost

Di dalam container pakai :8080, bukan localhost:8080. localhost mengacu ke container itu sendiri, sehingga port mapping host terlihat tidak bekerja.

Secret ter-bake di Dockerfile

ENV JWT_SECRET=... menempel permanen di layer dan terlihat di docker history. Secret harus masuk saat runtime.

Lupa CA certificate

scratch kosong. Request HTTPS ke payment gateway, S3, atau SMTP gagal dengan x509: certificate signed by unknown authority.

Lupa .dockerignore

Build context membengkak, cache mudah invalid, dan .env atau file lokal berisiko ikut masuk image.

Root user di final image

Tanpa USER nonroot, container jalan sebagai root dan ditolak di banyak gate keamanan CI.

Lupa graceful shutdown

Tanpa menangani SIGTERM, deploy memutus request checkout dan job worker di tengah jalan.

Shell form ENTRYPOINT

ENTRYPOINT /app (shell form) membuat binary bukan PID 1, sehingga sinyal SIGTERM tidak sampai. Selalu pakai exec form ["/app"].

⚠️GOARCH salah saat build dari Apple Silicon

Build di Mac M-series default arm64. Kalau target ECS pakai amd64, image akan gagal jalan atau lambat lewat emulasi. Set GOARCH=amd64 (atau build multi-arch) agar binary cocok dengan platform deploy.

🌉Jembatan: dari node_modules ke binary statis

Di Node kamu sering debug error “module not found” di container karena node_modules tidak ikut atau beda arsitektur. Di Go, dependency sudah ter-compile ke dalam binary, jadi kelas masalah itu hilang. Yang tersisa hanya memastikan CA, timezone, GOARCH, dan user benar.

12

Ringkasan & Poin Penting

Docker bukan tujuan akhir. Docker adalah format pengiriman API dan worker yang membuat testing, CI/CD, dan deploy AWS menjadi konsisten dan dapat diulang.

Yang Wajib Menempel

  • Pakai multi-stage build: golang:1.26 untuk build, gcr.io/distroless/static-debian13:nonroot untuk final.
  • Salin go.mod dan go.sum lebih dulu, jalankan go mod download, baru COPY . . agar cache layer efektif.
  • Tambah BuildKit cache mount (/go/pkg/mod dan /root/.cache/go-build) untuk rebuild cepat lintas build.
  • Set CGO_ENABLED=0 GOOS=linux GOARCH=amd64 dan flag -trimpath -ldflags="-s -w" untuk binary statis dan kecil.
  • Jalankan container sebagai non-root (USER nonroot atau 65532:65532), pakai exec form ENTRYPOINT agar PID 1 menerima SIGTERM.
  • Pastikan CA certificate dan timezone tersedia; distroless sudah bawa, scratch harus salin manual.
  • Masukkan konfigurasi lewat -e atau --env-file saat runtime, jangan bake secret ke image.
  • Satu image, dua container: app serve untuk API, app worker untuk antrian, lewat ENTRYPOINT plus CMD.

Untuk proyek skincare, image ini akan membawa API katalog, cart, checkout, auth, webhook payment, dan admin route, plus worker untuk email dan pemrosesan payment, ke pipeline deployment. Container tidak mengubah desain domain yang sudah kamu bangun di Roadmap 1 sampai 7, ia hanya membungkusnya menjadi artefak yang siap dikirim.

Langkah berikutnya di Roadmap 8 (Chapter 2) adalah menggabungkan container API dan worker dengan PostgreSQL dan Redis memakai Docker Compose. Di sana kita mensimulasikan environment production kecil: service saling kenal lewat nama host, depends_on dengan condition: service_healthy, named volume untuk data Postgres, dan env yang lebih terstruktur. Setelah itu, Chapter 3 mengangkat image yang sama ini ke CI pipeline (lint, test, build, push), sebelum akhirnya di-deploy ke ECS Fargate.

Progress disimpan lokal di browser ini.