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 SIGTERM atau SIGINT

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:

  1. Tandai aplikasi not ready agar tidak menerima traffic baru
  2. Hentikan worker dari mengambil job baru
  3. Tunggu request aktif dan job yang masih aman diselesaikan
  4. Tutup server HTTP
  5. 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=0 membantu 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_on membantu urutan startup, tetapi bukan pengganti readiness logic di aplikasi
  • stop_grace_period harus 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 EXPOSE atau 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_period cukup 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.