使用 Helm 管理 K8s 集群中的应用

✨ Helm 是一个 Kubernetes 的包管理工具,通过使用 Charts 描述 Kubernetes 资源的集合来管理和部署应用程序。Helm 3 相对于 Helm 2 提供了更好的安全性、稳定性和易用性。一个典型的 Chart 包含了部署一个应用所需的所有配置、依赖关系和相关资源。我们可以通过修改 Chart 中的配置文件和模板文件来自定义部署应用程序的方式。使用 Helm,我们可以轻松地管理和部署复杂的应用程序,并减少人工操作的错误和遗漏。我们可以通过编写脚本,从现有的 Kubernetes Deployment 配置中提取相应配置,并将其转换为 Helm Values 文件,从而实现自动化管理和部署。

在当前一个项目中,早期交付时的人员使用了 Rancher 的 Web 页面纯手工地逐个部署了整个系统的微服务。这导致之后每次版本更新都需要先从主分支拉取代码并进行编译构建镜像,然后去到 Rancher 设置新版本的镜像标签、配置环境变量等才能完成发布。也就是说这种方式仅仅是通过平台完成了镜像构建,而后续的操作仍然依赖人工进行。

但这样每次都得人工操作配置,难免会出纰漏。于是我提出要改进,小伙伴却又大多觉得发版时这样手动操作一下也没啥 ——“又不是不能用”.jpg 。

加上其他工作的压力,我只能将这个想法放在一边。但我一直惦记着,可能是因为有一点强迫症:对我来说,所有需要重复人工操作的情景,都应该自动化,无论是通过脚本还是其他方案。

对于 Kubernetes 集群的部署场景,主流的解决方案自然是 Helm 和 Kustomize。考虑到现有平台中有 Helm(但是仅支持 Helm 2版本,我真想去帮他们改进一下这个基础设施建设,太拉了),于是就朝着 Helm 的方式进行改造。

Helm

Helm 是一个 Kubernetes 的包管理工具,借助它我们可以更轻松地管理和部署应用程序。

Helm 使用 Charts 来描述 Kubernetes 资源的集合。一个 Chart 包含了部署一个应用所需的所有配置、依赖关系和相关资源。

主要有两个版本:Helm 2 和 Helm 3

版本差异
  • 配置存储方式

    在 Helm 2 中,配置信息存储在 Kubernetes 集群中的 ConfigMap 中,而在 Helm 3 中,配置信息存储在 Secret 中。这种改变带来了更好的安全性,因为 Secrets 可以更好地保护敏感信息,如密码和密钥。

  • Tiller 的移除

    Helm 2 需要使用 Tiller 来管理和协调 Chart 的部署。然而,Tiller 存在一些安全风险,因此 Helm 3 移除了对 Tiller 的依赖。在 Helm 3 中,所有的操作都直接与 Kubernetes API 交互,提高了安全性和稳定性。

  • Chart 仓库

    在 Helm 2 中,Chart 仓库的索引信息存储在集群中的 ConfigMap 中。而在 Helm 3 中,Chart 仓库的索引信息存储在本地计算机上,而不是存储在集群中。这使得 Chart 仓库的管理更加灵活,并且不会影响到集群的状态。

  • 命名空间隔离

    Helm 3 引入了命名空间隔离的概念,每个 Release(一个部署的实例)都与特定的命名空间相关联。这提供了更好的资源管理和隔离,使得在同一个集群中部署多个实例更加容易。

  • 命令行工具的改进

    Helm 3 对命令行工具进行了改进,使其更加直观和易用。一些命令的参数名称和行为也发生了变化,以提供更好的一致性和易读性。

综合来看,Helm 3 相对于 Helm 2 提供了更好的安全性、稳定性和易用性,但此处受限于基础设施条件,只得使用 Helm 2。

Chart

Chart 是 Helm 的核心概念之一,它是一个预定义的模板,用于描述如何部署一个应用程序到 Kubernetes。Chart 由多个文件组成,包括配置文件、模板文件和其他资源文件。

一个典型的 Chart 包含以下文件和目录:

  • Chart.yaml:Chart 的元数据,包括版本、名称、描述等信息。
  • values.yaml:默认的配置值,用于指定部署应用程序时的参数。
  • templates/:包含了用于生成 Kubernetes 资源的模板文件。
  • charts/:用于存放依赖的子 Chart。

通过修改 Chart 中的配置文件和模板文件,我们可以自定义部署应用程序的方式。比如,可以修改容器的副本数、暴露的端口号,或者添加其他 Kubernetes 资源,如 Service、Ingress 等。

Chart 的结构和文件配置提供了一种灵活且可复用的方式来管理 Kubernetes 应用程序的部署。通过使用 Helm,我们可以轻松地管理和部署复杂的应用程序,并且减少人工操作带来的错误和遗漏。

