Failure, Deployment and Observation
ความแตกต่างสำคัญระหว่างโปรแกรมธรรมดากับ networked system: เครื่องอื่นและเครือข่ายล้มเหลวได้อย่างไม่พร้อมกัน บางส่วน และไม่แจ้งเตือน — หัวข้อนี้สำคัญกว่าการรีบสอน microservice pattern จำนวนมาก
1Latency, Packet Loss และ Partial Failure
| ปรากฏการณ์ | ความหมาย |
|---|---|
| Latency | เวลาที่ request ใช้เดินทางไป-กลับ — ไม่คงที่ ผันผวนตามสภาพเครือข่ายและโหลดของปลายทาง (ดู intro-7 Performance เรื่อง RTT) |
| Packet loss | แพ็กเก็ตหายระหว่างทาง (ดู Congestion Control) — TCP retransmit ให้อัตโนมัติ แต่ทำให้ latency สูงขึ้นแบบไม่แน่นอน |
| Partial failure | บาง component ของระบบล้มเหลวขณะที่ส่วนอื่นยังทำงานปกติ — ต่างจาก failure ของโปรแกรมเดี่ยวที่มักเป็น all-or-nothing |
2Connection Refused เทียบกับ Timeout
สอง error ที่หน้าตาคล้ายกันแต่ความหมายต่างกันโดยสิ้นเชิง — ต้องแยกให้ออกเพื่อออกแบบ retry logic ให้ถูกต้อง:
| Connection Refused | Timeout | |
|---|---|---|
| เกิดขึ้นเมื่อไร | ทันที — OS ปลายทางตอบ TCP RST กลับมาเพราะไม่มี process ใด listen() ที่ port นั้น | ไม่มีการตอบกลับเลยภายในเวลาที่กำหนด — อาจเพราะ packet หาย, เครื่องปลายทางไม่ตอบ, หรือ process ค้าง |
| สื่อความหมายว่า | เครื่องปลายทางยังออนไลน์ แต่ไม่มี service รันอยู่ที่ port นั้น (service ตายหรือ config port ผิด) | ไม่รู้อะไรเลยแน่ชัด — เครื่องอาจล่ม, เครือข่ายอาจขาด, หรือ service อาจแค่ทำงานช้ามาก |
| ระดับความมั่นใจว่า request ไปถึงหรือไม่ | มั่นใจว่าไม่ถึง (connection ก่อตั้งไม่สำเร็จเลยด้วยซ้ำ) | ไม่มั่นใจเลย — นี่คือรากของปัญหา idempotency ในหัวข้อถัดไป |
3Timeout และ Retry
Retry คือการส่ง request ซ้ำเมื่อล้มเหลว — ฟังดูตรงไปตรงมา แต่มีอันตรายซ่อนอยู่ในหัวข้อถัดไป
4อันตรายจากการ Retry: Duplicate Request และ Idempotency
POST /orders) แล้ว timeout — clientไม่รู้ว่า server บันทึกสำเร็จไปแล้วหรือยัง (ดูหัวข้อ 1: partial failure) ถ้า client ตัดสินใจ retry ส่ง POST /orders ซ้ำ แล้วครั้งแรกจริงๆ สำเร็จไปแล้ว (แค่ response หายตอนส่งกลับ) ผลคือออร์เดอร์ถูกสร้างซ้ำสองใบ — ลูกค้าโดนตัดเงินสองครั้งกรณีที่ 2: Request ครั้งแรกถึง server, ประมวลผลสำเร็จ, แต่ response หาย → retry สร้างข้อมูลซ้ำถ้า operation ไม่ idempotent
กรณีที่ 3: Request ครั้งแรกถึง server แต่ยังประมวลผลไม่เสร็จตอน retry มาถึง → เสี่ยงประมวลผลซ้ำ/แข่งกัน (race)
PUT /keys/course (จากหัวข้อที่ 2) idempotent อยู่แล้วโดยธรรมชาติ (set ค่าซ้ำกี่ครั้งผลลัพธ์เหมือนเดิม) แต่ POST /orders (สร้างออร์เดอร์ใหม่ทุกครั้งที่เรียก) ไม่ idempotent โดยธรรมชาติ — ต้องออกแบบให้เป็น idempotent เพิ่มเติมถ้าต้องการ retry ได้อย่างปลอดภัย
POST /orders
Idempotency-Key: 8f14e45f-... (client สุ่มสร้างครั้งเดียวต่อ "เจตนา" การสั่งซื้อหนึ่งครั้ง)
{"item": "laptop", "qty": 1}
ฝั่ง server เก็บ mapping Idempotency-Key → ผลลัพธ์ที่เคยตอบไปแล้ว — ถ้า request ที่มี key เดิมมาถึงซ้ำ (ไม่ว่าเพราะ client retry ตอน timeout จริง) server ไม่สร้างออร์เดอร์ใหม่ แต่ตอบผลลัพธ์เดิมกลับไปแทน ทำให้ operation ที่ไม่ idempotent โดยธรรมชาติ กลาย "ปลอดภัยต่อการ retry" ได้ในทางปฏิบัติ
GET /orders?ref=...) เพื่อเช็คว่าสำเร็จไปแล้วหรือยังก่อนตัดสินใจส่งซ้ำ5Exponential Backoff
import time, random, requests
def call_with_retry(url, max_retries=5, base=0.1, max_delay=10):
for attempt in range(max_retries):
try:
resp = requests.get(url, timeout=3)
if resp.status_code < 500:
return resp # สำเร็จ หรือ 4xx ที่ retry ไปก็ไม่ช่วย
except requests.exceptions.RequestException:
pass # timeout / connection refused → ลอง retry
delay = min(base * (2 ** attempt), max_delay)
time.sleep(random.uniform(0, delay)) # jitter
raise TimeoutError("all retries exhausted")
6Health Check
GET /health) ที่บอกว่า service พร้อมรับ request หรือไม่ — ใช้โดย load balancer/orchestrator (เช่น Docker, Kubernetes) เพื่อตัดสินใจว่าจะส่ง traffic ไปยัง instance นั้นหรือไม่ และเพื่อ restart instance ที่ไม่ตอบสนอง| ประเภท | ตรวจอะไร |
|---|---|
| Liveness | Process ยัง "มีชีวิต" อยู่ไหม (ตอบ request ได้ไหม) — ถ้าไม่ผ่าน มักสั่ง restart container |
| Readiness | Service พร้อมรับ traffic ใหม่ไหม (เช่น เชื่อมต่อ database สำเร็จแล้วหรือยัง) — ถ้าไม่ผ่าน ถอดออกจาก load balancer ชั่วคราวแต่ไม่ restart |
Health check ทำงานร่วมกับ retry/backoff ของ caller: caller ที่ฉลาดจะเช็ค health ก่อนส่ง request จริง หรือใช้ผล health check ของ orchestrator เพื่อไม่ส่ง traffic ไปยัง instance ที่รู้อยู่แล้วว่าไม่พร้อม
7Log และ Correlation / Request ID
X-Request-ID) ไปทุก service ที่เกี่ยวข้องในการประมวลผล request นั้น — ทุก log line ที่เกี่ยวข้องกับ request นี้ log ID เดียวกันกำกับไว้ ทำให้ค้นหา/เรียงลำดับเหตุการณ์ข้าม service ได้ทั้งหมดด้วยการกรองตาม ID เดียว
import uuid, requests
from flask import Flask, request, g
app = Flask(__name__)
@app.before_request
def assign_request_id():
g.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
@app.post("/orders")
def create_order():
print(f"[{g.request_id}] received order request")
# ส่งต่อ ID เดิมไปยัง service ถัดไปในสาย
resp = requests.get(
"http://service-b:5001/inventory/laptop",
headers={"X-Request-ID": g.request_id},
timeout=3,
)
print(f"[{g.request_id}] inventory check → {resp.status_code}")
...
8Docker Networking และ Service Name
localhost หรือ IP ตายตัวlocalhost ใน container ของ Service A จะไม่ชี้ไปที่ container ของ Service B (ต่างจากตอนรันบนเครื่องเดียวกันแบบ process ธรรมดา) และ IP address ของ container เปลี่ยนได้ทุกครั้งที่ restartDocker (ผ่าน Docker Compose) แก้ปัญหานี้ด้วย service discovery ผ่านชื่อ: container ที่อยู่ใน network เดียวกันเรียกหากันได้ด้วยชื่อ service (ตามที่ตั้งไว้ใน docker-compose.yml) ราวกับเป็น hostname — Docker มี DNS ภายในที่ resolve ชื่อ service ไปเป็น IP ของ container นั้นให้อัตโนมัติ (สอดคล้องกับ DNS ที่เรียนไปแล้วใน Application Layer แต่ทำงานในสโคปแคบแค่ภายใน Docker network)
version: "3.9"
services:
service-a:
build: ./service_a
ports:
- "5000:5000"
depends_on:
- service-b
environment:
- INVENTORY_URL=http://service-b:5001 # ใช้ "service-b" แทน IP/localhost
service-b:
build: ./service_b
ports:
- "5001:5001"
client:
build: ./client
depends_on:
- service-a
สังเกตว่า Service A อ้างถึง Service B ด้วย http://service-b:5001 ตรงกับที่ใช้ไว้แล้วในโค้ด Service A ของหัวข้อที่ 2 พอดี
9Reverse Proxy และ Load Balancing (เบื้องต้น)
10Lab: Docker Compose + จำลอง Failure
ใช้ระบบสองบริการจากหัวข้อที่ 2 เป็นฐาน แล้วทำตามลำดับ:
- รัน client และสอง service ด้วย Docker Compose —
docker compose upยืนยันว่า Service A เรียก Service B ผ่านชื่อservice-bสำเร็จตามปกติ - ใส่ delay ให้ service หนึ่ง — เพิ่ม
time.sleep(5)ใน Service B แล้วสังเกตว่า Service A ค้างรอ (ถ้าตั้ง timeout ไว้ 3 วินาที ควรเห็น timeout error ที่ Service A) - ปิด service ระหว่างรับคำขอ —
docker compose stop service-bขณะ Service A กำลังส่ง request สังเกตว่าได้ connection refused/reset แทน timeout (เทียบหัวข้อ 2) - เพิ่ม timeout และ retry — ใช้โค้ด exponential backoff จากหัวข้อ 5 ใน Service A แล้วรัน Service B แบบ intermittent (ล่มๆ หายๆ) ดูว่า retry ช่วยให้ request สำเร็จในที่สุดได้จริงหรือไม่
- สังเกตว่า retry ทำให้รายการซ้ำหรือไม่ — เปลี่ยน endpoint ที่ retry จาก
GET /inventory(idempotent โดยธรรมชาติ) เป็นPOST /orders(ไม่ idempotent) แล้วนับจำนวน order ที่ถูกสร้างจริงเทียบกับจำนวนครั้งที่ user ตั้งใจสั่งซื้อ - ปรับ operation ให้เป็น idempotent — เพิ่ม idempotency key ตามหัวข้อ 4 แล้วทดสอบซ้ำขั้นตอนที่ 5 ยืนยันว่าจำนวน order ที่สร้างจริงตรงกับจำนวนความตั้งใจของ user แล้ว ไม่ว่าจะ retry กี่ครั้งก็ตาม
- Timeout กับ connection refused เกิดจากสาเหตุคนละแบบ และดู log/behavior ต่างกันจริง (ขั้น 2 vs ขั้น 3)
- Retry ที่ไร้เดียงสา (ไม่มี idempotency) สร้างข้อมูลซ้ำได้จริง ไม่ใช่แค่ทฤษฎี (ขั้น 5)
- Idempotency key แก้ปัญหานั้นได้จริง วัดผลได้เป็นตัวเลข (ขั้น 6)
- อธิบาย partial failure คืออะไร และทำไมมันต่างจาก failure ของโปรแกรมตัวเดียวบนเครื่องเดียว
- เปรียบเทียบ connection refused กับ timeout ในแง่ความหมายและผลต่อการตัดสินใจ retry
- อธิบายว่าทำไม exponential backoff ถึงต้องมี jitter ด้วย ไม่ใช่แค่เพิ่ม delay แบบ deterministic
- ยกตัวอย่าง operation ที่ idempotent โดยธรรมชาติ และ operation ที่ไม่ใช่ พร้อมอธิบายวิธีทำให้ operation หลังปลอดภัยต่อการ retry
- อธิบายว่า correlation ID ช่วยแก้ปัญหาอะไรใน distributed system ที่ log ธรรมดาแก้ไม่ได้
- โจทย์: Client ส่งคำสั่งซื้อแล้ว timeout แต่ไม่รู้ว่า server บันทึกสำเร็จหรือยัง ถ้าส่งใหม่อาจเกิดอะไรขึ้น และจะแก้อย่างไร — ตอบโดยอ้างอิงแนวคิด idempotency key
11ชุดการทดลอง Failure และ Deployment
การทดลองที่ 1 — สร้าง Baseline ก่อนทำให้พัง
- รันระบบสองบริการด้วย Docker Compose ตรวจ
docker compose psและเรียก happy path 20 ครั้ง - บันทึก response time ต่ำสุด ค่ากลาง สูงสุด อัตราสำเร็จ และ request ID ถ้ายังไม่มี baseline จะบอกไม่ได้ว่าระบบช้าลงจริงหรือเพียงรู้สึกว่าช้า
- ตรวจว่า container คุยกันด้วย service name ไม่ใช่
localhostเพราะ localhost ภายใน container หมายถึง container ตัวนั้นเอง - เก็บ config และ image tag ที่ใช้ เพื่อให้เพื่อนทำการทดลองซ้ำได้
docker compose up --build
docker compose ps
docker compose logs --tail=50 service-a service-b
curl -w "\nstatus=%{http_code} time=%{time_total}\n" \
http://localhost:5000/orders/1
การทดลองที่ 2 — แผนที่อาการของ Failure
| วิธีทำให้เกิด | สิ่งที่คาดว่าจะเห็น | หลักฐาน |
|---|---|---|
| หยุด process แต่ port ยังถูกอ้างถึง | connection refused เร็ว | exception และ TCP RST |
| ใช้ hostname ที่ไม่มี | DNS resolution error | resolver error, ไม่มี TCP handshake |
| หน่วง service เกิน timeout | client ยกเลิกการรอ | เวลาประมาณค่า timeout |
| คืน HTTP 500 | เชื่อมต่อสำเร็จแต่ application ล้มเหลว | status, response body, server log |
| คืน JSON ผิด schema | downstream ตอบแต่ caller ใช้คำตอบไม่ได้ | parse/validation error |
ให้ทำทุกแถวจริงและเขียน “ชั้นที่เกิดปัญหา” กำกับ การแยก DNS, TCP, TLS, HTTP และ application ออกจากกันช่วยไม่ให้เราแก้ผิดชั้น เช่น เพิ่ม retry ไม่ช่วยกรณี URL สะกดผิด
การทดลองที่ 3 — Timeout Budget ไม่ใช่ตัวเลขที่เดาเอา
- สร้างสาย Client → A → B ให้ client รอ 3 s, A รอ B 5 s แล้วสังเกตว่า A ยังทำงานต่อหลัง client เลิกรอหรือไม่
- ปรับให้ timeout ด้านในสั้นกว่าด้านนอก เผื่อเวลา serialize ส่งคำตอบ และ cleanup เช่น client 3 s, A→B 2 s
- ทดลอง B มี latency แกว่ง วาด histogram อย่างง่าย และเลือก timeout จากความต้องการของผู้ใช้กับ percentile ไม่ใช่ค่าเฉลี่ยอย่างเดียว
- แยก connect timeout กับ read timeout เพราะ “ต่อไม่ติด” และ “ต่อแล้วตอบช้า” บอกคนละปัญหา
การทดลองที่ 4 — Retry ที่ช่วยได้และ Retry ที่ทำร้ายระบบ
จำลอง B ล้มเหลว 50% แล้วเปรียบเทียบ 4 แบบ: ไม่ retry, retry ทันที 3 ครั้ง, exponential backoff, และ backoff พร้อม jitter เปิด client 20 ตัวพร้อมกันแล้วนับ request จริงที่ B ได้รับ
# แนวคิด ไม่ใช่สูตรตายตัว delay = min(cap, base * (2 ** attempt)) sleep(random.uniform(0, delay)) # full jitter ลดการตื่นพร้อมกัน
การทดลองที่ 5 — คำตอบหาย แต่ธุรกรรมอาจสำเร็จแล้ว
- ให้ B บันทึก order สำเร็จแล้วหน่วง/ตัด connection ก่อนตอบ A จากมุม A จะเหมือนล้มเหลว แต่ข้อมูลถูกเขียนแล้ว
- ให้ A retry และนับจำนวน order ที่เกิดซ้ำ จากนั้นเพิ่ม idempotency key กับ unique constraint
- ทดสอบ concurrent request ด้วย key เดียวกัน ถ้า check-then-insert แยกกันอาจยังแข่งกัน ต้องให้ฐานข้อมูลช่วยรับประกัน atomicity
- สรุปว่า exactly-once ที่ผู้ใช้เห็นมักสร้างจาก at-least-once delivery บวกการ deduplicate ไม่ได้เกิดจากการหวังว่าเครือข่ายจะไม่ทำคำตอบหาย
การทดลองที่ 6 — Liveness, Readiness และ Startup
สร้าง endpoint แยกกันสามตัว: liveness ตอบว่า process ยังเดิน, readiness ตรวจ dependency ที่จำเป็นต่อการรับงาน, startup บอกว่าการเริ่มต้นเสร็จหรือยัง จากนั้นตัด database แล้วตอบคำถามว่า orchestrator ควร restart process หรือเพียงหยุดส่ง traffic
| Probe | ถ้าไม่ผ่านควรทำอะไร | ความผิดพลาดที่พบบ่อย |
|---|---|---|
| Liveness | restart เมื่อ process ค้างจริง | ผูกกับ database จนทุก container restart พร้อมกัน |
| Readiness | ถอดจาก load balancer ชั่วคราว | ตอบพร้อมก่อน warm-up เสร็จ |
| Startup | ให้เวลาเริ่มระบบ | ใช้ liveness สั้นเกินจน service ถูกฆ่าระหว่างเริ่ม |
การทดลองที่ 7 — Correlation ID และ Structured Log
- ให้ gateway รับหรือสร้าง request ID แล้วส่งต่อผ่าน header ทุก hop
- เขียน log แบบ JSON ที่มี timestamp, level, service, request_id, operation, duration_ms, status และ error_type
- ยิงคำขอพร้อมกัน 50 รายการ แล้วดึงเรื่องราวของคำขอหนึ่งรายการจากหลาย service ด้วย request ID เดียว
- ห้าม log password, token หรือข้อมูลส่วนตัวเต็ม ๆ และแยกข้อความสำหรับคนออกจาก field ที่เครื่องค้นได้
การทดลองที่ 8 — Deployment โดยผู้ใช้ไม่รู้สึก
รัน service สองรุ่นหลัง reverse proxy ให้ v1 ตอบชื่อรุ่นและ v2 มีการเปลี่ยนแปลงเล็กน้อย ยิง traffic ต่อเนื่องระหว่าง rolling update บันทึก error และ distribution ของ version จากนั้นทำให้ v2 readiness fail แล้วตรวจว่า traffic ยังไปถึงหรือไม่
- Rolling: ต้องรองรับช่วงที่ v1/v2 อยู่พร้อมกัน โดยเฉพาะ schema และ message format
- Blue–green: สลับเร็วและย้อนกลับง่าย แต่ใช้ทรัพยากรสองชุด
- Canary: จำกัดผู้ใช้ที่เสี่ยง แต่ต้องมี metric และเกณฑ์หยุดที่ชัดเจน
- Graceful shutdown: หยุดรับงานใหม่ รอ in-flight request ตามเพดาน แล้วจึงปิด connection
สมุดบันทึกเหตุขัดข้องฉบับย่อ
| เวลา | อาการที่ผู้ใช้เห็น | สมมติฐาน | หลักฐานที่ตรวจ | สาเหตุ | การป้องกัน |
|---|---|---|---|---|---|
| เช่น 10:15 | 504 บางคำขอ | B ช้า | A/B log, latency, trace | pool เต็ม | limit, timeout, scale |
รายงานที่ดีต้องแยก timeline, impact, root cause, contributing factors และ action item การเขียนว่า “network มีปัญหา” กว้างเกินไป เพราะยังบอกไม่ได้ว่าผิดที่ DNS เส้นทาง TCP service หรือทรัพยากรของเครื่อง
ชิ้นงานที่ส่ง
- Compose project ที่รันซ้ำได้ พร้อม health check และ config ผ่าน environment
- failure matrix อย่างน้อย 8 กรณี มีคำสั่งสร้างอาการและหลักฐานยืนยัน
- กราฟหรือตารางก่อน–หลัง retry/backoff และคำอธิบาย load amplification
- log ข้าม service ที่ตาม request เดียวได้ และตัวอย่างข้อมูลที่ตั้งใจไม่บันทึก
- deployment experiment พร้อมเกณฑ์ตัดสินว่าจะไปต่อหรือ rollback
12สรุปและขั้นตอนถัดไป
เป้าหมายไม่ใช่ทำให้ระบบ “ไม่ล้มเลย” เพราะทำไม่ได้ แต่คือทำให้ล้มในขอบเขตที่ควบคุมได้ รู้เร็ว อธิบายได้ และฟื้นได้โดยไม่สร้างความเสียหายซ้ำ ขั้นถัดไปคือเลือก pattern ให้ตรงกับแรงกดดันที่พบ ไม่ใช่ใส่ทุก pattern เพียงเพราะชื่อดูทันสมัย