面向K8s設計誤區 K8s設計模式 有了錘子,看到的只有釘子 誤區1 一切設計皆yaml 誤區2 一切皆合一 誤區3 一切皆終態 誤區4 一切交互皆cr 總結

<meta name="source" content="lake">

K8s設計模式

Kubernetes是一個具有普遍意義的容器編排工具,它提供了一套基於容器構建分佈式系統的基礎依賴,其意義等同於Linux在操作系統中的地位,可以認爲是分佈式的操作系統。

自定義資源

K8s提供了Pod、Service、Volume等一系列基礎資源定義,爲了更好提供擴展性,CRD 功能是在1.7 版本被引入。

用戶可以根據自己的需求添加自定義的 Kubernetes 對象資源(CRD)。值得注意的是,這裏用戶自己添加的 Kubernetes 對象資源都是 native 的都是一等公民,和 Kubernetes 中自帶的、原生的那些 Pod、Deployment 是同樣的對象資源。在 Kubernetes 的 API Server 看來,它們都是存在於 etcd 中的一等資源。同時,自定義資源和原生內置的資源一樣,都可以用 kubectl 來去創建、查看,也享有 RBAC、安全功能。用戶可以開發自定義控制器來感知或者操作自定義資源的變化。

Operator

在自定義資源基礎上,如何實現自定義資源創建或更新時的邏輯行爲,K8s Operator提供了相應的開發框架。Operator通過擴展Kubernetes定義Custom Controller,list/watch 對應的自定義資源,在對應資源發生變化時,觸發自定義的邏輯。

Operator 開發者可以像使用原生 API 進行應用管理一樣,通過聲明式的方式定義一組業務應用的期望終態,並且根據業務應用的自身特點進行相應控制器邏輯編寫,以此完成對應用運行時刻生命週期的管理並持續維護與期望終態的一致性。

[圖片上傳失敗...(image-ded8d9-1618369399798)]

通俗的理解

CRD是K8s標準化的資源擴展能力,以java爲例,int、long、Map、Object是java內置的類,用戶可以自定義Class實現類的擴展,CRD就是K8s中的自定義類,CR就是對應類的一個instance。

Operator模式 = 自定義類 + 觀察者模式,Operator模式讓大家編寫K8s的擴展變得非常簡單快捷,逐漸成爲面向K8s設計的標準。

Operator提供了標準化的設計流程:

  1. 使用 SDK 創建一個新的 Operator 項目
  2. 通過添加自定義資源(CRD)定義新的資源 API
  3. 指定使用 SDK API 來 watch 的資源
  4. 自定義Controller實現K8s協調(reconcile)邏輯

有了錘子,看到的只有釘子

我們團隊(KubeOne團隊)一直在致力於解決複雜中間件應用如何部署到K8s,自然也是Operator模式的踐行者。經歷了近2年的開發,初步解決了中間件在各個環境K8s的部署,當前中間也走了很多彎路,踩了很多坑。

KubeOne內核也經歷3個大版本的迭代,前2次開發過程基本都是follow Operator標準開發流程進行開發設計。遵循一個標準的、典型的Operator的設計過程,看上去一切都是這麼的完美,但是每次設計都非常痛苦,踐行Operator模式之後,最值得反思和借鑑的就是”有了錘子,看到的只有釘子“,簡單總結一下就是4個一切:

  1. 一切設計皆yaml
  2. 一切皆合一
  3. 一切皆終態
  4. 一切交互皆cr

誤區1 一切設計皆yaml

K8s的API是yaml格式,Operator設計流程也是讓大家首先定義crd,所以團隊開始設計時直接採用了yaml格式。

案例

根據標準化流程,團隊面向yaml設計流程大體如下:

  1. 先根據已知的數據初步整理一個大而全的yaml,做一下初步的分類,例如應用大概包含基礎信息,依賴服務,運維邏輯,監控採集等,每個分類做一個子部分
  2. 開會討論具體的內容是否能滿足要求,結果每次開會都難以形成共識
  • 因爲總是有新的需求滿足不了,在討論A時,就有人提到B、C、D,不斷有新的需求
  • 每個部分的屬性非常難統一,因爲不同的實現屬性差異較大
  • 理解不一致,相同名字但使用時每個人的理解也不同
  1. 由於工期很緊,只能臨時妥協,做一箇中間態,後面再進一步優化
  2. 後續優化升級,相同的流程再來一遍,還是很難形成共識

這是第2個版本的設計:

