Helm 实践

工作需要批量部署同一套ToB(to business)服务到多个k8s cluster/namespace 上,经过几百根头发的研究,本文介绍Helm的实践(踩的坑)。

我们希望可以通过脚本一键、按顺序部署一套服务到指定的k8s cluster/namespace 上

部署

状态检查

kubectl,我们可以用 kubectl rollout status 检查部署的状态。

1
2
3
4
5
6
7
# kubectl rollout status deployment/probe-busybox -n tenant-test
Waiting for rollout to finish: 2 out of 3 new replicas have been updated...
deployment "probe-busybox" successfully rolled out

# kubectl get deployments deployment/probe-busybox -n tenant-test
NAME READY UP-TO-DATE AVAILABLE AGE
probe-busybox 3/3 3 3 18s

那么在这种情况下,Helm 的等价物是什么?

--wait Helm 将等到部署中启动了最少预期数量的 Pod,然后再将发布标记为成功。

--timeout --wait 超时时间时间,默认情况下,超时时间为 300 秒。

1
2
3
4
5
6
7
$ helm upgrade --install --wait --timeout 30s probe-busybox probe-busybox/ -n tenant-test
Release "probe-busybox" does not exist. Installing it now.
NAME: probe-busybox
LAST DEPLOYED: Tue Mar 14 15:04:38 2023
NAMESPACE: tenant-test
STATUS: deployed
REVISION: 1

部署按预期成功完成。让我们看看当我们尝试使用失败的部署时会发生什么。设置就绪检查错误端口

1
2
3
4
5
6
7
8
$ helm upgrade --install --wait --timeout 30s probe-busybox probe-busybox/ -n tenant-test --set readinessProbe.tcpSocket.port=8081
Error: UPGRADE FAILED: timed out waiting for the condition

$ helm history probe-busybox -n tenant-test
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Tue Mar 14 15:04:38 2023 deployed chart-tpl-0.1.0 1.16.0 Install complete
2 Tue Mar 14 15:07:49 2023 failed chart-tpl-0.1.0 1.16.0 Upgrade "probe-busybox" failed: timed out waiting for the condition

回滚

  • 手动部署一个正确的 release
1
2
3
4
5
6
7
8
9
10
11
12
13
$ helm upgrade --install --wait --timeout 30s probe-busybox probe-busybox/ -n tenant-test
Release "probe-busybox" has been upgraded. Happy Helming!
NAME: probe-busybox
LAST DEPLOYED: Tue Mar 14 15:10:10 2023
NAMESPACE: tenant-test
STATUS: deployed
REVISION: 3

$ helm history probe-busybox -n tenant-test
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Tue Mar 14 15:04:38 2023 superseded chart-tpl-0.1.0 1.16.0 Install complete
2 Tue Mar 14 15:07:49 2023 failed chart-tpl-0.1.0 1.16.0 Upgrade "probe-busybox" failed: timed out waiting for the condition
3 Tue Mar 14 15:10:10 2023 deployed chart-tpl-0.1.0 1.16.0 Upgrade complete
  • 手动回滚helm rollback 将版本回滚到以前的版本
1
2
3
4
5
6
7
8
9
$  helm rollback --wait --timeout 30s probe-busybox 1 -n tenant-test
Rollback was a success! Happy Helming!

$ helm history probe-busybox -n tenant-test
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Tue Mar 14 15:04:38 2023 superseded chart-tpl-0.1.0 1.16.0 Install complete
2 Tue Mar 14 15:07:49 2023 failed chart-tpl-0.1.0 1.16.0 Upgrade "probe-busybox" failed: timed out waiting for the condition
3 Tue Mar 14 15:10:10 2023 superseded chart-tpl-0.1.0 1.16.0 Upgrade complete
4 Tue Mar 14 15:13:07 2023 deployed chart-tpl-0.1.0 1.16.0 Rollback to 1
  • 自动回滚--atomic 在升级失败的情况下回滚所做的更改
1
2
3
4
5
6
7
8
9
10
11
$ helm upgrade --install --atomic --timeout 30s probe-busybox probe-busybox/ -n tenant-test --set readinessProbe.tcpSocket.port=8081          
Error: UPGRADE FAILED: release probe-busybox failed, and has been rolled back due to atomic being set: timed out waiting for the condition

