使用 go 语言,编写批量生成 k8s yaml 的工具

目录

  • 场景
  • 使用步骤
    • 检查模板
    • 检查配置
    • 生成 yaml
  • 自定义开发
    • 书写模板文件
      • 单一填值
      • 复杂结构展开
      • 深度解析
      • 生成多份
    • 指定配置值
      • 配置文件
      • 配置支持
    • 将占位符替换为真实值
      • 单一填值
      • 复杂结构展开
      • 深度解析
      • 生成多份
    • 文件输出
  • 总结

场景

游戏项目中的逻辑服务器需要部署在 k8s 中,由于逻辑服按照微服务体系划分(例如:login、gate、gm、account、chat 等),需要多个 k8s yaml 执行 kubectl apply -f *.yaml 完成批量部署。笔者在手写了 login-kube.yamlgate-kube.yaml 后发现了如下痛点:

  1. 每个种类资源的 k8s yaml 大同小异,拥有同样的结构
  2. 一个yaml 文件内,也有大量的重复变量(例如,login-kube.yaml 里,光是 “login” 这个字符串出现了十几次,包括但不限于 image、label、configmap 名等)
  3. 微服务太多,且port、label、hpa、image 改动频繁(image tag尤其频繁);假设需要批量将容器版本指定为 2022_01_12_1 ,则每个 yaml 都需要至少改动一处,人为操作太多,且不可控(人总会失误)。
  4. 懒。复制粘贴文件本身并不复杂、也不耗时。困难就困难在,每个 yaml 文件需要经过验证才能提交,复制粘贴修改的文件,不经过测试,很容易出现漏改或者设置错的情况;而每次新增、修改微服务,都需要做一次全量测试,整个维护 *.yaml 的工作量巨大

既然有这些痛点,那我们就着手做一个批量生成工具,只需要维护很少的变量,就能够批量生成所有微服务的 *.yaml 文件,从而批量部署。

工具下载地址

注:根据场景不同,第一次使用工具时需要手动更改 template 目录下的模板文件。如果场景不同,则可能需要改动 Go 相关的代码,才能正常使用

使用步骤

检查模板

检查模板是否需要更新,可以修改模板的结构和通用参数
使用 go 语言,编写批量生成 k8s yaml 的工具_第1张图片

检查配置

检查配置是否需要更新,可以增加修改删除服务配置,支持设置多个服务间的 共用值(例如,所有服务的镜像前缀相同,镜像tag相同)。单个服务的 特殊值 设置,会覆盖共用值。
使用 go 语言,编写批量生成 k8s yaml 的工具_第2张图片

生成 yaml

在项目根目录下执行

go run main.go # 也可以提前 go build 生成可执行文件

默认在 output/ (可配置) 目录下生成 k8s yaml 文件

使用 go 语言,编写批量生成 k8s yaml 的工具_第3张图片

自定义开发

书写模板文件

我们想要自动生成配置文件,第一步首先是书写模板文件,再通过模板文件自动替换变量达到我们的目的。

以 Deployment 模板为例

apiVersion: apps/v1
kind: Deployment
metadata:
  name: $(name)
  namespace: $(namespace)
  labels:
    app: $(name)
spec:
  replicas: $(replicas)
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: $(name)
  template:
    metadata:
      labels:
        app: $(name)
        type: service # 项目的日志分析ELK使用到了这个,用于分类
    spec:
      containers:
        - name: $(name)
          image: $(imageprefix)$(name):$(imagetag)
          lifecycle:
            preStop:
              exec:
                command: [ "sh","-c","sleep 5" ]
          ports: $(ports)
          resources: $(resources)
          volumeMounts:
            - name: timezone
              mountPath: /etc/localtime
            - name: $(name)-cm-v
              mountPath: /app/etc
      volumes:
        - name: timezone
          hostPath:
            path: /usr/share/zoneinfo/Asia/Shanghai
        - name: $(name)-cm-v
          configMap:
            name: $(name)-cm

单一填值

我选用了 $(PARAM_NAME) 来给变量提供占位符。
其中, image: $(imageprefix)$(name):$(imagetag) 配置,意味着我们的工具需要支持多变量的拼接和自由组合,即:

