Menjalankan aplikasi Go di dalam Docker terlihat sederhana, tetapi deployment yang benar untuk produksi membutuhkan lebih dari sekadar build lalu run. Untuk aplikasi API berbasis Go Fiber v3, Anda perlu memastikan image tetap kecil, konfigurasi mudah diubah lewat environment variable, log keluar ke stdout, health endpoint jelas, dan proses dapat berhenti dengan aman saat menerima sinyal dari Docker atau orchestrator.
Artikel ini membahas pola deployment yang praktis untuk Go Fiber v3 dengan fokus pada graceful shutdown, termasuk saat ada background worker yang sedang memproses job impor panjang. Contoh yang dipakai cukup realistis: API Fiber, PostgreSQL, Redis, dan worker internal yang memproses queue.
Arsitektur deployment yang akan kita bangun
Target akhirnya adalah satu container aplikasi Go yang menjalankan:
- HTTP API berbasis Fiber
- Koneksi ke PostgreSQL
- Koneksi ke Redis
- Background worker untuk memproses job
- Endpoint health untuk liveness dan readiness
- Shutdown yang tertib saat menerima
SIGTERMatauSIGINT
Kenapa ini penting? Karena di Docker, saat container dihentikan, proses utama akan menerima sinyal. Jika aplikasi tidak menangani sinyal dengan benar, beberapa masalah umum bisa terjadi:
- Request aktif terputus di tengah jalan
- Job background berhenti mendadak dan meninggalkan data setengah proses
- Koneksi database tidak ditutup dengan benar
- Container dianggap sehat padahal sebenarnya belum siap menerima traffic
Dengan pendekatan yang tepat, container bisa:
- Mulai cepat dan konsisten
- Menolak traffic saat belum siap
- Menghentikan penerimaan request baru saat proses shutdown dimulai
- Menunggu pekerjaan penting selesai atau membatalkannya secara terkendali
Struktur aplikasi dan komponen utama
Berikut contoh struktur proyek yang sederhana tetapi cukup rapi untuk deployment:
./cmd/api/main.go
./internal/config/config.go
./internal/http/server.go
./internal/worker/import_worker.go
./internal/store/postgres.go
./internal/cache/redis.go
./Dockerfile
./docker-compose.yml
Inti dari implementasi ada di:
- config: membaca environment variable
- server: inisialisasi Fiber, middleware, route, health endpoint
- worker: loop worker dengan context agar bisa dihentikan dengan benar
- main: wiring aplikasi, penanganan sinyal, shutdown orchestration
Konfigurasi environment yang aman dan mudah dipindah
Di lingkungan container, praktik yang paling aman dan fleksibel adalah menyimpan konfigurasi di environment variable, bukan di file hardcoded di image. Ini memudahkan perbedaan antara development, staging, dan production.
Contoh konfigurasi:
package config
import (
"os"
"strconv"
"time"
)
type Config struct {
AppEnv string
Port string
DatabaseURL string
RedisAddr string
ShutdownTimeout time.Duration
ReadTimeout time.Duration
WriteTimeout time.Duration
IdleTimeout time.Duration
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func getDuration(key string, fallback time.Duration) time.Duration {
if v := os.Getenv(key); v != "" {
d, err := time.ParseDuration(v)
if err == nil {
return d
}
}
return fallback
}
func getPort() string {
p := getEnv("APP_PORT", "8080")
if _, err := strconv.Atoi(p); err != nil {
return "8080"
}
return p
}
func Load() Config {
return Config{
AppEnv: getEnv("APP_ENV", "development"),
Port: getPort(),
DatabaseURL: getEnv("DATABASE_URL", "postgres://app:app@postgres:5432/app?sslmode=disable"),
RedisAddr: getEnv("REDIS_ADDR", "redis:6379"),
ShutdownTimeout: getDuration("SHUTDOWN_TIMEOUT", 20*time.Second),
ReadTimeout: getDuration("READ_TIMEOUT", 10*time.Second),
WriteTimeout: getDuration("WRITE_TIMEOUT", 15*time.Second),
IdleTimeout: getDuration("IDLE_TIMEOUT", 60*time.Second),
}
}
Timeout di atas penting untuk produksi. Tanpa timeout yang eksplisit, koneksi lambat atau request menggantung bisa memakan resource terlalu lama.
Membangun HTTP server Fiber v3 yang siap produksi
Setup Fiber, logging ke stdout, dan endpoint health
Di dalam container, log terbaik biasanya dikirim ke stdout/stderr. Docker, Compose, Kubernetes, dan platform cloud dapat mengumpulkan log tersebut tanpa konfigurasi tambahan. Hindari menulis log aplikasi ke file lokal dalam container, kecuali Anda memang punya strategi volume dan rotasi log yang jelas.
package httpserver
import (
"context"
"database/sql"
"log/slog"
"time"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/recover"
"github.com/redis/go-redis/v9"
)
type Server struct {
App *fiber.App
DB *sql.DB
Redis *redis.Client
Log *slog.Logger
ready bool
}
func New(db *sql.DB, rdb *redis.Client, logger *slog.Logger, readTimeout, writeTimeout, idleTimeout time.Duration) *Server {
app := fiber.New(fiber.Config{
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
})
s := &Server{
App: app,
DB: db,
Redis: rdb,
Log: logger,
ready: false,
}
app.Use(recover.New())
app.Get("/livez", func(c fiber.Ctx) error {
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "alive",
})
})
app.Get("/readyz", func(c fiber.Ctx) error {
if !s.ready {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"status": "starting",
})
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.DB.PingContext(ctx); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"status": "db_unavailable",
})
}
if err := s.Redis.Ping(ctx).Err(); err != nil {
return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{
"status": "redis_unavailable",
})
}
return c.Status(fiber.StatusOK).JSON(fiber.Map{
"status": "ready",
})
})
app.Get("/v1/hello", func(c fiber.Ctx) error {
return c.JSON(fiber.Map{"message": "ok"})
})
return s
}
func (s *Server) SetReady(v bool) {
s.ready = v
}
Liveness dan readiness punya fungsi berbeda:
- /livez: menandakan proses masih hidup. Endpoint ini sebaiknya ringan dan tidak bergantung pada database.
- /readyz: menandakan aplikasi siap menerima traffic. Di sini wajar untuk memeriksa dependency penting seperti PostgreSQL dan Redis.
Kesalahan umum adalah menyamakan keduanya. Jika liveness ikut memeriksa database, container bisa terus restart hanya karena dependency sementara bermasalah.
Graceful shutdown untuk API dan worker background
Kenapa graceful shutdown penting
Saat Docker mengirim SIGTERM, aplikasi sebaiknya masuk ke mode shutdown secara bertahap:
- Tandai aplikasi not ready agar tidak menerima traffic baru
- Hentikan worker dari mengambil job baru
- Tunggu request aktif dan job yang masih aman diselesaikan
- Tutup server HTTP
- Tutup koneksi database, Redis, dan resource lain
Urutan ini penting. Jika Anda menutup database dulu sebelum HTTP server berhenti, request yang masih berjalan bisa gagal di tengah proses.
Contoh worker yang bisa dihentikan dengan context
package worker
import (
"context"
"log/slog"
"sync"
"time"
)
type ImportWorker struct {
log *slog.Logger
wg sync.WaitGroup
}
func NewImportWorker(log *slog.Logger) *ImportWorker {
return &ImportWorker{log: log}
}
func (w *ImportWorker) Start(ctx context.Context) {
w.wg.Add(1)
go func() {
defer w.wg.Done()
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
w.log.Info("worker stopping: stop polling new jobs")
return
case <-ticker.C:
w.processOne(ctx)
}
}
}()
}
func (w *ImportWorker) processOne(ctx context.Context) {
w.log.Info("checking import job")
jobCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
select {
case <-jobCtx.Done():
w.log.Warn("job cancelled or timed out")
return
case <-time.After(5 * time.Second):
w.log.Info("job finished")
}
}
func (w *ImportWorker) Wait() {
w.wg.Wait()
}
Pola pentingnya adalah worker berhenti mengambil job baru saat context dibatalkan. Untuk job yang sedang berjalan, Anda punya dua pilihan:
- Biarkan selesai jika durasinya singkat dan aman
- Batalkan dengan context jika operasi bisa diulang atau perlu batas waktu ketat
Untuk proses impor besar, sebaiknya job dibuat idempotent atau bisa dilanjutkan ulang. Jangan mengandalkan asumsi bahwa shutdown selalu memberi waktu cukup lama.
Main function dengan orchestration shutdown
package main
import (
"context"
"database/sql"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9"
"yourapp/internal/config"
httpserver "yourapp/internal/http"
"yourapp/internal/worker"
)
func main() {
cfg := config.Load()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
rdb := redis.NewClient(&redis.Options{Addr: cfg.RedisAddr})
srv := httpserver.New(db, rdb, logger, cfg.ReadTimeout, cfg.WriteTimeout, cfg.IdleTimeout)
rootCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
importWorker := worker.NewImportWorker(logger)
importWorker.Start(rootCtx)
go func() {
srv.SetReady(true)
addr := ":" + cfg.Port
logger.Info("http server starting", "addr", addr)
if err := srv.App.Listen(addr); err != nil {
logger.Error("http server stopped", "error", err)
}
}()
<-rootCtx.Done()
logger.Info("shutdown signal received")
srv.SetReady(false)
time.Sleep(2 * time.Second)
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
if err := srv.App.ShutdownWithContext(shutdownCtx); err != nil {
logger.Error("fiber shutdown error", "error", err)
}
done := make(chan struct{})
go func() {
importWorker.Wait()
close(done)
}()
select {
case <-done:
logger.Info("worker stopped cleanly")
case <-shutdownCtx.Done():
logger.Warn("timeout waiting worker to stop")
}
if err := db.Close(); err != nil {
logger.Error("db close error", "error", err)
}
if err := rdb.Close(); err != nil {
logger.Error("redis close error", "error", err)
}
logger.Info("application stopped")
}
Ada beberapa detail penting di sini:
- signal.NotifyContext memudahkan propagasi sinyal ke seluruh komponen
- SetReady(false) dilakukan lebih awal agar endpoint readiness gagal dan load balancer berhenti mengirim traffic baru
- sleep singkat memberi waktu propagation sebelum server ditutup total
- ShutdownWithContext memberi batas waktu agar shutdown tidak menggantung selamanya
Jika Anda memiliki proses import yang bisa berjalan lama, pertimbangkan menyimpan status job di database: pending, running, retry, done, failed. Saat shutdown di tengah import, job bisa ditandai untuk dilanjutkan ulang oleh worker berikutnya.
Dockerfile multi-stage build untuk image kecil
Untuk aplikasi Go, multi-stage build adalah pilihan standar agar image hasil akhir tetap kecil dan tidak membawa toolchain compiler. Stage pertama digunakan untuk build binary, stage kedua hanya berisi binary final dan file minimum yang diperlukan.
FROM golang:1.24-alpine AS builder
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /app/bin/app ./cmd/api
FROM alpine:3.20
WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata
COPY --from=builder /app/bin/app /app/app
EXPOSE 8080
ENV APP_ENV=production
ENV APP_PORT=8080
HEALTHCHECK --interval=10s --timeout=3s --retries=3 CMD wget -qO- http://127.0.0.1:8080/livez || exit 1
USER nobody:nobody
ENTRYPOINT ["/app/app"]
Kenapa pendekatan ini bekerja:
- Stage builder memanfaatkan image Go lengkap untuk kompilasi
- Stage final hanya menyertakan binary hasil build dan paket runtime minimal
CGO_ENABLED=0membantu menghasilkan binary statis yang lebih mudah dipindahkan-ldflags="-s -w"mengurangi ukuran binary dengan membuang simbol debug
Trade-off: binary statis dengan CGO_ENABLED=0 cocok untuk banyak aplikasi API, tetapi beberapa dependency tertentu mungkin membutuhkan CGO. Jika ada kebutuhan seperti itu, Anda harus menyesuaikan base image dan runtime library.
docker-compose sederhana dengan PostgreSQL dan Redis
Untuk development lokal atau environment kecil, Compose masih sangat berguna. Contoh berikut menjalankan app, PostgreSQL, dan Redis dalam satu jaringan default.
services:
app:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
APP_ENV: production
APP_PORT: 8080
DATABASE_URL: postgres://app:app@postgres:5432/app?sslmode=disable
REDIS_ADDR: redis:6379
SHUTDOWN_TIMEOUT: 20s
READ_TIMEOUT: 10s
WRITE_TIMEOUT: 15s
IDLE_TIMEOUT: 60s
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
stop_grace_period: 30s
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/readyz"]
interval: 10s
timeout: 3s
retries: 3
mem_limit: 256m
cpus: 0.50
postgres:
image: postgres:16-alpine
environment:
POSTGRES_DB: app
POSTGRES_USER: app
POSTGRES_PASSWORD: app
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
volumes:
postgres_data:
Catatan penting:
depends_onmembantu urutan startup, tetapi bukan pengganti readiness logic di aplikasistop_grace_periodharus lebih besar dari waktu shutdown yang Anda butuhkan- limit CPU dan memori penting agar satu service tidak menghabiskan host secara berlebihan
Checklist production yang sering terlupakan
1. Timeout HTTP harus eksplisit
Pastikan read timeout, write timeout, dan idle timeout diatur. Ini melindungi aplikasi dari koneksi lambat dan request yang menggantung.
2. Logging ke stdout dalam format terstruktur
Gunakan JSON logging bila log akan dikirim ke agregator seperti Loki, ELK, atau Cloud Logging. Hindari log file lokal di dalam container.
3. Health check dibedakan antara liveness dan readiness
Liveness cukup memeriksa proses hidup. Readiness memeriksa apakah aplikasi siap menerima traffic, termasuk dependency penting jika memang dibutuhkan.
4. Resource limit harus ditetapkan
Tanpa limit, container bisa memakai memori berlebihan dan berakhir di-kill oleh kernel. Setidaknya definisikan batas CPU dan memori yang masuk akal.
5. Tangani sinyal saat proses import masih berjalan
Ini salah satu kasus nyata yang sering gagal. Saat shutdown dimulai:
- jangan ambil job baru
- simpan progress job jika memungkinkan
- batalkan operasi downstream yang mendukung context
- pastikan job bisa di-retry tanpa merusak data
Jika import menulis banyak record ke database, gunakan batch dan status checkpoint. Jangan menunggu job 30 menit selesai tanpa mekanisme resume.
6. Tutup dependency setelah server berhenti
Urutannya penting: hentikan readiness, stop penerimaan traffic, shutdown server, tunggu worker, lalu tutup koneksi DB/Redis.
7. Jalankan proses sebagai non-root
Di image final, gunakan user non-root jika memungkinkan. Ini tidak menyelesaikan semua isu keamanan, tetapi tetap menjadi praktik dasar yang baik.
Debugging masalah umum saat deployment
Container langsung restart
Periksa log aplikasi dengan docker compose logs -f app. Penyebab umum:
- DATABASE_URL salah
- port tidak sesuai dengan
EXPOSEatau mapping - panic saat startup
- health check gagal terus
Graceful shutdown tidak berjalan
Biasanya karena proses utama bukan binary aplikasi, misalnya dijalankan lewat shell wrapper yang tidak meneruskan sinyal dengan benar. Gunakan exec form pada ENTRYPOINT atau CMD, seperti ["/app/app"].
Readiness selalu gagal
Pastikan endpoint readiness tidak timeout terlalu cepat saat DB atau Redis memang butuh sedikit waktu untuk merespons. Namun jangan juga memberi timeout terlalu longgar, karena probe yang lambat bisa menyulitkan diagnosis.
Job background putus di tengah jalan
Jika job penting terhenti saat deploy, evaluasi dua hal:
- apakah
stop_grace_periodcukup panjang - apakah job mendukung resume/retry secara aman
Graceful shutdown bukan jaminan semua job panjang akan selalu selesai. Tujuan utamanya adalah penghentian yang terkendali, bukan keajaiban saat proses sangat lama.
Penutup
Deployment Go Fiber v3 ke Docker yang layak produksi tidak hanya soal membuat container bisa berjalan. Anda perlu memikirkan seluruh siklus hidup proses: startup, readiness, menerima traffic, menjalankan worker, hingga shutdown saat deploy atau scaling down.
Kombinasi multi-stage build, image kecil, environment-based config, logging ke stdout, health check yang benar, dan graceful shutdown akan membuat aplikasi jauh lebih stabil dan mudah dioperasikan. Jika aplikasi Anda memiliki proses background seperti import data, desain worker yang sadar terhadap context dan sinyal shutdown adalah investasi yang sangat penting.
Mulailah dari contoh di artikel ini, lalu sesuaikan dengan kebutuhan sistem Anda: apakah job harus bisa dilanjutkan, berapa lama timeout shutdown, dependency apa yang wajib dicek di readiness, dan bagaimana strategi retry untuk operasi yang terputus di tengah jalan.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!