ถอดรหัส Cache ใน Kubernetes Controller-Runtime: เบื้องหลังการทำงานที่คุณอาจไม่เคยรู้

ในโลกของการพัฒนาแอปพลิเคชันแบบกระจายตัว (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 เข้ามาใช้เพื่อแก้ปัญหานี้

ถอดรหัส Cache ใน Kubernetes Controller-Runtime: เบื้องหลังการทำงานที่คุณอาจไม่เคยรู้
ภาพประกอบ: สถาปัตยกรรมระบบคลาวด์และคอนเทนเนอร์บนสภาพแวดล้อม Production

เปิดโลกภายใน: 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

📐 SYSTEM ARCHITECTURE & WORKFLOW
INTERACTIVE DIAGRAM

1. Client / Input
Data Ingestion & Traffic

⚡ PROCESSING CORE
Execution & Logic
Low-latency Transformation
High Availability State

3. Output / Store
Verified Delivery

💡 Pro Tip สำหรับทีมพัฒนา

การนำเทคนิคนี้ไปปรับใช้บน Production ควรทดสอบบน Staging Environment และตั้งค่า Alert Monitoring ให้ครอบคลุมก่อนเสมอ

Leave a Comment