<pre class="cm-s-default" style="color: rgb(55, 61, 65); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);">apiVersion: apps.mwops.alibaba-inc.com/v1alpha1 kind: AppDefinition metadata: labels: app: "A" name: A-1.0 //chart-name+chart-version namespace: kubeone spec: appName: A //chart-name version: 1.0 //chart-version type: apps.mwops.alibaba-inc.com/v1alpha1.argo-helm workloadSettings: //注 workloadSettings 標識type應該使用的屬性 - name: "deployToK8SName" value: "" - name: "deployToNamespace" value: {resources:namespace-resource.name} parameterValues: //注 parameterValues標識業務屬性 - name: "enableTenant" value: "1" - name: "CPU" value: "1" - name: "MEM" value: "2Gi" - name: "jvm" value: "flag;gc" - name: vip.fileserver-edas.ip value:{resources:fileserver_edas.ip} - name: DB_NAME valueFromConfigMap: name: {resources:rds-resource.cm-name} expr:{database} - name: DB_PASSWORD valueFromSecret: name: {instancename}-rds-secret expr:{password} - name: object-storage-endpoint value: {resources:object-storage.endpoint} - name: object-storage-username valueFromSecret: name:{resources:object-storage.secret-name} expr: {username} - name: object-storage-password valueFromSecret: name:{resources:object-storage.secret-name} expr: {password} - name: redis-endpoint value:{resources:redis.endpoint} - name: redis-password value: {resources:redis.password} resources: - name: tolerations type: apps.mwops.alibaba-inc.com/tolerations parameterValues: - name: key value: "sigma.ali/is-ecs" - name: key value: "sigma.ali/resource-pool" - name: namespace-resource type: apps.mwops.alibaba-inc.com/v1alpha1.namespace parameterValues: - name: name value: edas - name: fileserver-edas type: apps.mwops.alibaba-inc.com/v1alpha1.database.vip parameterValues: - name: port value: 21,80,8080,5000 - name: src_port value: 21,80,8080,5000 - name: type value: ClusterIP - name: check_type value: "" - name: uri value: "" - name: ip value: "" - name: test-db type: apps.mwops.alibaba-inc.com/v1alpha1.database.mysqlha parameterValues: - name: name value: test-db - name: user value: test-user - name: password value: test-passwd - name: secret value: test-db-mysqlha-secret - name: service-slb type: apps.mwops.alibaba-inc.com/v1alpha1.slb mode: post-create parameterValues: - name: service value: "serviceA" - name: annotations value: "app:a,version:1.0" - name: external-ip value: - name: service-resource2 type: apps.mwops.alibaba-inc.com/v1alpha1.service parameterValues: - name: second-domain value: edas.console - name: ports value: "80:80" - name: selectors value: "app:a,version:1.0" - name: type value: "loadbalance" - name: service-dns type: apps.mwops.alibaba-inc.com/v1alpha1.dns parameterValues: - name: domain value: edas.server.{global:domain} - name: vip value: {resources:service-resource2.EXTERNAL-IP} - name: dns-resource type: apps.mwops.alibaba-inc.com/v1alpha1.dns parameterValues: - name: domain value: edas.console.{global:domain} - name: vip value: “127.0.0.1” - name: cni-resource type: apps.mwops.alibaba-inc.com/v1alpha1.cni parameterValues: - name: count value: 4 - name: ip_list value: - name: object-storage type: apps.mwops.alibaba-inc.com/v1alpha1.objectStorage.minio parameterValues: - name: namespace value: test-ns - name: username value: test-user - name: password value: test-password - name: storage-capacity value: 20Gi - name: secret-name value: minio-my-store-access-keys - name: endpoint value: minio-instance-external-service - name: redis type: apps.mwops.alibaba-inc.com/v1alpha1.database.redis parameterValues: - name: cpu value: 500m - name: memory value: 128Mi - name: password value: i_am_a_password - name: storage-capacity value: 20Gi - name: endpoint value: redis-redis-cluster - name: accesskey type: apps.mwops.alibaba-inc.com/v1alpha1.accesskey parameterValues: - name: name value: default - name: userName value: [email protected] exposes: - name: dns value: {resources:dns-resource.domain} - name: db-endpoint valueFromConfigmap: name:{resources:rds-resource.cm-name} expr: {endpoint}:3306/{database} - name: ip_list value: {resources:cni-resource.ip_list} - name: object-storage-endpoint value:{resources:object-storage.endpoint}.{resource:namespace-resource.name} - name: object-storage-username valueFromSecret: name:{resources:object-storage.secret-name} expr: {username} - name: object-storage-password valueFromSecret: name:{resources:object-storage.secret-name} expr: {password} - name: redis-endpoint value:{resources:redis.endpoint}.{resource:namespace-resource.name} - name: redis-password value:{resources:redis.password}</pre>

