包管理工具 HELM(7)

包管理工具 HELM(7)

1. Helm安装使用

Helm这个东西其实早有耳闻,但是一直没有用在生产环境,而且现在对这货的评价也是褒贬不一。正好最近需要再次部署一套测试环境,对于单体服务,部署一套测试环境我相信还是非常快的,但是对于微服务架构的应用,要部署一套新的环境,就有点折磨人了,微服务越多、你就会越绝望的。虽然我们线上和测试环境已经都迁移到了kubernetes环境,但是每个微服务也得维护一套yaml文件,而且每个环境下的配置文件也不太一样,部署一套新的环境成本是真的很高。如果我们能使用类似于yum的工具来安装我们的应用的话是不是就很爽歪歪了啊?Helm就相当于kubernetes环境下的yum包管理工具。

1.1 用途

做为 Kubernetes 的一个包管理工具,Helm具有如下功能:

  • 创建新的 chart
  • chart 打包成 tgz 格式
  • 上传 chart 到 chart 仓库或从仓库中下载 chart
  • Kubernetes集群中安装或卸载 chart
  • 管理用Helm安装的 chart 的发布周期

1.2 重要概念

Helm 有三个重要概念:

  • chart:包含了创建Kubernetes的一个应用实例的必要信息
  • config:包含了应用发布配置信息
  • release:是一个 chart 及其配置的一个运行实例

1.3 Helm组件

Helm 有以下两个组成部分: Helm Structrue

图片.png-919.6kB

Helm Client 是用户命令行工具,其主要负责如下:

  • 本地 chart 开发
  • 仓库管理
  • 与 Tiller sever 交互
  • 发送预安装的 chart
  • 查询 release 信息
  • 要求升级或卸载已存在的 release

Tiller Server是一个部署在Kubernetes集群内部的 server,其与 Helm client、Kubernetes API server 进行交互。Tiller server 主要负责如下:

  • 监听来自 Helm client 的请求
  • 通过 chart 及其配置构建一次发布
  • 安装 chart 到Kubernetes集群,并跟踪随后的发布
  • 通过与Kubernetes交互升级或卸载 chart
  • 简单的说,client 管理 charts,而 server 管理发布 release

1.4 安装

我们可以在Helm Realese页面下载二进制文件,这里下载的v2.10.0版本,解压后将可执行文件helm拷贝到/usr/local/bin目录下即可,这样Helm客户端就在这台机器上安装完成了。

现在我们可以使用Helm命令查看版本了,会提示无法连接到服务端Tiller

[root@node01 ~]# wget https://get.helm.sh/helm-v2.10.0-linux-amd64.tar.gz
[root@node01 ~]# tar xf helm-v2.10.0-linux-amd64.tar.gz 
[root@node01 ~]# ls linux-amd64/
helm  LICENSE  README.md
[root@node01 ~]# cp -a ./linux-amd64/helm /usr/local/bin/

[root@node01 ~]# helm version
Client: &version.Version{SemVer:"v2.10.0", GitCommit:"9ad53aac42165a5fadc6c87be0dea6b115f93090", GitTreeState:"clean"}
Error: could not find tiller

要安装 Helm 的服务端程序,我们需要使用到kubectl工具,所以先确保kubectl工具能够正常的访问 kubernetes 集群的apiserver哦。

然后我们在命令行中执行初始化操作:

[root@node01 ~]# helm init --upgrade --tiller-image cnych/tiller:v2.10.0 --stable-repo-url https://cnych.github.io/kube-charts-mirror/
Creating /root/.helm 
Creating /root/.helm/repository 
Creating /root/.helm/repository/cache 
Creating /root/.helm/repository/local 
Creating /root/.helm/plugins 
Creating /root/.helm/starters 
Creating /root/.helm/cache/archive 
Creating /root/.helm/repository/repositories.yaml 
Adding stable repo with URL: https://cnych.github.io/kube-charts-mirror/ 
Adding local repo with URL: http://127.0.0.1:8879/charts 
$HELM_HOME has been configured at /root/.helm.

Tiller (the Helm server-side component) has been installed into your Kubernetes Cluster.

Please note: by default, Tiller is deployed with an insecure 'allow unauthenticated users' policy.
To prevent this, run `helm init` with the --tiller-tls-verify flag.
For more information on securing your installation see: https://docs.helm.sh/using_helm/#securing-your-helm-installation
Happy Helming!

这个命令会把默认的 google 的仓库地址替换成我同步的一个镜像地址。

如果在安装过程中遇到了一些其他问题,比如初始化的时候出现了如下错误:

E0125 14:03:19.093131   56246 portforward.go:331] an error occurred forwarding 55943 -> 44134: error forwarding port 44134 to pod d01941068c9dfea1c9e46127578994d1cf8bc34c971ff109dc6faa4c05043a6e, uid : unable to do port forwarding: socat not found.
2018/01/25 14:03:19 (0xc420476210) (0xc4203ae1e0) Stream removed, broadcasting: 3
2018/01/25 14:03:19 (0xc4203ae1e0) (3) Writing data frame
2018/01/25 14:03:19 (0xc420476210) (0xc4200c3900) Create stream
2018/01/25 14:03:19 (0xc420476210) (0xc4200c3900) Stream added, broadcasting: 5
Error: cannot connect to Tiller

解决方案:在节点上安装socat可以解决

$ sudo yum install -y socat

Helm 服务端正常安装完成后,Tiller默认被部署在kubernetes集群的kube-system命名空间下:

[root@node01 ~]# kubectl get pod -n kube-system -l app=helm
NAME                             READY     STATUS    RESTARTS   AGE
tiller-deploy-86b844d8c6-d2kw8   1/1       Running   0          27s

此时,我们查看 Helm 版本就都正常了:

[root@node01 ~]# helm version
Client: &version.Version{SemVer:"v2.10.0", GitCommit:"9ad53aac42165a5fadc6c87be0dea6b115f93090", GitTreeState:"clean"}
Server: &version.Version{SemVer:"v2.10.0", GitCommit:"9ad53aac42165a5fadc6c87be0dea6b115f93090", GitTreeState:"clean"}

另外一个值得注意的问题是RBAC,我们的 kubernetes 集群是1.10.0版本的,默认开启了RBAC访问控制,所以我们需要为Tiller创建一个ServiceAccount,让他拥有执行的权限,详细内容可以查看 Helm 文档中的Role-based Access Control。 创建rbac.yaml文件:

[root@node01 ~]# vim rbac-config.yaml
[root@node01 ~]# cat rbac-config.yaml 
apiVersion: v1
kind: ServiceAccount
metadata:
  name: tiller
  namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1beta1
kind: ClusterRoleBinding
metadata:
  name: tiller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: tiller
    namespace: kube-system

然后使用kubectl创建:

[root@node01 ~]# kubectl create -f rbac-config.yaml
serviceaccount "tiller" created
clusterrolebinding.rbac.authorization.k8s.io "tiller" created

创建了tiller的 ServceAccount 后还没完,因为我们的 Tiller 之前已经就部署成功了,而且是没有指定 ServiceAccount 的,所以我们需要给 Tiller 打上一个 ServiceAccount 的补丁:

[root@node01 ~]# kubectl patch deploy --namespace kube-system tiller-deploy -p '{"spec":{"template":{"spec":{"serviceAccount":"tiller"}}}}'
deployment.extensions "tiller-deploy" patched

上面这一步非常重要,不然后面在使用 Helm 的过程中可能出现Error: no available release name found的错误信息。

至此, Helm客户端和服务端都配置完成了,接下来我们看看如何使用吧。

1.5 使用

我们现在了尝试创建一个 Chart:

[root@node01 ~]# helm create hello-helm
Creating hello-helm

[root@node01 ~]# tree hello-helm
hello-helm
├── charts
├── Chart.yaml
├── templates
│   ├── deployment.yaml
│   ├── _helpers.tpl
│   ├── ingress.yaml
│   ├── NOTES.txt
│   └── service.yaml
└── values.yaml

2 directories, 7 files

我们通过查看templates目录下面的deployment.yaml文件可以看出默认创建的 Chart 是一个 nginx 服务,具体的每个文件是干什么用的,我们可以前往 Helm 官方文档进行查看,后面会和大家详细讲解的。比如这里我们来安装 1.7.9 这个版本的 nginx,则我们更改 value.yaml 文件下面的 image tag 即可,将默认的 stable 更改为 1.7.9,为了测试方便,我们把 Service 的类型也改成 NodePort

[root@node01 ~]# vim hello-helm/values.yaml 
[root@node01 ~]# cat hello-helm/values.yaml 
...
image:
  repository: nginx
  tag: 1.7.9
  pullPolicy: IfNotPresent

nameOverride: ""
fullnameOverride: ""

service:
  type: NodePort
  port: 80
...

现在我们来尝试安装下这个 Chart :

[root@node01 ~]# helm install ./hello-helm
NAME:   foppish-billygoat
LAST DEPLOYED: Mon Sep 20 15:11:34 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1beta2/Deployment
NAME                          DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
foppish-billygoat-hello-helm  1        0        0           0          0s

==> v1/Pod(related)
NAME                                           READY  STATUS   RESTARTS  AGE
foppish-billygoat-hello-helm-79f56fc76f-9cdm8  0/1    Pending  0         0s

==> v1/Service
NAME                          TYPE      CLUSTER-IP    EXTERNAL-IP  PORT(S)       AGE
foppish-billygoat-hello-helm  NodePort  10.109.79.65  <none>       80:31941/TCP  0s


NOTES:
1. Get the application URL by running these commands:
  export NODE_PORT=$(kubectl get --namespace default -o jsonpath="{.spec.ports[0].nodePort}" services foppish-billygoat-hello-helm)
  export NODE_IP=$(kubectl get nodes --namespace default -o jsonpath="{.items[0].status.addresses[0].address}")
  echo http://$NODE_IP:$NODE_PORT
[root@node01 ~]# kubectl get pods -l app=hello-helm
NAME                                            READY     STATUS    RESTARTS   AGE
foppish-billygoat-hello-helm-79f56fc76f-9cdm8   1/1       Running   0          2m

[root@node01 ~]# kubectl get svc -l app=hello-helm
NAME                           TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
foppish-billygoat-hello-helm   NodePort   10.109.79.65   <none>        80:31941/TCP   3m

等到 Pod 创建完成后,我们可以根据创建的 Service 的 NodePort 来访问该服务了,然后在浏览器中打开http://k8s.haimaxy.com:31941就可以正常的访问我们刚刚部署的 nginx 应用了。

图片.png-76.7kB

查看release

[root@node01 ~]# helm list
NAME             	REVISION	UPDATED                 	STATUS  	CHART           	APP VERSION	NAMESPACE
foppish-billygoat	1       	Mon Sep 20 15:11:34 2021	DEPLOYED	hello-helm-0.1.0	1.0        	default

打包chart

[root@node01 ~]# helm package hello-helm
Successfully packaged chart and saved it to: /root/hello-helm-0.1.0.tgz

