From In-Process to Inter-Process (จากการเรียกภายในโปรเซสสู่การเรียกข้ามโปรเซส)
เอาล่ะ มาเริ่มจากเรื่องง่าย ๆ กันก่อน หรืออย่างน้อยก็สิ่งที่ผม หวัง ว่าจะง่าย นั่นคือ การเรียก ระหว่าง โปรเซสต่างกันข้ามเครือข่าย (inter-process) นั้นแตกต่าง อย่างมาก จากการเรียก ภายใน โปรเซสเดียวกัน (in-process) ในระดับหนึ่งเราอาจมองข้ามความแตกต่างนี้ได้ เช่น เราคิดง่าย ๆ ว่า object หนึ่งเรียก method ของอีก object หนึ่ง แล้วก็แค่ map ปฏิสัมพันธ์นี้ไปเป็น microservice สองตัวที่คุยกันผ่านเครือข่าย ถ้าไม่นับว่า microservice ไม่ใช่แค่ object เฉย ๆ การคิดแบบนี้อาจพาเราไปเจอปัญหาใหญ่ได้
มาดูความแตกต่างเหล่านี้กัน และดูว่ามันอาจเปลี่ยนวิธีที่คุณคิดเกี่ยวกับปฏิสัมพันธ์ระหว่าง microservice ของคุณได้อย่างไร
Performance (ประสิทธิภาพ)
ประสิทธิภาพของการเรียกแบบ in-process นั้นแตกต่างโดยพื้นฐานจากการเรียกแบบ inter-process เมื่อผมเรียกแบบ in-process compiler และ runtime เบื้องหลังสามารถทำ optimization ได้หลายอย่างเพื่อลดผลกระทบของการเรียก รวมถึงการทำ inline การเรียกนั้นจนแทบไม่เหมือนมีการเรียกเกิดขึ้นเลย แต่ optimization แบบนี้เป็นไปไม่ได้กับการเรียกแบบ inter-process แพ็กเก็ตต้องถูกส่งออกไปจริง ๆ overhead ของการเรียกแบบ inter-process ย่อมสูงกว่าการเรียกแบบ in-process อย่างมีนัยสำคัญ อย่างแรกวัดได้ชัดเจน แค่การส่งแพ็กเก็ตหนึ่งตัวไป-กลับใน data center ก็วัดเป็นมิลลิวินาทีแล้ว ในขณะที่ overhead ของการเรียก method นั้นแทบไม่ต้องกังวลเลย
เรื่องนี้มักทำให้เราต้องคิดทบทวน API ใหม่ API ที่สมเหตุสมผลตอน in-process อาจไม่เหมาะกับสถานการณ์ inter-process ผมสามารถเรียกข้าม API boundary แบบ in-process ได้เป็นพันครั้งโดยไม่ต้องกังวล แต่ผมอยากเรียก network call ระหว่าง microservice สองตัวเป็นพันครั้งจริง ๆ หรือ? อาจจะไม่
เวลาผมส่ง parameter เข้าไปใน method โครงสร้างข้อมูลที่ผมส่งเข้าไปมักไม่ได้ถูกย้ายตำแหน่งจริง ๆ สิ่งที่เกิดขึ้นคือผมส่ง pointer ไปยังตำแหน่งใน memory มากกว่า การส่ง object หรือ data structure เข้าไปใน method อื่นไม่จำเป็นต้องมีการจอง memory เพิ่มเพื่อ copy ข้อมูล
แต่เวลาเรียกระหว่าง microservice ผ่านเครือข่าย ข้อมูลต้องถูก serialize ให้อยู่ในรูปแบบที่ส่งผ่านเครือข่ายได้จริง ๆ แล้วข้อมูลก็ต้องถูกส่งและ deserialize ที่ปลายทาง ดังนั้นเราอาจต้องใส่ใจขนาดของ payload ที่ส่งระหว่างโปรเซสมากขึ้น ครั้งสุดท้ายที่คุณรู้ตัวถึงขนาดของ data structure ที่ส่งไปมาภายในโปรเซสคือเมื่อไหร่? ความจริงคือคุณอาจไม่เคยต้องรู้เลย แต่ตอนนี้คุณต้องรู้แล้ว สิ่งนี้อาจทำให้คุณต้องลดปริมาณข้อมูลที่ส่งหรือรับ (ซึ่งก็ไม่เลวถ้ามองในแง่ information hiding) เลือกกลไก serialization ที่มีประสิทธิภาพมากขึ้น หรือแม้แต่โยนข้อมูลไปเก็บใน filesystem แล้วส่งแค่ reference ไปยังตำแหน่งไฟล์นั้นแทน
ความแตกต่างเหล่านี้อาจยังไม่สร้างปัญหาให้คุณทันที แต่คุณต้องตระหนักถึงมันแน่นอน ผมเคยเห็นความพยายามมากมายที่จะซ่อนความจริงที่ว่ามี network call เกิดขึ้นจาก developer ความปรารถนาที่จะสร้าง abstraction เพื่อซ่อนรายละเอียดนั้นเป็นส่วนสำคัญที่ทำให้เราทำสิ่งต่าง ๆ ได้อย่างมีประสิทธิภาพมากขึ้น แต่บางครั้งเราสร้าง abstraction ที่ซ่อนมากเกินไป developer ต้องรู้ตัวว่ากำลังทำสิ่งที่จะนำไปสู่ network call หรือไม่ มิฉะนั้นคุณไม่ควรแปลกใจถ้าจะเจอ performance bottleneck แย่ ๆ ในภายหลัง ซึ่งเกิดจากปฏิสัมพันธ์ระหว่าง service แปลก ๆ ที่ developer ผู้เขียนโค้ดมองไม่เห็น
Changing Interfaces (การเปลี่ยนแปลง Interface)
เมื่อเราพิจารณาการเปลี่ยนแปลง interface ภายในโปรเซสเดียวกัน การ roll out การเปลี่ยนแปลงนั้นทำได้ง่าย ๆ ตรงไปตรงมา เพราะโค้ดที่ implement interface และโค้ดที่เรียกใช้ interface ถูก package รวมกันอยู่ในโปรเซสเดียว จริง ๆ แล้วถ้าผมเปลี่ยน method signature โดยใช้ IDE ที่มีความสามารถ refactor บ่อยครั้ง IDE เองจะ refactor การเรียกไปยัง method ที่เปลี่ยนนี้ให้อัตโนมัติเลย การ roll out การเปลี่ยนแปลงแบบนี้ทำได้แบบ atomic เพราะทั้งสองฝั่งของ interface ถูก package รวมกันอยู่ในโปรเซสเดียว
แต่ในการสื่อสารระหว่าง microservice นั้น microservice ที่ expose interface และ microservice ที่บริโภค interface นั้นแยกกันเป็นคนละ deployable microservice เมื่อจะทำการเปลี่ยนแปลง microservice interface แบบ backward-incompatible เราต้องเลือกว่าจะทำ lockstep deployment กับ consumer เพื่อให้แน่ใจว่าพวกเขาอัปเดตมาใช้ interface ใหม่ หรือไม่ก็ต้องหาวิธี phase การ rollout ของ microservice contract ใหม่ เราจะสำรวจแนวคิดนี้ในรายละเอียดต่อไปในบทนี้
Error Handling (การจัดการข้อผิดพลาด)
ภายใน โปรเซสหนึ่ง ถ้าผมเรียก method ธรรมชาติของ error มักจะตรงไปตรงมา พูดง่าย ๆ คือ error นั้นอาจเป็นสิ่งที่คาดไว้แล้วและจัดการง่าย หรือไม่ก็ร้ายแรงถึงขั้นที่เราแค่ propagate error ขึ้นไปตาม call stack error โดยรวมแล้วมักเป็น deterministic
แต่ในระบบแบบ distributed ธรรมชาติของ error อาจแตกต่างออกไป คุณเสี่ยงต่อ error หลายประเภทที่อยู่นอกเหนือการควบคุมของคุณ เครือข่าย timeout microservice downstream อาจไม่พร้อมใช้งานชั่วคราว เครือข่ายขาดการเชื่อมต่อ container ถูก kill เพราะใช้ memory มากเกินไป และในสถานการณ์รุนแรงที่สุด ส่วนหนึ่งของ data center คุณอาจถึงขั้นไฟไหม้ 1
ในหนังสือของพวกเขา Distributed Systems , 2 Andrew Tanenbaum และ Maarten Steen แบ่ง failure mode ออกเป็นห้าประเภทที่คุณจะเจอเมื่อดู inter-process communication นี่คือเวอร์ชันสรุปแบบง่าย ๆ:
Crash failure (ความล้มเหลวแบบ Crash)
ทุกอย่างปกติดีจนกระทั่ง server crash รีบูตเลย!
Omission failure (ความล้มเหลวแบบ Omission)
คุณส่งอะไรบางอย่างไปแล้ว แต่ไม่ได้ response กลับมา รวมถึงสถานการณ์ที่คุณคาดหวังให้ microservice downstream ยิง message (อาจรวมถึง event) ออกมาเรื่อย ๆ แต่จู่ ๆ มันก็หยุด
Timing failure (ความล้มเหลวเรื่องเวลา)
บางอย่างเกิดขึ้นช้าเกินไป (คุณไม่ได้รับมันทันเวลา) หรือบางอย่างเกิดขึ้นเร็วเกินไป!
Response failure (ความล้มเหลวของ Response)
คุณได้ response กลับมา แต่มันดูผิดปกติ เช่น คุณขอสรุปคำสั่งซื้อ แต่ข้อมูลที่จำเป็นบางส่วนหายไปจาก response
Arbitrary failure (ความล้มเหลวแบบไม่แน่นอน)
หรือที่รู้จักกันในชื่อ Byzantine failure คือกรณีที่มีอะไรบางอย่างผิดพลาด แต่ผู้เกี่ยวข้องไม่สามารถตกลงกันได้ว่าเกิด failure จริงหรือไม่ (หรือเพราะอะไร) ฟังดูก็รู้ว่าเป็นสถานการณ์ที่แย่มาก
error หลายแบบเหล่านี้มักเป็น transient คือเป็นปัญหาชั่วคราวที่อาจหายไปเอง ลองพิจารณาสถานการณ์ที่เราส่ง request ไปยัง microservice แต่ไม่ได้ยินอะไรตอบกลับมา (ซึ่งเป็น omission failure แบบหนึ่ง) นี่อาจหมายความว่า microservice downstream ไม่เคยได้รับ request นั้นเลยตั้งแต่แรก ดังนั้นเราต้องส่งใหม่ ปัญหาอื่น ๆ อาจจัดการได้ยากและอาจต้องให้ human operator เข้ามาแทรกแซง ดังนั้นการมี semantics ที่หลากหลายสำหรับการคืน error จึงสำคัญ เพื่อให้ client สามารถดำเนินการที่เหมาะสมได้
HTTP เป็นตัวอย่างของ protocol ที่เข้าใจความสำคัญของเรื่องนี้ ทุก HTTP response มี code โดย code ในช่วง 400 และ 500 series ถูกสงวนไว้สำหรับ error 400 series error code คือ request error กล่าวคือ downstream service กำลังบอก client ว่ามีบางอย่างผิดพลาดกับ request เดิม ดังนั้นจึงเป็นสิ่งที่คุณควรเลิกพยายาม จะ retry 404 Not Found ไปทำไม ยกตัวอย่างเช่น? ส่วน 500 series response code เกี่ยวข้องกับปัญหา downstream ซึ่งบางส่วนบ่งบอก client ว่าปัญหาอาจเป็นแค่ชั่วคราว 503 Service Unavailable ยกตัวอย่างเช่น บ่งบอกว่า downstream server ไม่สามารถจัดการ request ได้ แต่อาจเป็น state ชั่วคราว ซึ่งในกรณีนี้ upstream client อาจตัดสินใจ retry request ในทางกลับกัน ถ้า client ได้รับ response 501 Not Implemented การ retry ก็ไม่น่าจะช่วยอะไรได้มาก
ไม่ว่าคุณจะเลือก protocol แบบ HTTP-based สำหรับการสื่อสารระหว่าง microservice หรือไม่ ถ้าคุณมี semantics ที่หลากหลายเกี่ยวกับธรรมชาติของ error คุณจะทำให้ client ดำเนิน compensating action ได้ง่ายขึ้น ซึ่งในทางกลับกันจะช่วยให้คุณสร้างระบบที่แข็งแกร่งมากขึ้น