反思

這樣的痛苦難以用語言表達,感覺一切都脫離了掌控,沒有統一的判斷標準,設計標準,公說公有理婆說婆有理,內容一直加,字段一直改。事不過三,第三次設計時,我們集體討論反思爲什麼這麼難形成共識?爲什麼每個人理解不同?爲什麼總是在改?

結論很一致,沒有面向yaml的設計,只有面向對象的設計,設計語言也只有UML,只有這些歷經考驗、成熟的設計方法論,纔是最簡單也是最高效的。

從上面那個一個巨大無比的yaml大家可以體會我們設計的複雜,但是這還是不是最痛苦的。最痛苦的是大家拋棄了原有的設計流程及設計語言,試圖使用一個開放的Map來描述一切。當設計沒有對象,也沒有關係,只剩下Map裏一個個屬性,也就無所謂對錯,也無所謂優劣。最後爭來爭去,最後不過是再加一個字段,爭了一個寂寞。

適用範圍

那Operator先設計CRD,再開發controller的方式不正確嗎?

答案:部分正確

適用場景

與Java Class相同,簡單對象不需要經過複雜的設計流程,直接設計yaml簡單高效。

不適用場景

在設計一個複雜的體系時,例如:應用管理,包含多個對象且對象之間有複雜的關係,有複雜的用戶故事,UML和麪向對象的設計就顯得非常重要。

設計時只考慮UML和領域語言,設計完成後,CRD可以認爲是java的Class,或者是數據庫的表結構,只是最終要實現時的一種選擇。而且有很多對象不需要持久化,也不需要通過Operator機制觸發對應的邏輯,就不需要設計CRD,而是直接實現一個controller即可。

yaml是接口或Class聲明的一種格式化表達,常規yaml要儘可能小,儘可能職責單一,儘可能抽象。複雜的yaml是對簡單CRD資源的一種編排結果,提供類似一站式資源配套方案。

在第3個版本及PaaS-Core設計時,我們就採取瞭如下的流程:

  1. UML 用例圖
  2. 梳理用戶故事
  3. 基於用戶故事對齊Domain Object,確定關鍵的業務對象以及對象間關係
  4. 需要Operator化的對象,每個對象描述爲一個CRD,當然CRD缺乏接口、繼承等面向對象的能力,可以通過其他方式曲線表達
  5. 不需要Operator化的對象,直接編寫Controller

誤區2 一切皆合一

爲了保證一個應用的終態,或者爲了使用gitops管理一個應用,是否應該把應用相關的內容都放入一個CRD或一個IAC文件?根據gitops設計,每次變更時需要下發整個文件?

案例

案例1: 應用WordPress,需要依賴一個MySQL,終態如何定義?

<pre class="cm-s-default" style="color: rgb(55, 61, 65); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);">apiVersion: apps.mwops.alibaba-inc.com/v1alpha1 kind: AppDefinition metadata: labels: app: "WordPress" name: WordPress-1.0 //chart-name+chart-version namespace: kubeone spec: appName: WordPress //chart-name version: 1.0 //chart-version type: apps.mwops.alibaba-inc.com/v1alpha1.argo-helm parameterValues: //注 parameterValues標識業務屬性 - name: "enableTenant" value: "1" - name: "CPU" value: "1" - name: "MEM" value: "2Gi" - name: "jvm" value: "flag;gc" - name: replicas value: 3 - name: connectstring valueFromConfigMap: name: {resources:test-db.exposes.connectstring} expr:{connectstring} - name: db_user_name valueFromSecret: .... resources: - name: test-db //創建一個新的DB type: apps.mwops.alibaba-inc.com/v1alpha1.database.mysqlha parameterValues: - name: cpu value: 2 - name: memory value: 4G - name: storage value: 20Gi - name: username value: myusername - name: password value: i_am_a_password - name: dbname value: wordPress exposes: - name: connectstring - name: username - name: password exposes: - name: dns value: ...</pre>

上方的代碼是wordPress應用的終態嗎?這個文件包含了應用所需要的DB的定義和應用的定義,只要一次下發就可以先創建對應的數據庫,再把應用拉起。

案例2:每次變更時,直接修改整個yaml的部分內容,修改後直接下發到K8s,引起不必要的變更。例如:要從3個節點擴容到5個節點,修改上面yaml文件的replicas之後,需要下發整個yaml。整個下發的yaml經過二次解析成底層的StatefulSet或Deployment,解析邏輯升級後,可能會產生不符合預期的變化,導致所有pod重建。

反思