[root@node01 ~]# ll -d /root/hello-helm-0.1.0.tgz
-rw-r--r-- 1 root root 2598 9月  20 15:27 /root/hello-helm-0.1.0.tgz

然后我们就可以将打包的tgz文件分发到任意的服务器上,通过helm fetch就可以获取到该 Chart 了。

删除release

[root@node01 ~]# helm delete foppish-billygoat
release "foppish-billygoat" deleted

然后我们看到kubernetes集群上的该 nginx 服务也已经被删除了。

[root@node01 ~]# kubectl get pods -l app=hello-helm
No resources found.

还有更多关于Helm的使用命令,我们可以前往官方文档查看。

2. Helm 的基本使用

上节课我们成功安装了Helm的客户端以及服务端Tiller Server,我们也自己尝试创建了我们的第一个 Helm Chart 包,这节课就来和大家一起学习下 Helm 中的一些常用的操作方法。

2.1 仓库

Helm 的 Repo 仓库和 Docker Registry 比较类似,Chart 库可以用来存储和共享打包 Chart 的位置,我们在安装了 Helm 后,默认的仓库地址是 google 的一个地址,这对于我们不能上网的同学就比较苦恼了,没办法访问到官方提供的 Chart 仓库,可以用helm repo list来查看当前的仓库配置:

[root@node01 ~]# helm repo list
NAME  	URL                                        
stable	https://cnych.github.io/kube-charts-mirror/
local 	http://127.0.0.1:8879/charts

我们可以看到除了一个默认的 stable 的仓库配置外,还有一个 local 的本地仓库,这是我们本地测试的一个仓库地址。其实要创建一个 Chart 仓库也是非常简单的,Chart 仓库其实就是一个带有index.yaml索引文件和任意个打包的 Chart 的 HTTP 服务器而已,比如我们想要分享一个 Chart 包的时候,将我们本地的 Chart 包上传到该服务器上面,别人就可以使用了,所以其实我们自己托管一个 Chart 仓库也是非常简单的,比如阿里云的 OSS、Github Pages,甚至自己创建的一个简单服务器都可以。

为了解决上网的问题,我这里建了一个 Github Pages 仓库,每天会自动和官方的仓库进行同步,地址是:https://github.com/cnych/kube-charts-mirror,这样我们就可以将我们的 Helm 默认仓库地址更改成我们自己的仓库地址了:

[root@node01 ~]# helm repo remove stable
"stable" has been removed from your repositories
[root@node01 ~]# helm repo add stable https://cnych.github.io/kube-charts-mirror/
"stable" has been added to your repositories
[root@node01 ~]# helm repo list
NAME  	URL                                        
local 	http://127.0.0.1:8879/charts               
stable	https://cnych.github.io/kube-charts-mirror/

[root@node01 ~]# helm repo update
Hang tight while we grab the latest from your chart repositories...
...Skip local chart repository
...Successfully got an update from the "stable" chart repository
Update Complete. ⎈ Happy Helming!⎈

仓库添加完成后,可以使用 update 命令进行仓库更新。当然如果要我们自己来创建一个 web 服务器来服务 Helm Chart 的话,只需要实现下面几个功能点就可以提供服务了:

  • 将索引和Chart置于服务器目录中
  • 确保索引文件index.yaml可以在没有认证要求的情况下访问
  • 确保 yaml 文件的正确内容类型(text/yaml 或 text/x-yaml)

如果你的 web 服务提供了上面几个功能,那么也就可以当做 Helm Chart 仓库来使用了。

2.2 查找 chart

Helm 将 Charts 包安装到 Kubernetes 集群中,一个安装实例就是一个新的 Release,要找到新的 Chart,我们可以通过搜索命令完成。

记住,如果不能上网,将默认的 stable 的仓库地址更换成上面我们创建的地址

直接运行helm search命令可以查看有哪些 Charts 是可用的:

[root@node01 ~]# helm search
NAME                                     CHART VERSION    APP VERSION                     DESCRIPTION
...
stable/minio                             1.6.3            RELEASE.2018-08-25T01-56-38Z    Minio is a high performance distributed object storage se...
stable/mission-control                   0.4.2            3.1.2                           A Helm chart for JFrog Mission Control
stable/mongodb                           4.2.2            4.0.2                           NoSQL document-oriented database that stores JSON-like do...
stable/mongodb-replicaset                3.5.6            3.6                             NoSQL document-oriented database that stores JSON-like do...
...
stable/zetcd                             0.1.9            0.0.3                           CoreOS zetcd Helm chart for Kubernetes
...

如果没有使用过滤条件,helm search 显示所有可用的 charts。可以通过使用过滤条件进行搜索来缩小搜索的结果范围:

[root@node01 ~]# helm search mysql
NAME                            	CHART VERSION	APP VERSION	DESCRIPTION                                                 
stable/mysql                    	0.10.2       	5.7.14     	Fast, reliable, scalable, and easy to use open-source rel...
stable/mysqldump                	1.0.0        	5.7.21     	A Helm chart to help backup MySQL databases using mysqldump 
stable/prometheus-mysql-exporter	0.2.1        	v0.11.0    	A Helm chart for prometheus mysql exporter with cloudsqlp...
stable/percona                  	0.3.3        	5.7.17     	free, fully compatible, enhanced, open source drop-in rep...
stable/percona-xtradb-cluster   	0.5.0        	5.7.19     	free, fully compatible, enhanced, open source drop-in rep...
stable/phpmyadmin               	1.3.0        	4.8.3      	phpMyAdmin is an mysql administration frontend              
stable/gcloud-sqlproxy          	0.6.0        	1.11       	Google Cloud SQL Proxy                                      
stable/mariadb                  	5.2.3        	10.1.37    	Fast, reliable, scalable, and easy to use open-source rel...

可以看到明显少了很多 charts 了,同样的,我们可以使用 inspect 命令来查看一个 chart 的详细信息:

[root@node01 ~]# helm inspect stable/mysql|head -27
appVersion: 5.7.14
description: Fast, reliable, scalable, and easy to use open-source relational database
  system.
engine: gotpl
home: https://www.mysql.com/
icon: https://www.mysql.com/common/logos/logo-mysql-170x115.png
keywords:
- mysql
- database
- sql
maintainers:
- email: o.with@sportradar.com
  name: olemarkus
- email: viglesias@google.com
  name: viglesiasce
name: mysql
sources:
- https://github.com/kubernetes/charts
- https://github.com/docker-library/mysql
version: 0.10.2

---
## mysql image version
## ref: https://hub.docker.com/r/library/mysql/tags/
##
image: "mysql"
imageTag: "5.7.14"
...

使用 inspect 命令可以查看到该 chart 里面所有描述信息,包括运行方式、配置信息等等。

通过 helm search 命令可以找到我们想要的 chart 包,找到后就可以通过 helm install 命令来进行安装了。

2.3 安装 chart

要安装新的软件包,直接使用 helm install 命令即可。最简单的情况下,它只需要一个 chart 的名称参数:

[root@node01 ~]# helm install stable/mysql
NAME:   geared-moth
LAST DEPLOYED: Mon Sep 20 16:01:21 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Secret
NAME               TYPE    DATA  AGE
geared-moth-mysql  Opaque  2     1s

==> v1/ConfigMap
NAME                    DATA  AGE
geared-moth-mysql-test  1     1s

==> v1/PersistentVolumeClaim
NAME               STATUS   VOLUME  CAPACITY  ACCESS MODES  STORAGECLASS  AGE
geared-moth-mysql  Pending  1s

==> v1/Service
NAME               TYPE       CLUSTER-IP      EXTERNAL-IP  PORT(S)   AGE
geared-moth-mysql  ClusterIP  10.105.138.133  <none>       3306/TCP  1s

==> v1beta1/Deployment
NAME               DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
geared-moth-mysql  1        1        1           0          1s

==> v1/Pod(related)
NAME                                READY  STATUS   RESTARTS  AGE
geared-moth-mysql-58778f7d84-jrx57  0/1    Pending  0         0s


NOTES:
MySQL can be accessed via port 3306 on the following DNS name from within your cluster:
geared-moth-mysql.default.svc.cluster.local

To get your root password run:

    MYSQL_ROOT_PASSWORD=$(kubectl get secret --namespace default geared-moth-mysql -o jsonpath="{.data.mysql-root-password}" | base64 --decode; echo)

To connect to your database:

1. Run an Ubuntu pod that you can use as a client:

    kubectl run -i --tty ubuntu --image=ubuntu:16.04 --restart=Never -- bash -il

2. Install the mysql client:

    $ apt-get update && apt-get install mysql-client -y

3. Connect using the mysql cli, then provide your password:
    $ mysql -h geared-moth-mysql -p

To connect to your database directly from outside the K8s cluster:
    MYSQL_HOST=127.0.0.1
    MYSQL_PORT=3306

    # Execute the following command to route the connection:
    kubectl port-forward svc/geared-moth-mysql 3306

    mysql -h ${MYSQL_HOST} -P${MYSQL_PORT} -u root -p${MYSQL_ROOT_PASSWORD}

现在 mysql chart 已经安装上了,安装 chart 会创建一个新 release 对象。上面的 release 被命名为 hmewing-squid。如果你想使用你自己的 release 名称,只需使用--name参数指定即可,比如:

$ helm install stable/mysql --name mydb

在安装过程中,helm 客户端将打印有关创建哪些资源的有用信息,release 的状态以及其他有用的配置信息,比如这里的有访问 mysql 服务的方法、获取 root 用户的密码以及连接 mysql 的方法等信息。

值得注意的是 Helm 并不会一直等到所有资源都运行才退出。因为很多 charts 需要的镜像资源非常大,所以可能需要很长时间才能安装到集群中去。

要跟踪 release 状态或重新读取配置信息,可以使用 helm status 查看:

[root@node01 ~]# helm status geared-moth|head
LAST DEPLOYED: Mon Sep 20 16:01:21 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/PersistentVolumeClaim
NAME               STATUS   VOLUME  CAPACITY  ACCESS MODES  STORAGECLASS  AGE
geared-moth-mysql  Pending  4m

==> v1/Service
...

可以看到当前 release 的状态是DEPLOYED,下面还有一些安装的时候出现的信息。

[root@node01 ~]# helm ls
NAME       	REVISION	UPDATED                 	STATUS  	CHART       	APP VERSION	NAMESPACE
geared-moth	1       	Mon Sep 20 16:01:21 2021	DEPLOYED	mysql-0.10.2	5.7.14

2.4 自定义 chart

上面的安装方式是使用 chart 的默认配置选项。但是在很多时候,我们都需要自定义 chart 以满足自身的需求,要自定义 chart,我们就需要知道我们使用的 chart 支持的可配置选项才行。

要查看 chart 上可配置的选项,使用helm inspect values命令即可,比如我们这里查看上面的 mysql 的配置选项:

