使用 viper 實現 yaml 配置文件的合併

作爲小廠,我們的基礎設施還不夠完備,項目經理中秋節通知我們的系統近期要上 second-to-last stage 環境和生產環境,於是從運維人員部署效率方面考量,我們緊急開發了一個一鍵安裝腳本生成工具,這樣運維人員便可以利用該工具結合實際目標環境生成一鍵安裝腳本。這個工具的原理十分簡單,如下示意圖所示:

從上圖可以知道,我們的工具是基於模板定製最終的配置與安裝腳本的,其中:

custom 目錄下的兩個存儲定製化配置的文件是與目標環境緊密相關的

提到 template,Gopher 們首先想到的是 Go text/template 技術 [2],利用模板語法編寫上面 templates 目錄下的模板配置文件。不過基於 text/template 就需要我們事先將所有需要定製化的變量都一一識別出來,這個量有些大,且不夠靈活。

那我們還可以採用什麼技術方案呢?我最終選擇了 yaml 文件合併 (包括覆蓋與追加) 的方案,該方案示意圖如下:

這個示例包含了覆蓋和 (追加) 合併兩種情況,我們首先看一下覆蓋。

以 templates/manifests/a.yml 爲例,該模板中 metadata.name 的默認值爲 default,但運維人員根據目標環境定製了 (customizing)custom/manifests.yml 文件。在該文件中,a.yml 文件名作爲 key 值,然後將要覆蓋的配置項的全路徑配置到該文件中 (這裏的全路徑爲 metadata.name):

a.yml:
  metadata:
    name: foo

custom/manifests.yml 文件中對 namespace name 的修改值 foo 將會覆蓋原模板中的 default,這在最終的 xx_install/manifests/a.yml 中會體現出來:

// a.yml
apiVersion: v1
kind: Namespace
metadata:
  name: foo

對於原模板文件中沒有而 custom 中新增的配置,會追加到最終生成的配置文件中,以 b.yml 爲例。原模板目錄下的 b.yml 內容如下:

// templates/manifests/b.yml
log:
  type: file
  level: 0
  compress: true

這裏 log 下僅有三個子配置項:type、level 和 compress。

而運維在 custom/manifests.yml 爲 log 增加了其他若干種配置,比如 access_log、error_log 等:

// custom/manifests.yml
b.yml:
  log:
    level: 1
    compress: false
    access_log: "access.log"
    error_log: "error.log"
    max_age: 3
    maxbackups: 7
    maxsize: 100

這樣,除了 level、compress 會覆蓋原模板中的值之外,其餘新增的配置都會追加到生成的 xx_install/manifests/b.yml 中會體現出來:

// b.yml
log:
  type: file
  level: 1
  compress: false
  access_log: "access.log"
  error_log: "error.log"
  max_age: 3
  maxbackups: 7
  maxsize: 100

好了!方案確定了,那如何實現 yaml 文件的合併呢?Go 社區的 yaml 包要數 https://github.com/go-yaml/yaml(Canonical import paths 爲 gopkg.in/yaml.v2 或 gopkg.in/yaml.v3) 最爲知名,這個包實現了 YAML 1.2 規範 [3],可以方便實現 Yaml 與 go struct 之間的 marshal 與 unmarshal。不過,yaml 包提供的接口都比較初級,要想實現 yaml 文件的合併,還需要自己做較多額外工作,時間上可能不允許了。那有沒有現成可用的工具呢?答案是有的,它就是在 Go 社區大名鼎鼎的 viper[4]!

viper 是由 gohugo 作者、前 Go 語言項目組產品經理 Steve Francia[5] 開發的開源 Go 應用配置框架。viper 不僅支持命令行參數傳入配置,還支持從各種類型配置文件、環境變量、遠程配置系統 (etcd 等) 等獲取配置。除此之外,viper 還支持配置文件的 merge 和對配置文件的寫入操作。

