07 ทดสอบ, ล้มเหลว และแก้ไข
บันทึกสำหรับผู้สอนของโมดูล 07 — เวลา, แนวการบรรยาย, ปัญหาที่พบบ่อย และขั้นตอนรีเซ็ต
คู่มือประกอบของผู้ดำเนินเวิร์กช็อปสำหรับบทเรียนผู้เรียน 07 ทดสอบ, ล้มเหลว และแก้ไข
เวลา
ประมาณ 20 นาที นี่คือแล็บ incident กันเวลาให้พอเพื่อให้การวินิจฉัย ลงเอยได้ก่อนโมดูล 08 และการสรุปปิดท้าย แล็บจะรัน fault 01 (วินิจฉัย 5-15 นาที) ส่วน fault 02 (5-10 นาที) และ 03 (10-20 นาที และเฉพาะกับชุดข้อมูลเต็มราว 30 ล้านแถวเท่านั้น) ยังคงมีไว้เป็น incident เพิ่มเติมแบบทางเลือกสำหรับห้องที่ไปเร็ว กำหนด hard-stop ตอนซ้อม เพื่อไม่ให้การสรุปปิดท้ายถูกบีบเวลา
แนวการบรรยาย
- นี่คือผลตอบแทน: ใช้ทุกอย่างที่สร้างมาจนถึงตอนนี้เพื่อรับมือ incident จริง
- อธิบายอาการ ไม่ใช่สาเหตุ แล้วปล่อยให้ agent ลู่เข้าหาคำตอบจาก telemetry
- พิจารณาแสดงการวินิจฉัยของผู้เข้าร่วมหลายคนเทียบกันบนโปรเจกเตอร์
- แล็บจะรัน fault 01 ถ้าเวลาเหลือให้เพิ่มอีกรอบ ก็เพิ่ม fault 02 (และ fault 03 เฉพาะ เมื่อ seed ชุดข้อมูลเต็มไว้ล่วงหน้าแล้ว) ใช้การรีเซ็ตแบบ stash-and-switch ร่วมด้านล่าง ระหว่างแต่ละ fault
เฉลย
อย่าแบ่งปันส่วนนี้กับผู้เรียน มันอยู่ใน playbook เท่านั้นและไม่เคย
commit ลง repo ของแอป แต่ละ fault คือการเปลี่ยนแปลงเล็ก ๆ หนึ่งจุดบน branch ของตัวเองที่แยกจาก
build-workshop-v1
และการแก้คือ revert มัน รันหนึ่ง fault ต่อครั้ง incident ของแล็บคือ fault 01 (5-15 นาที
เป็นลูกโค้ง — backend ไม่ได้ผิด) ส่วนเสริมแบบทางเลือกสำหรับห้องที่ไปเร็ว: fault 02 (5-10 นาที
เป็นการวอร์มอัป trace บอกทุกอย่าง) และ fault 03 เฉพาะเมื่อมีชุดข้อมูลเต็ม
(10-20 นาที ต้องใช้การให้เหตุผลเรื่อง ClickHouse ที่ลึกกว่า)
การรีเซ็ตร่วมสำหรับทุก fault (การ stash รักษาการแก้ไขของผู้เข้าร่วมไว้และเลี่ยงการสลับ branch ที่ถูกบล็อก):
git stash push --include-untracked -m "module-07-fix"
git switch build-workshop-v1
docker compose --env-file .env.workshop \
-f docker-compose.workshop.yml -f docker-compose.otel.yml up -d --buildFault 02 — zone stats 500 (fault/02-zone-stats-500)
ข้อความ commit ที่ผู้เข้าร่วมเห็น: "backend: align zone stats column names with API params"
(แตะเฉพาะ zone_stats_sql ใน backend/app/query_builders.py) มันจัดกลุ่มด้วย
pickup_zone_id ซึ่งเป็นคอลัมน์ที่ไม่มีอยู่บน taxi_trips แทนที่จะเป็น
pickup_location_id
- อาการ: choropleth บนแผนที่ว่างเปล่า (polygon เรนเดอร์ออกมา แต่ทุก zone อยู่ใน
ช่วงสีอ่อนสุด) และคำบรรยาย "Query Nms" หายไป มี 500 พรั่งพรูบน
GET /api/metrics/zone_stats(React Query ลองซ้ำสามครั้ง) การ์ดอื่นทั้งหมดปกติดี - สัญญาณอยู่ที่ไหน: span
clickhouse.queryของ ClickStack ที่มีerror=True,error.category="query_failed"และdb.statementที่มีpickup_zone_id AS zone_idโดย exception ที่บันทึกไว้คือ ClickHouse Code 47UNKNOWN_IDENTIFIERlog ระดับ ERROR ที่ตรงกัน: "ClickHouse query failed (category=query_failed, elapsed_ms=...): ... | sql=..." - เส้นทางการวินิจฉัย: หา span ที่เป็น 500 -> อ่าน
db.statement->UNKNOWN_IDENTIFIERที่pickup_zone_id->DESCRIBE taxi_trips(หรือเทียบกับ query builder พี่น้อง) -> คอลัมน์จริงคือpickup_location_id - การแก้: เปลี่ยน
pickup_zone_idกลับเป็นpickup_location_idในzone_stats_sql(หรือgit revertcommit ของ fault) แล้ว rebuild backend - การรีเซ็ต: ใช้การรีเซ็ตร่วมด้านบน
Fault 03 — dashboard ช้า (fault/03-slow-dashboard)
ข้อความ commit: "backend: window the trend series by bucket in HAVING" (แตะ
timeseries_sql) มันย้าย predicate ของช่วงเวลาจาก WHERE ไปเป็น HAVING บน
alias ของ bucket ทำให้ WHERE กลายเป็น 1 ClickHouse จึงไม่สามารถตัดข้อมูลด้วย
primary key (car_type, pickup_datetime) ได้อีก และสแกน taxi_trips ทั้งตารางพร้อม state ของ
quantileTDigest สองตัว ทำให้ทะลุ max_execution_time 5 วินาที
- อาการ: มีเพียงการ์ด trend "What's happening now?" ที่ล้มเหลว — มันหมุนแล้วแสดง "504 Query timed out..." การ์ดพี่น้องทุกใบยังเร็วอยู่
- สัญญาณอยู่ที่ไหน: span
clickhouse.queryที่มีerror.category="timeout",db.elapsed_msราว 5000 และdb.statementที่แสดงWHERE 1 ... GROUP BY ts HAVING ts >= ...โดย exception คือ Code 159TIMEOUT_EXCEEDEDความต่างเทียบกับ span ที่เป็น 200 แบบเร็วบน endpoint อื่น ๆ คือเบาะแสสำคัญ - เส้นทางการวินิจฉัย: การ์ดช้าใบเดียวท่ามกลางพี่น้องที่เร็ว -> เปิด span ที่ timeout -> อ่าน
SQL -> ตัวกรองเวลาอยู่ใน
HAVINGและWHEREไม่มีช่วงของpickup_datetime-> เป็น anti-pattern เรื่องการตัดด้วย primary key / การผลัก predicate ลงไป (พี่น้องใส่ช่วงเวลาไว้ในWHERE) - การแก้: คืนช่วงเวลากลับไปที่
WHERE(revert commit ของ fault) แล้ว rebuild backend - การรีเซ็ต: ใช้การรีเซ็ตร่วมด้านบน
- ข้อควรระวังสำหรับผู้สอน: ความรุนแรงขึ้นอยู่กับปริมาณข้อมูลและขนาด service บนชุดข้อมูล
ที่ seed เต็ม (ราว 30 ล้านแถว) บน service ระดับเวิร์กช็อป (ซึ่งอาจกำลังตื่นจาก idle) timeout
5 วินาทีจะเกิดขึ้นอย่างแน่นอน แต่บน seed ตัวอย่างเล็ก ๆ มันแค่ช้าลง ไม่ถึงกับเป็น 504 เต็มตัว
ค่า
send_receive_timeoutฝั่ง client และmax_execution_timeฝั่ง server เป็น 5 วินาทีทั้งคู่ ดังนั้น การแข่งกันของ socket timeout อาจโผล่มาเป็น 500 แทน 504 ที่สะอาดได้เป็นครั้งคราว — เป็น คุณสมบัติของค่าตั้งต้น ไม่ใช่ของ fault
Fault 01 — แผนที่ไม่โหลด (fault/01-map-not-loading)
ข้อความ commit: "frontend: serve map geojson from /static asset path" (แตะ
path ของ fetch ใน frontend/src/ui/ZoneMap.tsx) มัน fetch /static/taxi_zones.geojson ซึ่ง
ไม่มีอยู่ ประเด็นสำคัญ: SPA fallback ของ nginx (try_files ... /index.html) คืน HTTP 200
พร้อมเอกสาร HTML แทนที่จะเป็น 404 ดังนั้น r.ok ผ่าน และ r.json() โยน
SyntaxError แบบ Unexpected token '<'
- อาการ: การ์ดแผนที่ตาย — base tile เรนเดอร์ออกมาแต่ไม่มี polygon ของ NYC หรือ choropleth และมี error สีแดงแสดงในบรรทัด อย่างอื่นปกติดีทั้งหมด
- สัญญาณอยู่ที่ไหน: ไม่มีอะไรใน ClickStack ฝั่ง backend — asset นั้นไม่เคยไปถึง
FastAPI คอนโซลของเบราว์เซอร์แสดง error การ parse JSON ที่ระบุชื่อ
/static/taxi_zones.geojsonแท็บ Network แสดงคำขอนั้นคืนtext/htmlด้วยสถานะ 200 และ access log ของ nginx แสดงการ fallback - เส้นทางการวินิจฉัย: trace ฝั่ง backend สะอาด -> หันไปที่คอนโซล / แท็บ Network ของเบราว์เซอร์
-> คำขอ
.geojsonคืน 200text/html-> จับได้ว่าเป็นกับดัก SPA fallback -> ไฟล์อยู่ที่ web root จริง ๆ คือ/taxi_zones.geojson - การแก้: revert path ของ fetch (สองบรรทัด) หรือ
git revertcommit ของ fault แล้ว rebuild frontend - การรีเซ็ต: ใช้การรีเซ็ตร่วมด้านบน
- จุดสอน: ไม่ใช่ทุกความล้มเหลวจะโผล่ใน trace ฝั่ง backend และ 404 อาจปลอมตัว เป็น 200 อยู่หลัง SPA fallback ได้ — ดังนั้นให้อ่าน response body และ content-type จริง ไม่ใช่ ดูแค่ status code
- หมายเหตุ (browser SDK): เมื่อเปิดใช้ HyperDX browser SDK (overlay ของโมดูล 05)
frontend จะเผยเรื่องนี้ใน ClickStack เองแล้ว — ความล้มเหลวในการ parse ของ
ZoneMapถูกบันทึก เป็น spanconsole.errorภายใต้ServiceName=nyc-taxi-frontendคู่กับ resource span ของtext/htmlที่เป็น 200 ดังนั้น agent สามารถระบุตำแหน่งปัญหาได้จาก telemetry โดยไม่ต้องออกจาก ClickStack คอนโซล / แท็บ Network ของเบราว์เซอร์เป็นทางสำรอง ไม่ใช่ทางเดียว
ตัวอย่างคำตอบของ agent (fault 01, ผ่าน ClickStack MCP):