[root@node01 ~]# helm inspect values stable/mysql
## mysql image version
## ref: https://hub.docker.com/r/library/mysql/tags/
##
image: "mysql"
imageTag: "5.7.14"

## Specify password for root user
##
## Default: random 10 character string
# mysqlRootPassword: testing

## Create a database user
##
# mysqlUser:
## Default: random 10 character string
# mysqlPassword:

## Allow unauthenticated access, uncomment to enable
##
# mysqlAllowEmptyPassword: true

## Create a database
##
# mysqlDatabase:

## Specify an imagePullPolicy (Required)
## It's recommended to change this to 'Always' if the image tag is 'latest'
## ref: http://kubernetes.io/docs/user-guide/images/#updating-images
##
imagePullPolicy: IfNotPresent

extraVolumes: |
  # - name: extras
  #   emptyDir: {}

extraVolumeMounts: |
  # - name: extras
  #   mountPath: /usr/share/extras
  #   readOnly: true

extraInitContainers: |
  # - name: do-something
  #   image: busybox
  #   command: ['do', 'something']

# Optionally specify an array of imagePullSecrets.
# Secrets must be manually created in the namespace.
# ref: https://kubernetes.io/docs/concepts/containers/images/#specifying-imagepullsecrets-on-a-pod
# imagePullSecrets:
  # - name: myRegistryKeySecretName

## Node selector
## ref: https://kubernetes.io/docs/concepts/configuration/assign-pod-node/#nodeselector
nodeSelector: {}

livenessProbe:
  initialDelaySeconds: 30
  periodSeconds: 10
  timeoutSeconds: 5
  successThreshold: 1
  failureThreshold: 3

readinessProbe:
  initialDelaySeconds: 5
  periodSeconds: 10
  timeoutSeconds: 1
  successThreshold: 1
  failureThreshold: 3

## Persist data to a persistent volume
persistence:
  enabled: true
  ## database data Persistent Volume Storage Class
  ## If defined, storageClassName: <storageClass>
  ## If set to "-", storageClassName: "", which disables dynamic provisioning
  ## If undefined (the default) or set to null, no storageClassName spec is
  ##   set, choosing the default provisioner.  (gp2 on AWS, standard on
  ##   GKE, AWS & OpenStack)
  ##
  # storageClass: "-"
  accessMode: ReadWriteOnce
  size: 8Gi
  annotations: {}

## Configure resource requests and limits
## ref: http://kubernetes.io/docs/user-guide/compute-resources/
##
resources:
  requests:
    memory: 256Mi
    cpu: 100m

# Custom mysql configuration files used to override default mysql settings
configurationFiles: {}
#  mysql.cnf: |-
#    [mysqld]
#    skip-name-resolve
#    ssl-ca=/ssl/ca.pem
#    ssl-cert=/ssl/server-cert.pem
#    ssl-key=/ssl/server-key.pem

# Custom mysql init SQL files used to initialize the database
initializationFiles: {}
#  first-db.sql: |-
#    CREATE DATABASE IF NOT EXISTS first DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;
#  second-db.sql: |-
#    CREATE DATABASE IF NOT EXISTS second DEFAULT CHARACTER SET utf8 DEFAULT COLLATE utf8_general_ci;

metrics:
  enabled: false
  image: prom/mysqld-exporter
  imageTag: v0.10.0
  imagePullPolicy: IfNotPresent
  resources: {}
  annotations: {}
    # prometheus.io/scrape: "true"
    # prometheus.io/port: "9104"
  livenessProbe:
    initialDelaySeconds: 15
    timeoutSeconds: 5
  readinessProbe:
    initialDelaySeconds: 5
    timeoutSeconds: 1

## Configure the service
## ref: http://kubernetes.io/docs/user-guide/services/
service:
  ## Specify a service type
  ## ref: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services---service-types
  type: ClusterIP
  port: 3306
  # nodePort: 32000

ssl:
  enabled: false
  secret: mysql-ssl-certs
  certificates:
#  - name: mysql-ssl-certs
#    ca: |-
#      -----BEGIN CERTIFICATE-----
#      ...
#      -----END CERTIFICATE-----
#    cert: |-
#      -----BEGIN CERTIFICATE-----
#      ...
#      -----END CERTIFICATE-----
#    key: |-
#      -----BEGIN RSA PRIVATE KEY-----
#      ...
#      -----END RSA PRIVATE KEY-----

## Populates the 'TZ' system timezone environment variable
## ref: https://dev.mysql.com/doc/refman/5.7/en/time-zone-support.html
##
## Default: nil (mysql will use image's default timezone, normally UTC)
## Example: 'Australia/Sydney'
# timezone:

# To be added to the database server pod(s)
podAnnotations: {}

然后,我们可以直接在 YAML 格式的文件中来覆盖上面的任何配置,在安装的时候直接使用该配置文件即可:(config.yaml)

[root@node01 ~]# vim config.yaml
[root@node01 ~]# cat config.yaml 
mysqlUser: haimaxyUser
mysqlDatabase: haimaxyDB
service:
  type: NodePort

我们这里通过 config.yaml 文件定义了 mysqlUser 和 mysqlDatabase,并且把 service 的类型更改为了 NodePort,然后现在我们来安装的时候直接指定该 yaml 文件:

[root@node01 ~]# helm install -f config.yaml stable/mysql --name mydb
NAME:   mydb
LAST DEPLOYED: Mon Sep 20 16:19:48 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Pod(related)
NAME                        READY  STATUS   RESTARTS  AGE
mydb-mysql-dfc999888-rpp4m  0/1    Pending  0         0s

==> v1/Secret
NAME        TYPE    DATA  AGE
mydb-mysql  Opaque  2     0s

==> v1/ConfigMap
NAME             DATA  AGE
mydb-mysql-test  1     0s

==> v1/PersistentVolumeClaim
NAME        STATUS   VOLUME  CAPACITY  ACCESS MODES  STORAGECLASS  AGE
mydb-mysql  Pending  0s

==> v1/Service
NAME        TYPE      CLUSTER-IP     EXTERNAL-IP  PORT(S)         AGE
mydb-mysql  NodePort  10.97.123.216  <none>       3306:31001/TCP  0s

==> v1beta1/Deployment
NAME        DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
mydb-mysql  1        1        1           0          0s


NOTES:
MySQL can be accessed via port 3306 on the following DNS name from within your cluster:
mydb-mysql.default.svc.cluster.local

To get your root password run:

    MYSQL_ROOT_PASSWORD=$(kubectl get secret --namespace default mydb-mysql -o jsonpath="{.data.mysql-root-password}" | base64 --decode; echo)

To connect to your database:

1. Run an Ubuntu pod that you can use as a client:

    kubectl run -i --tty ubuntu --image=ubuntu:16.04 --restart=Never -- bash -il

2. Install the mysql client:

    $ apt-get update && apt-get install mysql-client -y

3. Connect using the mysql cli, then provide your password:
    $ mysql -h mydb-mysql -p

To connect to your database directly from outside the K8s cluster:
    MYSQL_HOST=$(kubectl get nodes --namespace default -o jsonpath='{.items[0].status.addresses[0].address}')
    MYSQL_PORT=$(kubectl get svc --namespace default mydb-mysql -o jsonpath='{.spec.ports[0].nodePort}')

    mysql -h ${MYSQL_HOST} -P${MYSQL_PORT} -u root -p${MYSQL_ROOT_PASSWORD}

我们可以看到当前 release 的名字已经变成 mydb 了。然后可以查看下 mydb 关联的 Service 是否变成 NodePort 类型的了:

[root@node01 ~]# kubectl get svc
NAME                TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
geared-moth-mysql   ClusterIP   10.105.138.133   <none>        3306/TCP         19m
kubernetes          ClusterIP   10.96.0.1        <none>        443/TCP          97d
mydb-mysql          NodePort    10.97.123.216    <none>        3306:31001/TCP   1m

看到服务 mydb-mysql 变成了 NodePort 类型的,二之前默认创建的 mewing-squid-mysql 是 ClusterIP 类型的,证明上面我们通过 YAML 文件来覆盖 values 是成功的。

接下来我们查看下 Pod 的状况:

[root@node01 ~]# kubectl get pods
NAME                                 READY     STATUS    RESTARTS   AGE
geared-moth-mysql-58778f7d84-jrx57   0/1       Pending   0          20m
mydb-mysql-dfc999888-rpp4m           0/1       Pending   0          2m

比较奇怪的是之前默认创建的和现在的 mydb 的 release 创建的 Pod 都是 Pending 状态,直接使用 describe 命令查看下:

[root@node01 ~]# kubectl describe pod mydb-mysql-dfc999888-rpp4m
Name:           mydb-mysql-dfc999888-rpp4m
Namespace:      default
Node:           <none>
Labels:         app=mydb-mysql
                pod-template-hash=897555444
Annotations:    <none>
Status:         Pending
...
Events:
  Type     Reason            Age                From               Message
  ----     ------            ----               ----               -------
  Warning  FailedScheduling  16s (x15 over 3m)  default-scheduler  pod has unbound PersistentVolumeClaims

我们可以发现两个 Pod 处于 Pending 状态的原因都是 PVC 没有被绑定上,所以这里我们可以通过 storageclass 或者手动创建一个合适的 PV 对象来解决这个问题。

另外为了说明 helm 更新的用法,我们这里来直接禁用掉数据持久化,可以在上面的 config.yaml 文件中设置:

persistence:
  enabled: false

另外一种方法就是在安装过程中使用--set来覆盖对应的 value 值,比如禁用数据持久化,我们这里可以这样来覆盖:

$ helm install stable/mysql --set persistence.enabled=false --name mydb

2.5 升级

我们这里将数据持久化禁用掉来对上面的 mydb 进行升级:

[root@node01 ~]# cat config.yaml 
mysqlUser: haimaxyUser
mysqlDatabase: haimaxyDB
service:
  type: NodePort
persistence:
  enabled: false

[root@node01 ~]# helm upgrade -f config.yaml mydb stable/mysql
Release "mydb" has been upgraded. Happy Helming!
LAST DEPLOYED: Mon Sep 20 17:30:24 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
...

可以看到已经变成 DEPLOYED 状态了,现在我们再去看看 Pod 的状态呢:

[root@node01 ~]# kubectl get pods
NAME                                 READY     STATUS    RESTARTS   AGE
geared-moth-mysql-58778f7d84-jrx57   0/1       Pending   0          1h
mydb-mysql-6ffc84bbf6-jrkgx          1/1       Running   0          1m

我们看到 mydb 关联的 Pod 已经变成了 PodInitializing 的状态,已经不是 Pending 状态了,同样的,使用 describe 命令查看:

[root@node01 ~]# kubectl describe pod mydb-mysql-6ffc84bbf6-jrkgx
Name:           mydb-mysql-6ffc84bbf6-jrkgx
Namespace:      default
Node:           node01/192.168.200.11
Start Time:     Mon, 20 Sep 2021 17:30:24 +0800
Labels:         app=mydb-mysql
                pod-template-hash=2997406692