# 读取模板前设置好这些值
# imageprefix: nbserver/
# name: gate
# imagetag: 2022_01_12 

# 输出
spec:
  containers:
  - image: nbserver/gate:2022_01_12 

复杂结构展开

resources: $(resources) 配置,意味着我们的工具需要支持 yaml 的动态展开和格式校验,而这一点是难点,展开后参考如下:

# 读取模板前设置好这些值
# resources:
# limits:
#   cpu: 100m
#   memory: 100Mi
# requests:
#   cpu: 50m
#   memory: 20Mi

# 输出
resources:
  limits:
    cpu: 100m
    memory: 100Mi
  requests:
    cpu: 50m
    memory: 20Mi

深度解析

以 Service 为例

apiVersion: v1
kind: Service
metadata:
  name: $(name)-svc
  namespace: $(namespace)
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: external
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: ip
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
spec:
  type: LoadBalancer
  ports:
    - name: tcp-long-connection-port
      port: $(ports.network.port)
      targetPort: $(ports.network.targetport)
      protocol: TCP
  selector:
    app: $(name)

port: $(ports.network.port) 配置,意味着我们的工具需要支持深度不止为1的解析,需要把数组、字典、结构体的层级参数全部读取,并解析,参考如下:

# 读取模板前设置好这些值
# ports:
#   - port: 6000
#     type: normal
#   - port: 6001
#     type: network # 动态解析 type 类型,并提取成索引
#   - port: 6002
#     type: normal

# 输出
port: 6001

生成多份

很多资源对于同一个服务而言,会有多个,以 HPA 设置为例:

apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  name: $(name)-hpa-$(hpa.#.type)
  namespace: $(namespace)
  labels:
    app: $(name)-hpa-$(hpa.#.type)
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: $(name)
  minReplicas: $(hpa.#.minreplicas)
  maxReplicas: $(hpa.#.maxreplicas)
  metrics:
    - type: Resource
      resource:
        name: $(hpa.#.type)
        targetAverageUtilization: $(hpa.#.threshold)

minReplicas: $(hpa.#.minreplicas) 配置,意味着我们的工具需要支持对一个服务生成多个 hpa 设置,以 # 代表遍历数组;同时,单次遍历中的 # 代表同一个数组下标(index),参考如下:

# 读取模板前设置好这些值
# name: login
# namespace: nbserver
# hpa:
#   - minreplicas: 2
#     maxreplicas: 5
#     type: cpu
#     threshold: 80
#   - minreplicas: 2
#     maxreplicas: 5
#     type: memory
#     threshold: 80

# 输出
apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  labels:
    app: login-hpa-cpu
  name: login-hpa-cpu
  namespace: nbserver
spec:
  maxReplicas: 5
  metrics:
  - resource:
      name: cpu
      targetAverageUtilization: 80
    type: Resource
  minReplicas: 2
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: login

---

apiVersion: autoscaling/v2beta1
kind: HorizontalPodAutoscaler
metadata:
  labels:
    app: login-hpa-memory
  name: login-hpa-memory
  namespace: nbserver
spec:
  maxReplicas: 5
  metrics:
  - resource:
      name: memory
      targetAverageUtilization: 80
    type: Resource
  minReplicas: 2
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: login

数组设置的内容足够多,则可以一次性批量生成所有需求的资源。

指定配置值

至此,我们书写好了所有要用到的资源模板。下一步,我们需要编写配置文件。这样我们能够通过配置文件的读取,把配置值依次读取到内存中,从而改写 $(PARAM_NAME) 为实际值。同时也可配置模板文件和输出文件的路径。

由于本身需要输出 yaml,离不开对 yaml 的解析,故配置文件选用 yaml 格式。

配置文件

配置文件参考

outputpath: output/
templatepath: template/
default: # 默认配置,所有服务共享
  namespace: nbserver
  replicas: 1
  imageprefix: "nbserver/"
  imagetag: "2022_01_12"
  resources:
    requests:
      cpu: 20m
      memory: 20Mi
    limits:
      cpu: 100m
      memory: 100Mi
services: # 服务配置的特殊值,会覆盖默认配置
  - name: login # login 服务配置
    replicas: 2
    ports:
      - port: 8800
        type: application
    hpa:
      - minreplicas: 2
        maxreplicas: 5
        type: cpu
        threshold: 80
      - minreplicas: 2
        maxreplicas: 5
        type: memory
        threshold: 80
  - name: gm # gm 服务配置
    ports:
      - port: 6300
        type: network
      - port: 8000
  - name: account # account 服务配置
    ports:
      - port: 8086
......

上述配置了三个服务:login、gm、account。其中 login 有一个 Ingress (端口类型为 application),有两个 hpa 设置。

配置支持

配置文件结构体由 internal/config/config.go 编写:

package config

type PortConfig struct {
	Port       int
	Type       string
	TargetPort int
}

type ResourceConfig struct {
	CPU    string
	Memory string
}

type HPAConfig struct {
	MinReplicas int
	MaxReplicas int
	Type        string
	Threshold   int
}

type ContentConfig struct {
	Name        string
	Namespace   string
	Replicas    int
	Labels      map[string]string
	ImagePrefix string
	ImageTag    string
	Ports       []PortConfig
	Resources   map[string]ResourceConfig
	HPA         []HPAConfig
}

type Config struct {
	OutputPath   string
	TemplatePath string
	Services     []ContentConfig
	Default      ContentConfig
}

internal/config/tools.go 负责将 yaml 文件解析到内存中的结构体中,使用 “gopkg.in/yaml.v2” 库进行 yaml 格式解析。

import (
	"gopkg.in/yaml.v2"
	"io/ioutil"
)

ic := Config{}

// 读取文件
content, err := ioutil.ReadFile(path)
if err != nil {
	// handle error
}

// 将文件内容解析到结构体
err = yaml.Unmarshal(content, &ic)
if err != nil {
	// handle error
}

......

// 将模板值替换后,反解析为 []byte
b, err := yaml.Marshal(content)
if err != nil {
	// handle error
}
// 写入 output 文件
_, err = file.Write(bytes)
if err != nil {
	// handle error
}

将占位符替换为真实值

至此,我们书写好了所有要用到的资源模板,并把配置读取到了内存中,以结构体存储。下一步,我们需要将模板中的占位符,替换为正确的真实值。以下代码在 internal/logic/ 目录下

单一填值

直接使用 strings.ReplaceAll(...) 即可,参考代码如下:

// 替换占位符
func replaceMark(str string, c map[string]interface{}) interface{} {
	for {
		// 定位占位符
		indexStart := strings.Index(str, MarkTemplateStart)
		indexEnd := strings.Index(str, MarkTemplateEnd)
		if indexStart < 0 || indexEnd < 0 {
			return str
		}

		// 判断是否在配置列表里
		if value, ok := c[dictKey]; ok {
			vType := reflect.TypeOf(value).Kind()
			switch vType {
			case reflect.String:
				str = strings.Replace(str, str[indexStart:indexEnd+1], value.(string), 1)
				break
			// 其他复杂类型
			...
			}
		} else {
			// 不存在,直接去除无效标记
			str = strings.Replace(str, str[indexStart:indexEnd+1], "", 1)
		}
	}
}

复杂结构展开

接上文代码:

// 替换占位符
func replaceMark(str string, c map[string]interface{}) interface{} {
	for {
		// 定位占位符
		...

		// 判断是否在配置列表里
		if value, ok := c[dictKey]; ok {
			vType := reflect.TypeOf(value).Kind()
			switch vType {
			case reflect.String:
				str = strings.Replace(str, str[indexStart:indexEnd+1], value.(string), 1)
				break
			default:
				// 其他值无法拼接,直接返回原值,输出的时候根据 yaml 格式 Unmarshal,即复杂结构展开
				return value
			}
		} else {
			// 不存在,直接去除无效标记
			str = strings.Replace(str, str[indexStart:indexEnd+1], "", 1)
		}
	}
}

深度解析

配置支持深度解析的模式,所以我们需要把嵌套数组、字典、结构体的复杂格式,拉成一个扩展的字典存储,参考代码如下:

// 将 map 的深度打散,格式为 key1.key2;
// 数组的key为0、1、2...
// 保留关键字 key, key 会单独索引
keyMap := make(map[string]interface{})
c := make(map[interface{}]interface{})

// 将配置结构体输出为 map[interface{}]interface{},存储入c
...

// 重新生成需要的字典
regenerateMap(keyMap, c, "")

// 类似于深搜 DFS。复杂结构加深深度,继续搜索
func regenerateMap(result map[string]interface{}, item interface{}, k string) {
	// 反射判断值类型
	vTypeKind := reflect.TypeOf(item).Kind()
	switch vTypeKind {
	// 复杂类型 Map,加深深度继续搜索
	case reflect.Map:
		valueMap := item.(map[interface{}]interface{})
		for key, value := range valueMap {
			var newK string
			if k == "" {
				newK = key.(string)
			} else {
				newK = k + KeySplit + key.(string)
			}
			result[newK] = value
			// 递归
			regenerateMap(result, value, newK)
		}
		break
	// 复杂类型 Slice,加深深度继续搜索
	case reflect.Slice:
		valueSlice := item.([]interface{})
		for key, value := range valueSlice {
			var newK string
			if k == "" {
				newK = strconv.Itoa(key)
			} else {
				newK = k + KeySplit + strconv.Itoa(key)
			}
			result[newK] = value
			// 递归
			regenerateMap(result, value, newK)

			// type 特殊处理成 origin.{TYPE_NAME}.xxx
			if reflect.TypeOf(value).Kind() == reflect.Map {
				valueMap := value.(map[interface{}]interface{})
				if valueItem, ok := valueMap["type"]; ok {
					s := valueItem.(string)
					if s != "" {
						newK = k + KeySplit + s
						result[newK] = value
						regenerateMap(result, value, newK)
					}
				}
			}
		}
		break
	}
	// 其他都是简单类型,不需要处理直接退出
}

生成多份

配置支持一份配置生成多份资源,例如 HPA,参考代码如下:

globalIndex := 0
valueSlice := value.([]interface{})
for hpaIndex := range valueSlice {
	// 记录下当前生成的数组下标,从 0 开始,0、1、2 ...
	globalIndex = hpaIndex
	tc.content = getContent(tc.templatePath, TemplateName)
	// 处理生成逻辑
	...
}

func replaceMark(str string, c map[string]interface{}) interface{} {
	for {
		indexStart := strings.Index(str, MarkTemplateStart)
		indexEnd := strings.Index(str, MarkTemplateEnd)
		if indexStart < 0 || indexEnd < 0 {
			return str
		}

		// 替换 $(xx.#.yy) 中的 # 为数组下标
		dictKey := strings.ReplaceAll(str[indexStart+2:indexEnd], "#", strconv.Itoa(globalIndex))
		
		// 按照 dictKey 进行替换
		...
	}
}

文件输出

至此,内存中已经生成好了 k8s yaml 的所有内容,最后一步是生成系统文件,参考代码如下:

// mkdir 没有则创建文件夹,有则清除所有文件
mkdir(tc.outputPath)

// 获取配置中的文件名
OutputSuffix := "-kube.yaml"
name, ok := c["name"]
if !ok {
	// handle error
}
file, err := os.OpenFile(fmt.Sprintf("%s%s%s", tc.outputPath, name, OutputSuffix), os.O_RDWR|os.O_CREATE, 0766)
if err != nil {
	// handle error
}
defer file.Close()

// 上文中的方法,生成 content 在内存中
...

// 将结构体反解析为 []byte
bytes, err := config.Marshal(&content)
if err != nil {
	// handle error
}
// 写入文件
_, err = file.Write(bytes)
if err != nil {
	// handle error
}

总结

以上就是使用 go 语言,编写批量生成 k8s yaml 的工具的内容。其中运用了 yaml 的解析、reflect 反射以及一些占位符使用的技巧,欢迎对本文中分享的实现内容进一步探讨。

TODO:把工具做的更通用化,支持更多的配置项和模板

你可能感兴趣的:(kubernetes,游戏后端,golang,k8s,yaml,devops,开发工具)