以上是对 Helm 的基础介绍,可见要将现有运行着的应用改造为 Helm 管理,先确定好模板,然后再配置 values.yaml 文件即可。

# 创建chart
helm create mychart

# 编辑模板文件
vi templates/deployment.yaml
vi templates/service.yaml
vi templates/ingress.yaml

# 编辑 Chart 配置
vi Chart.yaml
vi values.yaml

# 打包 Chart, 生成一个名为 mychart-<version>.tgz 的打包文件
helm package mychart

# 部署 Chart
helm install myrelease mychart-<version>.tgz

但是系统的微服务少说都是几十个,一个个对照现有配置修改 values.yaml 也太麻烦了,纯复制粘贴的体力活。这种同一模式下的重复操作那自然就得把他自动化,如下 go 脚本,通过与 K8s 集群的API Server 交互得到 各个应用的现有配置,然后再将其解析转换为 values.yaml 所要求的格式。

ℹ️
因模板文件有针对性修改调整,故此脚本生成的 values.yaml 并不适用于 helm 命令所创建的标准Chart
package main

import (
	"context"
	"errors"
	"fmt"
	"gopkg.in/yaml.v3"
	"io"
	"io/ioutil"
	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/util/intstr"
	"k8s.io/client-go/kubernetes"
	"k8s.io/client-go/rest"
	"k8s.io/client-go/tools/clientcmd"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
)

type HelmValues struct {
	Name        string      `yaml:"name"`
	Deployment  Deployment  `yaml:"deployment"`
	Env         Env         `yaml:"env"`
	EnvFrom     EnvFrom     `yaml:"envFrom"`
	Resources   Resources   `yaml:"resources"`
	HealthCheck HealthCheck `yaml:"healthCheck"`
	Service     Service     `yaml:"service"`
}

type Deployment struct {
	Repository   string  `yaml:"repository"`
	Image        string  `yaml:"image"`
	Tag          string  `yaml:"tag"`
	ReplicaCount int32   `yaml:"replicaCount"`
	BatchSize    string  `yaml:"batchSize"`
	Storage      Storage `yaml:"storage"`
}

type Storage struct {
	Enabled  bool              `yaml:"enabled"`
	PathMaps map[string]string `yaml:"pathMaps"`
}

type Env struct {
	Enabled bool              `yaml:"enabled"`
	Items   map[string]string `yaml:"items"`
}

type EnvFrom struct {
	Enabled       bool     `yaml:"enabled"`
	SecretRefs    []string `yaml:"secretRefs"`
	ConfigMapRefs []string `yaml:"configMapRefs"`
}

type Resources struct {
	Requests ResMetric `yaml:"requests"`
	Limits   ResMetric `yaml:"limits"`
}

type ResMetric struct {
	Memory string `yaml:"memory"`
	CPU    string `yaml:"cpu"`
}

type HealthCheck struct {
	LivenessProbe  Probe `yaml:"livenessProbe"`
	ReadinessProbe Probe `yaml:"readinessProbe"`
}

type Probe struct {
	InitialDelaySeconds int     `yaml:"initialDelaySeconds"`
	FailureThreshold    int     `yaml:"failureThreshold"`
	PeriodSeconds       int     `yaml:"periodSeconds"`
	SuccessThreshold    int     `yaml:"successThreshold"`
	TimeoutSeconds      int     `yaml:"timeoutSeconds"`
	HTTPGet             HTTPGet `yaml:"httpGet"`
}

type HTTPGet struct {
	Path   string `yaml:"path"`
	Port   int    `yaml:"port"`
	Scheme string `yaml:"scheme"`
}

type Service struct {
	Enabled    bool   `yaml:"enabled"`
	Type       string `yaml:"type"`
	Port       int    `yaml:"port"`
	TargetPort int    `yaml:"targetPort"`
}

type HelmChart struct {
	APIVersion  string `yaml:"apiVersion"`
	AppVersion  string `yaml:"appVersion"`
	Description string `yaml:"description"`
	Name        string `yaml:"name"`
	Version     string `yaml:"version"`
}

const env = "prod"
// 配置文件名指定
var envConfig = fmt.Sprintf("config-%s", env)
// 命名空间
var namespace = fmt.Sprintf("ns-%s", env)
// 私有仓库地址,用于拼接镜像地址
var repository = "registry.example.com/private/"

func main() {
	// 调用K8s API获取deployment
	deploymentList := getDeploymentFromK8s(namespace, "")
	if deploymentList == nil {
		fmt.Println("Empty")
		return
	}
	for _, deployment := range deploymentList {
		flag := transformDeploymentToValue(deployment)
		if !flag {
			fmt.Println("Invalid deployment", deployment.Name)
			continue
		}
	}
}