Annotations:    <none>
Status:         Running
IP:             10.244.1.7
Controlled By:  ReplicaSet/mydb-mysql-6ffc84bbf6
...
Events:
  Type    Reason                 Age   From               Message
  ----    ------                 ----  ----               -------
  Normal  Scheduled              2m    default-scheduler  Successfully assigned mydb-mysql-6ffc84bbf6-jrkgx to node01
  Normal  SuccessfulMountVolume  2m    kubelet, node01    MountVolume.SetUp succeeded for volume "data"
  Normal  SuccessfulMountVolume  2m    kubelet, node01    MountVolume.SetUp succeeded for volume "default-token-ztfk2"
  Normal  Pulled                 2m    kubelet, node01    Container image "busybox:1.25.0" already present on machine
  Normal  Created                2m    kubelet, node01    Created container
  Normal  Started                2m    kubelet, node01    Started container
  Normal  Pulled                 2m    kubelet, node01    Container image "mysql:5.7.14" already present on machine
  Normal  Created                2m    kubelet, node01    Created container
  Normal  Started                2m    kubelet, node01    Started container

我们可以看到现在没有任何关于 PVC 的错误信息了,这是因为我们刚刚更新的版本中就是禁用掉了的数据持久化的,证明 helm upgrade 和 --values 是生效了的。现在我们使用 helm ls 命令查看先当前的 release:

[root@node01 ~]# helm ls
NAME       	REVISION	UPDATED                 	STATUS  	CHART       	APP VERSION	NAMESPACE
geared-moth	1       	Mon Sep 20 16:01:21 2021	DEPLOYED	mysql-0.10.2	5.7.14     	default  
mydb       	2       	Mon Sep 20 17:30:24 2021	DEPLOYED	mysql-0.10.2	5.7.14     	default

可以看到 mydb 这个 release 的REVISION已经变成2了,这是因为 release 的版本是递增的,每次安装、升级或者回滚,版本号都会加1,第一个版本号始终为1,同样我们可以使用 helm history 命令查看 release 的历史版本:

[root@node01 ~]# helm history mydb
REVISION	UPDATED                 	STATUS    	CHART       	DESCRIPTION     
1       	Mon Sep 20 16:19:48 2021	SUPERSEDED	mysql-0.10.2	Install complete
2       	Mon Sep 20 17:30:24 2021	DEPLOYED  	mysql-0.10.2	Upgrade complete

当然如果我们要回滚到某一个版本的话,使用 helm rollback 命令即可,比如我们将 mydb 回滚到上一个版本:

[root@node01 ~]# #helm rollback mydb 1

2.6 删除

上节课我们就学习了要删除一个 release 直接使用 helm delete 命令就 OK:

[root@node01 ~]# helm delete geared-moth
release "geared-moth" deleted

这将从集群中删除该 release,但是这并不代表就完全删除了,我们还可以通过--deleted参数来显示被删除掉 release:

[root@node01 ~]# helm list --deleted
NAME       	REVISION	UPDATED                 	STATUS 	CHART       	APP VERSION	NAMESPACE
geared-moth	1       	Mon Sep 20 16:01:21 2021	DELETED	mysql-0.10.2	5.7.14     	default  

[root@node01 ~]# helm list --all
NAME       	REVISION	UPDATED                 	STATUS  	CHART       	APP VERSION	NAMESPACE
geared-moth	1       	Mon Sep 20 16:01:21 2021	DELETED 	mysql-0.10.2	5.7.14     	default  
mydb       	2       	Mon Sep 20 17:30:24 2021	DEPLOYED	mysql-0.10.2	5.7.14     	default

helm list --all则会显示所有的 release,包括已经被删除的

由于 Helm 保留已删除 release 的记录,因此不能重新使用 release 名称。(如果 确实 需要重新使用此 release 名称,则可以使用此 --replace 参数,但它只会重用现有 release 并替换其资源。)这点是不是和 docker container 的管理比较类似

请注意,因为 release 以这种方式保存,所以可以回滚已删除的资源并重新激活它。

如果要彻底删除 release,则需要加上--purge参数:

[root@node01 ~]# helm delete geared-moth --purge
release "geared-moth" deleted
[root@node01 ~]# helm list --deleted
[root@node01 ~]#
[root@node01 ~]# helm list --all
NAME	REVISION	UPDATED                 	STATUS  	CHART       	APP VERSION	NAMESPACE
mydb	2       	Mon Sep 20 17:30:24 2021	DEPLOYED	mysql-0.10.2	5.7.14     	default

3. Helm 模板之内置函数和Values

上节课和大家一起学习了Helm的一些常用操作方法,这节课来和大家一起定义一个chart包,了解 Helm 中模板的一些使用方法。

3.1 定义 chart

Helm 的 github 上面有一个比较完整的文档,建议大家好好阅读下该文档,这里我们来一起创建一个chart包。

一个 chart 包就是一个文件夹的集合,文件夹名称就是 chart 包的名称,比如创建一个 mychart 的 chart 包:

[root@node01 ~]# helm create mychart
Creating mychart
[root@node01 ~]# tree mychart
mychart
├── charts
├── Chart.yaml
├── templates
│   ├── deployment.yaml
│   ├── _helpers.tpl
│   ├── ingress.yaml
│   ├── NOTES.txt
│   └── service.yaml
└── values.yaml

2 directories, 7 files

chart 包的目录上节课我们就已经学习过了,这里我们再来仔细看看 templates 目录下面的文件:

  • NOTES.txt:chart 的 “帮助文本”。这会在用户运行 helm install 时显示给用户。
  • deployment.yaml:创建 Kubernetes deployment 的基本 manifest
  • service.yaml:为 deployment 创建 service 的基本 manifest
  • ingress.yaml: 创建 ingress 对象的资源清单文件
  • _helpers.tpl:放置模板助手的地方,可以在整个 chart 中重复使用

这里我们明白每一个文件是干嘛的就行,然后我们把 templates 目录下面所有文件全部删除掉,这里我们自己来创建模板文件:

[root@node01 ~]# rm -rf mychart/templates/*.*

3.2 创建模板

这里我们来创建一个非常简单的模板 ConfigMap,在 templates 目录下面新建一个configmap.yaml文件:

[root@node01 ~]# vim mychart/templates/configmap.yaml
[root@node01 ~]# cat mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mychart-configmap
data:
  myvalue: "Hello World"

实际上现在我们就有一个可安装的 chart 包了,通过helm install命令来进行安装:

[root@node01 ~]# helm install ./mychart/
NAME:   doltish-scorpion
LAST DEPLOYED: Mon Sep 20 23:18:29 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/ConfigMap
NAME               DATA  AGE
mychart-configmap  1     0s

在上面的输出中,我们可以看到我们的 ConfigMap 资源对象已经创建了。然后使用如下命令我们可以看到实际的模板被渲染过后的资源文件:

[root@node01 ~]# helm get manifest doltish-scorpion

---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mychart-configmap
data:
  myvalue: "Hello World"

现在我们看到上面的 ConfigMap 文件是不是正是我们前面在模板文件中设计的,现在我们删除当前的release:

[root@node01 ~]# helm delete doltish-scorpion
release "doltish-scorpion" deleted

3.3 添加一个简单的模板

我们可以看到上面我们定义的 ConfigMap 的名字是固定的,但往往这并不是一种很好的做法,我们可以通过插入 release 的名称来生成资源的名称,比如这里 ConfigMap 的名称我们希望是:ringed-lynx-configmap,这就需要用到 Chart 的模板定义方法了。

Helm Chart 模板使用的是Go语言模板编写而成,并添加了Sprig库中的50多个附件模板函数以及一些其他特殊的函

需要注意的是kubernetes资源对象的 labels 和 name 定义被限制 63个字符,所以需要注意名称的定义。

现在我们来重新定义下上面的 configmap.yaml 文件:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"

我们将名称替换成了{{ .Release.Name }}-configmap,其中包含在{{}}之中的就是模板指令,{{ .Release.Name }} 将 release 的名称注入到模板中来,这样最终生成的 ConfigMap 名称就是以 release 的名称开头的了。这里的 Release 模板对象属于 Helm 内置的一种对象,还有其他很多内置的对象,稍后我们将接触到。

现在我们来重新安装我们的 Chart 包,注意观察 ConfigMap 资源对象的名称:

[root@node01 ~]# helm install ./mychart
NAME:   veering-toad
LAST DEPLOYED: Mon Sep 20 23:25:48 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/ConfigMap
NAME                    DATA  AGE
veering-toad-configmap  1     0s

可以看到现在生成的名称变成了quoting-zebra-configmap,证明已经生效了,当然我们也可以使用命令helm get manifest quoting-zebra查看最终生成的清单文件的样子。

3.4 调试

我们用模板来生成资源文件的清单,但是如果我们想要调试就非常不方便了,不可能我们每次都去部署一个release实例来校验模板是否正确,所幸的时 Helm 为我们提供了--dry-run --debug这个可选参数,在执行helm install的时候带上这两个参数就可以把对应的 values 值和生成的最终的资源清单文件打印出来,而不会真正的去部署一个release实例,比如我们来调试上面创建的 chart 包:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '46499'

[debug] SERVER: "127.0.0.1:46499"

[debug] Original chart version: ""
[debug] CHART PATH: /root/mychart

NAME:   dunking-manta
REVISION: 1
RELEASED: Mon Sep 20 23:48:30 2021
CHART: mychart-0.1.0
USER-SUPPLIED VALUES:
{}
...
service:
  port: 80
  type: ClusterIP
tolerations: []

HOOKS:
MANIFEST:

---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: dunking-manta-configmap
data:
  myvalue: "Hello World"

现在我们使用--dry-run就可以很容易地测试代码了,不需要每次都去安装一个 release 实例了,但是要注意的是这不能确保 Kubernetes 本身就一定会接受生成的模板,在调试完成后,还是需要去安装一个实际的 release 实例来进行验证的。

3.5 内置对象

