Extend Kubernetes ตอนที่ 2

Prawit Chaivong
Zeabix
Published in
5 min readJan 7, 2023

มาสร้าง CRD/Operator MySQLServer ด้วย operator-sdk

จากตอนที่แล้วได้ เราได้พูดถึงเรื่องของ Kubernetes CRD/Operator ในเชิงทฤษฎีกันมาคร่าวๆแล้ว ในบทความตอนนี้เราจะมาลงมือทำกัน โดย code ทั้งหมดที่เราทำในบทความนี้จะอยู่ใน github ตามลิ้งค์ข้างล่างนะครับ สำหรับท่านที่อาจจะไม่สะดวก hands on ด้วยกัน แต่อยากจะลองอ่าน code ควบคู่กันไปด้วย หรือว่าอยากจะแค่เอาไปลองเล่นก็ไม่มีปัญหาอะไร เรามาเริ่มกันเลยดีกว่า

การเตรียม development environment

ก่อนที่เราจะเริ่มลงมือ coding เรามาเตรียมเครื่องของเราให้พร้อมสำหรับการ coding กันก่อนนะครับ สิ่งที่เครื่องของเราต้องมี

  1. Docker Deskop + Kubernetes enabled

2. Golang

3. Text editor (ส่วนตัวผมใช้ VSCode แต่จะใช้ตัวอื่นๆก็ได้ครับ)

4. Operator-sdk ซึ่งจะเป็นพระเอกของเราในการ coding ของเรา

Setup project

หลังจากที่เราได้ install operator-sdk เราจะใช้มันในการช่วย generate project ของเราขึ้นมา ข้อดีของ tool ตัวนี้คือมันจะช่วย generate code ที่เรียกได้ว่าเป็นพวก boilerplate code ต่างๆให้เรา ทำให้เราโฟกัสไปที่เฉพาะตัว business logic ของเราจริงๆโดยที่ไม่ได้เสียเวลาไปกับ code ส่วนประกอบส่วนอื่นๆ

เราจะ initial project ของเราด้วย คำสั่งข้างล่าง

$ mkdir db-operator && cd db-operator
$ operator-sdk init --domain zeabix.com --repo github.com/zeabix-lab/db-operator

จะเห็นได้ว่าการใช้ operator-sdk สามารถ generate code บางส่วนให้เรา ต่อจากนี้เราจะเริ่มสร้าง CRD + controller ขึ้นมาสองตัว MySQLServer ที่จะเป็นตัวที่จะ represent MySQL Server Instance ของเรา แล้วอีกตัวหนึ่งก็คือ Database ซึ่งจะเป็น resource ที่เราจะสร้างขึ้นเพื่อให้ controller ไป create database, user, grant permission ให้เราจริงๆใน database instance

สร้าง CRD & Controller สำหรับ MySQLServer

ซึ่งเราจะสามารถสร้างได้โดยใช้คำสั่งนี้

$ operator-sdk create api --group db --version v1alpha1 --kind MySQLServer --resource --controller

โดยคำสั่งดังกล่าวจะ generate resource manifests และก็ skeleton code ของตัว controller เอง

เราจะเห็นได้ว่ามีไฟล์ api/v1alpha1/mysqlserver_types.go ถูก generate ขึ้นมาซึ่งเราจะเข้าไปแก้ไขเล็กน้อยเพื่อเราจะได้นำมาใช้งานได้

type MySQLServerSpec struct {
// MySQLServer instance FQDN hostname
Host string `json:"host,omitempty"'
SecretRef string `json:"secretRef,omitempty"'
}