$ helm history probe-busybox -n tenant-test
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Tue Mar 14 15:04:38 2023 superseded chart-tpl-0.1.0 1.16.0 Install complete
2 Tue Mar 14 15:07:49 2023 failed chart-tpl-0.1.0 1.16.0 Upgrade "probe-busybox" failed: timed out waiting for the condition
3 Tue Mar 14 15:10:10 2023 superseded chart-tpl-0.1.0 1.16.0 Upgrade complete
4 Tue Mar 14 15:13:07 2023 superseded chart-tpl-0.1.0 1.16.0 Rollback to 1
5 Tue Mar 14 15:15:10 2023 failed chart-tpl-0.1.0 1.16.0 Upgrade "probe-busybox" failed: timed out waiting for the condition
6 Tue Mar 14 15:15:43 2023 deployed chart-tpl-0.1.0 1.16.0 Rollback to 4

问题

Helm 支持开箱即用的检查部署和自动回滚,但有下面两个问题。

  • 没有提供类似于kubectl rollout status 检查部署的状态,echo $?返回为 0 代表成功的命令,可以通过轮询 helm status 检测 status 为 deployed (有点麻烦)。
  • 超时时间是全局的,而pod数量并不确定,如果预估超时时间过短,未等到pod全部部署成功就会失败。如果预估超时时间过长,会浪费无谓的等待时间。

一键部署

目录结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
helm/
bin # 执行脚本
chart-tpl # 模板目录
Chart.yaml/ # 包含了chart信息的YAML文件
templates/ # 模板目录
deployment.yaml # 创建Kubernetes 工作负载的基本清单
service.yaml # 为你的工作负载创建一个 service 终端基本清单。
_helpers.tpl # 放置可以通过chart复用的模板辅助对象
NOTES.txt # chart的"帮助文本"
conf/
secrets/
secrets.probe-busybox.yaml # 加密配置
...
values/
common.yaml # 公共配置
infra/ # 中间件服务
apollo-portal.yaml
apollo-adminservice.yaml
other/ # 其它服务
probe-busybox.yaml
releases/
000000/
images/
java_images.txt # 镜像版本信息

为了实现一键批量部署,对 Chart 进行了一些改造,通过创建一个通用的 Chart 模板来支持多个服务的安装。

  • 通过在 _helpers.tpl 创建模板,并在 yaml 中引用以及流控制等方式创建 yaml 通用模板
  • common.yaml 为公共配置,提供默认值以及全局配置,会默认加载。
  • service.yaml 位对应服务个性化配置,会覆盖 common.yaml 配置。
  • image 版本信息存储在 外部目录中,部署时加载外部 images.txt 中版本信息并通过 --set image.address=xxx 设置
  • 通过部署时传入的名称、版本号、命名空间实现一键批量部署升级到不通 namespace 中

Yaml 模板

这里以 service.yaml为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{{- if and .Values.service .Values.service.port }}
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.name }}-svc
labels:
{{- include "chart-tpl.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
protocol: TCP
name: tcp-{{ .Values.service.targetPort }}-{{ .Values.service.port }}
selector:
{{- include "chart-tpl.selectorLabels" . | nindent 4 }}
{{- end }}

公共配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 默认公共配置,可被 -f APP_NAME.yaml 或 --set 覆盖
global:
storageClassName: default-sc
imagePullSecrets: [{"name": "123456key"}]
internalSubnetId: subnet-123456

name: ""
replicaCount: 1

# 镜像
image:
address: ""
pullPolicy: IfNotPresent

resources:
limits:
cpu: 2
memory: 4Gi
requests:
cpu: 0.5
memory: 2Gi

env:
ENV: PRO
IDC: default

podAnnotations: {}

# pvcs
pvcs: []

# 负载均衡
service:
# ClusterIP/LoadBalancer/NodePort
type: ClusterIP

# 存活检查
livenessProbe:
successThreshold: 1
failureThreshold: 5
timeoutSeconds: 10
periodSeconds: 60
initialDelaySeconds: 60

# 就绪检查
readinessProbe:
successThreshold: 1
failureThreshold: 5
timeoutSeconds: 10
periodSeconds: 60
initialDelaySeconds: 60

服务配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 容器名
name: "probe-busybox"
replicaCount: 1

# 镜像
image:
address: ""
pullPolicy: IfNotPresent

# 存活检查
livenessProbe:
tcpSocket:
port: 8080

# 就绪检查
readinessProbe:
tcpSocket:
port: 8080

# 负载均衡
service:
# ClusterIP/LoadBalancer/NodePort
type: LoadBalancer
port: 80
targetPort: 8080
internalNetEnabled: false

pvcs:
- name: probe-test
storage: 10Gi
# ReadWriteOnce/ReadOnlyMany/ReadWriteMany
accessModes:
- ReadWriteMany
volumeMounts:
- name: log1
mountPath: /data/logs1
subPath: test

启动脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
#!/bin/bash
#处理参数,规范化参数

ARGS=`getopt -o i:r:d -l app-name:,release-version:,namespace:,dry-run,wait,timeout:,set: -- $*`

if [ $? -ne 0 ]; then
echo 'Usage: ...'
exit 1
fi
#重新排列参数顺序
set -- ${ARGS}
#通过shift和while循环处理参数
echo ${ARGS}
while :
do
case "$1" in
-i)
APP_IMAGE=$(echo $2 | tr -d "'")
shift 2
;;
-r)
RELEASE_VERSION=$(echo $2 | tr -d "'")
shift 2
;;
-d)
DEBUG_ENABLE=true
shift 1
;;
--dry-run)
COMMAND_OPT="${COMMAND_OPT} $1"
shift 1
;;
--set)
COMMAND_OPT="${COMMAND_OPT} $1 $2"
shift 1
;;
--wait)
COMMAND_OPT="${COMMAND_OPT} $1"
shift 1
;;
--timeout)
COMMAND_OPT="${COMMAND_OPT} $1 $2"
shift 2
;;
--atomic)
COMMAND_OPT="${COMMAND_OPT} $1"
shift 1
;;
--)
shift
break
;;
*)
shift
;;
esac
done

