游戏项目中的逻辑服务器需要部署在 k8s 中,由于逻辑服按照微服务体系划分(例如:login、gate、gm、account、chat 等),需要多个 k8s yaml 执行 kubectl apply -f *.yaml 完成批量部署。笔者在手写了 login-kube.yaml 、 gate-kube.yaml 后发现了如下痛点:
既然有这些痛点,那我们就着手做一个批量生成工具,只需要维护很少的变量,就能够批量生成所有微服务的 *.yaml 文件,从而批量部署。
工具下载地址
注:根据场景不同,第一次使用工具时需要手动更改 template 目录下的模板文件。如果场景不同,则可能需要改动 Go 相关的代码,才能正常使用
检查配置是否需要更新,可以增加修改删除服务配置,支持设置多个服务间的 共用值(例如,所有服务的镜像前缀相同,镜像tag相同)。单个服务的 特殊值 设置,会覆盖共用值。
在项目根目录下执行
go run main.go # 也可以提前 go build 生成可执行文件
默认在 output/ (可配置) 目录下生成 k8s yaml 文件
我们想要自动生成配置文件,第一步首先是书写模板文件,再通过模板文件自动替换变量达到我们的目的。
以 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:把工具做的更通用化,支持更多的配置项和模板