การวินิจฉัยที่คาดหวัง: agent เชื่อมโยงความล้มเหลวกับคำขอ geojson ที่คืน 200 text/html (SPA fallback), error JSON.parse ที่ตามมาซึ่งถูกบันทึกไว้ใต้ nyc-taxi-frontend และแนะนำให้ส่งไฟล์ asset ไปที่ /static/ หรือใส่การป้องกันรอบการ parse ด้วย .json()
ปัญหาที่พบบ่อย
- baseline ของ telemetry ไม่พอให้ agent ระบุตำแหน่งปัญหา โมดูล 05 ต้องถูกรันไว้ก่อนแล้ว
- ผู้เข้าร่วมกระโดดไปที่การแก้ก่อนที่จะยืนยันรากของปัญหาจากหลักฐาน
- อาการของ fault ยังไม่ปรากฏเพราะ stack เพิ่งถูกยกขึ้นมา — หรือในกรณีของ fault 03 เพราะโหลดข้อมูลย้อนหลังไว้น้อยเกินไป วัดได้ในการซ้อม: บน seed หนึ่งเดือนแบบ ค่าเริ่มต้น (ราว 3.17 ล้านแถว) fault แบบ WHERE-เทียบ-HAVING สังเกตไม่ได้ — การสแกนทั้งตาราง รันเสร็จในราว 300-560 มิลลิวินาที แยกไม่ออกจาก baseline ให้ seed หลายเดือนก่อน สาธิต fault 03 หรือใช้ fault 01 (ซึ่งเกิดซ้ำได้ทันที) และถือว่า fault 03 เป็นภาพประกอบสำหรับระดับสเกลใหญ่
- fault 01 ไม่ก่อให้เกิด trace ฝั่ง backend เลย และคำขอที่ผิดคืน 200
text/htmlผ่าน SPA fallback แทนที่จะเป็น 404 ผู้เข้าร่วมอาจค้น trace ไม่จบไม่สิ้น ผลักดันพวกเขา ไปที่คอนโซล / แท็บ Network ของเบราว์เซอร์ ที่ซึ่ง error การ parse JSON และการตอบtext/htmlจะแสดงอยู่ - ลืมใส่
--buildหลัง checkout branch ของ fault ทำให้ image เดิมยังทำงานอยู่
ขั้นตอนรีเซ็ต
- การรีเซ็ตแบบ stash-and-switch ร่วมจะคืนแอปให้ครบถ้วนโดยไม่ทิ้ง ความพยายามแก้ปัญหาของผู้เข้าร่วม
- ใส่ fault กลับเข้าไปด้วยการ checkout หนึ่งใน
fault/01-map-not-loading/fault/02-zone-stats-500/fault/03-slow-dashboardแล้ว rebuild ด้วยไฟล์ compose ของเวิร์กช็อป บวกกับของ otel - อาการ, สัญญาณ และการแก้ของแต่ละ fault อยู่ในส่วนเฉลยด้านบน เก็บ เฉลยไว้ใน playbook นี้เท่านั้น ไม่เคย commit ลง repo ของแอป