readonly APP_NAME=$(echo $1 | tr -d "'")
readonly NAMESPACE=$(echo $2 | tr -d "'")

if [ ! "${APP_NAME}" ]; then
echo "APP_NAME must not be null"
exit 1
fi

if [ "${RELEASE_VERSION}" ] && [ "${APP_IMAGE}" ]; then
echo "-r 版本迭代、-i 镜像 不能同时传递"
exit 1
fi

if [ ! "${NAMESPACE}" ]; then
echo "NAMESPACE must not be null"
exit 1
fi

readonly IMAGE_PREFIX="www.xxx.mirrors"
readonly PROJECT_DIR=$(cd "$(dirname "$0")/../.." || exit 1; pwd)
readonly HELM_DIR=${PROJECT_DIR}/helm
readonly RELEASE_DIR=${PROJECT_DIR}/releases/${RELEASE_VERSION}

function valuesPrase() {
local filepath=$1
local local_type=$(dirname "${filepath}" | cut -d '/' -f 2)

local local_app_name=$(basename "${filepath}" | cut -d '.' -f 1)
for image in $(cat ${RELEASE_DIR}/images/java_images.txt ${RELEASE_DIR}/images/other_images.txt); do
temp_app_name=$(echo ${image} | cut -d ':' -f 1)
if [[ "${local_app_name}" == "${temp_app_name}" ]]; then
helmUpgrade "${local_app_name}" "${image}"
break
fi
done
}

function helmUpgrade() {
local local_app_name=$1
local local_image=$2

cd "${HELM_DIR}/conf/values" || exit 1
local local_filepath=$(find . -type f -maxdepth 2 -mindepth 2 -name "${local_app_name}.yaml")
[ -z "${local_filepath}" ] && echo "${local_app_name} not found" && exit 1
local local_type=$(dirname "${local_filepath}" | cut -d '/' -f 2)

cd "${HELM_DIR}" || exit 1

HELM_COMMAND="helm secrets upgrade --install"
if [[ "${DEBUG_ENABLE}" = true ]]; then
HELM_COMMAND="${HELM_COMMAND} --debug"
fi
HELM_COMMAND="${HELM_COMMAND} -n ${NAMESPACE}"
HELM_COMMAND="${HELM_COMMAND} -f ./conf/values/common.yaml"
HELM_COMMAND="${HELM_COMMAND} -f ./conf/values/${local_type}/${local_app_name}.yaml"
local secret_file="./conf/secrets/secrets.${local_app_name}.yaml"
if [[ -f "$secret_file" ]]; then
HELM_COMMAND="${HELM_COMMAND} -f ${secret_file}"
fi
HELM_COMMAND="${HELM_COMMAND} ${local_app_name} ./chart-tpl"
HELM_COMMAND="${HELM_COMMAND} --set image.address=${IMAGE_PREFIX}/${local_image}"
HELM_COMMAND="${HELM_COMMAND} ${COMMAND_OPT}"

echo "${HELM_COMMAND}"
eval "${HELM_COMMAND}"
}

