CEDTAN

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 หนึ่งตัว เป็นฐานข้อมูลของทั้งระบบ
Monolithic ArchitectureBrowserSPAclient-side frameworkHTML / RESTWeb FrontendMembership ManagementQueue ManagementRestaurant ManagementWAR / EARTomcat AppServerMySQLฐานข้อมูลเดียวของทั้งระบบ
รูปที่ 1: SlowQueue ทั้งระบบถูกแพ็กเป็นไฟล์เดียว แล้วรันเป็นโปรเซสเดียว

จุดสำคัญอยู่ที่คำว่า 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 คืออะไร

SQ 4.0ApplicationMonolithic ArchitectureQueue ServiceRestaurant ServiceAds Service… ServiceMicroservice Architecture — loosely coupled, smaller units
รูปที่ 2: แยกก้อนเดียวออกเป็นหลายหน่วยที่เล็กลงและไม่ผูกกันแน่น

Microservice Architecture (สถาปัตยกรรมแบบบริการย่อย) เป็นรูปแบบหนึ่งของ SOA (Service-Oriented Architecture) (สถาปัตยกรรมที่จัดระบบเป็นชุดของบริการ) โดยมีลักษณะสี่ข้อ

  1. จัดแอปเป็นชุดของบริการที่ไม่ผูกกันแน่น (loosely coupled (เกี่ยวข้องกันแบบหลวม ๆ))
  2. บริการมีขนาดย่อย และโปรโตคอลที่ใช้คุยกันเบา — ปกติคือ REST
  3. ไม่ผูกกับแพลตฟอร์ม ไม่ยึดระบบปฏิบัติการหรือภาษาใดภาษาหนึ่ง
  4. แต่ละบริการโฟกัสความสามารถทางธุรกิจอย่างเดียว (single business capability)

ข้อ 4 คือเกณฑ์การแบ่ง ไม่ใช่แบ่งตามชั้นเทคนิค (controller / service / repository) แต่แบ่งตาม เรื่องทางธุรกิจ — คิว ร้านอาหาร โฆษณา สมาชิก

BrowserContentRouterQueue WebApplicationQueue ServiceDBAds WebApplicationAds ServiceDBMobileDeviceAPIGatewayRestaurant ServiceDBNotificationGatewayRESTNotificationแต่ละบริการถือฐานข้อมูลของตัวเอง ไม่มีใครอ่านตารางของคนอื่นตรง ๆ
รูปที่ 3: ระบบเดียวกันหลังแยกเป็นบริการ — client เข้าทางชั้น routing แล้วแต่ละบริการถือข้อมูลของตัวเอง

รูปที่ 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 ใหม่ และคนที่ต้องดูแลตอนตีสาม ทีมส่วนใหญ่จำกัดไว้สองสามภาษาโดยตั้งใจ

ทีมกับบริการเป็นเรื่องเดียวกัน

Mobile UITeamSource coderepositoryAutomated Deployment PipelineMobile UI FrontendQueueTeamSource coderepositoryAutomated Deployment PipelineQueue ServiceAdsTeamSource coderepositoryAutomated Deployment PipelineAds Serviceทำงานแยกกัน
รูปที่ 4: หนึ่งทีม หนึ่ง repository หนึ่ง pipeline หนึ่งบริการ — ไม่มีใครต้องรอใคร

นี่คือหัวใจของทั้งเรื่อง แต่ละทีมมี 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

ข้อ "บูตเร็วกว่ามาก" มีข้อมูลจริงรองรับ

1101001,00010,000248163264128256512DockerVMจำนวน container หรือ VM ที่สั่งขึ้นพร้อมกันเวลา (วินาที)แกนตั้งเป็นสเกลลอการิทึม — ขึ้นหนึ่งเส้นคือช้าลงสิบเท่า
รูปที่ 5: เวลาที่ใช้สั่ง N ตัวขึ้นพร้อมกัน — วาดใหม่จากกราฟในสไลด์หน้า 24 ค่าเป็นค่าประมาณที่อ่านจากกราฟ ที่มา Zhang et al., IEEE CLOUD 2018