我們是否可以直接使用 viper 的 Merge 系列操作呢?答案是不能!爲什麼呢?因爲這與我們上面的設計有關。我們將與環境有關的配置都放入了 custom/manifests.yml 這一個文件中了,這與一 merge 就會導致 custom/manifests.yml 中的配置數據出現在每一個最終生成的 templates/xx.yml 配置文件中。

那我們就自行來實現一套 merge(覆蓋和追加) 操作!

我們先來看驅動 merge 的 main 函數:

// github.com/bigwhite/experiments/tree/master/yml-merge-using-viper/main.go

var (
    sourceDir string
    dstDir    string
)

func init() {
    flag.StringVar(&sourceDir, "s""./""template directory path")
    flag.StringVar(&dstDir, "d""./k8s-install""the target directory path in which the generated files are put")
}

func main() {
    var err error
    flag.Parse()

    // create target directory if not exist
    err = os.MkdirAll(dstDir+"/conf", 0775)
    if err != nil {
        fmt.Printf("create %s error: %s\n", dstDir+"/conf", err)
        return
    }

    err = os.MkdirAll(dstDir+"/manifests", 0775)
    if err != nil {
        fmt.Printf("create %s error: %s\n", dstDir+"/manifests", err)
        return
    }

    // override manifests files with same config item in custom/manifests.yml,
    // store the final result to the target directory
    err = mergeManifestsFiles()
    if err != nil {
        fmt.Printf("override and generate manifests files error: %s\n", err)
        return
    }
    fmt.Printf("override and generate manifests files ok\n")
}

我們看到 main 包利用標準庫 flag 包創建了兩個命令行參數 - s 和 - d,分別代表存放 templates/custom 的源路徑和存儲生成文件的目標路徑。進入 main 函數後,我們首先在目標路徑下建立 manifests 和 conf 目錄用於分別存儲相關配置文件(本例中,conf 目錄下不生成任何文件),然後 main 函數調用 mergeManifestsFiles 對源路徑下的 templates/manifests 中的 yml 文件與 custom/manifests.yml 進行合併:

// github.com/bigwhite/experiments/tree/master/yml-merge-using-viper/main.go

var (
    manifestFiles = []string{
        "a.yml",
        "b.yml",
    }
)

func mergeManifestsFiles() error {
    for _, file := range manifestFiles {
        // check whether the file exist
        srcFile := sourceDir + "/templates/manifests/" + file
        _, err := os.Stat(srcFile)
        if os.IsNotExist(err) {
            fmt.Printf("%s not exist, ignore it\n", srcFile)
            continue
        }

        err = mergeConfig("yml", sourceDir+"/templates/manifests", strings.TrimSuffix(file, ".yml"),
            sourceDir+"/custom""manifests", dstDir+"/manifests/"+file)
        if err != nil {
            fmt.Println("mergeConfig error: ", err)
            return err
        }
        fmt.Printf("mergeConfig %s ok\n", file)

    }
    return nil
}

我們看到 mergeManifestsFiles 遍歷模板文件,並針對每個文件調用一次真正進行 yml 文件 merge 的函數 mergeConfig:

// github.com/bigwhite/experiments/tree/master/yml-merge-using-viper/main.go

func mergeConfig(configType, srcPath, srcFile, overridePath, overrideFile, target string) error {
    v1 := viper.New()
    v1.SetConfigType(configType) // e.g. "yml"
    v1.AddConfigPath(srcPath)    // file directory
    v1.SetConfigName(srcFile)    // filename(without postfix)
    err := v1.ReadInConfig()
    if err != nil {
        return err
    }

    v2 := viper.New()
    v2.SetConfigType(configType)
    v2.AddConfigPath(overridePath)
    v2.SetConfigName(overrideFile)
    err = v2.ReadInConfig()
    if err != nil {
        return err
    }

    overrideKeys := v2.AllKeys()

    // override special keys
    prefixKey := srcFile + "." + configType + "." // e.g "a.yml."
    for _, key := range overrideKeys {
        if !strings.HasPrefix(key, prefixKey) {
            continue
        }

        stripKey := strings.TrimPrefix(key, prefixKey)
        val := v2.Get(key)
        v1.Set(stripKey, val)
    }

    // write the final result after overriding
    return v1.WriteConfigAs(target)
}