if [ "${APP_IMAGE}" ]; then
# 指定镜像启动
helmUpgrade "${APP_NAME}" "${APP_IMAGE}"
elif [ "${RELEASE_VERSION}" ]; then
cd "${HELM_DIR}/conf/values" || exit 1
# 指定迭代启动
if [[ "${APP_NAME}" =~ "-" ]]; then
filepath=$(find . -type f -maxdepth 2 -mindepth 2 -name "${APP_NAME}.yaml")
[ -z "${filepath}" ] && echo "${APP_NAME} not found" && exit 1
valuesPrase "${filepath}"
elif [[ "${APP_NAME}" == "all" ]]; then
filepaths=$(find . -type f -maxdepth 2 -mindepth 2 -name "*.yaml")
for filepath in ${filepaths}
do
valuesPrase "${filepath}"
done
else
filepaths=$(find "./${APP_NAME}" -type f -name "*.yaml")
for filepath in ${filepaths}
do
valuesPrase "${filepath}"
done
fi
else
echo "-r 版本迭代、-i 镜像 至少需要一个"
exit 1
fi

启动命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 指定 image 启动
./helm.sh probe-busybox namespace -i probe-busybox:2302022222

# 指定-r迭代启动
# 启动单个服务
./helm.sh probe-busybox namespace -r 230202
# 按类型批量启动服务
./helm.sh infra namespace -r 230202
# 批量启动所有服务
./helm.sh all namespace -r 230202

# 设置 环境变量
./helm.sh probe-busybox namespace -r 230202 --set env.ENV=PRO --set env.CLUSTER=a
# 或
./helm.sh probe-busybox namespace -r 230202 --set env.ENV=PRO,env.CLUSTER=a

加密

由于上述配置是要通过git做版本控制的,实际应用中存在一些环境变量,如密码等不能明文提交到git上,这里使用了helm secrets插件实现加密后提交到git,并在 helm 安装的渲染时解密

安装

helm-secret

1
2
3
helm plugin install <https://github.com/futuresimple/helm-secrets>
# 查看
helm plugin list

helm-secrets 插件本身并没有任何加密与解密的能力,而它所有的工作,都是通过调用 SOPS 命令来帮助它完成的。

SOPS 是由 Mozilla 开发的一款开源的文本编辑工具,它支持对 YAML, JSON, ENV, INI 和 BINARY 文本格式的文件进行编辑,并利用 AWS KMS, GCP KMS, Azure Key Vault 或 PGP 等加密方式对编辑的文件进行加密和解密。

helm-secrets 插件会自动检测并安装 sops 命令到我们的系统中,sops -v 查看。

GPG

GPG,全名 GNU Privacy Guard,是隶属于 GNU 项目下的一款开源免费的加密工具。目前绝大部分 Linux 发行版本都默认安装了 GPG

1
2
3
4
5
6
7
8
9
10
# Ubuntu,Debian 用户
$ sudo apt install gnupg

# CentOS,Fedora,RHEL 用户
$ sudo yum install gnupg

# MacOS 用户
$ brew install gnupg

gpg --version

生成 GPG 密钥对

1
gpg --gen-key

列出当前系统中的公钥和私钥信息

1
2
gpg --list-key
gpg --list-secret-keys

设置秘钥配置文件

1
2
3
4
5
6
7
8
9
# 通过环境变量设置秘钥信息
export SOPS_PGP_FP=xxx
# 通过配置文件设置秘钥信息
# 每当执行 SOPS 命令时,它都会在当前目录下查找名为 .sops.yaml 的文件作为它的配置文件
creation_rules:
- pgp: "13D525EEF0A5FA38F4E78F7900E0160999E3C663"
# 删除 PGP 密钥对
$ gpg --delete-secret-key xxx
$ gpg --delete-key xxx

加解密

1
2
# 加密
helm secrets enc secrets.probe-busybox.yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 加密前
env:
password: test
# 加密后
env:
password: ENC[AES256_GCM,data:jo8lGGOFZuDY,iv:5Wwt/T0SskFxgWXNRjpoIPq+PIuTGILGIeN/dYOJ/Fg=,tag:ncC3t67xArsXvwwEjAGyLg==,type:str]

sops:
kms: []
gcp_kms: []
azure_kv: []
lastmodified: '2020-04-24T03:48:37Z'
mac: ENC[AES256_GCM,data:uFdXa2qWDSYqaeVsOLZiQos5K611uZYW91ZhLT00MJRb32TxE190RlJjhvl8+/GUOClZcIaU8DejebDP1TVqVFl6wpFqjVM3TLwW0JDm+b+zpCzMje9e17dNjLp7W2awBTPmrF3AXUopLi8oHOuopW89q2gKgFIUW215zjmQET0=,iv:A85xzE6gEXpcwUE6rIvHwHNhqmaCmFOHoYX3Y4qjaGI=,tag:VSB9b9vKLRJg4/klwliJbQ==,type:str]
pgp:
- created_at: '2020-04-24T03:48:35Z'
enc: |
-----BEGIN PGP MESSAGE-----