อ่านกราฟนี้ให้ถูกต้องอ่านแบบนี้

  • ที่จำนวนน้อย (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 describe

git 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:3000

stdin_open: true คือ -i ของ docker run — หน้าเว็บที่รันด้วย react-scripts start ต้องการ stdin เปิดไว้ ไม่งั้นมันจะปิดตัวเอง

ชื่อ image ในไฟล์นี้ไม่ตรงกับที่ build ไว้

สไลด์หน้า 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 ได้ยังไง?

ก่อน — ต่างคนต่างอยู่หลัง — อยู่บน todo-net เดียวกันtodoredis?80006379Host Portsเปิดพอร์ตออกหา host ทั้งคู่แต่ยังเรียกกันเองไม่ได้todoredistodo-net8000Host Portsredis ไม่ต้องเปิดพอร์ตออกหา host เลยtodo เรียก redis:6379 ได้ตรง ๆ
รูปที่ 6: เปิดพอร์ตออก host ไม่ได้ทำให้สอง container คุยกัน เครือข่ายร่วมต่างหากที่ทำ
จุดที่คนพลาดบ่อย

สัญชาตญาณแรกของเกือบทุกคนคือ "ก็ 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

ผลที่ได้สองอย่าง

  1. todo เรียก redis:6379 ได้ตรง ๆ เพราะ Docker แปลชื่อ container เป็น IP ให้บนเครือข่ายที่ผู้ใช้สร้างเอง
  2. 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:8000Todo Service
localhost:9000Notification 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 เป็นตัวอย่าง หน้าจอเดียวนั้นต้องดึงข้อมูลจากหกบริการ

  1. Order History — เคยซื้อไปแล้วหรือยัง
  2. Reviews — คะแนนและรีวิว
  3. Basic Product Info — ชื่อ ราคา รูป
  4. Recommendation — คนที่ซื้อสิ่งนี้ยังซื้ออะไรอีก
  5. Inventory — เหลือกี่ชิ้น
  6. Shipping — ส่งถึงเมื่อไหร่

ถ้าให้แอปมือถือยิงหกครั้งเอง จะเจอสามปัญหาพร้อมกัน: ต้องรู้ที่อยู่หกแห่ง, เสียเวลาไป-กลับหกรอบบนเครือข่ายมือถือที่ช้า, และถ้าบริการหนึ่งเปลี่ยนที่อยู่ ต้องปล่อยแอปใหม่

ทางออก: API Gateway

ทำไมต้องมีสิ่งนี้

ปัญหาคือ client ผูกอยู่กับ โครงสร้างภายใน ของระบบเรา ซึ่งเป็นสิ่งที่เปลี่ยนบ่อยที่สุด ทางออกคือหาอะไรมาคั่นตรงกลาง

รูปแบบนี้เรียกว่า Façade Pattern (รูปแบบด้านหน้าอาคาร — ซ่อนความยุ่งข้างในไว้หลังหน้าตาเรียบ ๆ อันเดียว) ในโลกของ microservices ตัวที่ทำหน้าที่นี้คือ API Gateway (ประตูเดียวที่รับคำขอจากภายนอกแล้วส่งต่อให้บริการข้างใน)

ไม่มี gatewayมี gatewayclient?Product CatalogShopping CartOrderInventoryShippingRecommendationReviewclientAPIGatewayProduct CatalogShopping CartOrderInventoryShippingRecommendationReviewฝั่งขวา client จำที่อยู่เดียว บริการหลังบ้านจะเพิ่ม ลด หรือย้ายก็ไม่กระทบ client
รูปที่ 7: ก่อน client ต้องรู้จักทุกบริการ หลัง client รู้จักที่อยู่เดียว

สไลด์หน้า 58 ระบุว่า gateway ทำอะไรให้บ้าง

ห่อโครงสร้างภายในของแอปไว้ เป็นข้อหลัก และทำให้ทำอีกห้าอย่างได้ง่ายขึ้นด้วย

ความสามารถทำอะไร
Load Balancing (กระจายโหลด)มีบริการเดียวกันหลายสำเนา gateway เลือกให้ว่าคำขอนี้ไปตัวไหน
Performance managementเมื่อคำขอเข้ามาเกินที่รับไหว บางส่วนได้ไปต่อ บางส่วนต้องรอ
Failure handlingบริการปลายทางล่ม gateway ตอบแทนได้ ไม่ต้องปล่อยให้ client ค้าง
Service discoverygateway เป็นคนหาที่อยู่ ไม่ใช่ 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 (ส่วนเล็ก ๆ ในบริการที่คอยแจ้งทะเบียนว่าตัวเองยังอยู่) คอยลงทะเบียนตัวเองไว้ จากนั้นมีสองวิธีที่จะใช้ทะเบียนนี้

Client-Side DiscoveryServer-Side Discoveryclientที่รู้จัก registryServiceRegistryserviceinstance Aserviceinstance Bserviceinstance CถามเรียกตรงลงทะเบียนclientLoadBalancerServiceRegistryserviceinstance Aserviceinstance Bserviceinstance Cถามกระจายโหลดลงทะเบียนซ้าย client ต้องฉลาดเอง — ขวา client ยิงที่เดียวจบ ตัวอย่างจริงคือ consul + consul-template + nginx
รูปที่ 8: สองรูปแบบของ service discovery — ต่างกันที่ใครเป็นคนถามทะเบียน

แบบที่ 1 — Client-Side Discovery

client เป็นคนถามเอง

  1. client ถาม registry ว่า service นี้อยู่ที่ไหนบ้าง
  2. registry ตอบมาเป็นรายการที่อยู่
  3. client เลือกเองว่าจะยิงไปตัวไหน แล้วเรียกตรง

ข้อดี: ไม่มีตัวกลางเพิ่ม จึงเร็วกว่าและมีจุดที่จะล่มน้อยกว่า

ข้อเสีย: ทุก client ต้องมีโค้ดส่วนนี้ ถ้ามี client สามภาษา ต้องเขียนสามครั้ง และเวลาจะเปลี่ยนวิธีเลือก ต้องปล่อย client ใหม่ทุกตัว

แบบที่ 2 — Server-Side Discovery

load balancer เป็นคนถามให้

  1. client ยิงคำขอไปที่ load balancer ที่อยู่คงที่
  2. load balancer ถาม registry เอง
  3. 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ตอนพังจะรู้ได้ยังไงว่าพังตรงไหน
Securityauthentication, authorization, encryption
Deployment & Orchestrationสั่งทั้งหมดขึ้นลงยังไง
REST กับ Kafka ต่างกันตรงไหน — เลือกยังไง

REST คือถามแล้วรอคำตอบ ผู้เรียกหยุดรอจนกว่าปลายทางจะตอบ

  • เหมาะกับ: อยากได้ข้อมูลกลับมาเดี๋ยวนี้ เช่น "ยอดคงเหลือเท่าไหร่"
  • ปัญหา: ถ้าปลายทางล่ม ผู้เรียกก็ค้างหรือพังตาม

Kafka คือฝากข้อความไว้ในคิว ผู้ส่งส่งแล้วไปต่อได้เลย ผู้รับมาหยิบเมื่อพร้อม

  • เหมาะกับ: งานที่ไม่ต้องรอผล เช่น "มีคนสั่งซื้อแล้ว ใครสนใจไปทำต่อ"
  • ข้อดี: ปลายทางล่มก็ไม่กระทบผู้ส่ง ข้อความรออยู่ในคิว
  • ปัญหา: ไม่รู้ว่าเสร็จเมื่อไหร่ และต้องดูแลระบบคิวเพิ่มอีกตัว

ระบบจริงส่วนใหญ่ใช้ทั้งสองอย่าง — REST สำหรับสิ่งที่ผู้ใช้รออยู่หน้าจอ และคิวสำหรับงานเบื้องหลัง

ทำ API Gateway ของจริงด้วย nginx

ทำไมต้องมีสิ่งนี้

สามหัวข้อที่ผ่านมาเป็นแนวคิด ส่วนนี้คือการลงมือทำจริงกับระบบ todo ที่เดินมาด้วยกันทั้งคาบ

เป้าหมายคือเปลี่ยนจาก "เบราว์เซอร์ต้องรู้จักพอร์ต 8000 และ 9000" เป็น "เบราว์เซอร์รู้จักแค่พอร์ต 8000 ที่เดียว"

todo-netBrowserTodo Webappพอร์ต 3000API Gatewayพอร์ต 8000Todo Serviceพอร์ต 8000Notification Serviceพอร์ต 9000Redisพอร์ต 6379localhost:3000localhost:8000/todo/notification
รูปที่ 9: หลังใส่ gateway — /todo และ /notification วิ่งเข้าที่อยู่เดียวกัน

ทำไมต้อง 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 ไม่ใช่ของประดับ

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

สังเกตสามอย่างในชุดคำสั่งนี้

  1. มีแค่สอง container ที่เปิดพอร์ตออก hostapi (8000) และ webapp (3000) ส่วน todo, notification, redis ไม่เปิดเลย เพราะไม่มีใครควรเรียกถึงจากข้างนอก
  2. ที่อยู่ของทุกอย่างส่งผ่าน --envREDIS_HOST=redis, NOTIFICATION_HOST=notification ไม่มีเลข IP โผล่ในคำสั่งไหนเลยสักอัน
  3. ลำดับสำคัญ — สร้าง redis ก่อน แล้วค่อย notification แล้วค่อย todo เพราะตัวหลังต้องการตัวหน้า
ระบบเดียวกันนี้ในรูปแบบ composeเสริม — ไม่ได้อยู่ในสไลด์

ชุดคำสั่งหกบรรทัดข้างบนเขียนเป็น 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 บนเครื่อง host
  • todo ฟังพอร์ต 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 จึงมาคู่กับการตัดสินใจแยกบริการ — และเป็นเรื่องของคาบหน้า

On this page

คาบนี้จะพาไปไหนส่วนที่ 1 — จาก monolith ไป microservicesเริ่มจากแอปตัวหนึ่งชื่อ SlowQueuemonolith ไม่ใช่ของแย่แล้วอะไรพังเทคโนโลยีก็ค้างอยู่ตรงนั้นด้วยMicroservice Architecture คืออะไรData-Per-Service คือกฎที่ผิดกันบ่อยที่สุดแนวคิดหลักสี่ข้อไม่ผูกกับแพลตฟอร์มแปลว่าอะไรทีมกับบริการเป็นเรื่องเดียวกันแล้วเสียอะไรไปส่วนที่ 2 — microservices กับ containercontainer กับ microservice ไม่ใช่เรื่องเดียวกันทำไมต้อง container ไม่ใช่ VMCompose แถม service discovery มาให้ฟรีตัวอย่างที่จะเดินไปด้วยกันตลอดคาบ: Todo Serviceขั้นที่ 1 — บริการเดียวขั้นที่ 2 — เพิ่มหน้าเว็บขั้นที่ 3 — ยกขึ้น Composeขั้นที่ 4 — เพิ่ม Redis แล้วเจอปัญหาจริงขั้นที่ 4 เขียนเป็น compose fileได้อะไรจากการใช้ container กับ microservicesส่วนที่ 3 — สถาปัตยกรรม microservicesปัญหาที่โผล่มาเมื่อบริการเพิ่มขึ้นปัญหาเดียวกันนี้ในระบบขนาดจริงหนึ่งหน้าจอ หลายการเรียกทางออก: API Gatewayกระจายโหลดจัดการเมื่อคนล้นService Discovery: ปัญหาที่อยู่ไม่นิ่งแบบที่ 1 — Client-Side Discoveryแบบที่ 2 — Server-Side Discoveryส่วนประกอบของสถาปัตยกรรม microservicesทำ API Gateway ของจริงด้วย nginxทำไมต้อง nginxไฟล์ตั้งค่าแพ็ก gateway เป็น imageสั่งทั้งระบบขึ้นได้อะไรจาก deployment orchestration