This content originally appeared on DEV Community and was authored by Somprasong Damyos
Originally published at https://somprasongd.work/blog/go/distributed-logging-1
เคยไหม? เปิด log ไฟล์มาแล้วต้องกวาดตาดู Stack Trace วนเป็นชั่วโมง กว่าจะเจอว่า Error อันนี้มาจาก Request ไหน แล้วถ้าเจอ Request หนึ่งกระจายยิงหลาย Service ยิ่งวุ่นเข้าไปใหญ่
นี่คือที่มาของ Request ID หรือบางคนเรียกว่า Correlation ID — ตัวช่วยเล็ก ๆ ที่ทำให้ Distributed Logging เป็นเรื่องง่ายขึ้น
บทความนี้จะพาไปดูวิธีทำ End-to-End Correlated Logging ตั้งแต่
- Proxy ชั้นนอก (NGINX)
- จนถึง Backend (Go Fiber)
- และวิธีส่งต่อ ID นี้ไปทั้ง Layer: Handler → Service → Repository
พร้อมตัวอย่างโค้ดจริง เอาไปต่อยอดได้เลย
ทำไมต้องมี Request ID?
เวลามี Request เข้า Service, เราอยากรู้ว่า:
- Log ไหนเป็นของ Request ไหน
- ถ้า Request เดียวกันทำงานหลาย Layer หรือเรียกหลาย Service, ทุก Log ต้องมี ID เดียวกัน
พอมี ID เดียวกัน เราจะ Search, Filter, Trace ข้ามระบบได้ง่าย (โดยเฉพาะถ้าใช้ OpenTelemetry หรือ ELK, Loki, Jaeger)
ภาพรวม Architecture
-
NGINX: ทำหน้าที่ Proxy, inject
X-Request-ID
ถ้ายังไม่มี -
Fiber Middleware รับ
X-Request-ID
แล้วสร้าง Logger ฝังrequest_id
ใส่context.Context
-
Layered Architecture: แบ่ง
Handler
→Service
→Repository
ทุก Layer รับ Context และดึง Logger จาก Context เท่านั้น - Logger: ใช้ Uber Zap Logger ซึ่งเป็น Production-ready logger ที่นิยมใน Go
โครงสร้างไฟล์โปรเจกต์
project/
├── cmd/
│ └── main.go
├── middleware/
│ └── request_context.go
├── handler/
│ └── user_handler.go
├── service/
│ └── user_service.go
├── repository/
│ └── user_repository.go
├── Dockerfile
├── docker-compose.yml
├── nginx.conf
├── go.mod
└── go.sum
Config NGINX ให้ใส่ X-Request-ID
เริ่มที่ Proxy ก่อน สมมติคุณมี nginx.conf
ประมาณนี้:
http {
server {
listen 80;
location / {
# ถ้ามี X-Request-ID แล้ว ให้ใช้ของเดิม
# ถ้าไม่มี ให้ generate ใหม่จาก $request_id ของ NGINX
proxy_set_header X-Request-ID $request_id;
proxy_pass http://backend;
}
}
# ตั้ง backend upstream
upstream backend {
server app:3000;
}
}
Tip:
-
$request_id
ของ NGINX คือ Unique ID ที่ NGINX generate ให้แต่ละ Request - ถ้าข้างหน้ามี Load Balancer ที่ generate ไว้แล้ว หรือ Client ส่ง
X-Request-ID
มาก่อนแล้ว$request_id
ของ NGINX จะ preserve ให้โดยอัตโนมัติ
Fiber Middleware: สร้าง Request ID และ Logger
ต่อมาใน Go Fiber เราต้องทำ Middleware ดึง X-Request-ID
ใส่ logger
สร้าง Context Key
// ctxkey/ctxkey.go
package ctxkey
type key int
const (
Logger key = iota
RequestID
)
สร้าง Logger
// logger/logger.go
package logger
import (
"context"
"demo-logger/ctxkey"
"go.uber.org/zap"
)
var baseLogger *zap.Logger
func InitLogger() {
l, _ := zap.NewProduction()
baseLogger = l.With(zap.String("app_name", "demo-logger"))
}
func Default() *zap.Logger {
return baseLogger
}
func Logger(ctx context.Context) *zap.Logger {
log, ok := ctx.Value(ctxkey.Logger).(*zap.Logger)
if ok {
return log
}
return baseLogger
}
สร้าง Middleware
// middleware/request_context.go
package middleware
import (
"context"
"demo-logger/ctxkey"
"demo-logger/logger"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"go.uber.org/zap"
)
func RequestContext() fiber.Handler {
return func(c *fiber.Ctx) error {
reqID := c.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.New().String()
}
// Bind Request ID ลง Response Header
c.Set("X-Request-ID", reqID)
// สร้าง child logger
reqLogger := logger.Default().With(zap.String("request_id", reqID))
// สร้าง Context ใหม่
ctx := context.WithValue(c.Context(), ctxkey.RequestID, reqID)
ctx = context.WithValue(ctx, ctxkey.Logger, reqLogger)
// แทน Context เดิม
c.SetUserContext(ctx)
return c.Next()
}
}
Handler → Service → Repository ใช้ Logger จาก Context
Handler
// handler/user_handler.go
package handler
import (
"demo-logger/logger"
"demo-logger/service"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
func (h *UserHandler) GetUser(c *fiber.Ctx) error {
userID := c.Params("id")
// ใช้ UserContext() เพราะใส่ logger ไว้ที่นี่
user, err := h.svc.GetUser(c.UserContext(), userID)
if err != nil {
// ดึง logger จาก context
logger.FromContext(c.UserContext()).Error("failed to get user")
return c.Status(fiber.StatusInternalServerError).SendString("error")
}
// ดึง logger จาก context
logger.FromContext(c.UserContext()).Info("success get user", zap.String("user_id", userID))
return c.JSON(user)
}
Service
// service/user_service.go
package service
import (
"context"
"demo-logger/logger"
"demo-logger/repository"
"go.uber.org/zap"
)
type UserService struct {
repo *repository.UserRepository
}
func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(ctx context.Context, userID string) (any, error) {
// ดึง logger จาก context
logger.FromContext(ctx).Info("calling repo", zap.String("user_id", userID))
return s.repo.FindByID(ctx, userID)
}
Repository
// repository/user_repository.go
package repository
import (
"context"
"demo-logger/logger"
"go.uber.org/zap"
)
type UserRepository struct {
// DB connection
}
func NewUserRepository() *UserRepository {
return &UserRepository{}
}
func (r *UserRepository) FindByID(ctx context.Context, userID string) (any, error) {
// ดึง logger จาก context
logger.FromContext(ctx).Info("querying database", zap.String("user_id", userID))
// สมมติคืน mock user
return map[string]string{"id": userID, "name": "ball"}, nil
}
Main
// cmd/main.go
package main
import (
"demo-logger/handler"
"demo-logger/logger"
"demo-logger/middleware"
"demo-logger/repository"
"demo-logger/service"
"github.com/gofiber/fiber/v2"
)
func main() {
logger.InitLogger()
app := fiber.New()
app.Use(middleware.RequestContext())
repo := repository.NewUserRepository()
svc := service.NewUserService(repo)
hdl := handler.NewUserHandler(svc)
app.Get("/user/:id", hdl.GetUser)
app.Listen(":3000")
}
Build: สร้าง Dockerfile และ docker-compose
Dockerfile
# ---------- STAGE 1: Build ----------
FROM golang:1.24 AS builder
# Set working dir
WORKDIR /app
# Copy go.mod and go.sum first for caching dependencies
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy the source code
COPY . .
# Build binary - youสามารถเปลี่ยนชื่อได้ตามต้องการ
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o app ./cmd/main.go
# ---------- STAGE 2: Run ----------
FROM alpine:latest
# ทำให้ binary ทำงานได้ (สำหรับบาง lib เช่น timezone)
RUN apk --no-cache add ca-certificates
# Set working dir
WORKDIR /root/
# Copy binary จาก builder stage
COPY --from=builder /app/app .
# Expose port (ถ้ามี)
EXPOSE 3000
# Command to run
CMD ["./app"]
docker-compose.yml
services:
nginx:
image: nginx:latest
container_name: nginx-proxy
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
app:
build: .
container_name: backend-app
Run
docker compose up -d --build
ทดสอบเรียก curl
http://localhost/users/1
ผลลัพธ์
เรียกดู Log ด้วยคำสั่ง docker compose logs app
backend-app | {"level":"info","ts":1751602673.5724216,"caller":"service/user_service.go:20","msg":"calling repo","app_name":"demo-logger","request_id":"29988fdef291b7afaadaa872cea0d5ba","user_id":"1"}
backend-app | {"level":"info","ts":1751602673.5769289,"caller":"repository/user_repository.go:19","msg":"querying database","app_name":"demo-logger","request_id":"29988fdef291b7afaadaa872cea0d5ba","user_id":"1"}
backend-app | {"level":"info","ts":1751602673.5770924,"caller":"handler/user_handler.go:28","msg":"success get user","app_name":"demo-logger","request_id":"29988fdef291b7afaadaa872cea0d5ba","user_id":"1"}
ทุก Log ที่เกิดใน Handler, Service, Repo จะมี request_id
ติดไปด้วย ทำให้เรา grep หรือ trace cross-service ได้ง่าย
สรุป
Request ID หรือ Correlation ID คือวิธีง่าย ๆ ที่ช่วยให้การ Debug ระบบ Distributed หรือ Microservices เป็นเรื่องง่ายขึ้น
จุดสำคัญคือ generate ID ครั้งเดียวที่ Proxy แล้วส่งต่อทุกจุดใน Layer ด้วย context.Context
Logger ต้องสร้างครั้งเดียวใน Middleware แล้วใช้ Logger จาก Context ทั้งหมด
แค่นี้คุณจะมี Log ที่เชื่อมโยงได้ชัดเจน ลดเวลาหา Bug ได้มาก
This content originally appeared on DEV Community and was authored by Somprasong Damyos