Lecture 4 — Microservices
ทำไมแอปก้อนเดียวถึงโตต่อไม่ไหว แยกเป็นบริการย่อยแล้วได้อะไร เสียอะไร และต้องมี API Gateway กับ service discovery มาอุดตรงไหน
เลกเชอร์ 4 · อ่าน 45 นาที · เรียบเรียงจากสไลด์ Lecture 4 ของ ผศ.ดร.ณัฐวุฒิ หนูไพโรจน์
Microservices ไม่ใช่เรื่องเทคนิค แต่เป็นเรื่อง ใครเปลี่ยนอะไรได้โดยไม่ต้องรอใคร เราแลกความง่ายของโค้ดก้อนเดียว กับความยากของระบบกระจาย และจะคุ้มก็ต่อเมื่อสิ่งที่ได้กลับมา — ทีมปล่อยของเองได้ บริการขยายแยกกันได้ — มีค่ามากกว่าราคาที่จ่าย
ข้อมูลในหน้านี้มาจากไหน
เนื้อหามาจากสไลด์ Lecture 4 ทั้ง 74 หน้า โค้ด ไฟล์ตั้งค่า คำสั่ง และผลลัพธ์บนหน้าจอทุกอันยกมาจากสไลด์จริง ส่วนคำอธิบาย รูป และตัวอย่างที่ติดป้าย "เสริม" เขียนขึ้นใหม่
คาบนี้เป็นของ ผศ.ดร.ณัฐวุฒิ หนูไพโรจน์ ซึ่งเป็นคนละท่านกับสามคาบแรก สไตล์จึงต่างออกไป — เน้นเหตุผลเชิงสถาปัตยกรรมมากกว่าคำสั่งบนเทอร์มินัล
คาบนี้จะพาไปไหน
สามคาบแรกสอนเครื่องมือ: container คืออะไร สร้าง image ยังไง สั่งหลาย container พร้อมกันยังไง
คาบนี้ถอยออกมาหนึ่งก้าวแล้วถามว่า แล้วควรแบ่งซอฟต์แวร์ออกเป็นกี่ชิ้น และแบ่งตรงไหน
| คำถาม | คำตอบของคาบนี้ |
|---|---|
| แอปก้อนเดียวมันแย่ตรงไหน | ส่วนที่ 1 — ไม่ได้แย่ แต่มันโตไม่ทันองค์กร |
| container ช่วยเรื่องนี้ยังไง | ส่วนที่ 2 — ทำให้ขอบเขตที่เราขีดไว้ ส่งมอบและปล่อยได้จริง |
| พอบริการเยอะแล้วอะไรพัง | ส่วนที่ 3 — client หาที่อยู่ไม่เจอ ต้องมี API Gateway กับ service discovery |
ส่วนที่ 1 — จาก monolith ไป microservices
เริ่มจากแอปตัวหนึ่งชื่อ SlowQueue
สไลด์ใช้แอปสมมติชื่อ SlowQueue เป็นตัวเดินเรื่องทั้งส่วนนี้ มันคือระบบจองคิวร้านอาหาร ประกอบด้วย
- Browser ที่รันหน้าเว็บแบบ SPA (Single Page Application) (เว็บหน้าเดียวที่โหลดครั้งเดียวแล้ววาดหน้าใหม่ด้วย JavaScript) คุยกลับมาด้วย HTML และ REST
- Tomcat AppServer หนึ่งตัว ที่ข้างในบรรจุสี่โมดูล — Web Frontend, Membership Management, Queue Management, Restaurant Management
- MySQL หนึ่งตัว เป็นฐานข้อมูลของทั้งระบบ
จุดสำคัญอยู่ที่คำว่า WAR/EAR ในรูป มันคือไฟล์แพ็กเกจของโลก Java — สี่โมดูลนั้นไม่ได้เป็นสี่โปรแกรม แต่ถูกคอมไพล์รวมเป็น ไฟล์เดียว แล้วรันอยู่ใน โปรเซสเดียว กัน
แบบนี้เรียกว่า Monolithic Architecture (สถาปัตยกรรมแบบก้อนเดียว) — สร้างครั้งเดียว ปล่อยครั้งเดียว ทั้งแอปขึ้นลงพร้อมกัน
เหมือนร้านอาหารที่ครัวเดียว เชฟทุกคนใช้เตาเดียวกัน ตู้เย็นเดียวกัน ตอนร้านเล็กมันเร็วมาก — ส่งของกันข้ามโต๊ะได้เลย แต่พอจะเปลี่ยนเตาแก๊สเป็นเตาเหนี่ยวนำ ทั้งครัวต้องหยุด
monolith ไม่ใช่ของแย่
คนเรียนเรื่อง microservices ใหม่ ๆ มักสรุปว่า monolith คือของโบราณที่ต้องเลิกใช้ สไลด์หน้า 6 พูดตรงข้าม และนี่คือประโยคที่สำคัญที่สุดของทั้งคาบ
monolith เป็นจุดเริ่มต้นที่ถูกต้องในกรณีส่วนใหญ่ เพราะ
- รันได้บนเครื่องเดียวหรือไม่กี่เครื่อง ประหยัด latency (เวลาหน่วงต่อหนึ่งคำขอ) ต่ำ throughput (ปริมาณงานที่รองรับได้ต่อวินาที) สูง — เพราะการเรียกข้ามโมดูลคือการเรียกฟังก์ชันธรรมดา ไม่ใช่การส่งข้อมูลผ่านเครือข่าย
- build, test, deploy เป็นก้อนเดียว จบเร็ว
- เหมาะมากกับแอปเล็กที่มีทีมพัฒนาเล็ก
อย่าเริ่มโปรเจกต์ใหม่ด้วย microservices เพราะ "มันทันสมัยกว่า" ถ้าทีมมีห้าคนและยังไม่รู้ว่าขอบเขตของแต่ละบริการควรอยู่ตรงไหน การแยกตั้งแต่วันแรกจะได้ระบบกระจายที่ยังคับแคบอยู่ดี — ได้ความยากมาครบ แต่ยังไม่ได้ประโยชน์อะไรเลย
แล้วอะไรพัง
ปัญหาไม่ได้เริ่มจากโค้ด แต่เริ่มจาก จำนวนคน
สไลด์หน้า 7 วาดไว้ชัด: วันแรก SlowQueue 1.0 มีทีมพัฒนาทีมเดียว พอมาถึง SlowQueue 4.0 มันกลายเป็นห้าทีม — Web UI, Mobile UI, Queue Management, Backend และ Ads
ห้าทีม แต่ยังปล่อยของจากไฟล์เดียวกัน
ทีม Ads แก้ขนาดแบนเนอร์โฆษณาสามบรรทัด อยากปล่อยขึ้นระบบวันนี้
แต่แบนเนอร์อยู่ในไฟล์ WAR เดียวกับ Queue Management ที่ทีมอื่นกำลังแก้อยู่ครึ่งทาง
ผลคือทีม Ads ต้อง รอ ให้ทีม Queue เขียนเสร็จ แล้วต้อง รันเทสต์ของทั้งระบบ ทั้งที่ตัวเองแตะแค่ CSS
สามบรรทัดใช้เวลาสองสัปดาห์ ไม่ใช่เพราะโค้ดยาก แต่เพราะขั้นตอนปล่อยของมันผูกกันอยู่
สไลด์หน้า 8 สรุปข้อจำกัดไว้ห้าข้อ
| ข้อจำกัด | ความหมายจริง ๆ |
|---|---|
| โค้ดเยอะมาก | คนใหม่ใช้เวลาเป็นเดือนกว่าจะกล้าแก้ |
| Tightly coupled (ผูกกันแน่น) | แก้นิดเดียวต้องเทสต์และปล่อยทั้งระบบ |
| test และ deploy เจ็บปวด | รอบปล่อยยาว คนเลยรวมของหลายอย่างมาปล่อยทีเดียว ซึ่งยิ่งเสี่ยง |
| พังง่าย | ของหลายอย่างเปลี่ยนพร้อมกัน หาสาเหตุยาก |
| แต่ละส่วนต้องการขยายและปล่อยของคนละจังหวะ | Ads อาจต้องปล่อยวันละสิบครั้ง ส่วนระบบสมาชิกปล่อยเดือนละครั้ง |
ข้อสุดท้ายคือใจความ ไม่ใช่ว่า monolith ช้า แต่ว่า ทุกส่วนถูกบังคับให้เดินด้วยความเร็วเดียวกัน
เทคโนโลยีก็ค้างอยู่ตรงนั้นด้วย
สไลด์หน้า 9 เติมปัญหาอีกชั้น: ทีม Queue อยากใช้ Node.js เพราะขยายง่ายกว่า ทีม Backend อยากใช้ Python เพราะจะทำ ML pipeline
แต่ทั้งระบบเป็น J2EE ก้อนเดียว จะเปลี่ยนภาษาต้องเขียนใหม่หมด — too big to rewrite
เมื่อรวมกับหน้า 10 ที่บอกว่าธุรกิจเปลี่ยนเร็วมาก และสิ่งที่ต้องการคือขยับจาก stability (ความนิ่ง ไม่ค่อยเปลี่ยน) ไปหา agility (ความคล่องตัว เปลี่ยนได้ไว) คือส่งซอฟต์แวร์ได้ เร็ว บ่อย และเชื่อถือได้ ก็จะเห็นว่า monolith ไปต่อไม่ไหวแล้ว
Microservice Architecture คืออะไร
Microservice Architecture (สถาปัตยกรรมแบบบริการย่อย) เป็นรูปแบบหนึ่งของ SOA (Service-Oriented Architecture) (สถาปัตยกรรมที่จัดระบบเป็นชุดของบริการ) โดยมีลักษณะสี่ข้อ
- จัดแอปเป็นชุดของบริการที่ไม่ผูกกันแน่น (loosely coupled (เกี่ยวข้องกันแบบหลวม ๆ))
- บริการมีขนาดย่อย และโปรโตคอลที่ใช้คุยกันเบา — ปกติคือ REST
- ไม่ผูกกับแพลตฟอร์ม ไม่ยึดระบบปฏิบัติการหรือภาษาใดภาษาหนึ่ง
- แต่ละบริการโฟกัสความสามารถทางธุรกิจอย่างเดียว (single business capability)
ข้อ 4 คือเกณฑ์การแบ่ง ไม่ใช่แบ่งตามชั้นเทคนิค (controller / service / repository) แต่แบ่งตาม เรื่องทางธุรกิจ — คิว ร้านอาหาร โฆษณา สมาชิก
รูปที่ 3 มีของใหม่สี่อย่างที่ monolith ไม่มี
- Content Router ตัดสินใจว่าเบราว์เซอร์ควรได้หน้าเว็บของแอปไหน
- API Gateway ประตูเดียวสำหรับ client ที่เรียก API (จะพูดยาว ๆ ในส่วนที่ 3)
- Notification Gateway ทางออกสำหรับข้อความที่ระบบส่งหา client
- ฐานข้อมูลแยกต่อบริการ — ไม่ใช่ MySQL ก้อนเดียวอีกแล้ว
Data-Per-Service คือกฎที่ผิดกันบ่อยที่สุด
Data-Per-Service Pattern (แต่ละบริการถือฐานข้อมูลของตัวเอง) แปลว่าบริการ A ห้ามยิง SQL เข้าตารางของบริการ B ถึงจะรู้รหัสผ่านก็ห้าม
Ads Service อยากรู้ว่าลูกค้าคนนี้จองคิวร้านไหนบ่อย เลยยิง SQL ตรงเข้าตาราง bookings ของ Queue Service
เดือนถัดมา ทีม Queue เปลี่ยนชื่อคอลัมน์ rest_id เป็น restaurant_id — เป็นการแก้ภายในบริการตัวเอง ซึ่งควรทำได้อย่างอิสระ
Ads Service พังทันที และทีม Queue ไม่รู้ตัวด้วยซ้ำว่าทำใครพัง
ถ้า Ads เรียกผ่าน GET /bookings?user=42 แทน ชื่อคอลัมน์จะเปลี่ยนกี่ครั้งก็ไม่มีผล เพราะสิ่งที่ตกลงกันไว้คือ หน้าตาของ API ไม่ใช่หน้าตาของตาราง
แนวคิดหลักสี่ข้อ
สไลด์หน้า 15 ย่อทั้งหมดเหลือสี่ข้อ ที่เหลือของคาบนี้คือการขยายสี่ข้อนี้
| แนวคิด | คือการตอบคำถามว่า |
|---|---|
| Service Decomposition | จะแบ่งเป็นกี่บริการ และสัญญาของแต่ละบริการคืออะไร |
| Inter-Service Communication | บริการคุยกันด้วยอะไร — ปกติ REST + JSON หรือ gRPC (โปรโตคอลเรียกฟังก์ชันข้ามเครื่องของ Google เร็วกว่า REST แต่อ่านด้วยตาเปล่าไม่ได้) |
| Data Management | ใครถือข้อมูลอะไร — Data-Per-Service |
| Service Discovery | จะหาที่อยู่ของบริการอีกตัวได้ยังไง |
ไม่ผูกกับแพลตฟอร์มแปลว่าอะไร
เมื่อบริการคุยกันด้วย REST หรือ gRPC สิ่งที่ข้ามเส้นระหว่างบริการมีแค่ข้อความ ไม่ใช่ object ในหน่วยความจำ
ผลคือแต่ละบริการเลือกภาษาของตัวเองได้ — สไลด์หน้า 16 วาดไว้ว่าบริการหนึ่งลอง Python อีกตัวย้ายไป JavaScript อีกตัวเขียน Go
"เลือกภาษาอะไรก็ได้" ไม่ได้แปลว่า "ควรใช้ทุกภาษา" ทุกภาษาที่เพิ่มเข้ามาคือ toolchain ใหม่ ระบบ build ใหม่ วิธีเก็บ log ใหม่ และคนที่ต้องดูแลตอนตีสาม ทีมส่วนใหญ่จำกัดไว้สองสามภาษาโดยตั้งใจ
ทีมกับบริการเป็นเรื่องเดียวกัน
นี่คือหัวใจของทั้งเรื่อง แต่ละทีมมี repository ของตัวเอง มี Automated Deployment Pipeline (สายพานปล่อยของอัตโนมัติ ตั้งแต่ build ถึงขึ้นระบบ) ของตัวเอง และปล่อยบริการของตัวเอง
ประโยคที่สไลด์เขียนกำกับไว้คือ working independently most of the time — สังเกตคำว่า most ไม่ใช่ all ยังมีเวลาที่ต้องคุยกัน เช่นตอนจะเปลี่ยนสัญญาของ API
สิ่งที่ microservices ทำให้เกิดได้ (สไลด์หน้า 18)
- ซอฟต์แวร์ที่ไม่ผูกกันแน่น
- ทีมเล็กและคล่อง
- เปลี่ยนไปใช้เทคโนโลยีใหม่ได้ทีละส่วน
- ทำ DevOps ง่ายขึ้น — automated testing และ continuous delivery/deployment ทำได้จริง เพราะขอบเขตที่ต้องเทสต์ต่อรอบเล็กลงมาก
แล้วเสียอะไรไป
ทุกคนพูดถึงข้อดีของ microservices สไลด์หน้า 14 กับ 19 พูดถึงราคา และนี่คือส่วนที่ตัดสินว่าควรใช้หรือไม่
Microservices แลก ความง่ายในระดับหน่วยเล็ก กับ ความซับซ้อนในระดับระบบ โค้ดแต่ละบริการอ่านง่ายขึ้นจริง แต่ระบบทั้งหมดเข้าใจยากขึ้นจริงเช่นกัน
- เครือข่ายล้มเหลวได้ การเรียกฟังก์ชันไม่เคยหายไปกลางทาง แต่การเรียกข้ามเครื่องหายได้ ช้าได้ ตอบซ้ำได้
- Data consistency (ความสอดคล้องของข้อมูล) ไม่มี transaction เดียวครอบทุกบริการอีกแล้ว
- Observability (ความสามารถในการมองเห็นสิ่งที่เกิดขึ้นในระบบ) หนึ่งคำขอวิ่งผ่านหกบริการ log กระจายอยู่หกที่
- งานปฏิบัติการ ของเดิมดูแลหนึ่งอย่าง ตอนนี้ยี่สิบอย่าง
- ความเป็นเจ้าของชัดเจน แต่ละบริการมีทีมรับผิดชอบแน่นอน
- ปล่อยของอิสระ ไม่ต้องรอรอบปล่อยของทั้งบริษัท
- ขยายแยกส่วนได้ เพิ่มสำเนาเฉพาะบริการที่โหลดหนัก ไม่ต้องเพิ่มทั้งแอป
เกณฑ์ตัดสินจากสไลด์: เลือก microservices เมื่อสิ่งที่องค์กรได้กลับมา มีค่ามากกว่าราคาของการเป็นระบบกระจาย
สไลด์หน้า 19 ขยายฝั่งราคาอีกสี่ข้อ
- ออกแบบยาก — จะแบ่งตรงไหน แต่ละบริการควรใหญ่แค่ไหน
- สถาปัตยกรรมซับซ้อนขึ้น
- ระบบกระจายมาก ทำให้หา cascading failure (ความล้มเหลวลูกโซ่ ตัวหนึ่งล้มแล้วลามไปตัวอื่น) ได้ยาก
- ขั้นตอน test และ deploy ยุ่งกว่าเดิม
และตอนจบสไลด์เขียนว่า along comes Software-Defined Systems — สามข้อหลังคือปัญหาที่วิชานี้ทั้งวิชาเกิดมาแก้
ลองตอบดู: ทีมหนึ่งอยากแยก monolith ออกเป็น 12 บริการ โดยแบ่งตามชั้นเทคนิค — auth-service, validation-service, logging-service, database-service, cache-service ... แบ่งแบบนี้มีปัญหาอะไร
มันไม่ได้ตัดการผูกกันเลย — แค่ย้ายการเรียกฟังก์ชันให้กลายเป็นการเรียกข้ามเครือข่าย
เพิ่มฟีเจอร์หนึ่งอย่างยังต้องแก้ทั้ง auth, validation และ database พร้อมกันเหมือนเดิม แต่ตอนนี้ต้องปล่อยของสามครั้งและอาจพังกลางทางได้ด้วย
เกณฑ์จากสไลด์คือ single business capability ไม่ใช่ชั้นเทคนิค ถ้าแบ่งถูก การเพิ่มฟีเจอร์หนึ่งอย่างควรแตะบริการเดียว
ส่วนที่ 2 — microservices กับ container
container กับ microservice ไม่ใช่เรื่องเดียวกัน
สองคำนี้มักถูกพูดคู่กันจนหลายคนคิดว่าเป็นของเดียวกัน สไลด์หน้า 22 แยกให้ชัดเพราะความเข้าใจผิดนี้ทำให้ทีมคิดว่าตัวเองทำ microservices แล้ว ทั้งที่ยังไม่ได้ทำ
สองอย่างนี้แก้คนละปัญหา
| สิ่งของ | แก้ปัญหาอะไร |
|---|---|
| Microservice | แยกโมดูลของซอฟต์แวร์ออกเป็นบริการ — เป็นการตัดสินใจเรื่อง การออกแบบ |
| Container | ซ่อนความต่างของสิ่งที่อยู่ข้างใน — เป็นการตัดสินใจเรื่อง การแพ็กและส่งมอบ |
จากนั้นสไลด์ก็ระบุตรง ๆ ว่า container ไม่ได้ทำให้เกิด microservices
- monolith รันใน container ได้สบาย และก็ยังเป็น monolith อยู่ดี
- microservice หนึ่งตัวอาจใช้หลาย container หรือหลายโปรเซสก็ได้
- ขอบเขตของบริการมาจาก ความเป็นเจ้าของ ข้อมูล และการเปลี่ยนแปลงที่เป็นอิสระต่อกัน
- container แค่ทำให้ขอบเขตที่ขีดไว้แล้ว แพ็กและ deploy ได้ง่าย
"เราย้ายแอปขึ้น Docker แล้ว เท่ากับเราทำ microservices" — ไม่ใช่ ถ้าไฟล์ WAR เดิมถูกยัดเข้า container เดียวก็ยังเป็น monolith ที่อยู่ใน container เท่านั้น ทุกทีมยังปล่อยของพร้อมกันเหมือนเดิม
ทำไมต้อง container ไม่ใช่ VM
คาบที่ 2 เทียบ VM กับ container ในเชิงโครงสร้างไปแล้ว คาบนี้เทียบในเชิง ตัวเลข สไลด์หน้า 23 ให้เหตุผลห้าข้อ
- แยกกันได้แน่นหนา (strong isolation)
- เบา overhead ต่ำกว่ามาก
- ขยายจำนวนง่าย
- บูตเร็วกว่ามาก
- สั่งงานแบบ declarative ได้ ซึ่งคือหัวใจของคำว่า software-defined
ข้อ "บูตเร็วกว่ามาก" มีข้อมูลจริงรองรับ
อ่านกราฟนี้ให้ถูกต้องอ่านแบบนี้
- ที่จำนวนน้อย (2–8 ตัว) แทบไม่ต่างกัน ประมาณ 3–13 วินาทีทั้งคู่ ถ้าคุณรัน 4 อย่างบนเครื่องพัฒนา VM ก็ไม่ได้ช้ากว่าจนรู้สึก
- ช่องว่างเปิดกว้างตามจำนวน ที่ 64 ตัว container ใช้ประมาณ 110 วินาที ส่วน VM ใช้ประมาณ 830 วินาที
- ที่ 256 ตัว ต่างกันเกินห้าสิบเท่า — ประมาณ 440 วินาที เทียบกับประมาณ 25,000 วินาที (เกือบเจ็ดชั่วโมง)
- ที่ 512 ตัว VM รันไม่ได้เลย เครื่องหมายกากบาทในกราฟคือจุดที่ VM ทำไม่สำเร็จ ส่วน container ยังขึ้นได้ในราวสิบห้านาที
ความแตกต่างไม่ได้อยู่ที่ "เร็วกว่านิดหน่อย" แต่อยู่ที่ จำนวนที่เป็นไปได้ microservices ต้องการรันของหลายสิบหลายร้อยหน่วยพร้อมกัน ซึ่งเป็นตัวเลขที่ VM ไปไม่ถึง
ทำไมแกนตั้งถึงเป็นสเกลลอการิทึม
ถ้าใช้แกนธรรมดา แท่งของ VM ที่ 25,000 วินาที จะสูงจนแท่งอื่นทั้งหมดแบนติดพื้นและดูไม่ออกเลย
สเกลลอการิทึมทำให้ระยะห่างเท่ากันบนแกน หมายถึง อัตราส่วน เท่ากัน — ขึ้นหนึ่งเส้นคือช้าลงสิบเท่า จึงเห็นทั้งค่าน้อยและค่ามากในรูปเดียวได้
ข้อควรระวัง: แท่งที่ดูสูงกว่ากันนิดเดียวบนสเกลนี้ อาจต่างกันหลายเท่าจริง ๆ
เมื่อรันหลายบริการพร้อมกัน สิ่งที่จัดการทั้งหมดให้คือ container orchestration ซึ่งเป็นเรื่องของคาบหน้า
Compose แถม service discovery มาให้ฟรี
คาบที่ 3 สอนเขียน compose file ไปแล้ว คาบนี้เพิ่มมุมที่สำคัญสำหรับ microservices โดยเฉพาะ (สไลด์หน้า 27)
- ทุก service ใน project เดียวกันถูกต่อเข้า network ปริยายของ project อัตโนมัติ
- เรียกกันด้วยชื่อ service เช่น
todoและredisไม่ใช่ IP ของ container - การคุยกันระหว่างบริการใช้พอร์ตของ container ไม่ใช่พอร์ตที่ map ออกมาที่ host
ports:มีไว้สำหรับทราฟฟิกที่เข้ามาจากข้างนอกเท่านั้น
ข้อสุดท้ายคือจุดที่คนพลาดบ่อยที่สุด และเราจะเห็นตัวอย่างจริงของมันในอีกไม่กี่หัวข้อ
ports: เหมือนประตูหน้าบ้านที่เปิดออกถนน ส่วน network ของ project เหมือนทางเดินภายในบ้าน คนในบ้านเดินหากันทางใน ไม่ต้องออกไปถนนแล้ววนกลับเข้าประตูหน้า
ตัวอย่างที่จะเดินไปด้วยกันตลอดคาบ: Todo Service
สไลด์ใช้ repository จริงสี่ตัวบน GitHub ของอาจารย์ผู้สอน (ทั้งหมดเป็น GPL v3)
| Repository | เป็นอะไร |
|---|---|
natawutn/todo | บริการหลัก เขียนด้วย Python |
natawutn/todo-webapp | หน้าเว็บ เขียนด้วย JavaScript |
natawutn/todo-notification | บริการแจ้งเตือน เขียนด้วย Python |
natawutn/todo-compose | ไฟล์ compose ที่ประกอบทุกอย่างเข้าด้วยกัน |
ตัว Todo Service เองทำแค่สามอย่าง — อ่าน เพิ่ม และแก้รายการสิ่งที่ต้องทำ โดยหนึ่งรายการมีห้าฟิลด์
{
"title": "todo-1",
"detail": "the first todo",
"tags": [],
"duedate": "2021-10-20 00:00:00",
"completed": false
}เรื่อง version ที่ต้องระวังตั้งแต่ต้น
สไลด์แยกสองสิ่งนี้ออกจากกันตลอด และมันไม่เท่ากัน
- tag ของ source code เช่น
release-1.3คือจุดใน git ที่ checkout - tag ของ image เช่น
release-1คือชื่อที่ติดตอนdocker build
ในสไลด์เขียนกำกับไว้แบบนี้ (src=release-1.3, img=release-1) — ถ้า checkout ผิด version แล้ว build จะได้ image ที่ใช้ชื่อถูกแต่ข้างในผิด และจะไปพังตอนรัน สไลด์เตือนเรื่องนี้ด้วยตัวพิมพ์ใหญ่ถึงสองหน้า
ขั้นที่ 1 — บริการเดียว
git clone https://github.com/natawutn/todo.git
cd todo
git tag
git checkout release-1.3
git describegit tag แสดง version ที่มีให้เลือก git describe ยืนยันว่าตอนนี้อยู่ที่ไหน
ดูผลลัพธ์เต็มของการ clone และเลือก version
(base) natawut@mini ~/sds/git % git clone https://github.com/natawutn/todo.git
Cloning into 'todo'...
remote: Enumerating objects: 129, done.
remote: Counting objects: 100% (129/129), done.
remote: Compressing objects: 100% (93/93), done.
remote: Total 129 (delta 50), reused 103 (delta 24), pack-reused 0
Receiving objects: 100% (129/129), 29.31 KiB | 370.00 KiB/s, done.
Resolving deltas: 100% (50/50), done.
(base) natawut@mini ~/sds/git % cd todo
(base) natawut@mini ~/sds/git/todo % git tag
release-1
release-1.1
release-1.2
release-1.3
release-2
release-2.1
release-3
(base) natawut@mini ~/sds/git/todo % git describe
release-3
(base) natawut@mini ~/sds/git/todo % git checkout release-1.3
HEAD is now at 1cfe3cf Version 1.2.3 - Revise readme file to contain docker information
(base) natawut@mini ~/sds/git/todo % git describe
release-1.3สังเกตว่า git describe ครั้งแรกตอบ release-3 — นั่นคือ version ล่าสุด ไม่ใช่ตัวที่เราต้องการ ต้อง checkout ก่อนเสมอ
ขั้นที่ 2 — เพิ่มหน้าเว็บ
บริการเดียวยังไม่แสดงอะไรเกี่ยวกับ microservices เลย ประเด็นจริงเริ่มตอนมีชิ้นที่สอง ที่เขียนด้วยภาษาคนละภาษา
หน้าเว็บอยู่ที่ natawutn/todo-webapp เป็น JavaScript รันบน Node.js ขณะที่บริการหลังบ้านเป็น Python + FastAPI — นี่คือข้อ "ไม่ผูกกับแพลตฟอร์ม" ที่พูดไว้ในส่วนที่ 1 ปรากฏเป็นของจริง
FROM node:16-alpine
RUN apk update
RUN apk add git
COPY . /root/todo-webapp
WORKDIR /root/todo-webapp
RUN yarn install
EXPOSE 3000
ENV TODO_ENDPOINT http://todo:8000/
CMD yarn startบรรทัดที่สำคัญที่สุดคือ
ENV TODO_ENDPOINT http://todo:8000/คำว่า todo ตรงนี้ ไม่ใช่ชื่อโฮสต์บนอินเทอร์เน็ต แต่เป็นชื่อ container ซึ่งจะแปลเป็น IP ได้ก็ต่อเมื่อ container ทั้งสองอยู่บนเครือข่ายเดียวกัน — ถ้าไม่อยู่ จะได้ error ว่าหาโฮสต์ไม่เจอ
และสังเกตว่าเลข 8000 ตรงนี้คือ พอร์ตของ container ไม่ใช่พอร์ตที่ map ออก host ตรงตามกฎในสไลด์หน้า 27
git checkout release-1.2
docker build -t todo-webapp:release-1 .
docker run -i --rm --name webapp -p 3000:3000 todo-webapp:release-1ดูผลลัพธ์เต็มตอนหน้าเว็บสตาร์ต
natawut@ares:~/todo/todo-webapp$ docker run -i --rm --name webapp -p 3000:3000 todo-webapp:release-1
yarn run v1.22.5
$ react-scripts start
i 「wds」: Project is running at http://172.17.0.3/
i 「wds」: webpack output is served from
i 「wds」: Content not from webpack is served from /root/todo-webapp/public
i 「wds」: 404s will fallback to /
Starting the development server...
Browserslist: caniuse-lite is outdated. Please run the following command: `npx browserslist --update-db`
Compiled successfully!
You can now view todo-webapp in the browser.
Local: http://localhost:3000
On Your Network: http://172.17.0.3:3000
Note that the development build is not optimized.
To create a production build, use yarn build.172.17.0.3 คือ IP ที่ container ได้มาจาก bridge network ปริยาย ซึ่งเปลี่ยนทุกครั้งที่รันใหม่ — เป็นเหตุผลตรง ๆ ว่าทำไมต้องเรียกกันด้วยชื่อ ไม่ใช่ IP
ขั้นที่ 3 — ยกขึ้น Compose
ตอนนี้ต้องพิมพ์ docker run สองครั้งทุกรอบ และต้องจำลำดับด้วย สไลด์จึงย้ายไป natawutn/todo-compose
git checkout release-1
docker-compose up
docker-compose downไฟล์ compose ของ release-1 หน้าตาแบบนี้
services:
todo:
image: "todo:release-1.3"
container_name: todo
ports:
- 8000:8000
webapp:
image: "todo-webapp:release-1"
container_name: webapp
stdin_open: true
ports:
- 3000:3000stdin_open: true คือ -i ของ docker run — หน้าเว็บที่รันด้วย react-scripts start ต้องการ stdin เปิดไว้ ไม่งั้นมันจะปิดตัวเอง
สไลด์หน้า 33 สั่ง docker build -t todo:release-1 . ซึ่งได้ image ชื่อ todo:release-1
แต่ compose file หน้า 41 อ้างถึง todo:release-1.3
ถ้าพิมพ์ตามสไลด์ทั้งสองหน้าตรง ๆ docker compose up จะหา image ไม่เจอแล้วไปดึงจาก Docker Hub แทน ซึ่งไม่มีอยู่จริง
ทางแก้: เลือกอย่างใดอย่างหนึ่งให้ตรงกัน — จะ build เป็น todo:release-1.3 หรือแก้ compose ให้อ้าง todo:release-1 ก็ได้ ขอแค่ชื่อตรงกัน
ขั้นที่ 4 — เพิ่ม Redis แล้วเจอปัญหาจริง
Todo Service รุ่นใหม่ (src=release-2.1, img=release-2) เก็บข้อมูลลง Redis (ฐานข้อมูลแบบเก็บในหน่วยความจำ อ่านเขียนเร็วมาก) แทนที่จะเก็บในหน่วยความจำของตัวเอง
สิ่งที่น่าสนใจคือ หน้าเว็บยังเป็น release-1.2 เหมือนเดิม ไม่ต้องแก้อะไรเลย เพราะสัญญาของ REST API ไม่เปลี่ยน ที่เปลี่ยนคือของภายในบริการ นี่คือ Encapsulation (การห่อรายละเอียดภายในไว้ไม่ให้คนนอกเห็น) ทำงานจริง
ลองสั่งแบบเดิม
docker run -i --rm --name todo -p 8000:8000 todo:release-2
docker run -i --rm --name redis -p 6379:6379 redisแล้ว todo จะเรียก redis ที่พอร์ต 6379 ได้ยังไง?
สัญชาตญาณแรกของเกือบทุกคนคือ "ก็ redis เปิดพอร์ต 6379 ออกมาแล้วนี่ ให้ todo เรียก localhost:6379 สิ"
ไม่ได้ เพราะ localhost ที่อยู่ ในตัว container todo หมายถึงตัว container todo เอง ไม่ใช่เครื่อง host พอร์ต 6379 ที่เปิดไว้นั้นเปิดอยู่บน host ไม่ได้เปิดอยู่ใน todo
การเปิดพอร์ตออก host เป็นทางเข้าสำหรับ คนนอก ไม่ใช่ทางเชื่อมระหว่าง container
ทางแก้คือสร้างเครือข่ายของตัวเองแล้วเอาทั้งคู่ไปอยู่ด้วยกัน
docker network create todo-net
docker network connect todo-net todo
docker network connect todo-net redisผลที่ได้สองอย่าง
todoเรียกredis:6379ได้ตรง ๆ เพราะ Docker แปลชื่อ container เป็น IP ให้บนเครือข่ายที่ผู้ใช้สร้างเองredisไม่ต้อง map พอร์ตออก host เลย — ซึ่งดีต่อความปลอดภัยด้วย เพราะไม่มีใครจากข้างนอกยิงเข้าฐานข้อมูลได้
คำสั่งเต็มชุดที่สไลด์ให้ไว้ สั่งเครือข่ายพร้อมกับตอนสร้าง container ได้เลย ไม่ต้อง connect ทีหลัง
docker network create todo-net
docker run -d --rm --name redis --network todo-net redis
docker run -d --rm --name todo --network todo-net \
--env REDIS_HOST=redis -p 8000:8000 todo:release-2
docker run -d -t --rm --name webapp --network todo-net \
-p 3000:3000 todo-webapp:release-1--env REDIS_HOST=redis คือวิธีบอกบริการว่าฐานข้อมูลอยู่ที่ไหน โดยไม่ต้องแก้โค้ดและไม่ต้อง build image ใหม่ — ที่อยู่ของของอื่นเป็น การตั้งค่า ไม่ใช่ส่วนหนึ่งของโปรแกรม
ขั้นที่ 4 เขียนเป็น compose file
services:
todo:
image: "todo:release-2"
container_name: todo
ports:
- 8000:8000
environment:
REDIS_HOST: redis
networks:
- todo-net
webapp:
image: "todo-webapp:release-1"
container_name: webapp
stdin_open: true
ports:
- 3000:3000
redis:
image: "redis"
container_name: redis
networks:
- todo-net
networks:
todo-net: {}อ่านไฟล์นี้เป็นสี่ส่วน
| ส่วน | ทำหน้าที่ |
|---|---|
todo | บริการหลังบ้าน เปิดพอร์ตออก host และอยู่บน todo-net |
webapp | หน้าเว็บ เปิดพอร์ตออก host |
redis | ฐานข้อมูล ไม่มี ports: อยู่แต่บน todo-net |
networks: ระดับบนสุด | ประกาศว่าจะใช้เครือข่ายชื่อ todo-net โดย {} แปลว่าใช้ค่าปริยายทั้งหมด |
สองจุดที่ควรรู้เกี่ยวกับไฟล์นี้
หนึ่ง — webapp ไม่ได้อยู่บน todo-net ในไฟล์นี้ ซึ่งหมายความว่ามันจะเรียก http://todo:8000/ ตาม TODO_ENDPOINT ที่ตั้งไว้ใน Dockerfile ไม่ได้ ถ้าจะให้ทำงาน ต้องเพิ่ม networks: - todo-net ให้ webapp ด้วย
สอง — image: "redis" ไม่ได้ระบุ version แปลว่าได้ redis:latest ซึ่งเปลี่ยนได้ทุกเมื่อ สไลด์อีกหน้าหนึ่งเขียนเป็น image: "redis:7.0.12" ซึ่งเป็นวิธีที่ถูกต้องกว่า — ระบุเลข version เสมอ ไม่งั้นวันหนึ่งระบบจะพังโดยที่ไม่มีใครแก้อะไรเลย
สไลด์หน้า 26 ยังใช้ไฟล์เดียวกันนี้อธิบายอีกมุมหนึ่ง คือระบายสีแบ่งเป็นสี่ชั้น — front-end, back-end, database และ network — แล้วชี้ว่าเลข version ของแต่ละบริการอยู่ตรงไหน ประเด็นคือ ไฟล์เดียวบอกได้ทั้งหมดว่าระบบนี้ประกอบด้วยอะไร รุ่นไหน ต่อกันยังไง
เวลาอ่านสไลด์หน้านั้น ให้ดูที่ตัวบริการ ไม่ใช่ลำดับของป้าย — ในระบบนี้ webapp คือ front-end (หน้าเว็บที่เบราว์เซอร์เปิด) และ todo คือ back-end (REST API)
ได้อะไรจากการใช้ container กับ microservices
สไลด์หน้า 49 สรุปสี่ข้อ
- deploy ง่ายขึ้น โดยเฉพาะเมื่อมีหลายสภาพแวดล้อม — dev, test, production ใช้ image ตัวเดียวกัน
- ต่อสายกันได้ด้วยโปรแกรม (programmable connectivity) — เครือข่ายเป็นบรรทัดในไฟล์ ไม่ใช่การไปตั้งค่าที่อุปกรณ์
- จัดการ version ของบริการได้ — รัน todo
release-1.3ให้ผู้ใช้จริง ขณะที่ทดสอบrelease-2.1อยู่ข้าง ๆ ในเวลาเดียวกัน - ควบคุม version ของโครงสร้างพื้นฐานได้ — compose file อยู่ใน git ถ้า
release-2มีปัญหา ก็ย้อนไฟล์กลับไปrelease-1แล้วสั่งขึ้นใหม่
ข้อสุดท้ายคือความหมายตรงตัวของคำว่า software-defined ที่คาบแรกพูดถึง — โครงสร้างพื้นฐานกลายเป็นไฟล์ข้อความที่ย้อน version ได้เหมือนโค้ด
ลองตอบดู: `redis` ในไฟล์ compose ข้างบนไม่มี `ports:` เลย แล้วจะเปิด `redis-cli` จากเครื่องตัวเองเข้าไปดูข้อมูลได้ยังไง
เข้าทาง container แทนที่จะเข้าทางเครือข่าย
docker compose exec redis redis-cliคำสั่งนี้รัน redis-cli ข้างใน container ของ redis ซึ่งมองเห็น localhost:6379 ของตัวเองอยู่แล้ว จึงไม่ต้องเปิดพอร์ตออกมาให้โลกภายนอก
ถ้าเปิด ports: - 6379:6379 เพื่อความสะดวก เท่ากับเปิดฐานข้อมูลให้ทุกคนที่ถึงเครื่องนี้ได้ — สะดวกตอน debug แต่ห้ามหลุดไป production
ส่วนที่ 3 — สถาปัตยกรรม microservices
ปัญหาที่โผล่มาเมื่อบริการเพิ่มขึ้น
ระบบ todo เพิ่ม Notification Service เข้ามาอีกตัว (พอร์ต 9000) ตอนนี้มีสี่ชิ้น — webapp, todo, notification, redis
แต่ปัญหาไม่ได้อยู่ที่จำนวน ปัญหาอยู่ที่ เบราว์เซอร์ต้องรู้จักที่อยู่ของทุกบริการ
| เบราว์เซอร์ต้องจำ | เพื่อเรียกอะไร |
|---|---|
localhost:3000 | หน้าเว็บ |
localhost:8000 | Todo Service |
localhost:9000 | Notification Service |
เพิ่มบริการที่สี่ ที่ห้า ที่หก ก็ต้องเพิ่มที่อยู่ในโค้ดฝั่ง client ทุกครั้ง แล้วต้องปล่อยแอปมือถือใหม่ทุกครั้งด้วย
ปัญหาเดียวกันนี้ในระบบขนาดจริง
สไลด์ยกตัวอย่างระบบแบบ Grab หรือ Uber ที่
- มีทั้งแอปมือถือและเว็บ
- ต่อกับบริการภายนอกหลายเจ้า — Twilio (บริการโทรศัพท์และ SMS) สำหรับเสียง, SendGrid (บริการส่งอีเมล) สำหรับอีเมล, Stripe (บริการรับชำระเงิน) สำหรับการจ่ายเงิน
- และตอนนี้ระบบเดิมที่เป็น monolith เริ่มไปต่อไม่ไหว
พอแยกออกมาก็ได้ประมาณหกบริการ — Passenger Management, Driver Management, Trip Management, Billing, Payments, Notification โดยตัวที่ต่อกับโลกภายนอกจะมี Adapter (ตัวแปลงที่คั่นระหว่างบริการของเรากับ API ของเจ้าอื่น) ของตัวเอง เช่น Billing มี Stripe Adapter และ Notification มีทั้ง Twilio Adapter และ SendGrid Adapter
สไลด์หน้า 57 ยังแสดงรายละเอียดที่ควรสังเกตอีกอย่าง: ไม่ใช่ทุก client ที่เข้าทาง gateway แอปมือถือยิงผ่าน API Gateway ส่วนเบราว์เซอร์บนคอมพิวเตอร์เปิดหน้าเว็บ (Passenger Web UI, Driver Web UI) ตรง ๆ แล้วให้หน้าเว็บนั้นเป็นฝ่ายเรียกบริการต่อ
เหตุผลคือหน้าเว็บเป็นของเราเอง ปล่อยใหม่เมื่อไหร่ก็ได้ ต่างจากแอปมือถือที่ผู้ใช้ต้องกดอัปเดตเอง — สิ่งที่เปลี่ยนยากที่สุดจึงเป็นสิ่งที่ต้องกันไว้หลัง gateway มากที่สุด
Adapter คือปลั๊กแปลง ถ้าวันหนึ่งเปลี่ยนจาก SendGrid ไปใช้เจ้าอื่น เราเปลี่ยนแค่ปลั๊กแปลง ไม่ต้องเปลี่ยนทั้งบ้าน
หนึ่งหน้าจอ หลายการเรียก
สไลด์หน้า 55 ใช้หน้ารายละเอียดสินค้าของ Amazon เป็นตัวอย่าง หน้าจอเดียวนั้นต้องดึงข้อมูลจากหกบริการ
- Order History — เคยซื้อไปแล้วหรือยัง
- Reviews — คะแนนและรีวิว
- Basic Product Info — ชื่อ ราคา รูป
- Recommendation — คนที่ซื้อสิ่งนี้ยังซื้ออะไรอีก
- Inventory — เหลือกี่ชิ้น
- Shipping — ส่งถึงเมื่อไหร่
ถ้าให้แอปมือถือยิงหกครั้งเอง จะเจอสามปัญหาพร้อมกัน: ต้องรู้ที่อยู่หกแห่ง, เสียเวลาไป-กลับหกรอบบนเครือข่ายมือถือที่ช้า, และถ้าบริการหนึ่งเปลี่ยนที่อยู่ ต้องปล่อยแอปใหม่
ทางออก: API Gateway
ปัญหาคือ client ผูกอยู่กับ โครงสร้างภายใน ของระบบเรา ซึ่งเป็นสิ่งที่เปลี่ยนบ่อยที่สุด ทางออกคือหาอะไรมาคั่นตรงกลาง
รูปแบบนี้เรียกว่า Façade Pattern (รูปแบบด้านหน้าอาคาร — ซ่อนความยุ่งข้างในไว้หลังหน้าตาเรียบ ๆ อันเดียว) ในโลกของ microservices ตัวที่ทำหน้าที่นี้คือ API Gateway (ประตูเดียวที่รับคำขอจากภายนอกแล้วส่งต่อให้บริการข้างใน)
สไลด์หน้า 58 ระบุว่า gateway ทำอะไรให้บ้าง
ห่อโครงสร้างภายในของแอปไว้ เป็นข้อหลัก และทำให้ทำอีกห้าอย่างได้ง่ายขึ้นด้วย
| ความสามารถ | ทำอะไร |
|---|---|
| Load Balancing (กระจายโหลด) | มีบริการเดียวกันหลายสำเนา gateway เลือกให้ว่าคำขอนี้ไปตัวไหน |
| Performance management | เมื่อคำขอเข้ามาเกินที่รับไหว บางส่วนได้ไปต่อ บางส่วนต้องรอ |
| Failure handling | บริการปลายทางล่ม gateway ตอบแทนได้ ไม่ต้องปล่อยให้ client ค้าง |
| Service discovery | gateway เป็นคนหาที่อยู่ ไม่ใช่ client |
| Security | ตรวจ authentication และ authorization ที่จุดเดียว ไม่ต้องเขียนซ้ำในทุกบริการ |
กระจายโหลด
สไลด์หน้า 59 กับ 60 วางคู่กันเพื่อให้เห็นความต่าง: หน้าแรกมี Service-1 หนึ่งตัว หน้าที่สอง Service-1 กลายเป็นสามสำเนา
สิ่งที่ ไม่เปลี่ยน คือฝั่ง client — มันยังยิงมาที่ gateway ที่อยู่เดิม ไม่รู้เลยว่าข้างในมีกี่ตัว
จัดการเมื่อคนล้น
หน้า 61 วาดคำขอสี่อันวิ่งเข้ามาหา Service-1 พร้อมกัน แต่กำลังรับได้แค่สอง gateway จึงตอบ OK ให้สอง และให้อีกสอง WAIT
ทำไมต้องให้รอ ทำไมไม่ส่งเข้าไปทั้งหมด?
เพราะถ้าปล่อยเข้าไปทั้งสี่ บริการจะช้าลงทุกคำขอ และอาจล่มไปเลย ซึ่งทำให้ทั้งสี่คำขอล้มเหลว
การให้รอ ทำให้สองคำขอแรกเสร็จเร็ว แล้วสองอันหลังค่อยเข้าไป — สุดท้ายทุกคนได้คำตอบ
นี่คือเหตุผลเดียวกับที่ร้านอาหารตั้งโต๊ะรอ แทนที่จะยัดทุกคนเข้าไปยืนในร้านพร้อมกัน
Service Discovery: ปัญหาที่อยู่ไม่นิ่ง
บนคลาวด์ บริการหนึ่งตัวไม่ได้อยู่ที่เดิมตลอด — IP ถูกจ่ายอัตโนมัติตอน container เกิด พอร์ตก็อาจถูกสุ่ม และมันเปลี่ยนทุกครั้งที่สร้างใหม่หรือขยายจำนวน
สไลด์หน้า 62 วาดสามสำเนาที่อยู่คนละที่กันหมด: 10.4.3.1:8756, 10.4.3.99:4545, 10.4.3.20:333 — ไม่มีรูปแบบให้เดา
จะเขียนที่อยู่เหล่านี้ลงไฟล์ตั้งค่าไม่ได้ ต้องมีคน จดไว้และให้ถาม สิ่งนั้นคือ Service Registry (ทะเบียนบริการ ที่จดว่าตอนนี้บริการไหนอยู่ที่ไหนบ้าง)
บริการทุกตัวมี Registry Client (ส่วนเล็ก ๆ ในบริการที่คอยแจ้งทะเบียนว่าตัวเองยังอยู่) คอยลงทะเบียนตัวเองไว้ จากนั้นมีสองวิธีที่จะใช้ทะเบียนนี้
แบบที่ 1 — Client-Side Discovery
client เป็นคนถามเอง
- client ถาม registry ว่า service นี้อยู่ที่ไหนบ้าง
- registry ตอบมาเป็นรายการที่อยู่
- client เลือกเองว่าจะยิงไปตัวไหน แล้วเรียกตรง
ข้อดี: ไม่มีตัวกลางเพิ่ม จึงเร็วกว่าและมีจุดที่จะล่มน้อยกว่า
ข้อเสีย: ทุก client ต้องมีโค้ดส่วนนี้ ถ้ามี client สามภาษา ต้องเขียนสามครั้ง และเวลาจะเปลี่ยนวิธีเลือก ต้องปล่อย client ใหม่ทุกตัว
แบบที่ 2 — Server-Side Discovery
load balancer เป็นคนถามให้
- client ยิงคำขอไปที่ load balancer ที่อยู่คงที่
- load balancer ถาม registry เอง
- load balancer เลือกตัวปลายทางแล้วส่งต่อ
ข้อดี: client ไม่ต้องรู้อะไรเลย ยิงที่เดิมเสมอ เปลี่ยนวิธีเลือกได้โดยไม่แตะ client
ข้อเสีย: มีชิ้นส่วนเพิ่มหนึ่งชิ้นที่ต้องดูแล และถ้ามันล่มก็ล่มทั้งหมด
สไลด์ยกตัวอย่างของจริงว่า consul + consul-template + nginx และ Kubernetes ในคาบหน้าก็ใช้แนวนี้
แล้ว Docker Compose อยู่ตรงไหนของสองแบบนี้
Compose ทำให้เราไม่ต้องเจอปัญหานี้เลยในสเกลเล็ก เพราะ DNS ภายในของ Docker แปลชื่อ service เป็น IP ให้อัตโนมัติ และถ้าสั่ง --scale ให้มีหลายสำเนา DNS ก็จะวนตอบ IP ต่างกันไป
พูดอีกอย่างคือ Docker ทำหน้าที่เป็นทั้ง registry และตัวช่วยค้นหาให้ในตัว — แต่ทำได้แค่ภายในเครื่องเดียว พอข้ามหลายเครื่องก็ต้องใช้ของจริงอย่าง consul หรือ Kubernetes ซึ่งเป็นเรื่องของคาบหน้า
ส่วนประกอบของสถาปัตยกรรม microservices
สไลด์หน้า 65 รวบทุกอย่างเป็นรายการเดียว ใช้เป็นเช็กลิสต์ได้
| ส่วนประกอบ | ตอบคำถามว่า |
|---|---|
| API Gateway | คนนอกเข้าระบบทางไหน |
| Service Registry & Discovery | บริการหากันเจอได้ยังไง |
| Communication Mechanism | คุยกันด้วยอะไร — REST (ถาม-ตอบทันที) หรือ Kafka (ระบบคิวข้อความ ผู้ส่งไม่ต้องรอผู้รับ) (ฝากข้อความไว้) |
| Observability | ตอนพังจะรู้ได้ยังไงว่าพังตรงไหน |
| Security | authentication, authorization, encryption |
| Deployment & Orchestration | สั่งทั้งหมดขึ้นลงยังไง |
REST กับ Kafka ต่างกันตรงไหน — เลือกยังไง
REST คือถามแล้วรอคำตอบ ผู้เรียกหยุดรอจนกว่าปลายทางจะตอบ
- เหมาะกับ: อยากได้ข้อมูลกลับมาเดี๋ยวนี้ เช่น "ยอดคงเหลือเท่าไหร่"
- ปัญหา: ถ้าปลายทางล่ม ผู้เรียกก็ค้างหรือพังตาม
Kafka คือฝากข้อความไว้ในคิว ผู้ส่งส่งแล้วไปต่อได้เลย ผู้รับมาหยิบเมื่อพร้อม
- เหมาะกับ: งานที่ไม่ต้องรอผล เช่น "มีคนสั่งซื้อแล้ว ใครสนใจไปทำต่อ"
- ข้อดี: ปลายทางล่มก็ไม่กระทบผู้ส่ง ข้อความรออยู่ในคิว
- ปัญหา: ไม่รู้ว่าเสร็จเมื่อไหร่ และต้องดูแลระบบคิวเพิ่มอีกตัว
ระบบจริงส่วนใหญ่ใช้ทั้งสองอย่าง — REST สำหรับสิ่งที่ผู้ใช้รออยู่หน้าจอ และคิวสำหรับงานเบื้องหลัง
ทำ API Gateway ของจริงด้วย nginx
สามหัวข้อที่ผ่านมาเป็นแนวคิด ส่วนนี้คือการลงมือทำจริงกับระบบ todo ที่เดินมาด้วยกันทั้งคาบ
เป้าหมายคือเปลี่ยนจาก "เบราว์เซอร์ต้องรู้จักพอร์ต 8000 และ 9000" เป็น "เบราว์เซอร์รู้จักแค่พอร์ต 8000 ที่เดียว"
ทำไมต้อง nginx
nginx (เว็บเซิร์ฟเวอร์โอเพนซอร์สที่ใช้เป็น reverse proxy ได้) เป็นตัวเลือกที่สไลด์ใช้ เพราะ
- ใช้เป็น Reverse Proxy (ตัวรับคำขอแทนเซิร์ฟเวอร์จริงแล้วส่งต่อให้ข้างหลัง) ได้
- เร็วและเสถียร
- ตั้งค่าง่าย
- กินทรัพยากรน้อย
- รองรับ load balancing
ในทางปฏิบัติ nginx ตัวเดียวทำหน้าที่ได้หลายอย่างพร้อมกัน — เป็นทั้ง API gateway, cache (ที่พักข้อมูลไว้ตอบซ้ำโดยไม่ต้องถามข้างหลัง), load balancer และ WAF (Web Application Firewall) (ตัวกรองคำขอที่เป็นการโจมตี)
ไฟล์ตั้งค่า
ไฟล์เดียวนี้คือหัวใจของทั้งเรื่อง ชื่อ api.conf
server {
listen 8000;
server_name localhost;
location /todo/ {
proxy_pass http://todo:8000/;
}
location /notification/ {
proxy_pass http://notification:9000/;
}
}อ่านทีละบรรทัด
| บรรทัด | ความหมาย |
|---|---|
listen 8000; | เปิดรับคำขอที่พอร์ต 8000 นี่คือพอร์ตเดียวที่คนนอกเห็น |
location /todo/ | คำขอที่ path ขึ้นต้นด้วย /todo/ ให้เข้ากฎนี้ |
proxy_pass http://todo:8000/; | ส่งต่อไปหา container ชื่อ todo ที่พอร์ต 8000 |
location /notification/ | คำขอที่ขึ้นต้นด้วย /notification/ |
proxy_pass http://notification:9000/; | ส่งต่อไปหา container ชื่อ notification ที่พอร์ต 9000 |
proxy_pass http://todo:8000/; — สังเกต / ตัวสุดท้าย
มี / แปลว่าตัด /todo/ ออกก่อนส่งต่อ ดังนั้น GET /todo/1 จะไปถึงบริการ todo ในรูป GET /1 ซึ่งตรงกับ API ของมันพอดี
ไม่มี / จะส่งต่อทั้ง path ทำให้บริการ todo ได้รับ GET /todo/1 ซึ่งมันไม่รู้จัก แล้วตอบ 404
ผิดตัวเดียวนี้ ระบบขึ้นได้ปกติ ไม่มี error ตอน start แต่ทุกคำขอตอบ 404 — เป็นบั๊กที่หายากมาก
แพ็ก gateway เป็น image
FROM nginx
COPY api.conf /etc/nginx/conf.d
ENTRYPOINT ["/docker-entrypoint.sh"]
EXPOSE 8000
STOPSIGNAL SIGTERM
CMD ["nginx", "-g", "daemon off;"]ทั้งหมดที่ทำคือ ก็อป api.conf เข้าไปในโฟลเดอร์ที่ nginx อ่าน ที่เหลือคือค่าปริยายของ image nginx เอง
จุดที่ควรสังเกตคือ daemon off; — ปกติ nginx จะแยกตัวเองไปรันเบื้องหลังแล้วโปรเซสแรกจบ ซึ่งจะทำให้ container ดับทันที การสั่ง daemon off บังคับให้ nginx อยู่เป็นโปรเซสหน้า container จึงอยู่ต่อได้
STOPSIGNAL SIGTERM บอก Docker ว่าเวลาจะหยุด container ให้ส่งสัญญาณอะไรไปให้โปรแกรมเก็บของก่อนตาย
สั่งทั้งระบบขึ้น
ระบบตอนนี้มีห้าชิ้น ทั้งหมดอยู่บน todo-net
docker network create todo-net
docker run -d --rm --name redis --network todo-net redis
docker run -d -t --rm --name notification --network todo-net \
--env REDIS_HOST=redis todo-notification:release-1.1
docker run -d --rm --name todo --network todo-net --env REDIS_HOST=redis \
--env NOTIFICATION_HOST=notification todo:release-3
docker run -d --rm --name api --network todo-net -p 8000:8000 api
docker run -d -t --rm --name webapp --network todo-net \
-p 3000:3000 todo-webapp:release-3.1สังเกตสามอย่างในชุดคำสั่งนี้
- มีแค่สอง container ที่เปิดพอร์ตออก host —
api(8000) และwebapp(3000) ส่วนtodo,notification,redisไม่เปิดเลย เพราะไม่มีใครควรเรียกถึงจากข้างนอก - ที่อยู่ของทุกอย่างส่งผ่าน
--env—REDIS_HOST=redis,NOTIFICATION_HOST=notificationไม่มีเลข IP โผล่ในคำสั่งไหนเลยสักอัน - ลำดับสำคัญ — สร้าง redis ก่อน แล้วค่อย notification แล้วค่อย todo เพราะตัวหลังต้องการตัวหน้า
ชุดคำสั่งหกบรรทัดข้างบนเขียนเป็น compose file ได้แบบนี้ ซึ่งอ่านง่ายกว่ามากและสั่งขึ้นทีเดียวจบ
services:
redis:
image: "redis:7.0.12"
networks: [todo-net]
notification:
image: "todo-notification:release-1.1"
environment:
REDIS_HOST: redis
networks: [todo-net]
todo:
image: "todo:release-3"
environment:
REDIS_HOST: redis
NOTIFICATION_HOST: notification
networks: [todo-net]
api:
image: "api"
ports:
- 8000:8000
networks: [todo-net]
webapp:
image: "todo-webapp:release-3.1"
stdin_open: true
tty: true
ports:
- 3000:3000
networks: [todo-net]
networks:
todo-net: {}tty: true คือ -t และ stdin_open: true คือ -i จากคำสั่งเดิม
ไฟล์นี้เขียนขึ้นเสริมจากคำสั่งในสไลด์ ไม่ได้อยู่ในสไลด์
ได้อะไรจาก deployment orchestration
สไลด์หน้าสุดท้ายสรุปห้าข้อ ซึ่งอ่านแล้วจะเห็นว่ามันตอบปัญหาที่เจอมาทั้งคาบพอดี
| ประโยชน์ | แก้ปัญหาที่เจอตรงไหน |
|---|---|
| deploy ระบบซับซ้อนได้ง่ายขึ้น | หกคำสั่ง docker run ที่ต้องพิมพ์ให้ถูกทุกครั้ง |
| ลดความผิดพลาดจาก dependency และจากคน | ลำดับ redis ก่อน notification ก่อน todo |
| deploy เร็วและมีประสิทธิภาพขึ้น | ไม่ต้องรอคนพิมพ์ทีละบรรทัด |
| ทำ automated และ regression testing (การทดสอบซ้ำว่าของเดิมยังไม่พัง) ได้สะดวก | สั่งระบบทั้งชุดขึ้นในเครื่องทดสอบได้ด้วยคำสั่งเดียว |
| ย้อนกลับไป version ก่อนหน้าได้ | เปลี่ยนเลข version ในไฟล์แล้วสั่งขึ้นใหม่ |
microservices ทำให้เกิด ชิ้นส่วนจำนวนมากที่ต้องประกอบให้ถูก ทุกครั้ง
การประกอบด้วยมือใช้ได้ตอนมีสามชิ้น พอถึงสิบชิ้นมันจะพลาด และนั่นคือเหตุผลที่ orchestration ไม่ใช่ของเสริม แต่เป็นของจำเป็นที่มาคู่กับการตัดสินใจแยกบริการเสมอ
ลองตอบดู: ทำไม gateway ในตัวอย่างนี้ถึงเปิดพอร์ต 8000 ทั้งที่ Todo Service ก็ใช้ 8000 เหมือนกัน — ชนกันไหม
ไม่ชน เพราะเป็นคนละพื้นที่ที่อยู่
-p 8000:8000ของapiจองพอร์ต 8000 บนเครื่อง hosttodoฟังพอร์ต 8000 ข้างในตัวเอง และไม่ได้เปิดออก host เลย
สอง container คนละตัวใช้พอร์ตเลขเดียวกันได้เสมอ ตราบใดที่ไม่มีมากกว่าหนึ่งตัว map ออกพอร์ตเดียวกันของ host
การใช้เลข 8000 เท่ากันยังมีข้อดีด้วย: โค้ดฝั่ง client ที่เคยยิง localhost:8000 ตรงไปหา todo ยังใช้ที่อยู่เดิมได้ เปลี่ยนแค่ path จาก /1 เป็น /todo/1
monolith กับ microservices
- monolith เป็นจุดเริ่มที่ถูกต้องในกรณีส่วนใหญ่ — เร็ว ประหยัด และง่ายเมื่อทีมเล็ก
- ปัญหาเริ่มเมื่อ จำนวนคนโต ไม่ใช่เมื่อโค้ดโต — ห้าทีมที่ปล่อยของจากไฟล์เดียวกันจะติดกันเอง
- microservice architecture คือ SOA ที่บริการเล็ก คุยกันด้วยโปรโตคอลเบา ไม่ผูกแพลตฟอร์ม และแบ่งตาม ความสามารถทางธุรกิจ ไม่ใช่ตามชั้นเทคนิค
- แนวคิดหลักสี่ข้อ: service decomposition, inter-service communication, data-per-service, service discovery
- ราคาที่จ่าย: เครือข่ายล้มได้, ข้อมูลไม่สอดคล้อง, มองเห็นระบบยาก, งานดูแลเพิ่ม — เลือกเมื่อสิ่งที่องค์กรได้กลับมาคุ้มกว่า
container
- container ไม่ได้ทำให้เป็น microservice — monolith ใน container ก็ยังเป็น monolith
- ขอบเขตของบริการมาจากความเป็นเจ้าของ ข้อมูล และการเปลี่ยนแปลงที่อิสระต่อกัน
- container ชนะ VM ตรงจำนวน — ที่ 256 ตัวต่างกันเกินห้าสิบเท่า และที่ 512 ตัว VM ทำไม่สำเร็จ
เครือข่ายและการค้นหา
- เปิดพอร์ตออก host ไม่ได้ ทำให้สอง container คุยกัน ต้องอยู่บนเครือข่ายเดียวกันถึงจะเรียกกันด้วยชื่อได้
- ฐานข้อมูลไม่ควรมี
ports:— เรียกจากข้างในผ่านเครือข่ายอย่างเดียว - ที่อยู่ของบริการอื่นส่งผ่าน environment variable ไม่ฝังในโค้ด
- บนคลาวด์ IP และพอร์ตเปลี่ยนตลอด จึงต้องมี service registry
- client-side discovery — client ถามทะเบียนเอง เร็วแต่ทุก client ต้องเขียนโค้ดส่วนนี้
- server-side discovery — load balancer ถามให้ client ไม่ต้องรู้อะไร ตัวอย่างคือ consul + consul-template + nginx
API Gateway
- แก้ปัญหา client ผูกกับโครงสร้างภายในของระบบ
- ให้ load balancing, จัดคิวเมื่อล้น, จัดการความล้มเหลว, service discovery และตรวจสิทธิ์ที่จุดเดียว
- ทำจริงด้วย nginx ได้ด้วยไฟล์ตั้งค่าไม่กี่บรรทัด —
locationคู่กับproxy_pass - อย่าลืม
/ท้ายproxy_passไม่งั้นได้ 404 ทุกคำขอโดยไม่มี error ให้เห็น
สุดท้าย
- แยกบริการแล้วชิ้นส่วนเพิ่มขึ้นเสมอ การประกอบด้วยมือจะพลาดเมื่อถึงสิบชิ้น
- orchestration จึงมาคู่กับการตัดสินใจแยกบริการ — และเป็นเรื่องของคาบหน้า