我們看到:mergeConfig 函數針對 templates/manifests 下的文件和 custom 下的 manifests.yml 文件創建了兩個 viper 實例 (viper.New()) 並分別加載各自的配置數據。然後遍歷 custom 下 manifests.yml 中的 key,將符合要求的配置項的值 set 到代表對 templates/manifests 下文件的 viper 實例中,最後我們將 merge 後的 viper 實例數據寫到目標文件中。

編譯運行該生成工具:

$make
go build 
$./generator 
mergeConfig a.yml ok
mergeConfig b.yml ok
override and generate manifests files ok

在默認命令行參數的情況下,文件被生成在 k8s-install 路徑下,我們查看一下生成的文件:

$cat k8s-install/manifests/a.yml
apiversion: v1
kind: Namespace
metadata:
    name: foo

$cat k8s-install/manifests/b.yml
log:
    access_log: access.log
    compress: false
    error_log: error.log
    level: 1
    max_age: 3
    maxbackups: 7
    maxsize: 100
    type: file

我們看到 merge 的結果與我們預期的一致 (字段順序不一致沒關係,這與 viper 內部存儲 key-value 時使用 go map 有關,go map 的遍歷順序是隨機的)。

不過細心的朋友可能會發現一處問題:那就是 a.yml 中原先的 apiVersion 在結果文件中變成了小寫的 apiversion,這會 a.yml 在提交給 k8s 時校驗失敗!

爲什麼會這樣呢?viper 官方給出的說明 [6] 如下 (機翻):

Viper合併了來自不同來源的配置,其中許多配置是不區分大小寫的,或者使用與其他來源不同的大小寫(例如,env vars)。爲了在使用多個資源時提供最佳體驗,我們決定讓所有按鍵不區分大小寫。

已經有一些人試圖實現大小寫敏感,但不幸的是,這不是那麼簡單的事情。我們可能會在Viper v2中試着實現它。。。。

好吧,既然官方說在 v2 可能支持,但 v2 又遙遙無期,我們就用 viper 的 fork 版本來解決這個問題吧!開發者 lnashier 曾因這個大小寫問題 fork 過一份 viper 代碼 [7] 並 fix 了這個問題,雖然比較 old(且可能改的不全面),但能滿足我們的要求就行!我們來試試將 spf13/viper 換爲 lnashier/viper[8],並重新構建和執行 generator:

$go mod tidy
go: finding module for package github.com/lnashier/viper
go: found github.com/lnashier/viper in github.com/lnashier/viper v0.0.0-20180730210402-cc7336125d12

$make clean
rm -fr generator k8s-install

$make
go build 

$./generator 
mergeConfig a.yml ok
mergeConfig b.yml ok
override and generate manifests files ok

$cat k8s-install/manifests/a.yml
apiVersion: v1
kind: Namespace
metadata:
  name: foo

$cat k8s-install/manifests/b.yml
log:
  access_log: access.log
  compress: false
  error_log: error.log
  level: 1
  max_age: 3
  maxbackups: 7
  maxsize: 100
  type: file

我們看到更換爲 lnashier/viper 後,a.yml 中的 apiVersion 這個 key 沒有再被改爲小寫。

這個工具基本可以使用了。但是這個工具是否沒有問題了呢?很遺憾不是的!當 generator 面對下面的兩種形式的配置文件時就會生成錯誤的文件:

//c.yml

apiVersion: v1
data:
  .dockerconfigjson: xxxxyyyyyzzz==
kind: Secret
type: kubernetes.io/dockerconfigjson
metadata:
  name: mysecret
  namespace: foo

//d.yml

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-conf
  namespace: foo
data:
  my-nginx.conf: |
    server {
          listen 80;
          client_body_timeout 60000;
          client_max_body_size 1024m;
          send_timeout 60000;
          proxy_headers_hash_bucket_size 1024;
          proxy_headers_hash_max_size 4096;
          proxy_read_timeout 60000;
          location /dashboard {
             proxy_pass http://localhost:8081;
          }
    }

