Helm是一款非常流行的k8s包管理工具。以前就一直想用它,但看到它产生的文件比k8s要复杂许多,就一直犹豫,不知道它的好处能不能抵消掉它的复杂度。但如果不用,而是用Kubectl来进行调式真的很麻烦。正好最近Helm3正式版出来了,比原来的Helm2简单了不少,就决定还是试用一下。结果证明确实很复杂,它的好处和坏处大致相当。有了它确实能大大简化对k8s的调式,但也需要花费比较多的时间来学习,而且产生的配置文件要复杂许多。但是事实是现在没有什么很方便的帮助调式k8s的工具,在没有更好的方案之前,我还是建议用它,只是前期需要花些功夫学习和掌握它。
Helm3和Helm2的语法差不太多,只是使用起来更方便,不用安装Tiller。一个比较明显的变化是不再需要“requirements.yaml”, 依赖关系是直接在“chart.yaml”中定义。有关Helm3和Helm2的区别,详情请参见 CHANGES SINCE HELM 2 。
网上有不少讲述Helm的文章,但大部分都是主要讲解安装和举一个简单的例子。但Helm使用起来还是比较复杂的,一定要有一个复杂的例子才能把它的功能讲清楚,里面有不少设计方面的问题需要思考。我刚开始接触的时候就觉得头绪繁多,不知从哪下手。本文就通过一个相对复杂的例子来讲解用Helm3来设计配置文件的思路,使上手更容易。
这里不讲Helm3的安装,它比较很容易。也不讲解Helm的基本语法,你可以自己去看其他文档。即使你不懂Helm,应该也能猜出七八成,剩下的就要读文档了( Charts )。Helm的语法还是比较复杂的,要想搞懂可能要花一两天时间。
本文假设你对helm有一个大概的了解,想要构建一个复杂的微服务,但有不知如何下手;或者你想了解一下构建Helm的最佳实践,那就请你继续读下去。
chart里一个很重要的概念就是模板(template),它就是Go语言模板,它是里面加入了编程逻辑的k8s文件。这些模板文件在使用时都要先进行模板解析,把其中的程序逻辑转化成对应的编码,最终生成k8s配置文件。
以上就是Helm自动生成的chart目录结构,在Helm里每个项目叫一个chart,它由下面几个组成部分:
Helm有四个基本元素,值,常量,变量和共享常量(这个后面会讲)
Helm在k8s的基础之上增加了模板功能,使k8s的配置文件更加灵活。里面的主要概念就是模板(Template),也就是在k8s的配置文件里增加了常量和变量以及编程逻辑。如果你不用这些新增功能,那么就是普通的YAML文件(k8s配置文件),里面用到的基本元素就是值。
如果你想复用重复的值,能把它定义成常量吗?YAML有一个功能叫节点定位(Node Anchor),类似于定义一个常量,然后引用。但它有一些限制,定义的必须是一个节点,因此不如真正的常量灵活。
例如如下文件中,用“&”定义了一个常量“&k8sdemoDatabaseService”,然后用“*k8sdemoDatabaseService”引用它。
global: k8sdemoDatabaseService: &k8sdemoDatabaseService k8sdemo-database-service mysqlHost: *k8sdemoDatabaseService
这时,“k8sdemoDatabaseService:”是YAML文件节点的键名,“&k8sdemoDatabaseService”是节点定位的名字,相当于常量名,“k8sdemo-database-service”是YAML节点的键值。在上述代码中,k8sdemoDatabaseService和mysqlHost的值都是“k8sdemo-database-service”。
有关节点定位(Node Anchor)的详细内容,请参见 YAML 。
由于节点定位的局限性,Helm引入了真正的常量,也就是在"values.yaml"里定义的内容,它可以定义是任何东西,不只限于节点。
在"values.yaml"里定义常量:
replicaCount: 1
在部署模板里引用:
replicas: {{ .Values.replicaCount }}
那么什么时候用常量,什么时候用值(Literal)呢?如果一个值在模板中出现多次,就要定义常量,避免重复。例如“accessModes”,既要在存储卷里出现,又要在存储卷申请里出现。另外如果值有可能变化(不论是随部署环境变化,还是随时间变化),那么就定义成常量,这样在修改时就只用改"values.yaml",而不必修改模板文件。例如“replicas”的值(也就是集群的个数)是可能变化的,就要定义成常量。在模板里可以引用常量的,但在"values.yaml"里不行,因为它只是普通YAML文件,没有模板解析功能,因此不支持常量,这里就只能用节点定位(来代替常量)。
有关Helm常量的详细内容,请参见 Use placeholders in yaml 和 Use YAML with variables 。
节点定位的功能是有限的,例如你想利用已有的节点定位,对它进行转换,定义一个新的节点定位,这在"values.yaml"里就不行了。
例如你已有节点定位“name”,你想在这个基础上定义一个新的节点定位“serviceName”,这个"values.yaml"就不支持了,你必须要用模板。
如下所示,这在"values.yaml"里是不支持的。
name: &name k8sdemo-backend serviceName:*name-service
这就引出了变量的概念,但它只能在模板里才行。 换句话说,模板既支持常量,也支持变量。但如果把变量的定义逻辑放在Helm每个模板里,就显得很乱。因此一般的做法是把这些逻辑放在一个单独的模板文件里,这个就是前面讲到的"_helpers.tpl"文件。当你需要对常量进行转换,生成新的常量,你就在定义变量,这部分代码就放在"_helpers.tpl"里。
下面就是"_helpers.tpl"中定义"k8sdemo.name"的代码。
{{- define "k8sdemo.name" -}} {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" -}} {{- end -}}
在以上这些元素中,常量(也就是在"values.yaml"中定义的)是最灵活的,能用它时尽量用它。而且因为它是定义在普通YAML文件中("values.yaml"),应用程序可以直接访问它,这样可以实现应用程序和k8s之间的数据共享。但如果你需要对常量进行编程转换,那就没办法了,只能定义变量,把它放在"_helpers.tpl"中。
在k8s中ConfigMap和Secret是用来存储共享配置参数和保密参数的,但在Helm中,由于有了上面讲的Helm基本元素,它们完全可以代替ConfigMap的功能,因此ConfigMap就不需要了,但Secret还是需要的,因为要存储加密信息。下面会讲解。
有关ConfigMap的设计局限性,请参见 把应用程序迁移到k8s需要修改什么?
现在我们就用一个具体的例子来展示Helm的chart设计。这个例子是一个微服务应用程序,它共有三层: 前端,后端和数据库,只有这样才能让Helm的一些设计问题付出水面,如果只有一层的话,就太简单了,没有参考价值。
在k8s中,每一层就是一个单独的服务,它里面有各种配置文件。Helm的优势是把这些不同的服务组成一个Chart来共同管理和调式,方便了许多。
上面就是最终的chart目录结构图。“chart”是总目录,里面有三个子目录“k8sdemo”,“k8sdemo-backend”,“k8sdemo-database”, 每一个对应一个服务,每个服务都是一个独立的chart,能单独调式部署,chart之间也可以有依赖关系。其中“k8sdemo”是父chart,同时也是前端服务,它的“charts”目录里有它依赖的另外两个服务。“k8sdemo-backend”是后端服务,“k8sdemo-database”是数据库服务。
处理Chart的依赖关系有两种方式:
这里采用的是依赖导入式方式,主要原因是我原来认为嵌入式需要一起调试,复杂度太高,如果你觉得这不是问题,这也是个不错的办法。用依赖导入式方式,可以单独调试各个chart,简单了很多。后来发现其实采用嵌入式也可以单独调试子chart,只是父chart不能单独调试而已。
当你采用依赖导入式方式时,调试顺序关系不大,因为各个chart是各自独立的,可以单独调试。举个例子,虽然“k8sdemo-backend”需要“k8sdemo-database”才能正常运行,但当没有数据库服务时,你的程序也可以运行,只不过输出的是错误信息,但这并不影响你调试chart。
我先调试“k8sdemo”,它虽然依赖另外两个chart,但没有它们也能单独工作。然后再调试“k8sdemo-backend”和“k8sdemo-database”,最后再把它们导入到“k8sdemo”中去再进行联调。
它的调试是最容易的,由于它里面没有真正的前端代码,只要把Nginx调试成功了就可以了。只要在生成的文件基础上做些修改就行了。
键入如下命令创建chart,其中“k8sdemo”是chart的名字,这个名字很重要,服务的名字和label都是由它产生的。
helm create k8sdemo
这之后,系统会自动创建前面讲到的chart目录结构。让后就是对已经生成的文件进行修改。
修改"values.yaml":
以下是"values.yaml"主要修改的地方
image: repository: nginx:1.17.6 pullPolicy: Never imagePullSecrets: [] nameOverride: "k8sdemo" fullnameOverride: "k8sdemo" service: type: NodePort port: 80 nodePort: 31080
另外,由于"ingress.yaml"和"serviceaccount.yaml"暂时没用,就把它们都设成了“false”
ingress: enabled: false serviceAccount: # Specifies whether a service account should be created create: false
修改"service.yaml":
apiVersion: v1 kind: Service metadata: name: {{ include "k8sdemo.fullname" . }} labels: {{- include "k8sdemo.labels" . | nindent 4 }} spec: type: {{.Values.service.type}} ports: - port: {{.Values.service.port}} nodePort: {{.Values.service.nodePort}} targetPort: http protocol: TCP name: http selector: {{- include "k8sdemo.selectorLabels" . | nindent 4 }}
修改"deployment.yaml":
。。。 containers: - name: {{ .Chart.Name }} securityContext: {{- toYaml .Values.securityContext | nindent 12 }} image: {{ .Values.image.repository }} imagePullPolicy: {{ .Values.image.pullPolicy }} 。。。
以上都是简单的修改,不涉及到设计问题。由于篇幅的关系,这里没有列出全部源码,如果有兴趣请在本文末尾找到源码地址。
在进行下面的调试之前,先要讲一个重要概念。 前面介绍Helm的基本元素时讲的都是在一个chart里共享值,如果要在不同chart之间共享值(例如k8s服务名,数据库用户名和端口),那么这些还不够,你需要共享常量. 通常情况下子chart和父chart之间的常量是不能共享的,如果需要共享,需要有一种特殊的方法来定义常量,这就是共享常量。它必须是定义在父chart中。
例如,你在“k8sdemo”的“values.yaml”加入下面代码,注意节点的名字必须是子chart名(例如“k8sdemo-backend”)
k8sdemo-backend: replicaCount: 2 k8sdemo-database: replicaCount: 2
在“k8sdemo”的模板里就可以通过“{{ .Values.k8sdemo-backend.replicaCount }}” 来访问。当Helm发现节点名是子chart名时,它会自动拷贝这个常量到子chart的“values.yaml”中,因此,在“k8sdemo-backend”中,你也可以通过“{{ .Values.replicaCount }}” 来访问这个常量。注意这里并没有包含子chart名(“k8sdemo-backend”),而是只有常量名,因为子chart名只是一个标识,而不是常量名的一部分。
共享常量只能把常量共享给一个字chart,如果你需要多个子chart之间共享,就需要创建全局常量,它用“global”来标识,下面是示例。
在“k8sdemo-backend”的"values.yaml"中定义:
global: k8sdemoDatabaseService: &k8sdemoDatabaseService k8sdemo-database-service mysqlUserName: dbuser mysqlUserPassword: dbuser mysqlPort: 3306 mysqlHost: *k8sdemoDatabaseService mysqlDatabase: service_config
在“k8sdemo-backend”的“deployment.yaml”中引用。
env: - name: MYSQL_USER_NAME value: {{ .Values.global.mysqlUserName }} - name: MYSQL_USER_PASSWORD value: {{ .Values.global.mysqlUserPassword }} - name: MYSQL_HOST value: {{ .Values.global.mysqlHost }} - name: MYSQL_PORT value: "{{ .Values.global.mysqlPort }}" - name: MYSQL_DATABASE value: {{ .Values.global.mysqlDatabase }}
在“k8sdemo-database”的"values.yaml"中定义:
global: k8sdemoDatabaseService: k8sdemo-database-service mysqlUserName: dbuser mysqlUserPassword: dbuser mysqlRootPassword: root mysqlDatabase: service_config
在“k8sdemo-database”的“deployment.yaml”中引用。
env: - name: MYSQL_ROOT_PASSWORD value: {{ .Values.global.mysqlRootPassword }} - name: MYSQL_USER_NAME value: {{ .Values.global.mysqlUserName }} - name: MYSQL_USER_PASSWORD value: {{ .Values.global.mysqlUserPassword }} - name: MYSQL_DATABASE value: {{ .Values.global.mysqlDatabase }}
当把“k8sdemo-backend”和“k8sdemo-database”导入"k8sdemo"后进行联调时, 就要把上面提到的全局常量写入"k8sdemo"的"values.yaml"文件中,这样就能让各个子chart共享这些常量。如下所示:
global: k8sdemoBackendService: k8sdemo-backend-service k8sdemoDatabaseService: &k8sdemoDatabaseService k8sdemo-database-service mysqlUserName: dbuser mysqlUserPassword: dbuser mysqlRootPassword: root mysqlPort: 3306 mysqlHost: *k8sdemoDatabaseService mysqlDatabase: service_config
如果父chart和子chart有重复的全局常量,这时父chart("k8sdemo")的全局常量值就会覆盖子chart的全局常量。
它的使用原则就是如果只是子chart独有的常量就在子chart的"values.yaml"中定义,如果是共享的常量就在父chart中定义。但如果采用的是依赖导入方式,由于子chart也要单独调试,这时你在子chart里也要定义这些全局常量。这样在进行chart总调试时,就会使用父chart的中的值。
详情请参见 Subcharts and Global Values 。
“k8sdemo-backend”的chart需要取(与“k8ssdemo”)不同的名字,
创建:
helm create k8sdemo-backend
上面就是“k8sdemo-backend”的目录图。由于它需要建持久卷,因此这里增加了两个文件“persistentvolume.yaml”和“persistentvolumeclaim.yaml” ( 不是自动生成的)。
值得一提的是k8s对象的命名。一般情况下,如果不需要对其进行引用,用chart的全名就行了。例如部署的名称,如下所示。
name: {{ include "k8sdemo.fullname" . }}
如果是服务名(Service Name),它需要在应用程序和k8s之间共享,也需要在父chart和子chart之间共享,这时最好单独定义一个全局共享常量。
在“values.yaml”中定义:
global: k8sdemoBackendService: k8sdemo-backend-service
在“service.yaml”中引用:
name: {{.Values.global.k8sdemoBackendService}}
它的调试方式与“k8sdemo-backend”大同小异,就不详细讲解了。
上面各个chart都单独调试成功之后,就要把它们合在一起进行联合调试。
在“k8sdemo”(父chart)中加入依赖关系(Chart.yaml)。
dependencies: - name: k8sdemo-backend repository: file://../k8sdemo-backend version: 0.1.0 - name: k8sdemo-database repository: file://../k8sdemo-database version: 0.1.0
这里为了简单起见,没有用到chart库(Chart Repository),使用了本地目录。这里的“file://”是针对chart的根的相对路径,“file://..”就是“k8sdemo”的上级目录。
详情请参见 How to refer to a helm chart in the same repository 。
修改全局常量("values.yaml"):
global: k8sdemoBackendService: k8sdemo-backend-service k8sdemoDatabaseService: &k8sdemoDatabaseService k8sdemo-database-service mysqlUserName: dbuser mysqlUserPassword: dbuser mysqlRootPassword: root mysqlPort: 3306 mysqlHost: *k8sdemoDatabaseService mysqlDatabase: service_config
只有需要在chart之间共享的常量才需要在父chart里的"values.yaml"定义,其余的在各自子chart里的"values.yaml"定义就可以了。
键入如下命令“helm dependency update k8sdemo”,更新依赖关系
~ # vagrant@ubuntu-xenial:~/jfeng45/k8sdemo/script/kubernetes/chart$ helm dependency update k8sdemo Hang tight while we grab the latest from your chart repositories... ...Successfully got an update from the "stable" chart repository Update Complete. ⎈Happy Helming!⎈ Saving 2 charts Deleting outdated charts
完成之后,生成的图如下所示,这时在“charts”目录下就导入了新的依赖关系“k8sdemo-backend”和“k8sdemo-database”的chart。
有一点需要注意的是,单独调试和联合调试时,生成的k8s配置文件大部分都是一样的,但有一个地方不同
下面是联合调试时“k8sdemo-database”的部署文件,最后一行“app.kubernetes.io/instance: ”的值是“k8sdemo”。
# Source: k8sdemo/charts/k8sdemo-database/templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: k8sdemo-database labels: helm.sh/chart: k8sdemo-database-0.1.0 app.kubernetes.io/name: k8sdemo-database app.kubernetes.io/instance: k8sdemo 。。。
下面是单独调试时“k8sdemo-database”的部署文件,最后一行“app.kubernetes.io/instance: ”的值是“”k8sdemo-database”。
# Source: k8sdemo/charts/k8sdemo-database/templates/deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: name: k8sdemo-database labels: helm.sh/chart: k8sdemo-database-0.1.0 app.kubernetes.io/name: k8sdemo-database app.kubernetes.io/instance: k8sdemo-database 。。。
因为“instance”的名字是“{{ .Release.Name }}”,而单独调试和联合调试时给的“release”名字不同。而其他的值都是由配置文件决定的,因此不会有意外。
安装k8sdemo:
vagrant@ubuntu-xenial:~/jfeng45/k8sdemo/script/kubernetes/chart$ helm upgrade k8sdemo ./k8sdemo Release "k8sdemo" has been upgraded. Happy Helming! NAME: k8sdemo LAST DEPLOYED: Fri Nov 29 01:28:55 2019 NAMESPACE: default STATUS: deployed REVISION: 2 NOTES: 1. Get the application URL by running these commands: export NODE_PORT=$(kubectl get --namespace default -o jsonpath="{.spec.ports[0].nodePort}" services k8sdemo) export NODE_IP=$(kubectl get nodes --namespace default -o jsonpath="{.items[0].status.addresses[0].address}") echo http://$NODE_IP:$NODE_PORT
获取Pod名称:
vagrant@ubuntu-xenial:~/jfeng45/k8sdemo/script/kubernetes/chart$ kubectl get pod NAME READY STATUS RESTARTS AGE k8sdemo-74cb7b997c-pgcj4 1/1 Running 0 33s k8sdemo-backend-5cd9d79856-dqlmz 1/1 Running 0 33s k8sdemo-database-85855485c6-jtksb 1/1 Running 0 33s k8sdemo-jenkins-deployment-675dd574cb-r57sb 1/1 Running 3 23d
运行程序进行测设:
vagrant@ubuntu-xenial:~/jfeng45/k8sdemo/script/kubernetes/chart$ kubectl exec -ti k8sdemo-backend-5cd9d79856-dqlmz -- /bin/sh ~ # ./main.exe time="2019-11-27T07:03:03Z" level=debug msg="connect to database " time="2019-11-27T07:03:03Z" level=debug msg="dataSourceName:dbuser:dbuser@tcp(k8sdemo-database-service:3306)/service_config?charset=utf8" time="2019-11-27T07:03:03Z" level=debug msg="FindAll()" time="2019-11-27T07:03:03Z" level=debug msg="created=2019-10-21" time="2019-11-27T07:03:03Z" level=debug msg="find user:{1 Tony IT 2019-10-21}" time="2019-11-27T07:03:03Z" level=debug msg="find user list:[{1 Tony IT 2019-10-21}]" time="2019-11-27T07:03:03Z" level=debug msg="user lst:[{1 Tony IT 2019-10-21}]" ~ #
由于篇幅有限,本文不可能把所有的问题都讲清楚,还有两个比较重要的问题,这里简单的提一下。
1. Secret :
本文用的都是明码,如果需要加密的话有两种方式,一种是 helm-secrets ,另一种是 Vault ,请阅读相关文档。
2. 为不同环境设置不同的常量 :
本文只创建了针对一种环境的文件 ,如果你需要针对不同环境(例如DEV,QA,PROD)配置不同的参数的话,你可以在“k8sdemo”的chart里给不同的环境创建不同的"values.yaml",例如“values-dev.yaml”给DEV环境。但在子chart里,就不能这样做,因为系统要求"values.yaml"。这时,你可以在父chart的“values-dev.yaml”里为不同的子chart创建常量,这样这些常量就能覆盖子chart里定义的常量。
在“values-dev.yaml”加入下面代码。
k8sdemo-backend: replicaCount: 2 k8sdemo-database: replicaCount: 2
键入如下命令试运行:
vagrant@ubuntu-xenial:~$ cd /home/vagrant/jfeng45/k8sdemo/script/kubernetes/chart vagrant@ubuntu-xenial:~/jfeng45/k8sdemo/script/kubernetes/chart$ helm install --dry-run --values ./k8sdemo/values-dev.yaml --debug k8sdemo ./k8sdemo
查看结果,子chart中的相应参数已被覆盖。
详情请参阅 How to set environment related values.yaml in Helm subcharts?
在调试过程中还是遇到了不少问题,但大多数都是与语法有关的问题,因为Helm和k8s都用的是YAML文件,而它对文件格式有着严格的要求,如果不满足要求就会报错。幸好它报错时包含了错误代码行号,这样查找起来比较容易。
Pod的状态是CrashLoopBackOff
它的症状是在用“helm install --dry-run --debug”调试时没有问题,但正式运行时出了问题,用下面命令检查,Pod的状态是“CrashLoopBackOff”。
vagrant@ubuntu-xenial:~$ kubectl get pod NAME READY STATUS RESTARTS AGE k8sdemo-74cb7b997c-gn5v2 1/1 Running 1 47h k8sdemo-backend-6cdbb96964-tb2xd 0/1 CrashLoopBackOff 129 9h k8sdemo-database-deployment-578fc88c88-mm6x8 1/1 Running 12 37d k8sdemo-jenkins-deployment-675dd574cb-r57sb 1/1 Running 3 19d
这个问题我以前调试k8s时也碰到过,主要是与Docker镜像有关,但这次明明镜像是 好的。试了很多组合,最后终于发现是自动生成的代码出了问题。
在“deployment.yaml”里有下面代码,这是Helm自动生成用来测试部署的。
livenessProbe: httpGet: path: / port: http readinessProbe: httpGet: path: / port: http
把它去掉之后就没有问题了。而且它只在特定的chart(“k8sedemo-backend”)里会出错,在“k8sdemo”里就没有问题。我现在也不是特别清楚问题在哪,只是把它暂时删除掉了。
持久卷未能绑定到持久卷申请
它的症状是宿主机的持久卷未能绑定到持久卷申请,导致持久卷申请又另外创建了一个持久卷。你用“kubectl get pv”就能看到新创建的持久卷,但实际上它是不必要的,只要把持久卷申请绑定到已有的PV上就行了。这个错误并不是每次都发生,而是随机的。大部分时间绑定正确,少数时候绑定错误。我开始想是不是因为执行k8s文件的顺序问题,但k8s文件是按照文件类别(kind)来执行的,按理来说顺序应该是正确的。再有一个可能就是时间延迟,因为创建持久卷需要时间,而如果持久卷申请没有检测到这个持久卷,那么它就会另外创建一个。如果真是这样的话,就要在创建时设定一个延迟。但它暂时来讲对我影响不大,因此就偷了一下懒,以后有时间再来调试。
完整源码的github链接:
k8sdemo不堆砌术语,不罗列架构,不迷信权威,不盲从流行,坚持独立思考