type MySQLServerStatus struct {
Health string `json:"health,omitempty"'
}

struct ทั้งสองคือสิ่งที่เราออกแบบขึ้นมาเอง โดยในกรณีนี้ตัว CRD MySQLServer จะ represent MySQL Servers instance ซึ่งเราต้องการเก็บ information สำหรับตัว controller ของเราจะ connect เข้าไป ดังนั้นจึงเป็นเหตุผลว่าทำไมเราถึงมี field Host สำหรับ username/password ของ database นั้น เราจะไม่ใส่เข้าไปใน MySQLServer spec ตรงๆ โดยเราจะเก็บไว้ใน Secret แล้วเราก็จะให้ controller ไปอ่านเอาเองจาก secret ดังกล่าว ซึ่งการที่จะให้มันไปอ่านที่ Secret ชื่ออะไรก็โดยดูจาก fields SecretRef

ในส่วนของ MySQLServerStatus เองนั้นก็ไม่มีอะไรมาก โดยในตัวอย่างนี้เราจะมีแค่ fields Health เอาไว้ใช้ check ว่าตัว controller เองสามารถเชื่อมต่อกับ database ได้หรือเปล่า

หลังจากที่เราได้ define ตัว MySQLServerSpec กับตัว MySQLServerStatus ด้วย fields ต่างๆที่เราได้ design เอาไว้แล้ว เราก็สามารถที่จะใช้ tools ที่มากับ operator-sdk ในการช่วย generate ตัว CRD โดยใช้ command

$ make manifests

ซึ่งตัว CRD จะถูก generate ใน config/crd/base/db.zeabix.com_mysqlservers.yaml ซึ่งเราสามารถที่จะเอาไป apply ใน kubernetes cluster (docker-desktop) ของเราได้เลยโดยใช้ command

$ kubectl apply -f ./config/crd/base/db.zeabix.com_mysqlservers.yaml

เราสามารถตรวจสอบได้จาก command

$ kubectl get crd

เราจะเห็นได้ว่ามี CRD ของเราถูก apply เข้าไปใน cluster ของเราแล้ว

จริงๆแล้วเราสามารถสร้าง manifest MySQLServer แล้ว apply เข้าไปได้เลยโดยจะไม่ติด error ใดๆ โดย ใน config/samples/db_v1alpha1_mysqlserver.yaml แต่ว่าอย่างที่ได้เคยบอกไปในบทความตอนที่แล้ว มันสร้างได้แต่ว่ามันยังไม่มีในส่วนของ business logic code ที่เราอยากให้มัน execute

ขั้นตอนต่อไป เราจำเป็นที่จะต้องเขียน code ในส่วนของ controller ก่อนที่เราจะเรียกใช้ struct ที่เราได้ define ไว้ตอนต้น ให้เรารันคำสั่งนี้ก่อน เพื่อที่จะให้ operator-sdk ช่วยเราในการ generate code บางส่วนให้เราก่อน

$ make generate

เอาหล่ะครับ ต่อไปเราก็จะเข้าสู่การเขียน code สำหรับ business logic ของเราละ ซึ่งก็คือการ make connection เข้าไปยัง MySQLServer ของเรา ให้เปิดไฟล์ controllers/mysqlserver_controller.go จะเห็นได้ว่า code ส่วนใหญ่ได้ถูก generate ให้เราแล้ว เราแค่ไปเพิ่ม business logic ของเราใน function Reconcile

func (r *MySQLServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = log.FromContext(ctx)
// Get MySQLServer spec
db := &dbv1alpha1.MySQLServer{}
err := r.Get(ctx, req.NamespacedName, db)
if err != nil {
log.Log.Error(err, "Unable to deserialized MySQLServer Object")
return ctrl.Result{}, err
}
...
...

หลังจากที่ได้ get Spec (จาก code ข้างบน) ขั้นตอนต่อไป เราต้องไป retrieve ตัว secret ที่ได้ ระบุไว้ใน Spec (fields secretRef) สำหรับ username/password ของ admin ส่วนตัว Host นั้น ได้ถูกระบุอยู่ใน Spec อยู่แล้ว ก็ไม่ต้องทำอะไรเพิ่ม

// Get information for MySQL credential
var secret corev1.Secret
secretRef := types.NamespacedName{
Namespace: req.Namespace,
Name: db.Spec.SecretRef,
}
if err := r.Get(ctx, secretRef, &secret); err != nil {
log.Log.Error(err, "Unable to retrieve secret")
return ctrl.Result{}, err
}

// Gather connection string
host := db.Spec.Host
username := string(secret.Data["username"])
password := string(secret.Data["password"])

หลังจากที่เราได้ information ครบทั้งหมดในการที่จะ connect ไปยัง MySQL Server แล้ว เราก็พร้อมแล้วในการ connect เข้าไป แล้ว test ping โดยที่ถ้าเกิดเหตุการณ์ที่ว่าเราต่อไม่ได้ (อาจจะเป็นเพราะว่า database down หรือว่าสาเหตุอะไรก็แล้วแต่) เราก็จะ update status ของ MySQLServer ตัวนี้ ให้เป็น unvailable ในขณะที่ถ้าต่อได้และ ping ได้ เราจะ update status มันให้เท่ากับ ok

// Create Connection string
connectionStr := fmt.Sprintf("%s:%s@tcp(%s)/mysql?allowNativePasswords=true", username, password, host)

// Create mysql connection
mysqldb, err := sql.Open("mysql", connectionStr)
if err != nil {
log.Log.Error(err, "Unable to connect to database")
_ = r.updateHealth(ctx, db, MySQLServerStatusUnavailable)
return ctrl.Result{}, err
}
defer mysqldb.Close()

// Test Ping
err = mysqldb.Ping()
if err != nil {
log.Log.Error(err, "Unable to connect to database")
_ = r.updateHealth(ctx, db, MySQLServerStatusUnavailable)
return ctrl.Result{}, err
}

หลังจาก test connection ทุกอย่างเสร็จเรียบร้อยแล้ว แสดงว่าตัว controller ของเราสามารถต่อกับ MySQLServer instance ได้ ก็ให้เรา update status ของมันเป็น ok

// No news is good news
log.Log.Info("Database is good ;)")
_ = r.updateHealth(ctx, db, MySQLServerStatusOK)

return ctrl.Result{RequeueAfter: time.Second * 5}, nil

แค่นี้เราก็พร้อมแล้วสำหรับ CRD/controller สำหรับ MySQLServer ตัวแรกของเรา

Setup MySQLServer และ secret

ก่อนที่เราจะ test CRD/controller ที่เราเพิ่งทำเสร็จไปได้ เราต้องสร้าง MySQLServer ขึ้นมาเองก่อน เพราะว่าตัว controller ของเราที่ได้ design ไว้ไม่ได้ทำหน้าที่ในการ provision แต่แค่ represent + store information ในการ connect ไป MySQLServer instance นั้นๆ เท่านั้น โดยที่จะ install mySQL ที่ไหนก็ได้ครับ อาจจะอยู่บน cloud ก็ได้ไม่ได้ติดอะไร แต่คิดว่าวิธีการที่ง่ายที่สุดคือการ deploy ลงไปใน kubernetes เลย แล้วก็ create Service ชื่อ mysql-service ที่ชี้ไปยัง pod ของ mysql ที่เราได้ deploy ไป ซึ่งรายละเอียดตรงนี้ผมขออนุญาติข้ามไปเร็วๆละนะกันนะครับ น่าจะมี tutorial ที่สอนทำตรงนี้ง่ายๆอยู่ละ

แต่สิ่งที่เราต้องทำเพิ่มหลังจากที่เรามี mysql server run ขึ้นมาแล้วคือการเข้าไปสร้าง user admin

> CREATE USER 'operator'@'%' IDENTIFIED BY 'P@ssw0rd';
> GRANT ALL PRIVILEGES ON *.* TO 'operator'@'%' WITH GRANT OPTION;
> FLUSH PRIVILEGES;

DISCLAIMER เนื่องจากว่าเป็นแค่ poc ก็เลย GRANT ALL PRIVILGES ให้ user คนนี้เลย เวลาเอาไป implement จริงๆ ควรจะได้ให้ทาง DBA ช่วย review privileges ตรงนี้หน่อยนะครับ จริงๆแล้วเราควรที่จะ grant least privileges ให้มัน แทนที่จะ grant ให้ทั้งหมด

สำหรับ username & password ก็สามารถใช้ค่าอื่นๆได้นะครับ แต่ว่าขอให้จำค่านี้ไว้เพราะเดี๋ยวเราจะต้องเอาค่านี้ไปสร้าง secret

ขั้นตอนต่อไปคือการสร้าง secret สำหรับ controller ตัวนี้เพื่อที่จะให้มันสามารถ connect เข้าไปใน mysql server ได้

$ kubectl create secret generic mysql-cred --from-literal=username=operator --from-literal=password=P@ssw0rd

ลอง run MySQLServer controller ครั้งแรก

การรันตัว controller มีหลายแบบ แต่วิธีที่ผมคิดว่าเร็ว แล้ว friendly กับ developers มากที่สุดคือ run ตัว controller จากเครื่องของ developers เลย โดยทำผ่านคำสั่ง

$ make run

ซึ่งจริงๆแล้ว คำสั่งนี้ก็จะ base มาจาก go run main.go นั่นเอง แต่ว่าการ run แบบนี้จะ tricky นิดนึงในกรณีที่เราใช้ mysql server ที่รันอยู่ใน cluster (docker-desktop) เพราะว่าตัว controller ของเราไม่สามารถต่อเข้าไปตรงๆได้ แต่ว่ามี trick ที่สามารถที่จะทำได้โดยสอง step นี้

  • รัน port-forward

ให้เปิดอีก terminal อีกตัวขึ้นมาแล้วรันคำสั่ง นี้ (สำหรับท่านที่สร้าง Service สำหรับ mysql ด้วยชื่ออื่นที่ไม่ใช่ mysql-service ก็ได้ update ค่านี้ให้ตรงกับของท่านเองนะครับ)

$ kubectl port-forward svc/mysql-service 3306
  • update /etc/hosts file

ให้ update ชื่อ service ของ mysql ในไฟล์​ hosts

127.0.0.1 mysql-service

สำหรับขั้นตอนในการทดสอบ เราจะยกยอดไปตอนหน้านะครับ (เหมือนกับจะติด limit ของ medium เอง ยาวกว่านี้ไม่น่าจะได้ละ)

บทความใน series ตอนอื่นๆ

--

--