這兩個問題就比較棘手了,lnashier/viper 也無法解決。我也只能 fork lnashier/viper 到 bigwhite/viper[9] 自己解決這個問題,並且像 d.yml 這樣的配置形式十分特化,不具有通用性,因此 bigwhite/viper 並不具有通用性,這裏就不細說了,有興趣的朋友可以自行閱讀代碼 (commit diff) 來查看解決上述問題的方法。

本文涉及的代碼可以從這裏 [10] 下載。


後記:

kustomize 是 k8s 官方工具,它可以讓你基於 k8s resource 模板 YAML 文件 (類似本文的 templates/manifests 目錄下的文件) 並結合 kustomization.yaml(類似 custom/manifests.yaml)爲多種目的定製 YAML 文件,原始的 YAML 不會進行任何改動。

不過它的目標僅僅是 k8s 相關的 yaml 文件,對於我們的業務服務配置可能無能爲力。

CUE 是這兩年流行起來的一種強大的聲明性配置語言,它由前 Go 核心團隊成員 Marcel van Lohuizen[13] 創建,他曾與人合作創建了 Borg 配置語言(BCL)-- 在谷歌用於部署所有應用程序的語言。CUE 是谷歌多年編寫配置語言經驗的結晶,旨在改善開發者的體驗,同時避免一些陷阱。它是 JSON 的超集且還具有額外的功能特性。Docker 之父 Solomon Hykes 的新創業項目 dagger[14] 大量使用 CUE,阿里力推的企業雲原生應用管理平臺 kubevela[15] 也是 CUE 的重度用戶。

關於如何使用 CUE 來替代我上述的方案,還待後續深入研究。


“Gopher 部落” 知識星球 [16] 旨在打造一個精品 Go 學習和進階社羣!高品質首發 Go 技術文章,“三天” 首發閱讀權,每年兩期 Go 語言發展現狀分析,每天提前 1 小時閱讀到新鮮的 Gopher 日報,網課、技術專欄、圖書內容前瞻,六小時內必答保證等滿足你關於 Go 語言生態的所有需求!2022 年,Gopher 部落全面改版,將持續分享 Go 語言與 Go 應用領域的知識、技巧與實踐,並增加諸多互動形式。歡迎大家加入!

Gopher Daily(Gopher 每日新聞) 歸檔倉庫 - https://github.com/bigwhite/gopherdaily

我的聯繫方式:

參考資料

[1] 

k8s yaml 腳本: https://tonybai.com/2019/02/25/introduction-to-yaml-creating-a-kubernetes-deployment/

[2] 

Go text/template 技術: https://pkg.go.dev/text/template

[3] 

YAML 1.2 規範: https://yaml.org/spec/1.2.2/

[4] 

viper: https://github.com/spf13/viper

[5] 

Steve Francia: https://github.com/spf13/

[6] 

viper 官方給出的說明: https://github.com/spf13/viper#does-viper-support-case-sensitive-keys

[7] 

開發者 lnashier 曾因這個大小寫問題 fork 過一份 viper 代碼: https://github.com/spf13/viper/issues/373

[8] 

lnashier/viper: https://github.com/lnashier/viper

[9] 

bigwhite/viper: https://github.com/bigwhite/viper

[10] 

這裏: https://github.com/bigwhite/experiments/tree/master/yml-merge-using-viper

[11] 

kustomize: https://github.com/kubernetes-sigs/kustomize

[12] 

CUE 數據配置語言: https://cuelang.org/

[13] 

Marcel van Lohuizen: https://github.com/mpvl

[14] 

dagger: https://dagger.io

[15] 

kubevela: https://kubevela.io

[16] 

“Gopher 部落” 知識星球: https://wx.zsxq.com/dweb2/index/group/51284458844544

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/sqq8PNBvhPDmD8T5W4h65g