刚刚我们使用{{.Release.Name}}将 release 的名称插入到模板中。这里的 Release 就是 Helm 的内置对象,下面是一些常用的内置对象,在需要的时候直接使用就可以:

  • Release:这个对象描述了 release 本身。它里面有几个对象:
    • Release.Name:release 名称
    • Release.Time:release 的时间
    • Release.Namespace:release 的 namespace(如果清单未覆盖)
    • Release.Service:release 服务的名称(始终是 Tiller)。
    • Release.Revision:此 release 的修订版本号,从1开始累加。
    • Release.IsUpgrade:如果当前操作是升级或回滚,则将其设置为 true。
    • Release.IsInstall:如果当前操作是安装,则设置为 true。
  • Values:从values.yaml文件和用户提供的文件传入模板的值。默认情况下,Values 是空的。
  • Chart:Chart.yaml文件的内容。所有的 Chart 对象都将从该文件中获取。chart 指南中Charts Guide列出了可用字段,可以前往查看。
  • Files:这提供对 chart 中所有非特殊文件的访问。虽然无法使用它来访问模板,但可以使用它来访问 chart 中的其他文件。请参阅 "访问文件" 部分。
    • Files.Get 是一个按名称获取文件的函数(.Files.Get config.ini)
    • Files.GetBytes 是将文件内容作为字节数组而不是字符串获取的函数。这对于像图片这样的东西很有用。
  • Capabilities:这提供了关于 Kubernetes 集群支持的功能的信息。
    • Capabilities.APIVersions 是一组版本信息。
    • Capabilities.APIVersions.Has $version 指示是否在群集上启用版本(batch/v1)。
    • Capabilities.KubeVersion 提供了查找 Kubernetes 版本的方法。它具有以下值:Major,Minor,GitVersion,GitCommit,GitTreeState,BuildDate,GoVersion,Compiler,和 Platform。
    • Capabilities.TillerVersion 提供了查找 Tiller 版本的方法。它具有以下值:SemVer,GitCommit,和 GitTreeState。
  • Template:包含有关正在执行的当前模板的信息
  • Name:到当前模板的文件路径(例如 mychart/templates/mytemplate.yaml)
  • BasePath:当前 chart 模板目录的路径(例如 mychart/templates)。

上面这些值可用于任何顶级模板,要注意内置值始终以大写字母开头。这也符合Go的命名约定。当你创建自己的名字时,你可以自由地使用适合你的团队的惯例。

3.6 values 文件

上面的内置对象中有一个对象就是 Values,该对象提供对传入 chart 的值的访问,Values 对象的值有4个来源:

  • chart 包中的 values.yaml 文件
  • 父 chart 包的 values.yaml 文件
  • 通过 helm install 或者 helm upgrade 的-f或者--values参数传入的自定义的 yaml 文件(上节课我们已经学习过)
  • 通过--set参数传入的值

chart 的 values.yaml 提供的值可以被用户提供的 values 文件覆盖,而该文件同样可以被--set提供的参数所覆盖。

这里我们来重新编辑 mychart/values.yaml 文件,将默认的值全部清空,添加一个新的数据:(values.yaml)

[root@node01 ~]# vim mychart/values.yaml 
[root@node01 ~]# cat mychart/values.yaml 
course: k8s

然后我们在上面的 templates/configmap.yaml 模板文件中就可以使用这个值了:(configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  course: {{ .Values.course }}

可以看到最后一行我们是通过{{ .Values.course }}来获取 course 的值的。现在我们用 debug 模式来查看下我们的模板会被如何渲染:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '37197'

[debug] SERVER: "127.0.0.1:37197"

[debug] Original chart version: ""
[debug] CHART PATH: /root/mychart

NAME:   garish-waterbuffalo
REVISION: 1
RELEASED: Mon Sep 20 23:52:57 2021
CHART: mychart-0.1.0
USER-SUPPLIED VALUES:
{}

COMPUTED VALUES:
course: k8s

HOOKS:
MANIFEST:

---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: garish-waterbuffalo-configmap
data:
  myvalue: "Hello World"
  course: k8s

我们可以看到 ConfigMap 中 course 的值被渲染成了 k8s,这是因为在默认的 values.yaml 文件中该参数值为 k8s,同样的我们可以通过--set参数来轻松的覆盖 course 的值:

[root@node01 ~]# helm install --dry-run --debug --set course=python ./mychart
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: flippant-hound-configmap
data:
  myvalue: "Hello World"
  course: python

由于--set 比默认 values.yaml 文件具有更高的优先级,所以我们的模板生成为 course: python。

values 文件也可以包含更多结构化内容,例如,我们在 values.yaml 文件中可以创建 course 部分,然后在其中添加几个键:

[root@node01 ~]# vim mychart/values.yaml 
[root@node01 ~]# cat mychart/values.yaml 
course:
  k8s: devops
  python: django

现在我们稍微修改模板:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  k8s: {{ .Values.course.k8s }}
  python: {{ .Values.course.python }}

同样可以使用 debug 模式查看渲染结果:

[root@node01 ~]# helm install --dry-run --debug ./mychart
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: truculent-gorilla-configmap
data:
  myvalue: "Hello World"
  k8s: devops
  python: django

可以看到模板中的参数已经被 values.yaml 文件中的值给替换掉了。虽然以这种方式构建数据是可以的,但我们还是建议保持 value 树浅一些,平一些,这样维护起来要简单一点。

到这里,我们已经看到了几个内置对象的使用方法,并用它们将信息注入到了模板之中。

4. Helm 模板之模板函数与管道

上节课我们学习了如何将信息渲染到模板之中,但是这些信息都是直接传入模板引擎中进行渲染的,有的时候我们想要转换一下这些数据才进行渲染,这就需要使用到 Go 模板语言中的一些其他用法。

4.1 模板函数

比如我们需要从.Values中读取的值变成字符串的时候就可以通过调用quote模板函数来实现:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  k8s: {{ quote .Values.course.k8s }}
  python: {{ .Values.course.python }}

模板函数遵循调用的语法为:functionName arg1 arg2...。在上面的模板文件中,quote .Values.course.k8s调用quote函数并将后面的值作为一个参数传递给它。最终被渲染为:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '41661'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: vested-iguana-configmap
data:
  myvalue: "Hello World"
  k8s: "devops"
  python: django

我们可以看到.Values.course.k8s被渲染成了字符串devops。上节课我们也提到过 Helm 是一种 Go 模板语言,拥有超过60多种可用的内置函数,一部分是由Go 模板语言本身定义的,其他大部分都是Sprig模板库提供的一部分,我们可以前往这两个文档中查看这些函数的用法。

比如我们这里使用的quote函数就是Sprig 模板库提供的一种字符串函数,用途就是用双引号将字符串括起来,如果需要双引号",则需要添加\来进行转义,而squote函数的用途则是用双引号将字符串括起来,而不会对内容进行转义。

所以在我们遇到一些需求的时候,首先要想到的是去查看下上面的两个模板文档中是否提供了对应的模板函数,这些模板函数可以很好的解决我们的需求。

4.2 管道

模板语言除了提供了丰富的内置函数之外,其另一个强大的功能就是管道的概念。和UNIX中一样,管道我们通常称为Pipeline,是一个链在一起的一系列模板命令的工具,以紧凑地表达一系列转换。简单来说,管道是可以按顺序完成一系列事情的一种方法。比如我们用管道来重写上面的 ConfigMap 模板:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  k8s: {{ .Values.course.k8s | quote }}
  python: {{ .Values.course.python }}

这里我们直接调用quote函数,而是调换了一个顺序,使用一个管道符|将前面的参数发送给后面的模板函数{{ .Values.course.k8s | quote }},使用管道我们可以将几个功能顺序的连接在一起,比如我们希望上面的 ConfigMap 模板中的 k8s 的 value 值被渲染后是大写的字符串,则我们就可以使用管道来修改:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  k8s: {{ .Values.course.k8s | upper | quote }}
  python: {{ .Values.course.python }}

这里我们在管道中增加了一个upper函数,该函数同样是Sprig 模板库提供的,表示将字符串每一个字母都变成大写。然后我们用debug模式来查看下上面的模板最终会被渲染成什么样子:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '44004'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: jaundiced-giraffe-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: django

我们可以看到之前我们的devops已经被渲染成了"DEVOPS"了,要注意的是使用管道操作的时候,前面的操作结果会作为参数传递给后面的模板函数,比如我们这里希望将上面模板中的 python 的值渲染为重复出现3次的字符串,则我们就可以使用到Sprig 模板库提供的repeat函数,不过该函数需要传入一个参数repeat COUNT STRING表示重复的次数:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  k8s: {{ .Values.course.k8s | upper | quote }}
  python: {{ .Values.course.python | quote | repeat 3 }}

repeat函数会将给定的字符串重复3次返回,所以我们将得到这个输出:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '35730'

[debug] SERVER: "127.0.0.1:35730"

[debug] Original chart version: ""
[debug] CHART PATH: /root/mychart

Error: YAML parse error on mychart/templates/configmap.yaml: error converting YAML to JSON: yaml: line 7: did not find expected key

---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: dining-goose-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "django""django""django"

我们可以看到上面的输出中 python 对应的值变成了3个相同的字符串,这显然是不符合我们预期的,我们的预期是形成一个字符串,而现在是3个字符串了,而且上面还有错误信息,根据管道处理的顺序,我们将quote函数放到repeat函数后面去是不是就可以解决这个问题了:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: "Hello World"
  k8s: {{ .Values.course.k8s | upper | quote }}
  python: {{ .Values.course.python | repeat 3 | quote }}

现在是不是就是先重复3次.Values.course.python的值,然后调用quote函数:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '34845'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: precise-lionfish-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "djangodjangodjango"

现在是不是就正常了,也得到了我们的预期结果,所以我们在使用管道操作的时候一定要注意是按照从前到后一步一步顺序处理的。

4.3 default 函数

另外一个我们会经常使用的一个函数是default 函数default DEFAULT_VALUE GIVEN_VALUE。该函数允许我们在模板内部指定默认值,以防止该值被忽略掉了。比如我们来修改上面的 ConfigMap 的模板:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: {{ .Values.hello | default  "Hello World" | quote }}
  k8s: {{ .Values.course.k8s | upper | quote }}
  python: {{ .Values.course.python | repeat 5 | quote }}

由于我们的values.yaml文件中只定义了 course 结构的信息,并没有定义 hello 的值,所以如果没有设置默认值的话是得不到{{ .Values.hello }}的值的,这里我们为该值定义了一个默认值:Hello World,所以现在如果在values.yaml文件中没有定义这个值,则我们也可以得到默认值:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '36302'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: exiled-ferret-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "djangodjangodjangodjangodjango"

我们可以看到myvalue值被渲染成了Hello World,证明我们的默认值生效了。

5. Helm 模板之控制流程

模板函数和管道是通过转换信息并将其插入到YAML文件中的强大方法。但有时候需要添加一些比插入字符串更复杂一些的模板逻辑。这就需要使用到模板语言中提供的控制结构了。

控制流程为我们提供了控制模板生成流程的一种能力,Helm 的模板语言提供了以下几种流程控制:

  • if/else 条件块
  • with 指定范围
  • range 循环块

除此之外,它还提供了一些声明和使用命名模板段的操作:

  • define在模板中声明一个新的命名模板
  • template导入一个命名模板
  • block声明了一种特殊的可填写的模板区域

关于命名模板的相关知识点,我们会在后面的课程中和大家接触到,这里我们暂时和大家介绍if/elsewithrange这3中控制流程的用法。

5.1 if/else 条件

if/else块是用于在模板中有条件地包含文本块的方法,条件块的基本结构如下:

{{ if PIPELINE }}
  # Do something
{{ else if OTHER PIPELINE }}
  # Do something else
{{ else }}
  # Default case
{{ end }}

当然要使用条件块就得判断条件是否为真,如果值为下面的几种情况,则管道的结果为 false:

  • 一个布尔类型的
  • 一个数字
  • 一个的字符串
  • 一个nil(空或null)
  • 一个空的集合(mapslicetupledictarray)

除了上面的这些情况外,其他所有条件都为

同样还是以上面的 ConfigMap 模板文件为例,添加一个简单的条件判断,如果 python 被设置为 django,则添加一个web: true:(tempaltes/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: {{ .Values.hello | default  "Hello World" | quote }}
  k8s: {{ .Values.course.k8s | upper | quote }}
  python: {{ .Values.course.python | repeat 3 | quote }}
  {{ if eq .Values.course.python "django" }}web: true{{ end }}

在上面的模板文件中我们增加了一个条件语句判断{{ if eq .Values.course.python "django" }}web: true{{ end }},其中运算符eq是判断是否相等的操作,除此之外,还有neltgtandor等运算符都是 Helm 模板已经实现了的,直接使用即可。这里我们{{ .Values.course.python }}的值在values.yaml文件中默认被设置为了django,所以正常来说下面的条件语句判断为,所以模板文件最终被渲染后会有web: true这样的的一个条目:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '36977'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: dandy-crab-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "djangodjangodjango"
  web: true

可以看到上面模板被渲染后出现了web: true的条目,如果我们在安装的时候覆盖下 python 的值呢,比如我们改成 ai:

[root@node01 ~]# helm install --dry-run --debug --set course.python=ai ./mychart
[debug] Created tunnel using local port: '45502'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: banking-fish-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "aiaiai"

根据我们模板文件中的定义,如果{{ .Values.course.python }}的值为django的话就会新增web: true这样的一个条目,但是现在我们是不是通过参数--set将值设置为了 ai,所以这里条件判断为,正常来说就不应该出现这个条目了,上面我们通过 debug 模式查看最终被渲染的值也没有出现这个条目,证明条件判断是正确的。

5.2 空格控制

上面我们的条件判断语句是在一整行中的,如果平时经常写代码的同学可能非常不习惯了,我们一般会将其格式化为更容易阅读的形式,比如:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: {{ .Values.hello | default  "Hello World" | quote }}
  k8s: {{ .Values.course.k8s | upper | quote }}
  python: {{ .Values.course.python | repeat 3 | quote }}
  {{ if eq .Values.course.python "django" }}
  web: true
  {{ end }}

这样的话看上去比之前要清晰很多了,但是我们通过模板引擎来渲染一下,会得到如下结果:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '43335'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: pioneering-zebra-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "djangodjangodjango"
  
  web: true

我们可以看到渲染出来会有多余的空行,这是因为当模板引擎运行时,它将一些值渲染过后,之前的指令被删除,但它之前所占的位置完全按原样保留剩余的空白了,所以就出现了多余的空行。YAML文件中的空格是非常严格的,所以对于空格的管理非常重要,一不小心就会导致你的YAML文件格式错误。

我们可以通过使用在模板标识{{后面添加破折号和空格{{-来表示将空白左移,而在}}前面添加一个空格和破折号-}}表示应该删除右边的空格,另外需要注意的是换行符也是空格

使用这个语法,我们来修改我们上面的模板文件去掉多余的空格:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: {{ .Values.hello | default  "Hello World" | quote }}
  k8s: {{ .Values.course.k8s | upper | quote }}
  python: {{ .Values.course.python | repeat 3 | quote }}
  {{- if eq .Values.course.python "django" }}
  web: true
  {{- end }}

现在我们来查看上面模板渲染过后的样子:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '38602'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: quieting-deer-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "djangodjangodjango"
  web: true

现在是不是没有多余的空格了,另外我们需要谨慎使用-}},比如上面模板文件中:

python: {{ .Values.course.python | repeat 3 | quote }}
{{- if eq .Values.course.python "django" -}}
web: true
{{- end }}

如果我们在if条件后面增加-}},这会渲染成:

python: "djangodjangodjango"web: true

因为-}}它删除了双方的换行符,显然这是不正确的。

有关模板中空格控制的详细信息,请参阅官方 Go 模板文档Official Go template documentation

5.3 使用 with 修改范围

接下来我们来看下with关键词的使用,它用来控制变量作用域。还记得之前我们的{{ .Release.xxx }}或者{{ .Values.xxx }}吗?其中的.就是表示对当前范围的引用,.Values就是告诉模板在当前范围中查找Values对象的值。而with语句就可以来控制变量的作用域范围,其语法和一个简单的if语句比较类似:

{{ with PIPELINE }}
  #  restricted scope
{{ end }}

with语句可以允许将当前范围.设置为特定的对象,比如我们前面一直使用的.Values.course,我们可以使用with来将.范围指向.Values.course:(templates/configmap.yaml)

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: {{ .Values.hello | default  "Hello World" | quote }}
  {{- with .Values.course }}
  k8s: {{ .k8s | upper | quote }}
  python: {{ .python | repeat 3 | quote }}
  {{- if eq .python "django" }}
  web: true
  {{- end }}
  {{- end }}

可以看到上面我们增加了一个{{- with .Values.course }}xxx{{- end }}的一个块,这样的话我们就可以在当前的块里面直接引用.python.k8s了,而不需要进行限定了,这是因为该with声明将.指向了.Values.course,在{{- end }}后.就会复原其之前的作用范围了,我们可以使用模板引擎来渲染上面的模板查看是否符合预期结果。

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '39377'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: intentional-goose-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "djangodjangodjango"
  web: true

不过需要注意的是在with声明的范围内,此时将无法从父范围访问到其他对象了,比如下面的模板渲染的时候将会报错,因为显然.Release根本就不在当前的.范围内,当然如果我们最后两行交换下位置就正常了,因为{{- end }}之后范围就被重置了:

{{- with .Values.course }}
k8s: {{ .k8s | upper | quote }}
python: {{ .python | repeat 3 | quote }}
release: {{ .Release.Name }}
{{- end }}

5.4 range 循环

如果大家对编程语言熟悉的话,几乎所有的编程语言都支持类似于forforeach或者类似功能的循环机制,在 Helm 模板语言中,是使用range关键字来进行循环操作。

我们在values.yaml文件中添加上一个课程列表:

[root@node01 ~]# vim mychart/values.yaml 
[root@node01 ~]# cat mychart/values.yaml 
course:
  k8s: devops
  python: django
courselist:
- k8s
- python
- search
- golang

现在我们有一个课程列表,修改 ConfigMap 模板文件来循环打印出该列表:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  myvalue: {{ .Values.hello | default  "Hello World" | quote }}
  {{- with .Values.course }}
  k8s: {{ .k8s | upper | quote }}
  python: {{ .python | repeat 3 | quote }}
  {{- if eq .python "django" }}
  web: true
  {{- end }}
  {{- end }}
  courselist:
  {{- range .Values.courselist }}
  - {{ . | title | quote }}
  {{- end }}

可以看到最下面我们使用了一个range函数,该函数将会遍历{{ .Values.courselist }}列表,循环内部我们使用的是一个.,这是因为当前的作用域就在当前循环内,这个.从列表的第一个元素一直遍历到最后一个元素,然后在遍历过程中使用了titlequote这两个函数,前面这个函数是将字符串首字母变成大写,后面就是加上双引号变成字符串,所以按照上面这个模板被渲染过后的结果为:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '39867'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: banking-cow-configmap
data:
  myvalue: "Hello World"
  k8s: "DEVOPS"
  python: "djangodjangodjango"
  web: true
  courselist:
  - "K8s"
  - "Python"
  - "Search"
  - "Golang"
  - "Golang"

我们可以看到courselist按照我们的要求循环出来了。除了 list 或者 tuple,range 还可以用于遍历具有键和值的集合(如map 或 dict),这个就需要用到变量的概念了。

5.5 变量

前面我们已经学习了函数、管理以及控制流程的使用方法,我们知道编程语言中还有一个很重要的概念叫:变量,在 Helm 模板中,使用变量的场合不是特别多,但是在合适的时候使用变量可以很好的解决我们的问题。如下面的模板:

{{- with .Values.course }}
k8s: {{ .k8s | upper | quote }}
python: {{ .python | repeat 3 | quote }}
release: {{ .Release.Name }}
{{- end }}

我们在with语句块内添加了一个.Release.Name对象,但这个模板是错误的,编译的时候会失败,这是因为.Release.Name不在该with语句块限制的作用范围之内,我们可以将该对象赋值给一个变量可以来解决这个问题:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  {{- $releaseName := .Release.Name -}}
  {{- with .Values.course }}
  k8s: {{ .k8s | upper | quote }}
  python: {{ .python | repeat 3 | quote }}
  release: {{ $releaseName }}
  {{- end }}

我们可以看到我们在with语句上面增加了一句{{- $releaseName := .Release.Name -}},其中$releaseName就是后面的对象的一个引用变量,它的形式就是$name,赋值操作使用:=,这样with语句块内部的$releaseName变量仍然指向的是.Release.Name,同样,我们 DEBUG 下查看结果:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '45820'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: musty-cheetah-configmap
data:
  k8s: "DEVOPS"
  python: "djangodjangodjango"
  release: musty-cheetah

可以看到已经正常了,另外变量在range循环中也非常有用,我们可以在循环中用变量来同时捕获索引的值:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
courselist:
{{- range $index, $course := .Values.courselist }}
- {{ $index }}: {{ $course | title | quote }}
{{- end }}

例如上面的这个列表,我们在range循环中使用$index$course两个变量来接收后面列表循环的索引和对应的值,最终可以得到如下结果:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '46112'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: hasty-pug-configmap
data:
  k8s: "DEVOPS"
  python: "djangodjangodjango"
  release: hasty-pug
courselist:
- 0: "K8s"
- 1: "Python"
- 2: "Search"
- 3: "Golang"