func transformDeploymentToValue(deployment appsv1.Deployment) bool {
	values := constructParams(deployment)
	// 将 values 写入到 Helm chart 的 values.yaml 文件中
	valuesYaml, err := yaml.Marshal(values)
	if err != nil {
		fmt.Println(err)
		return false
	}
	dirPath := filepath.Join("charts", values.Name, strings.Split(namespace, "-")[1], values.Name)
	err = os.MkdirAll(dirPath, 0755)
	if err != nil {
		fmt.Println(err)
		return false
	}
	err = ioutil.WriteFile(filepath.Join(dirPath, "values.yaml"), valuesYaml, 0644)
	if err != nil {
		fmt.Println(err)
		return false
	}
	chart := HelmChart{
		APIVersion:  "v1",
		AppVersion:  "1.0",
		Description: "A Helm chart for Kubernetes",
		Name:        values.Name,
		Version:     "1.0.0",
	}
	chartYaml, err := yaml.Marshal(chart)
	if err != nil {
		fmt.Println(err)
		return false
	}
	err = ioutil.WriteFile(filepath.Join(dirPath, "Chart.yaml"), chartYaml, 0644)
	if err != nil {
		fmt.Println(err)
		return false
	}
	fmt.Println("helm chart generated successfully!")
	err = CopyDir(filepath.Join(os.Getenv("HOME"), "/helm/templates"), filepath.Join(dirPath, "templates"))
	if err != nil {
		fmt.Println(err)
		return false
	}
	return true
}

func constructParams(deployment appsv1.Deployment) HelmValues {
	name := deployment.ObjectMeta.Name
	fmt.Println("正在处理 Deployment: ", name)
	replicas := deployment.Spec.Replicas
	volumes := deployment.Spec.Template.Spec.Volumes
	volumeMounts := deployment.Spec.Template.Spec.Containers[0].VolumeMounts
	port := deployment.Spec.Template.Spec.Containers[0].Ports[0].ContainerPort
	env := deployment.Spec.Template.Spec.Containers[0].Env
	envFrom := deployment.Spec.Template.Spec.Containers[0].EnvFrom
	resources := deployment.Spec.Template.Spec.Containers[0].Resources
	livenessProbe := deployment.Spec.Template.Spec.Containers[0].LivenessProbe
	readinessProbe := deployment.Spec.Template.Spec.Containers[0].ReadinessProbe

	var helmStorage Storage
	if len(volumes) > 0 && len(volumeMounts) > 0 {
		pathMap := make(map[string]string)
		pathMap[volumes[0].PersistentVolumeClaim.ClaimName] = volumeMounts[0].MountPath
		helmStorage = Storage{
			Enabled:  true,
			PathMaps: pathMap,
		}
	}
	helmDeploy := Deployment{
		Repository:   repository,
		Image:        name,
		ReplicaCount: *replicas,
		BatchSize:    "75%",
		Storage:      helmStorage,
	}
	var helmEnv Env
	if len(env) > 0 {
		envVars := make(map[string]string)
		for _, e := range env {
			envVars[e.Name] = e.Value
		}
		helmEnv = Env{
			Enabled: true,
			Items:   envVars,
		}
	}
	var helmEnvFrom EnvFrom
	if len(envFrom) > 0 {
		var secretRefs []string
		var configMapRefs []string
		for _, envRef := range envFrom {
			if envRef.SecretRef != nil {
				secretRefs = append(secretRefs, envRef.SecretRef.Name)
			}
			if envRef.ConfigMapRef != nil {
				configMapRefs = append(configMapRefs, envRef.ConfigMapRef.Name)
			}
		}
		helmEnvFrom = EnvFrom{
			Enabled:       true,
			SecretRefs:    secretRefs,
			ConfigMapRefs: configMapRefs,
		}
	}
	helmResources := Resources{
		Requests: ResMetric{
			Memory: resources.Requests.Memory().String(),
			CPU:    resources.Requests.Cpu().String(),
		},
		Limits: ResMetric{
			Memory: resources.Limits.Memory().String(),
			CPU:    resources.Limits.Cpu().String(),
		},
	}
	helmLivenessProbe := Probe{
		InitialDelaySeconds: 300,
		FailureThreshold:    3,
		PeriodSeconds:       10,
		SuccessThreshold:    1,
		TimeoutSeconds:      5,
		HTTPGet: HTTPGet{
			Path:   "/actuator/health",
			Port:   getIntOrString(livenessProbe, port),
			Scheme: "HTTP",
		},
	}
	helmReadinessProbe := Probe{
		InitialDelaySeconds: 200,
		FailureThreshold:    3,
		PeriodSeconds:       10,
		SuccessThreshold:    1,
		TimeoutSeconds:      5,
		HTTPGet: HTTPGet{
			Path:   "/actuator/health",
			Port:   getIntOrString(readinessProbe, port),
			Scheme: "HTTP",
		},
	}
	helmHealthCheck := HealthCheck{
		LivenessProbe:  helmLivenessProbe,
		ReadinessProbe: helmReadinessProbe,
	}

	helmService := Service{
		Enabled:    true,
		Type:       "ClusterIP",
		Port:       int(port),
		TargetPort: int(port),
	}

	values := HelmValues{
		Name:        name,
		Deployment:  helmDeploy,
		Env:         helmEnv,
		EnvFrom:     helmEnvFrom,
		Resources:   helmResources,
		HealthCheck: helmHealthCheck,
		Service:     helmService,
	}
	return values
}

