ในโลกของการพัฒนาแอปพลิเคชันแบบกระจายตัว (Distributed Systems) บน Kubernetes การสร้าง Custom Controller กลายเป็นเรื่องปกติและจำเป็นอย่างยิ่งสำหรับนักพัฒนาหลายคน ด้วยเครื่องมืออย่าง kubebuilder ที่ทำงานอยู่บน controller-runtime ทำให้การสร้าง Controller ของคุณเป็นเรื่องง่ายและรวดเร็ว แต่ภายใต้ความง่ายดายนั้น มีกลไกสำคัญบางอย่างที่ซ่อนอยู่ ซึ่งหากเราเข้าใจผิดไป อาจนำไปสู่ปัญหาที่คาดไม่ถึงใน Production ได้
บ่อยครั้งที่นักพัฒนา โดยเฉพาะผู้ที่เพิ่งเริ่มต้น เข้าใจว่าเมื่อเรียกใช้ r.Get() หรือ r.List() ภายในฟังก์ชัน Reconcile ของ Controller ตัว Controller จะทำการสอบถามข้อมูลจาก kube-apiserver โดยตรงในทันที และเชื่อว่าข้อมูลที่ได้นั้นคือ “มุมมองที่สดใหม่ที่สุด” ของโลก Kubernetes เสมอ นอกจากนี้ยังคิดว่าหลังจากที่ Controller เรียก r.Update() แล้ว การเรียก r.Get() ซ้ำทันทีจะได้รับสถานะใหม่ที่อัปเดตแล้ว ซึ่งความเข้าใจนี้เป็นรากฐานของปัญหาหลายอย่างที่อาจเกิดขึ้นเมื่อระบบเริ่มมีโหลดสูงขึ้น หรือ Controller ทำงานในลักษณะที่เราไม่คาดคิด
เมื่อความเข้าใจผิดเรื่อง Cache นำไปสู่ปัญหาคาดไม่ถึง
ความจริงที่สำคัญคือ controller-runtime ไม่ได้เรียก kube-apiserver โดยตรงทุกครั้งที่ Controller ของคุณเรียก r.Get() หรือ r.List() แต่เบื้องหลังการทำงานนั้นซับซ้อนและมีประสิทธิภาพมากกว่านั้นมาก โดยใช้กลไกที่เรียกว่า “Cache” ซึ่งเป็นหัวใจหลักในการทำให้ Controller ของคุณทำงานได้อย่างราบรื่นและไม่ทำให้ API Server ล่ม
ความเข้าใจผิดที่ว่าทุกการเรียก
r.Get()หรือr.List()คือการสอบถาม API Server โดยตรง อาจนำไปสู่การออกแบบ Controller ที่ไม่มีประสิทธิภาพและก่อให้เกิดภาระมหาศาลต่อ Kubernetes API Server โดยไม่จำเป็น.
หากปราศจาก Cache Controller ทุกตัวจะส่งคำขอ GET/LIST ไปยัง kube-apiserver โดยตรงทุกครั้งที่ต้องการข้อมูล ซึ่งจะทำให้ API Server ถูกโจมตีด้วย Traffic จำนวนมหาศาล โดยเฉพาะเมื่อมี Object จำนวนมาก หรือเมื่อ Controller มีการ Reconcile บ่อยครั้ง นี่คือเหตุผลที่ controller-runtime ได้นำกลไก Cache เข้ามาใช้เพื่อแก้ปัญหานี้
เปิดโลกภายใน: Controller-Runtime Cache ทำงานอย่างไร
controller-runtime ใช้ Cache ภายใน Controller ของคุณเพื่อเก็บสำเนาของ Kubernetes Objects ที่ Controller นั้นสนใจ แทนที่จะเรียก API Server โดยตรงในทุกครั้ง กลไกนี้ทำงานโดยอาศัย Watch API ของ Kubernetes
หัวใจสำคัญ: Eventual Consistency เพื่อประสิทธิภาพ
เมื่อ Controller เริ่มทำงาน controller-runtime จะตั้งค่า Watcher สำหรับ Object ประเภทต่างๆ ที่ Controller นั้นสนใจ (เช่น Pods, Deployments, Custom Resources) โดย Watcher จะคอย “ฟัง” การเปลี่ยนแปลงที่เกิดขึ้นใน kube-apiserver เมื่อมี Object ถูกสร้าง, อัปเดต หรือลบ API Server จะแจ้งเตือนการเปลี่ยนแปลงเหล่านี้ไปยัง Watcher และ controller-runtime จะนำข้อมูลที่เปลี่ยนแปลงไปอัปเดตใน Cache ภายใน Controller ของคุณ
กระบวนการนี้เรียกว่า “Eventual Consistency” ซึ่งหมายความว่าข้อมูลใน Cache ของ Controller อาจจะไม่ “สดใหม่ที่สุด” ในทันทีที่เกิดการเปลี่ยนแปลงบน API Server แต่อย่างไรก็ตาม ข้อมูลจะถูกอัปเดตให้สอดคล้องกับสถานะจริงในที่สุด (eventually). ความล่าช้านี้มักจะอยู่ในระดับมิลลิวินาที ซึ่งในกรณีส่วนใหญ่ไม่เป็นปัญหา แต่ในบางสถานการณ์ที่ต้องการความถูกต้องของข้อมูลแบบ Real-time ที่สุด (ซึ่ง Controller ส่วนใหญ่ไม่ต้องการ) อาจต้องพิจารณาวิธีการอื่น
ทำไมต้องมี Cache? ประโยชน์ที่มองไม่เห็น
การใช้ Cache มีประโยชน์มหาศาล:
- ลดภาระ API Server: Controller ไม่ต้องส่งคำขอ GET/LIST ไปยัง API Server บ่อยๆ ทำให้ API Server สามารถทำงานอื่นๆ ได้อย่างมีประสิทธิภาพมากขึ้น
- เพิ่มประสิทธิภาพการอ่านข้อมูล: เมื่อข้อมูลอยู่ใน Cache (ซึ่งอยู่ใน Memory ของ Controller) การเรียก
r.Get()หรือr.List()จะเป็นการอ่านข้อมูลจาก Memory โดยตรง ซึ่งเร็วกว่าการสื่อสารผ่านเครือข่ายไปยัง API Server มาก - ความทนทานต่อความผิดพลาด: หาก API Server มีปัญหาชั่วคราว Controller ของคุณยังคงสามารถทำงานได้โดยใช้ข้อมูลจาก Cache ที่มีอยู่
- ลดโอกาสเกิด Rate Limit: การส่งคำขอจำนวนมากไปยัง API Server อาจทำให้คุณถูก Rate Limit ได้ การใช้ Cache ช่วยลดปัญหานี้
ผลกระทบจากการ “เข้าใจผิด” และวิธีรับมือ
เมื่อเราเข้าใจว่าข้อมูลใน Cache ไม่ได้สดใหม่ทันทีเสมอไป เราจะสามารถออกแบบ Controller ได้อย่างรอบคอบมากขึ้น ลองดูตัวอย่างโค้ด Go ง่ายๆ ที่แสดงถึงสถานการณ์ที่อาจทำให้เกิดความสับสน:
// แพ็คเกจและอิมพอร์ตที่จำเป็น
import (
"context"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/log"
myappv1 "github.com/example/my-controller/api/v1" // สมมติว่านี่คือ CRD ของเรา
)
// MyReconciler implements the Reconciler interface.
type MyReconciler struct {
// ... field อื่นๆ เช่น client.Client
}
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
func (r *MyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
logger := log.FromContext(ctx)
// 1. อ่าน Object จาก Cache
myObject := &myappv1.MyObject{}
if err := r.Get(ctx, req.NamespacedName, myObject); err != nil {
if apierrors.IsNotFound(err) {
// Object ไม่พบใน Cache (อาจถูกลบไปแล้ว)
logger.Info("MyObject resource not found. Ignoring since object must be deleted.")
return ctrl.Result{}, nil
}
// ข้อผิดพลาดในการอ่าน Object - อาจเป็นปัญหาชั่วคราว
logger.Error(err, "Failed to get MyObject")
return ctrl.Result{}, err
}
// 2. ตรวจสอบเงื่อนไขและอัปเดตสถานะ
if myObject.Status.State != "Ready" {
myObject.Status.State = "Ready"
logger.Info("Updating object status to Ready", "name", myObject.Name)
if err := r.Status().Update(ctx, myObject); err != nil {
logger.Error(err, "Failed to update MyObject status")
return ctrl.Result{}, err
}
// !!! จุดสำคัญที่ต้องระวัง !!!
// หลังจากการ Update นี้ ข้อมูลใน Cache อาจจะยังไม่ถูกอัปเดตทันที
// การเรียก r.Get() ซ้ำทันทีใน Reconcile รอบเดียวกัน อาจได้ข้อมูลเก่า
// หรือหากมีการเรียก r.List() เพื่อหา MyObject ทั้งหมด ก็อาจไม่เห็นการเปลี่ยนแปลงนี้ทันที
// โดยทั่วไปแล้ว Controller ควรจะ Requeue ตัวเอง (ส่งกลับมาประมวลผลใหม่)
// เพื่อให้ Cache มีเวลาอัปเดตข้อมูลให้ตรงกับ API Server
logger.Info("MyObject status updated. Requeueing to ensure cache consistency.")
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil // Requeue เพื่อให้ Cache อัปเดต
}
// ... ทำงานอื่นๆ ที่เกี่ยวข้องกับ Object ที่ 'Ready' แล้ว
logger.Info("MyObject is already Ready. Proceeding with other logic.")
return ctrl.Result{}, nil
}
ในตัวอย่างข้างต้น หลังจากที่เราเรียก r.Status().Update() เพื่อเปลี่ยนสถานะของ myObject เป็น “Ready” ข้อมูลใน kube-apiserver จะถูกอัปเดตทันที แต่ข้อมูลใน Cache ของ Controller อาจจะยังคงเป็นสถานะเก่าอยู่ชั่วขณะหนึ่ง หากเราพยายามเรียก r.Get() หรือ r.List() อีกครั้งทันทีใน Reconcile รอบเดียวกัน เราอาจได้รับข้อมูลที่ไม่ถูกต้อง การแก้ไขที่เหมาะสมคือการสั่งให้ Controller ทำการ Requeue ตัวเอง (เช่น return ctrl.Result{RequeueAfter: ...}, nil) เพื่อให้ Controller กลับมาทำงานใหม่ในอีกไม่กี่วินาที ซึ่งเป็นเวลาที่เพียงพอให้ Cache อัปเดตข้อมูลให้ตรงกับสถานะล่าสุดของ API Server
เคล็ดลับการออกแบบ Controller ที่ทนทานต่อ Cache Stale Data คือการทำให้ฟังก์ชัน Reconcile เป็น Idempotent (ทำงานซ้ำได้โดยไม่มีผลข้างเคียง) และใช้กลไกการ Requeue อย่างชาญฉลาด เพื่อให้ Controller ได้รับข้อมูลที่อัปเดตแล้วในรอบถัดไป.
ตารางเปรียบเทียบ: Cache vs. Direct API Call
เพื่อความชัดเจน ลองพิจารณาความแตกต่างระหว่างการเข้าถึงข้อมูลผ่าน Cache ที่ controller-runtime จัดการให้ และการพยายามเข้าถึง API Server โดยตรง (ซึ่ง controller-runtime ไม่ได้ออกแบบมาให้ทำเช่นนั้นใน Reconciler ทั่วไป):
| คุณสมบัติ | การเรียกผ่าน Cache (โดย controller-runtime) |
การเรียกตรงไปยัง kube-apiserver (ไม่แนะนำสำหรับ Reconciler) |
|---|---|---|
| ความสดใหม่ของข้อมูล | Eventually Consistent (อาจมีความล่าช้าเพียงเล็กน้อย) | สดใหม่ที่สุด (ณ เวลาที่เรียก) |
| ภาระงานบน API Server | ต่ำมาก (ใช้ Watch API เพียงครั้งเดียวต่อ Object Type) | สูง (ทุกการเรียกคือ 1 Request ไปยัง API Server) |
| ประสิทธิภาพการอ่าน | สูงมาก (อ่านจาก Memory ใน Controller Process) | ปานกลาง (มี Latency ของ Network และ API Server Processing) |
| ความซับซ้อนในการจัดการ | ต่ำ (controller-runtime จัดการ Watch/List/Cache ให้) |
สูง (ต้องจัดการ Watch/List เอง, พิจารณา Rate Limit, Retry Logic) |
| โอกาสเกิด Rate Limit | ต่ำ (เพราะจำนวน API Request ต่ำ) | สูง (โดยเฉพาะในระบบที่มี Object จำนวนมาก) |
มุมมองสำหรับนักพัฒนาไทย
สำหรับนักพัฒนาและทีม DevOps ในประเทศไทย การเข้าใจกลไกเบื้องหลังของ controller-runtime Cache เป็นสิ่งสำคัญอย่างยิ่ง:
- ลดเวลาในการดีบัก: เมื่อเกิดปัญหา Controller ทำงานผิดปกติ การเข้าใจเรื่อง Cache จะช่วยให้คุณวิเคราะห์และแก้ไขปัญหาได้ตรงจุดมากขึ้น ไม่ใช่แค่ลองผิดลองถูก เช่น หาก Controller ไม่ตอบสนองต่อการเปลี่ยนแปลงทันที คุณจะรู้ว่าไม่ใช่บั๊ก แต่เป็นพฤติกรรมปกติของ Eventual Consistency
- สร้างระบบที่ยั่งยืนและมีประสิทธิภาพ: การออกแบบ Controller โดยคำนึงถึง Cache จะช่วยให้คุณสร้างระบบที่ Scalable และ Reliable โดยไม่ก่อให้เกิดภาระที่ไม่จำเป็นต่อ Infrastructure ซึ่งสำคัญมากสำหรับองค์กรที่มีทรัพยากรจำกัด หรือต้องการควบคุมค่าใช้จ่าย
- ยกระดับมาตรฐานการพัฒนา: การลงทุนทำความเข้าใจใน Core Concepts เช่นนี้ จะช่วยยกระดับความสามารถของทีมและลด Tech Debt ในระยะยาว ทำให้สามารถรับมือกับความท้าทายที่ซับซ้อนขึ้นได้
- ปรับตัวเข้ากับ Best Practices สากล: แนวคิดเรื่อง Eventual Consistency และการใช้ Cache เป็นหลักการพื้นฐานในการออกแบบ Distributed Systems การเข้าใจสิ่งเหล่านี้จะช่วยให้นักพัฒนาไทยสามารถนำ Best Practices จากทั่วโลกมาปรับใช้ได้อย่างมีประสิทธิภาพ
การเขียน Kubernetes Controller ไม่ใช่แค่การเขียนโค้ดให้ทำงานได้ แต่คือการออกแบบระบบที่เข้าใจธรรมชาติของ Kubernetes เอง การเข้าใจว่า controller-runtime Cache ทำงานอย่างไร จะช่วยให้คุณเป็นนักพัฒนาที่เก่งกาจขึ้น และสร้างสรรค์โซลูชันที่แข็งแกร่งและมีประสิทธิภาพบน Kubernetes ได้อย่างแท้จริง
ที่มา: ดัดแปลงและเพิ่มเติมจาก Kubernetes Blog
INTERACTIVE DIAGRAM
1. Client / Input
Data Ingestion & Traffic
⚡ PROCESSING CORE
Execution & Logic
Low-latency Transformation
High Availability State
3. Output / Store
Verified Delivery
การนำเทคนิคนี้ไปปรับใช้บน Production ควรทดสอบบน Staging Environment และตั้งค่า Alert Monitoring ให้ครอบคลุมก่อนเสมอ