我们可以看到 courselist 下面将索引和对应的值都打印出来了,实际上具有键和值的数据结构我们都可以使用range来循环获得二者的值,比如我们可以对.Values.course这个字典来进行循环:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
data:
  {{- range $key, $value := .Values.course }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '34481'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: gilded-seagull-configmap
data:
  k8s: "devops"
  python: "django"

直接使用range循环,用变量$key$value来接收字段.Values.course的键和值。这就是变量在 Helm 模板中的使用方法。

6. Helm模板之命名模板

前面我们学习了一些 Helm 模板中的一些常用使用方法,但是我们都是操作的一个模板文件,在实际的应用中,很多都是相对比较复杂的,往往会超过一个模板,如果有多个应用模板,我们应该如何进行处理呢?这就需要用到新的概念:命名模板。

命名模板我们也可以称为子模板,是限定在一个文件内部的模板,然后给一个名称。在使用命名模板的时候有一个需要特别注意的是:模板名称是全局的,如果我们声明了两个相同名称的模板,最后加载的一个模板会覆盖掉另外的模板,由于子 chart 中的模板也是和顶层的模板一起编译的,所以在命名的时候一定要注意,不要重名了。

为了避免重名,有个通用的约定就是为每个定义的模板添加上 chart 名称:{{define "mychart.labels"}}define关键字就是用来声明命名模板的,加上 chart 名称就可以避免不同 chart 间的模板出现冲突的情况。

6.1 声明和使用命名模板

使用define关键字就可以允许我们在模板文件内部创建一个命名模板,它的语法格式如下:

{{ define "ChartName.TplName" }}
# 模板内容区域
{{ end }}

比如,现在我们可以定义一个模板来封装一个 label 标签:

{{- define "mychart.labels" }}
  labels:
    from: helm
    date: {{ now | htmlDate }}
{{- end }}

然后我们可以将该模板嵌入到现有的 ConfigMap 中,然后使用template关键字在需要的地方包含进来即可:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
{{- define "mychart.labels" }}
  labels:
    from: helm
    date: {{ now | htmlDate }}
{{- end }}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  {{- template "mychart.labels" }}
data:
  {{- range $key, $value := .Values.course }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}

我们这个模板文件被渲染过后的结果如下所示:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '40254'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: singed-cardinal-configmap
  labels:
    from: helm
    date: 2021-09-21
data:
  k8s: "devops"
  python: "django"

我们可以看到define区域定义的命名模板被嵌入到了template所在的区域,但是如果我们将命名模板全都写入到一个模板文件中的话无疑也会增大模板的复杂性。

还记得我们在创建 chart 包的时候,templates 目录下面默认会生成一个_helpers.tpl文件吗?我们前面也提到过 templates 目录下面除了NOTES.txt文件和以下划线_开头命令的文件之外,都会被当做 kubernetes 的资源清单文件,而这个下划线开头的文件不会被当做资源清单外,还可以被其他 chart 模板中调用,这个就是 Helm 中的partials文件,所以其实我们完全就可以将命名模板定义在这些partials文件中,默认就是_helpers.tpl文件了。

现在我们将上面定义的命名模板移动到 templates/_helpers.tpl 文件中去:

{{/* 生成基本的 labels 标签 */}}
{{- define "mychart.labels" }}
  labels:
    from: helm
    date: {{ now | htmlDate }}
{{- end }}

一般情况下面,我们都会在命名模板头部加一个简单的文档块,用/**/包裹起来,用来描述我们这个命名模板的用途的。

现在我们讲命名模板从模板文件 templates/configmap.yaml 中移除,当然还是需要保留 template 来嵌入命名模板内容,名称还是之前的 mychart.lables,这是因为模板名称是全局的,所以我们可以能够直接获取到。我们再用 DEBUG 模式来调试下是否符合预期?

6.2 模板范围

上面我们定义的命名模板中,没有使用任何对象,只是使用了一个简单的函数,如果我们在里面来使用 chart 对象相关信息呢:

[root@node01 ~]# vim mychart/templates/_helpers.tpl
[root@node01 ~]# cat mychart/templates/_helpers.tpl
{{/* 生成基本的 labels 标签 */}}
{{- define "mychart.labels" }}
  labels:
    from: helm
    date: {{ now | htmlDate }}
    chart: {{ .Chart.Name }}
    version: {{ .Chart.Version }}
{{- end }}

当命名模板被渲染时,它会接收由 template 调用时传入的作用域,我们还要在 template 后面加上作用域范围即可:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  {{- template "mychart.labels" . }}
data:
  {{- range $key, $value := .Values.course }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}

我们在 template 末尾传递了.,表示当前的最顶层的作用范围,如果我们想要在命名模板中使用.Values范围内的数据,当然也是可以的,现在我们再来渲染下我们的模板:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '34355'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: crazy-lionfish-configmap
  labels:
    from: helm
    date: 2021-09-21
    chart: mychart
    version: 0.1.0
data:
  k8s: "devops"
  python: "django"

我们可以看到 chart 的名称和版本号都已经被正常渲染出来了。

6.3 include 函数

假如现在我们将上面的定义的 labels 单独提取出来放置到 _helpers.tpl 文件中:

[root@node01 ~]# vim mychart/templates/_helpers.tpl
[root@node01 ~]# cat mychart/templates/_helpers.tpl
{{/* 生成基本的 labels 标签 */}}
{{- define "mychart.labels" }}
from: helm
date: {{ now | htmlDate }}
chart: {{ .Chart.Name }}
version: {{ .Chart.Version }}
{{- end }}

现在我们将该命名模板插入到 configmap 模板文件的 labels 部分和 data 部分:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  labels:
    {{- template "mychart.labels" . }}
data:
  {{- range $key, $value := .Values.course }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
  {{- template "mychart.labels" . }}

然后同样的查看下渲染的结果:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '44078'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: incendiary-lionfish-configmap
  labels:
from: helm
date: 2021-09-21
chart: mychart
version: 0.1.0
data:
  k8s: "devops"
  python: "django"
from: helm
date: 2021-09-21
chart: mychart
version: 0.1.0

我们可以看到渲染结果是有问题的,不是一个正常的 YAML 文件格式,这是因为template只是表示一个嵌入动作而已,不是一个函数,所以原本命名模板中是怎样的格式就是怎样的格式被嵌入进来了,比如我们可以在命名模板中给内容区域都空了两个空格,再来查看下渲染的结构:

---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: mortal-cricket-configmap
  labels:
  from: helm
  date: 2021-09-21
  chart: mychart
  version: 0.1.0
data:
  k8s: "devops"
  python: "django"
  from: helm
  date: 2021-09-21
  chart: mychart
  version: 0.1.0

我们可以看到 data 区域里面的内容是渲染正确的,但是上面 labels 区域是不正常的,因为命名模板里面的内容是属于 labels 标签的,是不符合我们的预期的,但是我们又不可能再去把命名模板里面的内容再增加两个空格,因为这样的话 data 里面的格式又不符合预期了。

为了解决这个问题,Helm 提供了另外一个方案来代替template,那就是使用include函数,在需要控制空格的地方使用indent管道函数来自己控制,比如上面的例子我们替换成include函数:

[root@node01 ~]# vim mychart/templates/configmap.yaml 
[root@node01 ~]# cat mychart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap
  labels:
{{- include "mychart.labels" . | indent 4 }}
data:
  {{- range $key, $value := .Values.course }}
  {{ $key }}: {{ $value | quote }}
  {{- end }}
{{- include "mychart.labels" . | indent 2 }}

在 labels 区域我们需要4个空格,所以在管道函数indent中,传入参数4就可以,而在 data 区域我们只需要2个空格,所以我们传入参数2即可以,现在我们来渲染下我们这个模板看看是否符合预期呢:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '39001'
...
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: ugly-mouse-configmap
  labels:    
    from: helm
    date: 2021-09-21
    chart: mychart
    version: 0.1.0
data:
  k8s: "devops"
  python: "django"  
  from: helm
  date: 2021-09-21
  chart: mychart
  version: 0.1.0

可以看到是符合我们的预期,所以在 Helm 模板中我们使用 include 函数要比 template 更好,可以更好地处理 YAML 文件输出格式。

7. Helm模板之其他注意事项

上节课我们学习了命名模板的使用,命名模板是 Helm 模板中非常重要的一个功能,在我们实际开发 Helm Chart 包的时候非常有用,到这里我们基本上就把 Helm 模板中经常使用到的一些知识点和大家介绍完了。但是仍然还是有一些在开发中值得我们注意的一些知识点,比如 NOTES.txt 文件的使用、子 Chart 的使用、全局值的使用,这节课我们就来和大家一起了解下这些知识点。

7.1 NOTES.txt 文件

我们前面在使用 helm install 命令的时候,Helm 都会为我们打印出一大堆介绍信息,这样当别的用户在使用我们的 chart 包的时候就可以根据这些注释信息快速了解我们的 chart 包的使用方法,这些信息就是编写在 NOTES.txt 文件之中的,这个文件是纯文本的,但是它和其他模板一样,具有所有可用的普通模板函数和对象。

现在我们在前面的示例中 templates 目录下面创建一个 NOTES.txt 文件:

[root@node01 ~]# vim mychart/templates/NOTES.txt
[root@node01 ~]# cat mychart/templates/NOTES.txt
Thank you for installing {{ .Chart.Name }}.

Your release is named {{ .Release.Name }}.

To learn more about the release, try:

  $ helm status {{ .Release.Name }}
  $ helm get {{ .Release.Name }}

我们可以看到我们在 NOTES.txt 文件中也使用 Chart 和 Release 对象,现在我们在 mychart 包根目录下面执行安装命令查看是否能够得到上面的注释信息:

[root@node01 ~]# helm install ./mychart
NAME:   eloping-fly
LAST DEPLOYED: Tue Sep 21 23:07:31 2021
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/ConfigMap
NAME                   DATA  AGE
eloping-fly-configmap  6     0s


NOTES:
Thank you for installing mychart.

Your release is named eloping-fly.

To learn more about the release, try:

  $ helm status eloping-fly
  $ helm get eloping-fly

现在已经安装成功了,而且下面的注释部分也被渲染出来了,我们可以看到 NOTES.txt 里面使用到的模板对象都被正确渲染了。

为我们创建的 chart 包提供一个清晰的 NOTES.txt 文件是非常有必要的,可以为用户提供有关如何使用新安装 chart 的详细信息,这是一种非常友好的方式方法。

7.2 子 chart 包

我们到目前为止都只用了一个 chart,但是 chart 也可以有 子 chart 的依赖关系,它们也有自己的值和模板,在学习字 chart 之前,我们需要了解几点关于子 chart 的说明:

  • 子 chart 是独立的,所以子 chart 不能明确依赖于其父 chart
  • 子 chart 无法访问其父 chart 的值
  • 父 chart 可以覆盖子 chart 的值
  • Helm 中有全局值的概念,可以被所有的 chart 访问

7.3 创建子 chart

现在我们就来创建一个子 chart,还记得我们在创建 mychart 包的时候,在根目录下面有一个空文件夹 charts 目录吗?这就是我们的子 chart 所在的目录,在该目录下面添加一个新的 chart:

[root@node01 ~]# cd mychart/charts/
[root@node01 charts]# helm create mysubchart
Creating mysubchart
[root@node01 charts]# rm -rf mysubchart/templates/*.*

[root@node01 charts]# tree ..
..
├── charts
│   └── mysubchart
│       ├── charts
│       ├── Chart.yaml
│       ├── templates
│       └── values.yaml
├── Chart.yaml
├── templates
│   ├── configmap.yaml
│   ├── config.yaml
│   ├── _helpers.tpl
│   └── NOTES.txt
└── values.yaml

5 directories, 8 files

同样的,我们将子 chart 模板中的文件全部删除了,接下来,我们为子 chart 创建一个简单的模板和 values 文件了。

[root@node01 charts]# cat > mysubchart/values.yaml <<EOF
> in: mysub
> EOF

[root@node01 charts]# cat mysubchart/values.yaml 
in: mysub
[root@node01 charts]# cat > mysubchart/templates/configmap.yaml <<EOF
> apiVersion: v1
> kind: ConfigMap
> metadata:
>   name: {{ .Release.Name }}-configmap2
> data:
>   in: {{ .Values.in }}
> EOF

[root@node01 charts]# cat mysubchart/templates/configmap.yaml 
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ .Release.Name }}-configmap2
data:
  in: {{ .Values.in }}