func getDeploymentFromK8s(namespaceName string, deploymentName string) []appsv1.Deployment {
	// 读取 k8s kubeConfig
	kubeConfig, err := clientcmd.BuildConfigFromFlags("", filepath.Join(os.Getenv("HOME"), "Desktop/kt/", envConfig))
	if err != nil {
		// 如果在 Kubernetes 集群内部运行,则使用 InClusterConfig()
		kubeConfig, err = rest.InClusterConfig()
		if err != nil {
			panic(err.Error())
		}
	}

	// 创建 Kubernetes client
	clientSet, err := kubernetes.NewForConfig(kubeConfig)
	if err != nil {
		panic(err.Error())
	}
	// 获取 deployment
	if deploymentName == "" {
		deployments, err := clientSet.AppsV1().Deployments(namespaceName).List(context.Background(), metav1.ListOptions{})
		if err != nil {
			panic(err.Error())
		}
		var deploymentList []appsv1.Deployment
		for _, item := range deployments.Items {
      // 只获取 example- 开头的服务信息
			if match, _ := regexp.MatchString("^example-", item.Name); match {
				if strings.Index(item.Name, "-db-") == -1 && strings.Index(item.Name, "tool") == -1 && strings.Index(item.Name, "kibana") == -1 {
					deploymentList = append(deploymentList, item)
				}
			}
		}
		return deploymentList
	} else {
		deployment, err := clientSet.AppsV1().Deployments(namespaceName).Get(context.Background(), deploymentName, metav1.GetOptions{})
		if err != nil {
			panic(err.Error())
		}
		return []appsv1.Deployment{*deployment}
	}
}

func getIntOrString(probe *corev1.Probe, port int32) int {
	if probe == nil {
		return int(port) + 1
	}
	if probe.HTTPGet == nil {
		return 80
	}
	value := probe.HTTPGet.Port
	if value.Type == intstr.Int {
		return int(value.IntVal)
	}
	intVal, err := strconv.Atoi(value.StrVal)
	if err != nil {
		fmt.Println(err)
		return 0
	}
	return intVal
}

func CopyDir(srcPath string, destPath string) error {
	//check if the source directory exists
	if srcInfo, err := os.Stat(srcPath); err != nil {
		fmt.Println(err.Error())
		return err
	} else {
		if !srcInfo.IsDir() {
			e := errors.New("srcPath is not a valid directory!")
			fmt.Println(e.Error())
			return e
		}
	}
	//create the destination directory if it doesn't exist
	if err := os.MkdirAll(destPath, 0755); err != nil {
		fmt.Println(err.Error())
		return err
	}
	//Get a list of all files and folders in the source directory
	files, err := ioutil.ReadDir(srcPath)
	if err != nil {
		fmt.Println(err.Error())
		return err
	}
	//loop through each file/folder in the source directory
	for _, file := range files {
		srcFile := filepath.Join(srcPath, file.Name())
		destFile := filepath.Join(destPath, file.Name())
		//if the current file is a directory, recursively call this function
		if file.IsDir() {
			if err := CopyDir(srcFile, destFile); err != nil {
				fmt.Println(err.Error())
				return err
			}
		} else {
			//if the current file is a regular file, copy it to the destination
			if err := CopyFile(srcFile, destFile); err != nil {
				fmt.Println(err.Error())
				return err
			}
		}
	}
	return nil
}

// CopyFile helper function to copy a single file
func CopyFile(srcPath string, destPath string) error {
	srcFile, err := os.Open(srcPath)
	if err != nil {
		fmt.Println(err.Error())
		return err
	}
	defer srcFile.Close()
	destFile, err := os.Create(destPath)
	if err != nil {
		fmt.Println(err.Error())
		return err
	}
	defer destFile.Close()
	if _, err := io.Copy(destFile, srcFile); err != nil {
		fmt.Println(err.Error())
		return err
	}
	return nil
}

以上脚本完成了从现有 K8s 集群中提取配置转换为 values.yaml , 同时按照服务名,按照 Chart 文件夹格式分别保存。基于此我们可以快速地得到相应的 Helm 配置,之后就是配置 CI/CD 的流水线。

以后版本更新就可以不再需要那么多人工操作了。