先回答第一個問題,上方yaml文件不是應用的終態,而是一個編排,此編排包含了DB的定義和應用的定義。應用的終態只應該包含自己必須的依賴引用,而不包含依賴是如何創建的。因爲這個依賴引用可以是新創建的,也可以是一個已有的,也可以是手工填寫的,依賴如何創建與應用終態無關。

<pre class="cm-s-default" style="color: rgb(55, 61, 65); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);">apiVersion: apps.mwops.alibaba-inc.com/v1alpha1 kind: AppDefinition metadata: labels: app: "WordPress" name: WordPress-1.0 //chart-name+chart-version namespace: kubeone spec: appName: WordPress //chart-name version: 1.0 //chart-version name: WordPress-test type: apps.mwops.alibaba-inc.com/v1alpha1.argo-helm parameterValues: //注 parameterValues標識業務屬性 - .... resources: - name: test-db-secret value: "wordPress1Secret" //引用已有的secret exposes: - name: dns value: ...</pre>

創建一個應用,就不能先創建db,再創建應用嗎?

可以的,多個對象之間依賴是通過編排實現的。編排有單個應用創建的編排,也有一個複雜站點創建的編排。以Argo爲例。

<pre class="cm-s-default" style="color: rgb(55, 61, 65); margin: 0px; padding: 0px; background: none 0% 0% / auto repeat scroll padding-box border-box rgba(0, 0, 0, 0);">apiVersion: argoproj.io/v1alpha1 kind: Workflow metadata: generateName: wordPress- spec: templates: - name: wordPress steps: # 創建db - - name: wordpress-db template: wordpress-db arguments: parameters: [{name: wordpress-db1}] # 創建應用 - - name: template: wordpress arguments: parameters: [{db-sercet: wordpress-db1}]</pre>

針對第2個案例,是否每次交互都需要下發全部完整的yaml?

答案:

  1. 編排是一次性的配置,,編排文件下發一次之後,後續操作都是操作單個對象,例如:變更時,只會單獨變更wordPress,或單獨變更wordPressDB,而不會一次性同時變更2個對象。
  2. 單獨變更應用時,是否需要下發整個終態yaml,這個要根據實際情況進行設計,值得大家思考。後面會提出針對整個應用生命週期狀態機的設計,裏面有詳細的解釋。

適用範圍

適用場景

CRD或Iac定義時,單個對象的終態只應該包含自身及對依賴的引用。與面向對象的設計相同,我們不應該把所有類的定義都放到一個Class裏面。

不適用場景

多個對象要一次性創建,並且需要按照順序創建,存在依賴關係,需要通過編排層實現。

誤區3 一切皆終態

體驗了K8s的終態化之後,大家在設計時言必稱終態,彷彿不能用上終態設計,不下發一個yaml聲明對象的終態就是落伍,就是上一代的設計。

案例

案例1:應用編排 還是以WordPress爲例,將WordPressDB和WordPress放在一起進行部署,先部署DB,再創建應用。示例yaml同上。

案例2:應用發佈 應用第一次部署及後續的升級直接下發一個完整的應用yaml,系統會自動幫你到達終態。但爲了能夠細粒度控制發佈的流程,努力在Deployment或StatefulSet上下功夫,進行partition的控制,試圖在終態裏增加一點點的交互性。

反思

說到終態,必然要提到命令式、聲明式編程,終態其實就是聲明式最終的執行結果。我們先回顧一下命令式、終態式編程。

命令式編程

命令式編程的主要思想是關注計算機執行的步驟,即一步一步告訴計算機先做什麼再做什麼。

比如:如果你想在一個數字集合 collection(變量名) 中篩選大於 5 的數字,你需要這樣告訴計算機:

  1. 第一步,創建一個存儲結果的集合變量 results;

  2. 第二步,遍歷這個數字集合 collection;

  3. 第三步:一個一個地判斷每個數字是不是大於 5,如果是就將這個數字添加到結果集合變量 results 中。

代碼實現如下:

List results = new List();

foreach(var num in collection)

{

if (num > 5)

results.Add(num);

}

很明顯,這個樣子的代碼是很常見的一種,不管你用的是 C, C++ 還是 C#, Java, Javascript, BASIC, Python, Ruby 等等,你都可以以這個方式寫。

聲明式編程

聲明式編程是以數據結構的形式來表達程序執行的邏輯。它的主要思想是告訴計算機應該做什麼,但不指定具體要怎麼做。

SQL 語句就是最明顯的一種聲明式編程的例子,例如:

SELECT * FROM collection WHERE num > 5

除了 SQL,網頁編程中用到的 HTML 和 CSS 也都屬於聲明式編程。