我们上面已经提到过每个子 chart 都是独立的 chart,所以我们可以单独给 mysubchart 进行测试:

[root@node01 charts]# helm install --dry-run --debug ./mysubchart
[debug] Created tunnel using local port: '42049'
...
---
# Source: mysubchart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: cold-peahen-configmap2
data:
  in: mysub

我们可以看到正常渲染出了结果。

7.4 值覆盖

现在 mysubchart 这个子 chart 就属于 mychart 这个父 chart 了,由于 mychart 是父级,所以我们可以在 mychart 的 values.yaml 文件中直接配置子 chart 中的值,比如我们可以在 mychart/values.yaml 文件中添加上子 chart 的值:

[root@node01 ~]# vim mychart/values.yaml 
[root@node01 ~]# cat mychart/values.yaml 
course:
  k8s: devops
  python: django
courselist:
- k8s
- python
- search
- golang

mysubchart:
  in: parent

注意最后两行,mysubchart 部分内的任何指令都会传递到 mysubchart 这个子 chart 中去的,现在我们在 mychart 根目录中执行调试命令,可以查看到子 chart 也被一起渲染了:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '44314'
...
---
# Source: mychart/charts/mysubchart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: hazy-albatross-configmap2
data:
  in: parent
---
# Source: mychart/templates/configmap.yaml
...

我们可以看到子 chart 中的值已经被顶层的值给覆盖了。但是在某些场景下面我们还是希望某些值在所有模板中都可以使用,这就需要用到全局 chart 值了。

7.5 全局值

全局值可以从任何 chart 或者子 chart中进行访问使用,values 对象中有一个保留的属性是Values.global,就可以被用来设置全局值,比如我们在父 chart 的 values.yaml 文件中添加一个全局值:

[root@node01 ~]# vim mychart/values.yaml 
[root@node01 ~]# cat mychart/values.yaml 
course:
  k8s: devops
  python: django
courselist:
- k8s
- python
- search
- golang

mysubchart:
  in: parent

global:
  allin: helm

我们在 values.yaml 文件中添加了一个 global 的属性,这样的话无论在父 chart 中还是在子 chart 中我们都可以通过{{ .Values.global.allin }}来访问这个全局值了。比如我们在 mychart/templates/configmap.yaml 和 mychart/charts/mysubchart/templates/configmap.yaml 文件的 data 区域下面都添加上如下内容:

...
data:
  allin: {{ .Values.global.allin }}
...

现在我们在 mychart 根目录下面执行 debug 调试模式:

[root@node01 ~]# helm install --dry-run --debug ./mychart
[debug] Created tunnel using local port: '32908'
...
---
# Source: mychart/charts/mysubchart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: joyous-lambkin-configmap
data:
  allin: helm
---
# Source: mychart/templates/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: joyous-lambkin-configmap
data:
  allin: helm

我们可以看到两个模板中都输出了allin: helm这样的值,全局变量对于传递这样的信息非常有用,不过也要注意我们不能滥用全局值。

另外值得注意的是我们在学习命名模板的时候就提到过父 chart 和子 chart 可以共享模板。任何 chart 中的任何定义块都可用于其他 chart,所以我们在给命名模板定义名称的时候添加了 chart 名称这样的前缀,避免冲突。

8. Helm Hooks

和 Kubernetes 里面的容器一样,Helm 也提供了 Hook 的机制,允许 chart 开发人员在 release 的生命周期中的某些节点来进行干预,比如我们可以利用 Hooks 来做下面的这些事情:

  • 在加载任何其他 chart 之前,在安装过程中加载 ConfigMap 或 Secret
  • 在安装新 chart 之前执行作业以备份数据库,然后在升级后执行第二个作业以恢复数据
  • 在删除 release 之前运行作业,以便在删除 release 之前优雅地停止服务

值得注意的是 Hooks 和普通模板一样工作,但是它们具有特殊的注释,可以使 Helm 以不同的方式使用它们。

Hook 在资源清单中的 metadata 部分用 annotations 的方式进行声明:

apiVersion: ...
kind: ....
metadata:
  annotations:
    "helm.sh/hook": "pre-install"
# ...

接下来我们就来和大家介绍下 Helm Hooks 的一些基本使用方法。

8.1 Hooks

在 Helm 中定义了如下一些可供我们使用的 Hooks:

  • 预安装pre-install:在模板渲染后,kubernetes 创建任何资源之前执行
  • 安装后post-install:在所有 kubernetes 资源安装到集群后执行
  • 预删除pre-delete:在从 kubernetes 删除任何资源之前执行删除请求
  • 删除后post-delete:删除所有 release 的资源后执行
  • 升级前pre-upgrade:在模板渲染后,但在任何资源升级之前执行
  • 升级后post-upgrade:在所有资源升级后执行
  • 预回滚pre-rollback:在模板渲染后,在任何资源回滚之前执行
  • 回滚后post-rollback:在修改所有资源后执行回滚请求
  • crd-install:在运行其他检查之前添加 CRD 资源,只能用于 chart 中其他的资源清单定义的 CRD 资源。

8.2 生命周期

Hooks 允许开发人员在 release 的生命周期中的一些关键节点执行一些钩子函数,我们正常安装一个 chart 包的时候的生命周期如下所示:

  • 用户运行helm install foo
  • chart 被加载到服务端 Tiller Server 中
  • 经过一些验证,Tiller Server 渲染 foo 模板
  • Tiller 将产生的资源加载到 kubernetes 中去
  • Tiller 将 release 名称和其他数据返回给 Helm 客户端
  • Helm 客户端退出

如果开发人员在 install 的生命周期中定义了两个 hook:pre-installpost-install,那么我们安装一个 chart 包的生命周期就会多一些步骤了:

  • 用户运行helm install foo
  • chart 被加载到服务端 Tiller Server 中
  • 经过一些验证,Tiller Server 渲染 foo 模板
  • Tiller 将 hook 资源加载到 kubernetes 中,准备执行pre-install hook
  • Tiller 会根据权重对 hook 进行排序(默认分配权重0,权重相同的 hook 按升序排序)
  • Tiller 然后加载最低权重的 hook
  • Tiller 等待,直到 hook 准备就绪
  • Tiller 将产生的资源加载到 kubernetes 中
  • Tiller 执行post-install hook
  • Tiller 等待,直到 hook 准备就绪
  • Tiller 将 release 名称和其他数据返回给客户端
  • Helm 客户端退出

等待 hook 准备就绪,这是一个阻塞的操作,如果 hook 中声明的是一个 Job 资源,那么 Tiller 将等待 Job 成功完成,如果失败,则发布失败,在这个期间,Helm 客户端是处于暂停状态的。

对于所有其他类型,只要 kubernetes 将资源标记为加载(添加或更新),资源就被视为就绪状态,当一个 hook 声明了很多资源是,这些资源是被串行执行的。

另外需要注意的是 hook 创建的资源不会作为 release 的一部分进行跟踪和管理,一旦 Tiller Server 验证了 hook 已经达到了就绪状态,它就不会去管它了。

所以,如果我们在 hook 中创建了资源,那么不能依赖helm delete去删除资源,因为 hook 创建的资源已经不受控制了,要销毁这些资源,需要在pre-delete或者post-delete这两个 hook 函数中去执行相关操作,或者将helm.sh/hook-delete-policy这个 annotation 添加到 hook 模板文件中。

8.3 写一个 hook

上面我们也说了 hook 和普通模板一样,也可以使用普通的模板函数和常用的一些对象,比如ValuesChartRelease等等,唯一和普通模板不太一样的地方就是在资源清单文件中的 metadata 部分会有一些特殊的注释 annotation。

例如,现在我们来创建一个 hook,在前面的示例 templates 目录中添加一个 post-install-job.yaml 的文件,表示安装后执行的一个 hook:

[root@node01 ~]# vim mychart/templates/post-install-job.yaml
[root@node01 ~]# cat mychart/templates/post-install-job.yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: {{ .Release.Name }}-post-install-job
  lables:
    release: {{ .Release.Name }}
    chart: {{ .Chart.Name }}
    version: {{ .Chart.Version }}
  annotations:
    # 注意,如果没有下面的这个注释的话,当前的这个Job就会被当成release的一部分
    "helm.sh/hook": post-install
    "helm.sh/hook-weight": "-5"
    "helm.sh/hook-delete-policy": hook-succeeded
spec:
  template:
    metadata:
      name: {{ .Release.Name }}
      labels:
        release: {{ .Release.Name }}
        chart: {{ .Chart.Name }}
        version: {{ .Chart.Version }}
    spec:
      restartPolicy: Never
      containers:
      - name: post-install-job
        image: alpine
        command: ["/bin/sleep", "{{ default "10" .Values.sleepTime }}"]

上面的 Job 资源中我们添加一个 annotations,要注意的是,如果我们没有添加下面这行注释的话,这个资源就会被当成是 release 的一部分资源:

annotations:
  "helm.sh/hook": post-install

当然一个资源中我们也可以同时部署多个 hook,比如我们还可以添加一个post-upgrade的钩子:

annotations:
  "helm.sh/hook": post-install,post-upgrade

另外值得注意的是我们为 hook 定义了一个权重,这有助于建立一个确定性的执行顺序,权重可以是正数也可以是负数,但是必须是字符串才行。

annotations:
  "helm.sh/hook-weight": "-5"

最后还添加了一个删除 hook 资源的策略:

annotations:
  "helm.sh/hook-delete-policy": hook-succeeded

删除资源的策略可供选择的注释值:

  • hook-succeeded:表示 Tiller 在 hook 成功执行后删除 hook 资源
  • hook-failed:表示如果 hook 在执行期间失败了,Tiller 应该删除 hook 资源
  • before-hook-creation:表示在删除新的 hook 之前应该删除以前的 hook

当 helm 的 release 更新时,有可能 hook 资源已经存在于群集中。默认情况下,helm 会尝试创建资源,并抛出错误"... already exists"

我们可以选择 "helm.sh/hook-delete-policy": "before-hook-creation",取代 "helm.sh/hook-delete-policy": "hook-succeeded,hook-failed" 因为:

例如为了手动调试,将错误的 hook 作业资源保存在 kubernetes 中是很方便的。 出于某种原因,可能有必要将成功的 hook 资源保留在 kubernetes 中。同时,在 helm release 升级之前进行手动资源删除是不可取的。 "helm.sh/hook-delete-policy": "before-hook-creation" 在 hook 中的注释,如果在新的 hook 启动前有一个 hook 的话,会使 Tiller 将以前的release 中的 hook 删除,而这个 hook 同时它可能正在被其他一个策略使用。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值