โหมดมืด
บทที่ 1 — Logging ลึก
หลังจบบท คุณจะ:
- เขียน log ที่ search ได้จริง
- ใช้ MDC + correlation ID ข้าม service
- ตั้ง retention strategy (กลยุทธ์การเก็บนาน — log จะอยู่กี่วันก่อนลบ) ที่ไม่เปลือง $$
- Query log ด้วย LogQL (ภาษา query log ของ Loki) / Lucene (ลู-ซีน — search engine library พื้นฐานของ Elasticsearch ใช้เป็นภาษา query log)
- หลีกเลี่ยง log ที่เป็น noise
1. ทำไม Logging ยังสำคัญ
แม้มี metrics + traces แล้ว — log ยังจำเป็นเพราะ:
Metric: "มี error 5%" — แต่ทำไม?
Trace: "request นี้ใช้เวลา 5 วินาที" — แต่ตอนนั้นเกิดอะไรขึ้น?
Log: "Database timeout at line 142, retrying..." — ข้อมูลรายละเอียด→ Log = detailed context ที่ metric/trace ไม่มี
2. Log Level — ใช้ยังไง
TRACE → รายละเอียดสุดยอด (เข้า/ออก method) — ตอน dev / debug
DEBUG → ข้อมูลที่อาจช่วย debug — production มัก off (ปิด)
INFO → event ปกติที่อยากเห็น (login = เข้าสู่ระบบ, order created = สร้างคำสั่งซื้อ)
WARN → เกือบ error (retry = ลองใหม่, fallback used = ใช้แผนสำรอง, slow query = query ฐานข้อมูลช้า)
ERROR → error จริง (exception = ข้อยกเว้น, failed operation = ทำงานล้มเหลว)
FATAL → (เฟ-ทอล = ถึงตาย) ระบบใกล้พัง (รุนแรงสุด)กฎ
- Production: INFO + WARN + ERROR (DEBUG = off ตามปกติ)
- เพิ่ม DEBUG เฉพาะ class ที่กำลัง debug
- ERROR = ควรมีคน investigate (ถ้า ERROR ทุก request → bug ใน config)
yaml
# src/main/resources/application.yml
logging:
level:
root: INFO
com.example.payment: DEBUG # เปิด debug แค่ที่นี่3. Structured Logging — JSON เป็นมาตรฐาน
หัวใจของ logging สมัยใหม่คือเขียน log เป็น JSON (structured) ไม่ใช่ข้อความเปล่า — เพราะ JSON ทำให้ search/filter ตาม field ได้ (เช่น userId=42), parse ได้ทุก tool และแยกชนิดข้อมูล (number vs string) ต่างจาก plain text ที่ต้อง regex หา:
ก่อน (plain text):
2026-05-18 10:00:00 INFO Starting order processing for user 42
หลัง (JSON):
# field timestamp, level, logger, thread = อัตโนมัติจาก logging library
# field userId, orderId, service, environment, version, traceId, spanId = dev ใส่เอง (ผ่าน MDC / kv / config)
{
"timestamp": "2026-05-18T10:00:00.123Z",
"level": "INFO",
"logger": "com.example.OrderService",
"thread": "http-nio-8080-exec-1",
"message": "Starting order processing",
"userId": 42,
"orderId": 1001,
"service": "order-service",
"environment": "production",
"version": "1.2.3",
"traceId": "abc-123",
"spanId": "def-456"
}ข้อดี:
- ✅ Search/filter ตาม field
- ✅ Parse ได้ทุก tool
- ✅ Type-aware (number vs string)
4. Spring Boot + Logback + Logstash
📎 ศัพท์เร็ว: Logback = logging library default ของ Spring Boot · encoder = ตัวแปลง log object → text/JSON ·
springProfile= แท็กของ Spring Boot ที่แบ่งพฤติกรรม config ตาม environment (dev/prod)
ตั้งค่า structured logging ใน Spring Boot ผ่าน logstash-logback-encoder — เคล็ดลับที่นิยมคือใช้ springProfile แยกพฤติกรรม: dev แสดง log แบบ readable (อ่านง่ายตอน debug) ส่วน prod ส่งเป็น JSON (ให้ระบบ log เก็บ/ค้นได้):
xml
<!-- pom.xml -->
<dependency>
<groupId>net.logstash.logback</groupId>
<artifactId>logstash-logback-encoder</artifactId>
<!-- ใช้ version 8.x ขึ้นไป (ล่าสุดรองรับ Logback 1.4+ / Spring Boot 3+) — ตัวอย่าง 7.4 เก่าแล้ว -->
<version>8.0</version>
</dependency>📎 ไฟล์
logback-spring.xmlวางที่src/main/resources/logback-spring.xml— Spring Boot จะอ่านอัตโนมัติตอน startup · ถ้าใช้ Maven multi-module ก็วางในresources/ของ module ที่รัน app · ตั้งชื่อlogback-spring.xml(ไม่ใช่logback.xmlธรรมดา) ถ้าอยากใช้<springProfile>กับ<springProperty>
XML นี้แสดงโครงสร้างทั้งหมด — มือใหม่ copy ใช้ไปก่อน ค่อยปรับทีหลัง:
xml
<!-- logback-spring.xml -->
<configuration>
<!-- springProperty = ดึงค่าจาก application.yml มาใช้ใน XML นี้ -->
<springProperty name="appName" source="spring.application.name"/>
<springProperty name="env" source="spring.profiles.active"/>
<!-- appender = ปลายทางที่ log ถูกเขียนออก (console / file / network) -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoder = ตัวแปลง log → JSON ก่อนพ่นออก console -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdc>true</includeMdc>
<customFields>{"service":"${appName}","environment":"${env}"}</customFields>
<!-- fieldNames = เปลี่ยนชื่อ field ใน JSON output ตามใจ -->
<fieldNames>
<timestamp>timestamp</timestamp>
<message>message</message>
<thread>thread</thread>
<logger>logger</logger>
<level>level</level>
</fieldNames>
</encoder>
</appender>
<!-- springProfile name="!prod" = ใช้ block นี้เมื่อ profile ไม่ใช่ prod (เช่น dev/test) -->
<springProfile name="!prod">
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId},%X{spanId}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</springProfile>
<!-- prod ใช้ JSON appender ที่ตั้งไว้ข้างบน -->
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="JSON"/>
</root>
</springProfile>
</configuration>→ dev เห็น readable text, prod ส่ง JSON
5. SLF4J Best Practices
📎 SLF4J = Simple Logging Facade for Java — interface กลางที่ Spring Boot ใช้ ไม่ผูกกับ logging library ใดเฉพาะ (จะใช้ Logback / Log4j2 ก็เปลี่ยนข้างหลังได้โดยไม่ต้องแก้ code)
มีหลักการเขียน log ด้วย SLF4J ที่ควรทำให้ติดเป็นนิสัย — ใช้ placeholder {} (lazy, ไม่ concat ถ้า level ปิด), ส่ง exception เป็น argument แยก (ได้ stack trace ครบ), และใช้ structured field แทนการฝังค่าใน message (ค้นหาได้):
ใช้ Placeholder
java
// ❌ String concat (ต่อสตริงเอง)
log.info("User " + userId + " logged in at " + time);
// → ถ้า log level DEBUG ปิด, concat ก็ยังทำงาน (เปลือง CPU ฟรี ๆ)
// ✅ Placeholder (ใช้ {} แทนค่า)
log.info("User {} logged in at {}", userId, time);
// → placeholder {} จะถูกแทนด้วย argument ตามลำดับ (เหมือน printf แต่ปลอดภัยกว่า)
// → lazy (ขี้เกียจ — รอจนใช้จริง) ไม่ concat ถ้า level ปิด (ไม่ active)Pass Exception แยก
java
// ❌
log.error("Failed: " + e.getMessage()); // ไม่มี stack trace!
// ✅
log.error("Failed to process order {}", orderId, e); // exception เป็น arg สุดท้ายStructured Field
java
import static net.logstash.logback.argument.StructuredArguments.*;
// ❌ Embed ใน message
log.info("User {} placed order {} for amount {}", userId, orderId, amount);
// → field ใน JSON เป็น string ทั้งหมด
// ✅ Structured
log.info("Order placed",
kv("userId", userId),
kv("orderId", orderId),
kv("amount", amount));
// → JSON: { "userId": 42, "orderId": 1001, "amount": 99.99 }6. MDC — Mapped Diagnostic Context
MDC = "ใส่ context ไว้ → ทุก log ใน thread นี้มี context นั้น"
💡 thread = "เส้นทางทำงาน 1 เส้นของ JVM" — ใน Spring Boot 1 request HTTP มัก = 1 thread → ทุก log ที่เกิดใน request เดียวกันจะมี MDC context เดียวกัน (เช่นทุกบรรทัด log ของ request นี้มี requestId="abc-123")
java
MDC.put("userId", String.valueOf(user.getId()));
MDC.put("requestId", UUID.randomUUID().toString());
try {
log.info("Processing request"); // มี userId + requestId
processOrder(); // log ภายใน processOrder ก็มี
} finally {
MDC.clear();
}ใส่ใน Filter
OncePerRequestFilter = Spring filter ที่รันครั้งเดียวต่อ request (กัน execute ซ้ำเมื่อมี internal forward/include)
java
@Component
public class RequestContextFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
// ⚠️ trust client header ตรง ๆ = ช่อง log poisoning — ตรวจ format ก่อน
String requestId = req.getHeader("X-Request-ID");
if (requestId == null || !requestId.matches("[a-fA-F0-9-]{8,64}")) {
requestId = UUID.randomUUID().toString();
}
MDC.put("requestId", requestId);
MDC.put("method", req.getMethod());
MDC.put("path", req.getRequestURI());
MDC.put("clientIp", req.getRemoteAddr());
// Spring Boot 3 + Micrometer Tracing — auto add traceId, spanId
try {
res.setHeader("X-Request-ID", requestId);
chain.doFilter(req, res);
} finally {
MDC.clear();
}
}
}→ ทุก log ใน 1 request = มี requestId เดียวกัน → search ง่าย
7. Async + MDC — ระวัง!
📋 หัวข้อนี้สมมุติว่าคุณรู้จัก thread pool,
@Async,CompletableFutureใน Java แล้ว — ถ้ายัง ข้ามไปก่อนได้ (กลับมาอ่านตอนเริ่มทำ async work จริง)
กับดักที่พลาดบ่อยกับ correlation ID: MDC เก็บข้อมูลผูกกับ thread ปัจจุบัน — พองาน async ไปรันบน thread ใหม่ (thread pool) ค่า MDC จะหาย ทำให้ log ในงาน async ไม่มี requestId ต้อง copy context ข้าม thread เอง (หรือใช้ TaskDecorator — ตัวห่อ task ก่อน execute, ใช้ copy context ไป thread อื่น):
MDC ผูกกับ thread → async / pool ใหม่ = MDC หาย
java
// ❌ MDC หายใน async
@Async
public void process(Order order) {
log.info("Processing"); // ไม่มี requestId เพราะ thread ใหม่
}
// ✅ Pass MDC manually
public CompletableFuture<Result> process() {
Map<String, String> context = MDC.getCopyOfContextMap();
return CompletableFuture.supplyAsync(() -> {
try {
if (context != null) MDC.setContextMap(context);
log.info("Processing");
return doWork();
} finally {
MDC.clear();
}
});
}Spring TaskDecorator
java
@Configuration
public class AsyncConfig {
@Bean
public TaskDecorator mdcTaskDecorator() {
return runnable -> {
Map<String, String> context = MDC.getCopyOfContextMap();
return () -> {
try {
if (context != null) MDC.setContextMap(context);
runnable.run();
} finally {
MDC.clear();
}
};
};
}
@Bean
public Executor taskExecutor(TaskDecorator decorator) {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setTaskDecorator(decorator);
executor.initialize();
return executor;
}
}8. Correlation ID Across Service (ไอดีเชื่อมโยงข้าม service — ใช้ร้อย log/trace ของ request เดียวเข้าด้วยกัน)
Service A:
MDC: requestId=abc-123
→ call Service B (HTTP)
→ ส่ง header X-Request-ID: abc-123
Service B:
Filter อ่าน header → MDC.put("requestId", "abc-123")
→ log มี requestId เดียวกัน→ Search "abc-123" ใน Loki → เห็น log จาก ทั้ง A + B
ใน OpenTelemetry, traceId ทำหน้าที่นี้อยู่แล้ว — ไม่ต้องคิดเอง
9. ห้าม Log อะไร
log เป็นช่องทางที่ข้อมูลลับรั่วได้ง่ายและร้ายแรง (ใครเข้าถึง log ก็เห็น) — ห้าม log password, เลขบัตร, JWT เต็ม, secret, PII โดยเด็ดขาด ถ้าต้องบันทึกก็ mask ค่า และทำ filter ดักไว้อีกชั้น (defense in depth = ป้องกันหลายชั้น — หลักการ security ที่ใช้หลาย layer ของการป้องกัน; ถ้า layer หนึ่งพัง layer อื่นยังกั้น) เผื่อมีคนเผลอ log:
❌ Password
❌ Credit card number
❌ Full JWT
❌ SSN / national ID ← SSN = Social Security Number (เลขประจำตัวอเมริกา เทียบเลขบัตร ปชช. ไทย)
❌ API key / secret
❌ PII (full address, phone, ที่ไม่จำเป็น) ← PII = Personally Identifiable Information: ข้อมูลที่ระบุตัวบุคคลได้
❌ Request body ของ login / payment endpoint (มี secret)
❌ Stack trace ใน response ให้ userMask Pattern
java
private static String maskEmail(String email) {
if (email == null || !email.contains("@")) return "***";
int at = email.indexOf('@');
if (at < 3) return "***" + email.substring(at);
return email.substring(0, 2) + "***" + email.substring(at);
}
private static String maskCard(String card) {
if (card == null || card.length() < 4) return "****";
return "****-****-****-" + card.substring(card.length() - 4);
}
log.info("Login attempt", kv("email", maskEmail(email)));
// "Login attempt email=an***@example.com"Filter Sensitive Fields
java
// Custom logback converter
public class SecretMaskingConverter extends ClassicConverter {
private static final Pattern CARD_PATTERN = Pattern.compile("\\d{4}-?\\d{4}-?\\d{4}-?(\\d{4})");
// Base64URL alphabet ของ JWT = [A-Za-z0-9_-] บวก padding '=' ที่อาจมีท้าย section
private static final Pattern JWT_PATTERN =
Pattern.compile("eyJ[A-Za-z0-9_=-]{20,}\\.[A-Za-z0-9_=-]+\\.[A-Za-z0-9_=-]+");
@Override
public String convert(ILoggingEvent event) {
String msg = event.getFormattedMessage();
msg = CARD_PATTERN.matcher(msg).replaceAll("****-****-****-$1");
msg = JWT_PATTERN.matcher(msg).replaceAll("eyJ***");
return msg;
}
}ลงทะเบียน converter ใน logback-spring.xml ก่อนใช้งาน:
xml
<configuration>
<!-- ลงทะเบียน converter เป็น conversion word "mask" -->
<conversionRule conversionWord="mask"
converterClass="com.example.SecretMaskingConverter"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- ใช้ %mask แทน %msg เพื่อให้ converter ทำงานทุกบรรทัด -->
<pattern>%d %-5level %logger - %mask%n</pattern>
</encoder>
</appender>
</configuration>⚠️ ข้อควรระวัง:
- masking ระดับ message regex มี perf cost ใน hot path — ทางที่ดีกว่าคือ mask ที่ producer (ตอนเขียน log) ตั้งแต่ต้น
- masking pattern = defense in depth ไม่ใช่ primary control — primary control ต้องไม่ส่ง secret มาที่ log code ตั้งแต่แรก
→ Defense in depth — ใครเผลอ log secret → masking ดักไว้
10. What to Log
ตรงข้ามกับข้อห้าม — มี event ที่ควร log เสมอเพราะมีคุณค่าตอน debug/audit: การ start/stop, login (สำเร็จ+ล้มเหลว), business event, external call พร้อม duration, และ error พร้อม context ส่วนสิ่งที่ไม่ควร log คืองานถี่ ๆ ไร้ค่า (method entry, healthcheck) ที่ทำให้ log ท่วม:
✅ Service start / stop
✅ Login (success + fail) + reason
✅ Authorization decision (การตัดสินใจสิทธิ์ — denied = ถูกปฏิเสธเพราะอะไร)
✅ Business event (เหตุการณ์เชิงธุรกิจ — order created, payment received)
✅ External API call (เรียก service ภายนอก — with duration = ใช้เวลาเท่าไหร่ + status = สำเร็จ/ล้มเหลว)
✅ Slow operation (> threshold)
✅ Error / exception (with context)
✅ Configuration changes
✅ Background job start/endไม่ log:
- ❌ Every method entry / exit (ทุกการเข้า/ออก method — ใช้ trace แทน ไม่ต้อง log)
- ❌ Every SQL query (ทุก query SQL — ใช้ slow query log ของ DB แทน)
- ❌ Every cache hit
- ❌ Healthcheck probe (k8s probe = ทุก 10 วินาที = log ระเบิด)
11. Log Volume Management
log volume โตเร็วกว่าที่คิดมาก (service เดียวอาจสร้างหลาย TB/เดือน) ถ้าไม่จัดการจะจ่ายค่า storage มหาศาล — กลยุทธ์หลักคือ sample log ระดับต่ำ (DEBUG), drop log ที่ไร้ค่า, แปลงบางอย่างเป็น metric แทน และใช้ async logging ไม่ให้ I/O ของ log ไปหน่วง business logic:
Calculate
1 service:
- 1000 req / sec
- Avg 5 log per request
= 5000 log / sec
= 432M log / day
Avg log size: 500 bytes
= 216 GB / day
= 6.5 TB / month→ ใหญ่มาก — ต้อง manage
Strategies
1. Sampling DEBUG/TRACE
- DEBUG: 1% sample
- INFO: 100%
- WARN/ERROR: 100%
2. Drop noisy logs
- "Health check OK" → drop
- Cache hit log → drop
3. Aggregate
- แทน log "request X" → metric request_total++
- Log แค่ context สำคัญ
4. Async logging
- Use AsyncAppender (Logback)
- ลด blocking I/O ใน hot path (เส้นทางร้อน — code path ที่ถูกเรียกบ่อยและสำคัญต่อ performance)Logback Async
xml
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>10000</queueSize>
<!-- discardingThreshold = ค่า default 20 (= 20% of queueSize) → ทิ้ง TRACE/DEBUG/INFO เมื่อ queue เหลือ < 20%
ถ้าตั้ง 0 = ปิด discarding (ไม่ทิ้ง log ระดับใดเลย) แล้วจะ block หรือ drop ตาม neverBlock -->
<discardingThreshold>20</discardingThreshold>
<!-- neverBlock=false (default) → ถ้า queue เต็มจริง app จะรอ (block) ดีกว่าทำ log หาย
ถ้าตั้ง true → drop log เมื่อ queue เต็ม (เร็วแต่หาย) -->
<neverBlock>false</neverBlock>
<appender-ref ref="JSON"/>
</appender>
<root level="INFO">
<appender-ref ref="ASYNC"/>
</root>📎 อย่าตั้ง
discardingThreshold=0คู่กับneverBlock=true— สอง flag ขัดกัน (ห้ามทิ้ง แต่ห้าม block) เลือกชุดให้ตรงเจตนา: (a) drop strategy =discardingThreshold>0, neverBlock=false(default) หรือ (b) never drop =discardingThreshold=0, neverBlock=false(block on full → ป้องกัน log หาย แต่ latency กระชาก)
→ Log call ไม่ block business logic
12. Log Pipeline (ท่อส่ง log — จาก app ไปยังที่เก็บกลาง)
log ต้องไหลจาก app ไปยังที่เก็บกลางผ่าน pipeline — app เขียนออก stdout (JSON), collector (Promtail/Vector/Fluentbit) ดึงไป storage (Loki/Elasticsearch) แล้ว query ผ่าน UI กฎสำคัญคือ "อย่าเขียน log ลงไฟล์" เพราะ container ephemeral (อยู่ชั่วคราว — pod ตายเมื่อไหร่ข้อมูลใน container หาย) พอ pod ตาย log หาย — ให้เขียน stdout แล้วปล่อย runtime จัดการ:
[App]
↓ stdout (JSON)
[Container]
↓ docker / k8s log
[Log Collector] ← Promtail, Vector, Fluentbit, Fluentd
↓
[Storage] ← Loki, Elasticsearch, OpenSearch, S3
↓
[Query UI] ← Grafana, KibanaDon't Write to File
❌ Spring Boot log to /var/log/app.log
- Container ephemeral (อยู่ชั่วคราว — ไม่ persist)
- Pod ตาย = log หาย
- Manage rotation เอง
✅ Log to stdout/stderr
- Container runtime handle (Docker/K8s)
- Collector pick up automatically13. Loki — Lightweight Logs
Loki เป็นระบบเก็บ log ที่เน้นต้นทุนต่ำ — แทนที่จะ index เนื้อหา log ทั้งหมด (แบบ Elasticsearch) มัน index แค่ label แล้วเก็บเนื้อหาแบบ compressed ทำให้ถูกและ scale ง่าย เหมาะกับ log volume สูง query ผ่านภาษา LogQL ที่คล้าย PromQL:
Loki = "Prometheus for logs" (Grafana Labs) — Prometheus = ระบบ metric ยอดนิยม รายละเอียดในบทที่ 2
- Index แค่ label (metadata)
- Log content เก็บ compressed
- Cheap
Architecture
[App] → stdout → [Promtail] → [Loki] → [Grafana]Promtail Config
📎 Promtail ยัง maintained แต่ Grafana ประกาศ (2024) แนะนำ migrate ไป Grafana Alloy (agent ตัวเดียวรวม log + metric + trace) — ดูบทที่ 8 §10–11 สำหรับ Alloy config · ตัวอย่างต่อไปนี้ยังใช้ Promtail เพื่อความง่ายในการสาธิต concept
yaml
# promtail.yml
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock # ค้นหา container อัตโนมัติผ่าน Docker socket
relabel_configs:
# แปลง metadata ของ container ให้กลายเป็น label ที่ค้นใน Loki ได้
- source_labels: ['__meta_docker_container_name']
target_label: container # ตั้ง label "container" = ชื่อ container
- source_labels: ['__meta_docker_container_label_app']
target_label: app # ตั้ง label "app" = ค่า label app ของ containerLogQL — Query Language (ตัวอย่างพอเห็นภาพ)
📎 บทนี้แสดงแค่ตัวอย่าง LogQL พอเห็นภาพ — รายละเอียดเต็มอยู่ที่ บทที่ 8 — Loki + LogQL
syntax พื้นฐาน: {label=value} = filter ตาม label (เหมือนเลือก stream ที่จะอ่าน) — Loki ต้องระบุ label selector ทุก query
logql
# เอา log ทั้งหมดจาก container "myapp"
{container="myapp"}
# กรองเฉพาะบรรทัดที่มีคำว่า ERROR ( |= = บรรทัดที่ "มีข้อความนี้" )
{container="myapp"} |= "ERROR"
# นับอัตรา error ต่อวินาที (เฉลี่ยใน 5 นาที)
sum(rate({container="myapp"} |= "ERROR" [5m]))14. Elasticsearch / ELK — Traditional
ELK (Elasticsearch + Logstash + Kibana) เป็น stack ดั้งเดิมที่ index เนื้อหา log ทั้งหมด — ทำให้ full-text search และ analytics ทรงพลังกว่า Loki แต่แลกกับ storage cost ที่สูงและ setup ที่ซับซ้อนกว่า เลือกเมื่อต้องการ query ขั้นสูงจริง ๆ:
[App] → [Filebeat / Fluentbit] → [Logstash] → [Elasticsearch] → [Kibana]Pros / Cons vs Loki
| Loki | Elasticsearch | |
|---|---|---|
| Storage cost | low | high |
| Query power | basic (label-based) | full-text search |
| Best for | high volume, structured | rich query, analytics |
| Setup | easy | complex |
| Cardinality | careful | OK |
→ Modern preference: Loki (cost) — ELK (need advanced query)
15. Retention Strategy
ไม่จำเป็นต้องเก็บ log ทั้งหมดบน storage เร็ว (แพง) ตลอดไป — แบ่งเป็นชั้นตามอายุ: hot (ค้นเร็ว, SSD, 7-14 วัน), warm (HDD), cold (archive S3 Glacier เข้าถึงนาน ๆ ครั้ง) การทำ tier แบบนี้ช่วยลดค่าใช้จ่ายได้ 80%+:
Hot (search fast): 7-14 days SSD storage
Warm: 30-90 days HDD
Cold (archive): 1 year+ S3 Glacier (rare access)Tier Policy
yaml
# Loki retention
schema_config:
configs:
- from: 2023-01-01
store: tsdb # ใช้ TSDB index (ดัชนีแบบ time-series)
object_store: s3 # เก็บ chunk บน S3
schema: v12
index:
prefix: index_
period: 24h # สร้าง index ใหม่ทุก 24 ชม.
limits_config:
retention_period: 168h # เก็บ log ไว้ 7 วัน (168 ชม.) แล้วลบทิ้ง
compactor:
retention_enabled: true # เปิดให้ compactor ลบ log เกินอายุ (ถ้าไม่เปิด = ไม่ลบ)
storage_config:
aws:
# ⚠️ snippet นี้เป็นโครงคร่าว ๆ — boot Loki จริงต้องตั้งค่าครบ:
# s3: s3://<access_key>:<secret_key>@<region>/logs-archive
# หรือใช้ environment variable / IAM role
# ดู full config: https://grafana.com/docs/loki/latest/storage/
bucketnames: logs-archive
s3: s3://ACCESS_KEY:SECRET_KEY@ap-southeast-1/logs-archiveCost Comparison
1 TB / day x 90 days = 90 TB hot
$0.03 / GB / month = $2700 / month
vs
7 days hot (7 TB) + 83 days S3 Standard (83 TB) + 0 cold
= ~$1500 / month
vs all-S3 Glacier (cheaper but 12-hour retrieval)
= $300 / month→ Tier policy = save 80%+
16. Search Pattern — สิ่งที่ควรค้นเจอ
structured logging จะมีค่าก็ต่อเมื่อค้นเจอสิ่งที่ต้องการเร็ว — รวม query pattern ที่ใช้บ่อยตอน debug จริง: หาตาม request ID (ไล่ 1 request), ตาม user, error ในช่วงเวลา, จับ pattern ด้วย regex, และ aggregate นับ error ตาม class:
1. By Request ID
{job="myapp"} | json | requestId="abc-123"2. By User
{job="myapp"} | json | userId="42"3. Errors in Window
{job="myapp"} | json | level="ERROR"📎 ตั้ง time range ใน Grafana UI (มุมขวาบน) หรือ
--from/--toของ logcli — ไม่ใช่| range last 1h(ไม่ใช่ LogQL syntax)
4. Pattern Match
{job="myapp"} |~ "Out of memory|Stack overflow"5. Aggregate Error
sum by (className) (
count_over_time({job="myapp"} | json | level="ERROR" [1h])
)17. Live Tail (real-time)
นอกจากค้นย้อนหลัง ยัง "tail" log แบบ real-time ได้ (เหมือน tail -f แต่ข้ามทุก service) — มีประโยชน์ตอนกำลัง deploy หรือ reproduce bug เพื่อเฝ้าดู error เกิดสด ๆ ทำได้ทั้งผ่าน CLI, Kibana และ Grafana:
bash
# Loki via CLI (bash / zsh)
logcli tail '{job="myapp",level="ERROR"}'
# Windows PowerShell — single quote ของ PowerShell ไม่ allow nested double quote ตรง ๆ ต้อง escape:
# logcli tail '{job=\"myapp\",level=\"ERROR\"}'
# หรือใช้ here-string:
# logcli tail @'
# {job="myapp",level="ERROR"}
# '@
# Kibana
Discover → live mode
# Grafana
Logs panel → "live" toggle→ Watch error เกิดแบบ real-time
18. Logs + Traces Correlation
พลังที่แท้จริงเกิดเมื่อเชื่อม log กับ trace เข้าด้วยกัน — ถ้าทุก log มี traceId (Spring Boot 3 + Micrometer ใส่ให้อัตโนมัติ) ก็ตั้ง Grafana ให้คลิก traceId ใน log แล้วกระโดดไปดู trace เต็มใน Tempo ได้ทันที สลับไปมาระหว่าง "เกิดอะไร" (log) กับ "ช้าตรงไหน" (trace):
java
// Spring Boot 3+ + Micrometer Tracing — auto
log.info("Order created");
// JSON output มี: traceId, spanId
// Grafana ตั้ง config:
// Loki datasource → derived field:
// "trace_id" pattern: → link to Tempo data source
// → click traceId ใน log → jump ไป trace ใน Tempo19. Anti-Patterns
รวมความผิดพลาดเรื่อง logging ที่พบบ่อย — เขียนลงไฟล์ใน container (log หายเมื่อ pod ตาย), log แบบ printf (ค้นยาก), log object ทั้งก้อน (เปลือง), log ใน loop จนได้ stack trace พันอัน, log ทุก HTTP request และ log health check (noise ล้วน) แต่ละข้อมีวิธีแก้ที่ถูกต้องกำกับ:
❌ Log to File (in container)
File ใน container → pod restart → log หาย→ Always stdout/stderr
❌ printf-style log (สไตล์ printf จากภาษา C — ใช้ + ต่อสตริงเอง ไม่ใช้ placeholder)
java
log.info("user " + userId + " did " + action);→ ไม่มี structure — search ยาก
❌ Log Full Object
java
log.info("Order: " + order.toString());
// → 1KB log per entry × 1M orders = 1 GB log→ Log specific field:
java
log.info("Order", kv("id", order.getId()), kv("total", order.getTotal()));❌ Log + Exception ใน loop
java
for (Order order : orders) {
try {
process(order);
} catch (Exception e) {
log.error("Failed", e); // 1000 orders fail → 1000 stack traces
}
}→ Aggregate:
java
List<Exception> failures = new ArrayList<>();
for (Order order : orders) {
try { process(order); }
catch (Exception e) { failures.add(e); }
}
if (!failures.isEmpty()) {
log.error("Batch failed: {} of {} failed, first error", failures.size(), orders.size(), failures.get(0));
}❌ Log every HTTP request (verbose)
java
log.info("Request: {} {}", method, path);
log.info("Headers: {}", headers);
log.info("Body: {}", body);
log.info("Response: {}", response);→ Use access log + structured metric แทน
❌ Log Health Check
K8s liveness probe: GET /health every 10 sec
→ 8640 log entry per day per service
→ pure noise→ Filter health check out
java
// Logback filter
public class HealthCheckFilter extends Filter<ILoggingEvent> {
@Override
public FilterReply decide(ILoggingEvent event) {
return event.getFormattedMessage().contains("/health")
? FilterReply.DENY
: FilterReply.NEUTRAL;
}
}20. Audit Log — ไม่ใช่ Application Log
audit log ต่างจาก application log โดยสิ้นเชิง — app log ไว้ debug (ลบได้, free text) แต่ audit log ไว้ทำ compliance/forensic (ต้อง immutable, append-only, เข้ารหัส, เก็บหลายปี) บันทึก "ใครทำอะไรเมื่อไหร่จากที่ไหน" ควรแยกระบบและ storage ออกจาก app log:
Application log: "Order processing... done"
Audit log: "User 42 created order 1001 at 10:30:00 from IP 1.2.3.4"ความต่าง:
| App log | Audit log | |
|---|---|---|
| Purpose | Debug | Compliance + forensic |
| Format | Free text + JSON | Structured + immutable |
| Retention | 30-90 days | 1-7 years (compliance) |
| Tamper protection | low | high (append-only) |
| Encryption | optional | required ที่ rest |
📎 โค้ดในบทนี้ใช้ Lombok (
@RequiredArgsConstructor,@Slf4j, ฯลฯ) — เพิ่มในpom.xml:xml<dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency>
- ติดตั้ง Lombok plugin ใน IDE · ถ้าไม่ใช้ Lombok ก็ต้องเขียน constructor + logger field เอง (ผลลัพธ์เหมือนกัน แค่ verbose กว่า)
java
// Separate audit logger
@Component
@RequiredArgsConstructor
public class AuditService {
private final AuditEventRepository auditRepo;
public void log(String action, Long userId, Map<String, Object> details) {
AuditEvent event = new AuditEvent();
event.setAction(action);
event.setUserId(userId);
event.setIpAddress(getCurrentIp());
event.setDetails(details);
event.setTimestamp(Instant.now());
auditRepo.save(event); // ไป DB ที่ append-only
}
}21. Performance Impact
การ log มีต้นทุนด้าน performance ที่มองไม่เห็น — log แบบ sync ทุกครั้งคือรอ I/O ถ้า log ถี่ใน hot path อาจเพิ่ม latency หลายสิบ ms ทางแก้คือ async logging (เก็บ log ใน queue แล้ว flush เบื้องหลัง) แต่ต้องจูน queue ระวังไม่ให้ ERROR หายตอน queue เต็ม:
Sync log (no async):
- Each log = I/O wait
- 1ms × 100 log/req = 100ms latency added!
Async log (AsyncAppender):
- Log queue ใน memory
- Background thread flush
- Near-zero latency impact
Drop log on full queue?
- discardingThreshold = 0 → **ปิด** discarding (เก็บทุก level — queue เต็มจะ block หรือ drop ตาม neverBlock)
- discardingThreshold = 20 (default) → ทิ้ง TRACE/DEBUG/INFO เมื่อ queue เหลือ < 20% (ERROR/WARN ยังเก็บเสมอ)
- ระวัง: ตั้ง neverBlock=true พร้อม discardingThreshold>0 → log ERROR ไม่หาย แต่ INFO ที่ถูกทิ้งอาจเป็นบริบทสำคัญ → tune queue size22. Multi-line Log (Stack Trace)
stack trace กินหลายบรรทัด ซึ่งสร้างปัญหากับ log collector ที่มองทีละบรรทัด — มันอาจ split stack trace 1 อันเป็นหลาย record ทำให้อ่านยาก ทางแก้คือใช้ JSON encoder (เก็บ trace ใน field เดียว) หรือตั้ง multiline config ที่ collector:
Spring Boot stack trace = หลายบรรทัด
Log collector อาจ split → 1 line per row → ดูยาก
✅ JSON encoder = stack trace ใน 1 field
→ 1 record, multiline เก็บใน field "stack_trace"
❌ Plain text:
2026-05-18 10:00 ERROR Exception
at com.example.Foo
at com.example.Bar
→ 3 records
→ Promtail/Fluentbit ต้องตั้ง multiline configyaml
# Promtail multiline
pipeline_stages:
- multiline:
firstline: '^\d{4}-\d{2}-\d{2}' # บรรทัดที่ขึ้นต้น timestamp
max_wait_time: 3s23. Cost Optimization
ค่า log มักเป็นค่าใช้จ่ายก้อนใหญ่ของ observability — รวมเทคนิคลดต้นทุนเป็นชุด: drop log ไร้ค่า, sample log ที่ success, แปลงบางอย่างเป็น metric, ตัด field ที่ไม่จำเป็น, compress, ทำ tier ตามอายุ, และ self-host (Loki ถูกกว่า Splunk มาก) ใช้ร่วมกันลดได้หลายเท่า:
1. Drop log
- Health check (noisy)
- DEBUG/TRACE in prod
- Library log ที่ไม่ใช้
2. Sample
- INFO success: 10%
- INFO failure: 100%
- WARN/ERROR: 100%
3. Aggregate
- "request started" → drop
- "request finished" + duration → keep as metric
4. Drop fields
- request body
- user agent
- referrer
5. Compress
- gzip / zstd ใน storage
6. Tier
- hot 7 days
- cold 90 days (S3)
- delete > 1 year
7. Self-host
- Loki cheaper than Splunk (50-90% saving)📎 ตัวเลข cost saving 50-90% มาจาก Grafana marketing comparison — apple-to-apple ไม่เสมอ เพราะ Splunk index full-text ทุก field ส่วน Loki index แค่ label · ขึ้นกับ workload + query pattern:
- ระบบที่ query ส่วนใหญ่เป็น filter by label + recent time range → Loki saving สูง (เคยเห็น 5-10x)
- ระบบที่ ad-hoc full-text search ใน historical data → Loki ช้า + อาจไม่ถูกกว่ามาก
- Self-host cost engineering time อย่าลืมใส่ใน TCO (Total Cost of Ownership = ต้นทุนรวมทั้งหมดของการเป็นเจ้าของ — รวมเวลา engineer ในการดูแลด้วย ไม่ใช่แค่ค่า server)
Benchmark ของตัวเองก่อน commit decision
Example
yaml
# Promtail drop healthcheck
pipeline_stages:
- match:
selector: '{job="myapp"} |= "/actuator/health"'
action: drop24. Real-world Example
ปิดท้ายด้วยตัวอย่างจริงที่รวมทุกหลักการในบทนี้ — OrderService ที่ใช้ structured log (kv), บันทึก business event สำคัญพร้อม context (userId/orderId/amount), จัดการ error อย่างเหมาะสม สังเกตว่า log แต่ละบรรทัดมีคุณค่าและ search ได้ ไม่ใช่ noise:
java
// kv() = StructuredArguments.kv — static import จาก logstash-logback-encoder
import static net.logstash.logback.argument.StructuredArguments.kv;
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {
private final OrderRepository orderRepo;
private final PaymentService paymentService;
public Order createOrder(CreateOrderRequest req, Long userId) {
// ✅ Structured log with kv
log.info("Creating order",
kv("userId", userId),
kv("itemCount", req.items().size()),
kv("totalAmount", req.totalAmount()));
try {
Order order = new Order(userId, req.items(), req.totalAmount());
order = orderRepo.save(order);
log.info("Order created",
kv("orderId", order.getId()),
kv("userId", userId));
try {
PaymentResult result = paymentService.charge(order, req.paymentMethod());
log.info("Payment processed",
kv("orderId", order.getId()),
kv("paymentId", result.id()),
kv("amount", result.amount()));
} catch (PaymentException e) {
// Log ที่ pertinent — ไม่ใช่ stack trace ทุก fail
log.warn("Payment failed",
kv("orderId", order.getId()),
kv("reason", e.getCode()));
throw e;
}
return order;
} catch (Exception e) {
// ✅ Exception with context
log.error("Failed to create order",
kv("userId", userId),
kv("totalAmount", req.totalAmount()),
e); // <- exception ที่ argument สุดท้าย
throw e;
}
}
}→ Log สำคัญ + ไม่ verbose + searchable
25. Checkpoint
🛠️ Checkpoint 1.1 — Structured Logging
- Spring Boot project — switch to JSON logging
- ใช้ kv() everywhere
- Test ใน Loki / Grafana → search ด้วย field
🛠️ Checkpoint 1.2 — MDC + Correlation
- Add filter ที่ generate + propagate X-Request-ID
- ใส่ MDC: requestId, method, path
- ทดสอบ: send request → search log ทั้งหมดของ request นั้น
🛠️ Checkpoint 1.3 — Setup Loki
📋 ต้องมีก่อน: ✅ Docker + docker compose (ยังไม่มี ดู Docker บท 0) · ✅ Git · ✅ Spring Boot app ที่ log JSON อยู่ (ดู §4 ในบทนี้ + Spring Boot บท 1)
ขั้นตอน:
bash
# 1) Clone Loki getting-started ตัวอย่าง (verified docker-compose)
git clone https://github.com/grafana/loki && cd loki/examples/getting-started
# หรือดู doc ทางการ: https://grafana.com/docs/loki/latest/get-started/quick-start/
# 2) สตาร์ท stack: Loki (3100) + Promtail + Grafana (3000)
docker compose up -d
# 3) ตรวจ Loki พร้อมรับ log
# เปิด http://localhost:3100/ready → ควรตอบ "ready"ต่อ Spring Boot เข้า Loki (2 ทางเลือก):
ทางเลือก A — ส่งตรงผ่าน loki-logback-appender (เพิ่มใน pom.xml)
xml
<dependency>
<groupId>com.github.loki4j</groupId>
<artifactId>loki-logback-appender</artifactId>
<version>1.5.2</version>
</dependency>แล้วเพิ่ม appender ใน logback-spring.xml:
xml
<appender name="LOKI" class="com.github.loki4j.logback.Loki4jAppender">
<http><url>http://localhost:3100/loki/api/v1/push</url></http>
<format>
<label><pattern>app=myapp,host=${HOSTNAME}</pattern></label>
<message class="com.github.loki4j.logback.JsonLayout"/>
</format>
</appender>
<root level="INFO"><appender-ref ref="LOKI"/></root>ทางเลือก B — เขียน stdout แล้วให้ Promtail scrape (เหมาะกับ container)
- รัน Spring Boot ใน Docker network เดียวกับ stack ด้านบน
- Promtail config (sample ของ getting-started) จะ scrape Docker logs ให้อัตโนมัติผ่าน
/var/run/docker.sock
ผลลัพธ์:
- Send Spring Boot log → Loki
- เปิด Grafana → query
✅ เมื่อสำเร็จคุณควรเห็น: http://localhost:3100/ready ตอบ
ready+ Grafana Explore → เลือก Loki datasource → query{job="..."}เห็น log stream เข้ามา realtime
🛠️ Checkpoint 1.4 — Drop Noise
- Filter healthcheck out
- ตรวจ noisy log → drop ที่ collector
- Compare volume ก่อน/หลัง
🛠️ Checkpoint 1.5 — Async + MDC
- ใช้ @Async ใน Spring
- MDC จะหาย — แก้ด้วย TaskDecorator
- Verify ใน log ว่า async task มี requestId เดียวกัน
26. คำศัพท์บทนี้ (Glossary)
| คำ | ความหมาย |
|---|---|
| MDC | Mapped Diagnostic Context — ที่เก็บ context (เช่น requestId) ผูกกับ thread ปัจจุบัน ทำให้ทุก log ใน thread นั้นมี context เดียวกัน |
| SLF4J | Simple Logging Facade for Java — หน้ากากกลางที่เรียก logging library ไหนก็ได้ |
| Logback / LogstashEncoder | logging library ของ Java + encoder ที่แปลง log เป็น JSON |
| PII | Personally Identifiable Information — ข้อมูลที่ระบุตัวบุคคลได้ (ห้าม log) |
| SSN | Social Security Number — เลขประจำตัวอเมริกา (เทียบเลขบัตร ปชช.) |
| JWT | JSON Web Token — token ที่ฝัง claim ไว้ (ห้าม log แบบเต็ม) |
| LogQL | ภาษา query log ของ Loki (` |
| correlation ID / request ID | id ที่ร้อย log ของ 1 request เข้าด้วยกัน ค้นทีเดียวเห็นทั้ง flow |
| retention tier | แบ่งชั้นเก็บ log ตามอายุ (hot/warm/cold) เพื่อลด cost |
| audit log | log สำหรับ compliance — immutable, append-only (ต่างจาก application log) |
27. สรุปบท
✅ Structured logging (JSON encoder — ตัวแปลง log เป็น JSON) = standard — search/filter/correlate ได้
✅ Logback + LogstashEncoder (ตัวแปลง log → JSON) สำหรับ Spring Boot
✅ SLF4J placeholder {} — ไม่ใช่ string concat (ต่อสตริงเอง)
✅ MDC สำหรับ context propagation (ส่ง context ต่อ) — ใส่ใน filter
✅ Async + MDC → ต้อง TaskDecorator (ตัวห่อ task ก่อน execute — มัก MDC หายข้าม thread)
✅ Correlation ID ข้าม service ผ่าน header / OpenTelemetry
✅ ห้าม log secret + mask sensitive
✅ Loki + Grafana = lightweight, low cost
✅ LogQL = search by label + filter content
✅ Retention tier: hot 7d → warm 90d → cold 1y → delete
✅ Anti-patterns: log file, log full object, log health check, sync log
✅ Separate audit log จาก application log