通過觀察聲明式編程的代碼我們可以發現它有一個特點是它不需要創建變量用來存儲數據。

另一個特點是它不包含循環控制的代碼如 for, while。

換言之

• 命令式編程:命令“機器”如何去做事情(how),這樣不管你想要的是什麼(what),它都會按照你的命令實現。

• 聲明式編程:告訴“機器”你想要的是什麼(what),讓機器想出如何去做(how)。

當接口越是在表達“要什麼”,就是越聲明式;越是在表達“要怎樣”,就是越命令式。SQL就是在表達要什麼(數據),而不是表達怎麼弄出我要的數據,所以它就很“聲明式”。

簡單的說,接口的表述方式越接近人類語言——詞彙的串行連接(一個詞彙實際上是一個概念)——就越“聲明式”;越接近計算機語言——“順序+分支+循環”的操作流程——就越“命令式”。

越是聲明式,意味着下層要做更多的東西,或者說能力越強,也意味着效率的損失。越是命令式,意味着上層對下層有更多的操作空間,可以按照自己特定的需求要求下層按照某種方式來處理。

簡單的講,Imperative Programming Language (命令式語言)一般都有control flow, 並且具有可以和其他設備進行交互的能力。而Declarative Programming language (聲明式語言) 一般做不到這些。

基於以上的分析,編排或工作流本質是一個流程性控制的過程,一般是一次性的過程,無需強行終態化,而且建站編排執行結束後,不能保持終態,因爲後續會根據單個應用進行發佈和升級。案例1是一個典型的編排,只是一次性的創建了2個對象DB和應用的終態。

應用發佈其實是通過一個發佈單或工作流,控制2個不同版本的應用節點和流量的終態化的過程,不應該是應用終態的一部分,而是一個獨立的控制流程。

[圖片上傳失敗...(image-6f237d-1618369399796)]

適用範圍

聲明式或終態設計

適用場景

無過多交互,無需關注底層實現的場景,即把聲明提供給系統後,系統會自動化達到聲明所要求的狀態,而不需要人爲干預。

不適用場景

一次性的流程編排,有頻繁交互的控制流程

命令式和聲明式本就是2種互補的編程模式,就像有了面向對象之後,有人就鄙視面向過程的編程,現在有了聲明式,就開始鄙視命令式編程,那一屋!

誤區4 一切交互皆cr

因爲K8s的API交互只能通過yaml,導致大家的設計都以cr爲中心,所有的交互都設計爲下發一個cr,通過watch cr觸發對應的邏輯。

案例

  1. 調用一個http接口或function,需要下發一個cr
  2. 應用crud都下發完整cr

反思

案例1

是否所有的邏輯都需要下發一個cr?

下發cr其實做了比較多的事情,流程很長,效率並不高,流程如下:

  1. 通過API傳入cr,cr 保存到etcd
  2. 觸發informer
  3. controller接收到對應的事件,觸發邏輯
  4. 更新cr狀態
  5. 清理cr,否則會佔用etcd存儲

如果需要頻繁的調用對應的接口,儘量通過sdk直接調用。

案例2

K8s 對yaml操作命令有 create、apply、patch、delete、get等,但一個應用的生命週期狀態機不只是這幾個命令可以涵蓋,我們比較一下應用狀態機(上)和yaml狀態機(下):

不同的有狀態應用,在收到不同的指令,需要觸發不同的邏輯,例如:MQ在收到stop指令時,需要先停寫,檢查數據是否消費完成。如果只是通過yaml狀態機是無法涵蓋應用狀態機相關的event,所以我們必須打破下發cr的模式。對於應用來說,理想的交互方式是通過event driven 應用狀態機的變化,狀態發生變換時觸發對應的邏輯。

適用範圍

適用場景

需要持久化,保持終態的數據

不適用場景

高頻的服務調用,無需持久化的數據

複雜狀態機的驅動

總結

K8s給我們打開了一扇門,帶給了我們很多優秀的設計,優秀的理念,但是這些設計和理念也是有自己的適用的場景,並不是放之四海而皆準。我們不應該盲從,試圖一切都要follow k8s的設計和規則,而拋棄之前的優秀設計理念。

軟件設計經歷了10多年的發展,形成了一套行之有效的設計方法論,k8s也是在這些設計方法論的支持下設計出來的。取其精華去其糟粕,是我們程序員應該做的事情。

參考文章:

  1. 揭祕Kubernetes Operator http://www.dockone.io/article/8769
  2. 聲明式編程和命令式編程有什麼區別 https://www.zhihu.com/question/22285830
  3. 如何在Kubernetes中編寫自定義控制器 https://www.sohu.com/a/363619791_198222

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。'

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章