hQIMA8ebLzL57hqOAQ//fOzjkY5tW1/fGd/HWrxsgC02YxAjmHggI2ek5VacdhYP
A9RUYhpipJpBt1LnwHq/B1rV1E4dkOu1lpyAmI9P0qIc+6o0+6jEhqEyjsDQSGn5
kh31oBNYfLq8XpHQg33jOIHpv6/BU7tqzsVMum3HjvnsSrhc3gRtBq5LZoLP/smA
3y36tRLHIGFGqOEwy3CdSiPmsyCKQBEYRK0+7mhXX+ulEMudYXKgXk4qCL1UAB0y
X03K0UATNYp8fRkHqzcpf5nLDNzpCGI0BNbxBQYZdbcP3KFNyKGDFtDaNCcJq9jv
d6yMnioNWYBCtDlrZXlQGzipheWKwZ7JnNa1nmYpCJ3uh6I3mbtkHjljD88QUm6Y
czGAsTDYESJPl5y2wdKdMxHOyE++Ii5LvNr2UD3D+ePYvAIpK1TWjfokCe18ZvvD
v4kHbqbJfffpLCmy0CRVFu/yLnGdZGqniPY/UPPRk28cnKF+fxpX7EmLvzCUgadC
4emIrR6nBUgGvU+fInZrNOccRhYU2S4So45CW2EXW5E4uNj8ayfUgtaUeRwW8pRE
ZMGe1yna7a8UC0syiubC1rr8KHKs8nITfRrelV/BtEkfFDI9sm77AMcaWaAaaBz8
C1L3A1iPhnclDnt3USqOTioLnZs9CjysyNSeiTvehsTC1E3GqgmVbUGob+0Im2fS
XgHaA9fXLtulXkRQGFYpaNEt6r0mkgdq0DXCCfba6EflHg9BvPfrK0dtXrchlCY/
K154U0LkPNHtLBXB0rNwz0Z9aA1CwBdRZ6r8V67SJS1nbsiIvyHfc4dq8n3qhVM=
=HOrp
-----END PGP MESSAGE-----
fp: 00E0160999E3C663
unencrypted_suffix: _unencrypted
version: 3.5.0

查看解密内容:

1
helm secrets view secrets.probe-busybox.yaml

解密后的内容将被存储到新创建的 secrets.probe-busybox.yaml.dec 文件中去。

1
helm secrets dec secrets.probe-busybox.yaml

服务依赖

服务中存在依赖关系,因此需要等待前一个依赖的服务部署完成后才能部署后一个服务,比如需要先部署apollo再部署业务服务。

然而不行的是 包括docker-compose 、kubectl 、helm 在内都没有类似的功能。

  • docker-compose 的 depends_on 也只是会先启动依赖的服务,再启动自己,并不会等到依赖的服务部署完成。

  • 而 k8s 中更没有和 depends_on 类似的语句,我们可以通过entrypoint、initContainer 或者 就绪探针 写一段shell脚本一直循环运行,直到检测到依赖服务都启动完毕才退出,并启动自身容器。但太重了,不符合上面k8s理念。

  • helm dependencies 只是管理依赖并依据依赖创建pod,按照类型和名称排序并启动(详见上一篇helm 依赖顺序),还不如 docker-compose。

Kubernetes云原生的理念要求任何应用应该是“独立的”,应用自身是可以处理未连接或者重连接的情况,而不是交由Kuberntest集群层面来做,因为对于k8s来说,Pod或者Container是随时会被调度/重起的,k8s自身会检测所有的pod里的容器并确保它们活着并给他们贴上Healthy的标签,所以应该是尽量独立解耦的,不应该依赖其他服务才可以启动。

总结

考虑到第一次部署时,肯定要等到所有服务部署完毕,才会放流量,因此服务启动顺序并不很重要,只要保证最终服务都正常即可。

服务升级部署时,由于是滚动更新,当新pod就绪检查通过后,旧pod才会销毁,也可以保证服务不会因此中断,只需支持启动失败时自动回滚即可。

因此服务应尽量独立解耦,服务间存在依赖也不会有明显问题,helm 部署脚本不指定服务启动顺序。

所有 values 中的值均可用 --set 覆盖,在可以使用命令行一键部署的情况下,还可以为后面 Saas 平台管理数据部署提供扩展。

参考


Helm 实践
https://zhengshuoo.github.io/posts/016-helm-Practice
作者
zhengshuo
发布于
2023年3月12日
许可协议