Skip to content

Helm Chart 开发示例

作者: ryan 发布于: 2025/8/13 更新于: 2025/8/13 字数: 0 字 阅读: 0 分钟

前面介绍了 Helm 的基本使用,以及 Helm chart 包开发相关的一些知识点,下面我们用一个实例来演示下如何开发一个真正的 Helm chart 包。

应用

我们这里以 Ghost 博客应用为例来演示如何开发一个完整的 Helm chart 包,Ghost 是基于 Node.js 的开源博客平台。

在开发 Helm chart 包之前我们最需要做的的就是要知道我们自己的应用应该如何使用、如何部署,不然是不可能编写出对应的 Chart 包的。

启动 Ghost 最简单的方式是直接使用镜像启动:

bash
docker run -d --name my-ghost -p 2368:2368 ghost

现在我们就可以通过 http://localhost:2368 访问 Ghost 博客了。

如果我们想要在 Kubernetes 集群中部署两个副本的 Ghost,可以直接应用下面的资源清单文件即可:

yaml
##ghost/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ghost
spec:
  selector:
    matchLabels:
      app: ghost-app
  replicas: 2
  template:
    metadata:
      labels:
        app: ghost-app
    spec:
      containers:
        - name: ghost-app
          image: ghost
          ports:
            - containerPort: 2368
---
# ghost/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: ghost
spec:
  type: NodePort
  selector:
    app: ghost-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 2368

直接通过 kubectl 应用上面的资源对象即可:

bash
$ kubectl apply -f ghost/deployment.yaml ghost/service.yaml
deployment.apps/ghost created
service/ghost created

$ kubectl get pod -l app=ghost-app 
NAME                    READY   STATUS    RESTARTS   AGE
ghost-dfd958cc9-s7t89   1/1     Running   0          2m3s
ghost-dfd958cc9-zvtkf   1/1     Running   0          2m3s

$ kubectl get svc ghost
NAME    TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
ghost   NodePort   10.105.75.105   <none>        80:32625/TCP   2m17s

这样我们就可以通过 http://<nodeip>:32625 访问到 Ghost 了:

看上去要部署 Ghost 是非常简单的,但是如果我们需要针对不同的环境进行不同的设置呢?比如我们想将它部署到不同环境(staging、prod)中去,是不是我们需要一遍又一遍地复制我们的 Kubernetes 资源清单文件,这还只是一个场景,还有很多场景可能需要我们去部署应用,这种方式维护起来是非常困难的,这个时候就可以由 Helm 来解放我们了。

基础模板

现在我们开始创建一个新的 Helm chart 包。直接使用 helm create 命令即可

bash
$ helm create my-ghost
Creating my-ghost

$ tree my-ghost/
my-ghost/
├── charts
├── Chart.yaml
├── templates
   ├── deployment.yaml
   ├── _helpers.tpl
   ├── hpa.yaml
   ├── ingress.yaml
   ├── NOTES.txt
   ├── serviceaccount.yaml
   ├── service.yaml
   └── tests
       └── test-connection.yaml
└── values.yaml

3 directories, 10 files

该命令会创建一个默认 Helm chart 包的脚手架,可以删掉下面的这些使用不到的文件

bash
templates/tests/test-connection.yam1
templates/serviceaccount.yaml
templates/ingress.yaml
templates/hpa.yaml
templates/NOTES.txt

修改 templates/deployment.yaml 模板文件:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ghost
spec:
  selector:
    matchLabels:
      app: ghost-app
  replicas: {{ .Values.replicaCount }}
  template:
    metadata:
      labels:
        app: ghost-app
    spec:
      containers:
        - name: ghost-app
          image: {{ .Values.image }}
          ports:
            - containerPort: 2368
          env:
            - name: NODE_ENV
              value: {{ .Values.node_env | default "production" }}
            {{- if .Values.url }}
            - name: url
              value: http://{{ .Values.url }}
            {{- end }}
这和我们前面的资源清单文件非常类似,只是将 replicas 的值使用{{ .Values.repliCacount }};

模板来进行替换了,表示会用 replicacount 这个 Values 值进行渲染

还可以通过设置环境变量来配置 Ghost,同样修改templates/service.yaml 模板文件的内容:
yaml
# templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: ghost
spec:
  selector:
    app: ghost-app
  type: {{ .Values.service.type }}
  ports:
    - protocol: TCP
      targetPort: 2368
      port: {{ .Values.service.port }}
      {{- if (and (or (eq .Values.service.type "NodePort") (eq .Values.service.type "LoadBalancer")) (not (empty .Values.service.nodePort))) }}
      nodePort: {{ .Values.service.nodePort }}
      {{- end }}

同样为了能够兼容多个场景,这里我们允许用户来定制 Service type,如果是 NodePort 类型则还可以配置nodePort的值,不过需要注意这里的判断,因为有可能即使配置为 NodePort 类型,用户也可能不会主动提供nodePort,所以这里我们在模板中做了一个条件判断:

yaml
      {{- if (and (or (eq .Values.service.type "NodePort") (eq .Values.service.type "LoadBalancer")) (not (empty .Values.service.nodePort))) }}
      nodePort: {{ .Values.service.nodePort }}
      {{- end }}

需要 service.typeNodePort 或者 LoadBalancer 并且 service,nodeport 不为空的情况下才会渲染nodePort

接下来最重要的就是要在values.yaml 文件中提供默认的 Values 值,如下所示我们提供的默认的 Values 值:

yaml
# values.yaml
replicaCount: 1
image: ghost:latest
node_env: production
url: ghost.k8s.local

service:
  type: NodePort
  port: 80
  nodePort: 31233

我们使用 helm template 命令来渲染我们的模板输出结果:

bash
$ root@master01:/k8s-data/helm/ghost# helm template --debug my-ghost
install.go:218: [debug] Original chart version: ""
install.go:235: [debug] CHART PATH: /k8s-data/helm/ghost/my-ghost

---
# Source: my-ghost/templates/service.yaml
# templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: ghost
spec:
  selector:
    app: ghost-app
  type: NodePort
  ports:
    - protocol: TCP
      targetPort: 2368
      port: 80
      nodePort: 31233
---
# Source: my-ghost/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ghost
spec:
  selector:
    matchLabels:
      app: ghost-app
  replicas: 1
  template:
    metadata:
      labels:
        app: ghost-app
    spec:
      containers:
        - name: ghost-app
          image: ghost:latest
          ports:
            - containerPort: 2368
          env:
            - name: NODE_ENV
              value: production
            - name: url
              value: http://ghost.k8s.local

上面的渲染结果和我们上面的资源清单文件基本上一致了,只是我们现在的灵活性更大了,比如可以控制环境变量、服务的暴露方式等等。

命名模板

虽然现在我们可以使用 Helm charts 模板来渲染安装 Ghost 了,但是上面我们的模板还有很多改进的地方,比如资源对象的名称我们是固定的,这样我们就没办法在同一个命名空间下面安装多个应用了,所以一般我们也会根据 chart名称或者 Release 名称来替换资源对象的名称。

前面默认创建的模板中包含一个 _helpers.tpl 的文件,该文件中包含一些和名称、标签相关的命名模板,我们可以直接使用即可,下面是默认生成的已有的命名模板:

yaml
{{/*
Expand the name of the chart.
*/}}
{{- define "my-ghost.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "my-ghost.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "my-ghost.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Common labels
*/}}
{{- define "my-ghost.labels" -}}
helm.sh/chart: {{ include "my-ghost.chart" . }}
{{ include "my-ghost.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{/*
Selector labels
*/}}
{{- define "my-ghost.selectorLabels" -}}
app.kubernetes.io/name: {{ include "my-ghost.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Create the name of the service account to use
*/}}
{{- define "my-ghost.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "my-ghost.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

{{- define "my-ghost.fullname" -}}: 这行定义了一个 Helm 模板,名称为 "my-ghost.fullname"。

{{- if .Values.fullnameOverride }}: 这是一个条件语句,检查是否定义了 .Values.fullnameOverride。如果定义了,就执行下面的逻辑。

{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}: 如果 .Values.fullnameOverride 存在,将其值截断为最多 63 个字符,并去除末尾的 "-" 符号。

{{- else }}: 如果 .Values.fullnameOverride 不存在,则执行下面的逻辑。

{{- $name := default .Chart.Name .Values.nameOverride }}: 这行定义了一个变量 $name,其值为 .Values.nameOverride,如果为空则为 .Chart.Name。

{{- if contains $name .Release.Name }}: 这是另一个条件语句,检查 $name 是否包含 .Release.Name 的值。

{{- .Release.Name | trunc 63 | trimSuffix "-" }}: 如果 $name 包含 .Release.Name 的值,则将 .Release.Name 截断为最多 63 个字符,并去除末尾的 "-" 符号。

{{- else }}: 如果 $name 不包含 .Release.Name 的值,则执行下面的逻辑。

{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}: 最后,使用 printf 函数将 .Release.Name 和 $name 组合成一个字符串,中间用 "-" 分隔,截断为最多 63 个字符,并去除末尾的 "-" 符号。

替换Deployment 的名称和标签

我们可以将 Deployment 的名称和标签替换掉:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ template "my-ghost.fullname" . }}
  lables:
{{ include "my-ghost.labels" . | indent 4 }}
spec:
  selector:
    matchLabels:
      {{- include "my-ghost.selectorLabels" . | nindent 6 }} #nindent 在去掉前面空格时候换行
  replicas: {{ .Values.replicaCount }}
  template:
    metadata:
      labels:
{{ include "my-ghost.selectorLabels" . | indent 8 }}
    spec:
    # other spec...

增加label 标签

为 Deployment 增加 label 标签,同样 labeSelector 中也使用 my-ghost.selectorLabels 这个命名模板进行替换,同样对 Service 也做相应的改造:

yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ template "my-ghost.fullname" . }}
spec:
  type: {{ .Values.service.type }}
  selector:
    {{- include "my-ghost.selectorLabels" . | nindent 4 }}
  ports:
    - protocol: TCP
      targetPort: 2368
      port: {{ .Values.service.port }}
      {{- if (and (or (eq .Values.service.type "NodePort") (eq .Values.service.type "LoadBalancer")) (not (empty .Values.service.nodePort))) }}
      nodePort: {{ .Values.service.nodePort }}
      {{- end }}

渲染验证

现在我们可以再使用 helm template 渲染验证结果是否正确:

bash
$ helm install --generate-name --dry-run ghost
NAME: my-ghost-1721036551
LAST DEPLOYED: Mon Jul 15 17:42:31 2024
NAMESPACE: default
STATUS: pending-install
REVISION: 1
TEST SUITE: None
HOOKS:
MANIFEST:
---
# Source: my-ghost/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-ghost-1721036551
spec:
  type: NodePort
  selector:
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: my-ghost-1721036551
  ports:
    - protocol: TCP
      targetPort: 2368
      port: 80
      nodePort: 31233
---
# Source: my-ghost/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-ghost-1721036551
  labels:
    helm.sh/chart: my-ghost-0.1.0
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: my-ghost-1721036551
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: my-ghost
      app.kubernetes.io/instance: my-ghost-1721036551
  replicas: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: my-ghost
        app.kubernetes.io/instance: my-ghost-1721036551
    spec:
      containers:
        - name: ghost-app
          image: ghost:latest
          ports:
            - containerPort: 2368
          env:
            - name: NODE_ENV
              value: production
            - name: url
              value: http://ghost.k8s.local

版本兼容

由于 Kubernetes 的版本迭代非常快,所以我们在开发 chart 包的时候有必要考虑到对不同版本的 Kubernetes 进行兼容,最明显的就是Ingress 的资源版本。

Kubernetes 在1.19 版本为 Ingress,资源引入了一个新的 API:networking.k8s.io/v1,这与之前的 networking.k8s.io/v1beta1 beta 版本使用方式基本一致,但是和前面的extensions/v1beta1 这个版本在使用上有很大的不同,资源对象的属性上有一定的区别,所以要兼容不同的版本,我们就需要对模板中的 Ingress 对象做兼容处理。

新版本的资源对象格式如下所示:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: minimal-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
  - http:
      paths:
      - path: /testpath
        pathType: Prefix
        backend:
          service:
            name: test
            port:
              number: 80

而旧版本的资源对象格式如下:

yaml
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: minimal-ingress
  annorations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
  - http:
      paths:
      - path: /testpath
        backend:
          serviceName: test
          servicePort: 80

创建 Ingress 的模板

现在我们再为 Ghost 添加一个 Ingress 的模板,新建 template/ingress.yaml模板文件,先添加一个v1 版本的 Ingress 模板:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ghost
spec:
  ingressClassName: nginx
  rules:
  - host: ghost.k8s.local
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: ghost
            port:
              number: 80

同样将名称和服务名称这些使用参数进行替换:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ template "my-ghost.fullname" . }}
  labels: 
{{ include "my-ghost.labels" . | indent 4 }}
spec:
  ingressClassName: nginx
  rules:
  - host: {{ .Valuse.url }}
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: {{ template "my-ghost.fullname" . }}
            port:
              number: {{ .Values.service.port }}

创建用于判断集群版本或 API 的命名模板

然后接下来我们来兼容下其他的版本格式,这里需要用到 Capabilities 对象,在 Chart 包的 _helpers.tpl 文件中添加几个用于判断集群版本或 API 的命名模板:

yaml
{{/* Allow KubeVersion to be overridden. */}}
{{- define "my-ghost.kubeVersion" -}}
  {{- default .Capabilities.KubeVersion.Version .Values.kubeVersionOverride -}}
{{- end -}}



{{/* 获取 Ingress 的 API 版本 */}}
{{- define "my-ghost.ingress.apiVersion" -}}
  {{- if and (.Capabilities.APIVersions.Has "networking.k8s.io/v1") (semverCompare ">= 1.19-0" (include "my-ghost.kubeVersion" .)) -}}
      {{- print "networking.k8s.io/v1" -}}
  {{- else if .Capabilities.APIVersions.Has "networking.k8s.io/v1beta1" -}}
    {{- print "networking.k8s.io/v1beta1" -}}
  {{- else -}}
    {{- print "extensions/v1beta1" -}}
  {{- end -}}
{{- end -}}


{{/* Check Ingress stability */}}
{{- define "my-ghost.ingress.isStable" -}}
  {{- eq (include "my-ghost.ingress.apiVersion" .) "networking.k8s.io/v1" -}}
{{- end -}}

{{/* Check Ingress supports pathType */}}
{{/* pathType was added to networking.k8s.io/v1beta1 in Kubernetes 1.18 */}}
{{- define "my-ghost.ingress.supportsPathType" -}}
  {{- or (eq (include "my-ghost.ingress.isStable" .) "true") (and (eq (include "my-ghost.ingress.apiVersion" .) "networking.k8s.io/v1beta1") (semverCompare ">= 1.18-0" (include "my-ghost.kubeVersion" .))) -}}
{{- end -}}

模板解析

获取 K8S 集群版本

整个逻辑的意思是:如果 kubeVersionOverride 被定义且非空,那么使用它的值;否则,使用 .Capabilities.KubeVersion.Version 的值。

yaml
{{/* Allow KubeVersion to be overridden. */}}
{{- define "my-ghost.kubeVersion" -}}
  {{- default .Capabilities.KubeVersion.Version .Values.kubeVersionOverride -}}
{{- end -}}

default 函数:Helm 模板语言中的 default 函数用于返回第一个非空参数的值。如果所有参数都是空的,则返回第一个参数。

.Capabilities.KubeVersion.Version:这是一个内置的 Helm 变量,表示当前 Kubernetes 集群的版本。

.Values.kubeVersionOverride:这是一个自定义的值,如果在 values.yaml 文件或命令行中提供了 kubeVersionOverride 的值,那么这个值将被使用。

获取 Ingress 的 API 版本

yaml
{{/* 获取 Ingress 的 API 版本 */}}
{{- define "my-ghost.ingress.apiVersion" -}}
  {{- if and (.Capabilities.APIVersions.Has "networking.k8s.io/v1") (semverCompare ">= 1.19-0" (include "my-ghost.kubeVersion" .)) -}}
      {{- print "networking.k8s.io/v1" -}}
  {{- else if .Capabilities.APIVersions.Has "networking.k8s.io/v1beta1" -}}
    {{- print "networking.k8s.io/v1beta1" -}}
  {{- else -}}
    {{- print "extensions/v1beta1" -}}
  {{- end -}}
{{- end -}}

这段逻辑分为以下步骤

.Capabilities.APIVersions.Has 用于检查当前 Kubernetes 集群是否支持某个特定的 API 版本。它返回一个布尔值,如果集群中存在该 API 版本,则返回 true;否则返回 false

semverCompare 函数:用于比较两个语义化版本字符串,并返回一个布尔值。这个函数支持多种比较操作符,如 >=, <=, >, <, ==,等。

语义化版本是一种版本命名规范,格式为 MAJOR.MINOR.PATCH,例如 1.19.0

  1. 检查 networking.k8s.io/v1 版本:
yaml
{{- if and (.Capabilities.APIVersions.Has "networking.k8s.io/v1") (semverCompare ">= 1.19-0" (include "my-ghost.kubeVersion" .)) -}}
    {{- print "networking.k8s.io/v1" -}}

首先检查两个条件:

  • 集群是否支持 networking.k8s.io/v1 API 版本。
  • Kubernetes 版本是否大于等于 1.19

如果这两个条件都满足,返回 networking.k8s.io/v1

  1. 检查 networking.k8s.io/v1beta1 版本:
yaml
{{- else if .Capabilities.APIVersions.Has "networking.k8s.io/v1beta1" -}}
    {{- print "networking.k8s.io/v1beta1" -}}

如果不满足上面的条件,则检查集群是否支持 networking.k8s.io/v1beta1 API 版本。如果支持,返回 networking.k8s.io/v1beta1

  1. 回退到 extensions/v1beta1 版本:
yaml
{{- else -}}
    {{- print "extensions/v1beta1" -}}

如果前两个条件都不满足,则返回 extensions/v1beta1,这是一个更旧的 Ingress API 版本。

最后结束定义

yaml
{{- end -}}

这行结束了模板定义。

检查 Ingress API 版本是否是稳定版本

yaml
{{/* Check Ingress stability */}}
{{- define "my-ghost.ingress.isStable" -}}
  {{- eq (include "my-ghost.ingress.apiVersion" .) "networking.k8s.io/v1" -}}
{{- end -}}

定义了一个名为 my-ghost.ingress.isStable 的模板,用于检查 Ingress API 版本是否是稳定版本(networking.k8s.io/v1

使用 eq 函数和 include 函数来判断 Ingress API 版本是否为 networking.k8s.io/v1

  • eq 函数:这是 Helm 模板语言中的一个函数,用于比较两个值是否相等。如果相等,返回 true,否则返回 false
  • include 函数:用于包含并执行另一个模板。在这里,它包含并执行之前定义的 my-ghost.ingress.apiVersion 模板。
  • (include "my-ghost.ingress.apiVersion" .): 调用 my-ghost.ingress.apiVersion 模板并传递当前的上下文 .,获取 Ingress API 版本。

检查 Ingress 是否支持 pathType

yaml
{{/* Check Ingress supports pathType */}}
{{/* pathType was added to networking.k8s.io/v1beta1 in Kubernetes 1.18 */}}
{{- define "my-ghost.ingress.supportsPathType" -}}
  {{- or (eq (include "my-ghost.ingress.isStable" .) "true") (and (eq (include "my-ghost.ingress.apiVersion" .) "networking.k8s.io/v1beta1") (semverCompare ">= 1.18-0" (include "my-ghost.kubeVersion" .))) -}}
{{- end -}}

pathType 是在 Kubernetes 1.18 中添加到 networking.k8s.io/v1beta1 API 版本中的。

  1. 检查是否为稳定版本:
  2. 检查是否为 networking.k8s.io/v1beta1 且 Kubernetes 版本 >= 1.18:

该模板通过检查 Ingress API 版本是否为稳定版本 networking.k8s.io/v1,或者 Ingress API 版本是否为 networking.k8s.io/v1beta1 且 Kubernetes 版本是否大于等于 1.18.0 来确定是否支持 pathType

上面我们通过 .Capabilities.APIVersions.Has 来判断我们应该使用的 APIVersion,如果版本为 networking.k8s.io/v1,则定义为 isStable,此外还根据版本来判断是否需要支持 pathType 属性,之后 在 Ingress 对象模板中就可以使用上面定义的命名模板来决定应该使用哪些属性,如下所示:

yaml
{{- if .Values.ingress.enabled }}
{{- $apiIsStable := eq (include "my-ghost.ingress.isStable" .) "true" -}}
{{- $ingressSupportsPathType := eq (include "my-ghost.ingress.supportsPathType" .) "true" -}}
apiVersion: {{ include "my-ghost.ingress.apiVersion" . }}
kind: Ingress
metadata:
  name: {{ template "my-ghost.fullname" . }}
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
    {{- if and .Values.ingress.ingressClass (not $apiIsStable) }}
    kubernetes.io/ingress.class: {{ .Values.ingress.ingressClass }}
    {{- end }}
  labels:
    {{- include "my-ghost.labels" . | nindent 4 }}
spec:
  {{- if and .Values.ingress.ingressClass $apiIsStable }}
  ingressClassName: {{ .Values.ingress.ingressClass }}
  {{- end }}
  rules:
  {{- if not (empty .Values.url) }}
  - host: {{ .Values.url }}
    http:
  {{- else }}
  - http:
  {{- end }}
      paths:
      - path: /
        {{- if $ingressSupportsPathType }}
        pathType: Prefix
        {{- end }}
        backend:
          {{- if $apiIsStable }}
          service:
            name: {{ template "my-ghost.fullname" . }}
            port:
              number: {{ .Values.service.port }}
          {{- else }}
          serviceName: {{ template "my-ghost.fullname" . }}
          servicePort: {{ .Values.service.port }}
          {{- end }}
{{- end }}

由于有的场景下面并不需要使用 Ingress 来暴露服务,所以首先我们通过一个 ingress.enabled 属性来控制是否需要渲染,然后定义了一个 $apiIsStable 变量,来表示当前集群是否是稳定版本的 API,然后需要根据该变量去渲染不同的属性,比如对于 ingressClass,如果是稳定版本的 API 则是通过 spec.ingressClassName 来指定,否则是通过 kubernetes.io/ingress.class 这个 annotations 来指定。然后这里我们在 values.yaml 文件中添加如下所示默认的 Ingress 的配置数据:

yaml
ingress:
  enabled: true
  ingressClass: nginx

现在我们再次渲染 Helm Chart 模板来验证资源清单数据:

bash
$ helm template --debug my-ghost
install.go:218: [debug] Original chart version: ""
install.go:235: [debug] CHART PATH: /k8s-data/helm/ghost/my-ghost

---
# Source: my-ghost/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: release-name-my-ghost
spec:
  type: NodePort
  selector:
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: release-name
  ports:
    - protocol: TCP
      targetPort: 2368
      port: 80
      nodePort: 31233
---
# Source: my-ghost/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: release-name-my-ghost
  labels:
    helm.sh/chart: my-ghost-0.1.0
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: my-ghost
      app.kubernetes.io/instance: release-name
  replicas: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: my-ghost
        app.kubernetes.io/instance: release-name
    spec:
      containers:
        - name: ghost-app
          image: ghost:latest
          ports:
            - containerPort: 2368
          env:
            - name: NODE_ENV
              value: production
            - name: url
              value: http://ghost.k8s.local
---
# Source: my-ghost/templates/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: release-name-my-ghost
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
  labels:
    helm.sh/chart: my-ghost-0.1.0
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: release-name
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  ingressClassName: nginx
  rules:
  - host: ghost.k8s.local
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: release-name-my-ghost
            port:
              number: 80
yaml
$ helm install --generate-name --dry-run my-ghost
NAME: my-ghost-1721122638
LAST DEPLOYED: Tue Jul 16 17:37:18 2024
NAMESPACE: default
STATUS: pending-install
REVISION: 1
TEST SUITE: None
HOOKS:
MANIFEST:
---
# Source: my-ghost/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: my-ghost-1721122638
spec:
  type: NodePort
  selector:
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: my-ghost-1721122638
  ports:
    - protocol: TCP
      targetPort: 2368
      port: 80
      nodePort: 31233
---
# Source: my-ghost/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-ghost-1721122638
  labels:
    helm.sh/chart: my-ghost-0.1.0
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: my-ghost-1721122638
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: my-ghost
      app.kubernetes.io/instance: my-ghost-1721122638
  replicas: 1
  template:
    metadata:
      labels:
        app.kubernetes.io/name: my-ghost
        app.kubernetes.io/instance: my-ghost-1721122638
    spec:
      containers:
        - name: ghost-app
          image: ghost:latest
          ports:
            - containerPort: 2368
          env:
            - name: NODE_ENV
              value: production
            - name: url
              value: http://ghost.k8s.local
---
# Source: my-ghost/templates/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-ghost-1721122638
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "false"
  labels:
    helm.sh/chart: my-ghost-0.1.0
    app.kubernetes.io/name: my-ghost
    app.kubernetes.io/instance: my-ghost-1721122638
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
spec:
  ingressClassName: nginx
  rules:
  - host: ghost.k8s.local
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: my-ghost-1721122638
            port:
              number: 80

从上面的资源清单可以看出是符合我们的预期要求的,我们可以来安装测试下结果:

bash
$ helm upgrade --install my-ghost ./my-ghost -n default
Release "my-ghost" does not exist. Installing it now.
NAME: my-ghost
LAST DEPLOYED: Tue Jul 16 17:42:31 2024
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None


$ helm ls -n default
NAME            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
grafana         default         1               2024-04-07 21:26:33.091735868 +0800 CST deployed        grafana-6.29.2  8.5.0
my-ghost        default         1               2024-07-16 17:42:31.097252981 +0800 CST deployed        my-ghost-0.1.0  1.16.0

$ kubectl get pods -n default
NAME                        READY   STATUS    RESTARTS   AGE
my-ghost-6ccf5fddff-zqffx   1/1     Running   0          68s

$ kubectl get svc -n default
NAME          TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
kubernetes    ClusterIP   10.96.0.1       <none>        443/TCP          117d
my-ghost      NodePort    10.105.11.158   <none>        80:31233/TCP     79s

$ kubectl get ingress -n default
NAME       CLASS   HOSTS             ADDRESS        PORTS   AGE
my-ghost   nginx   ghost.k8s.local   192.168.18.7   80      95s

正常就可以部署成功 Ghost 了,并且可以通过域名http://ghost.k8s.local 进行访问了:

持久化

上面我们使用的 Ghost 镜像默认使用 SQLite数据库,所以非常有必要将数据进行持久化,当然我们要将这个开关给到用户去选择,修改 templates/deployment.yaml 模板文件,增加 volumes 相关配置:

修改deployment 模板

yaml
# other spec...
spec:
  volumes:
    - name: ghost-data
    {{- if .Values.persistence.enabled }}
      persistentVolumeClaim:
        claimName: {{ .Values.persistence.existingClaim | default (include "my-ghost.fullname" .) }}
    {{- else }}
      emptyDir: {}
    {{ end }}
  containers:
    - name: ghost-app
      image: {{ .Values.image }}
      volumeMounts:
        - name: ghost-data
          mountPath: /var/lib/ghost/content
      # other spec...
  • if .Values.persistence.enabled:判断 Values 中的 persistence.enabled 是否为 true
  • persistentVolumeClaim:如果持久化存储被启用,使用持久卷声明(PVC)。
  • claimName: 指定 PVC 的名称。如果 persistence.existingClaim 存在,则使用它;否则使用 my-ghost.fullname 模板生成的名称。
  • else 部分:如果持久化存储没有启用,使用 emptyDir 卷。

emptyDir 卷在 Pod 运行期间是临时的,Pod 停止时数据会丢失。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ template "my-ghost.fullname" . }}
  labels:
{{ include "my-ghost.labels" . | indent 4 }}
spec:
  selector:
    matchLabels:
{{ include "my-ghost.selectorLabels" . | indent 6 }}
  replicas: {{ .Values.replicaCount }}
  template:
    metadata:
      labels:
{{ include "my-ghost.selectorLabels" . | indent 8 }}
    spec:
      volumes:
        - name: ghost-data
        {{- if .Values.persistence.enabled }}
          persistentVolumeClaim:
            claimName: {{ .Values.persistence.existingClaim | default (include "my-ghost.fullname" .) }}
        {{- else }}
          emptyDir: {}
        {{ end }}
      containers:
        - name: ghost-app
          image: {{ .Values.image }}
          volumeMounts:
            - name: ghost-data
              mountPath: /var/lib/ghost/content
          ports:
            - containerPort: 2368
          env:
            - name: NODE_ENV
              value: {{ .Values.node_env | default "production" }}
            {{- if .Values.url }}
            - name: url
              value: http://{{ .Values.url }}
            {{- end }}

创建 PVC 模板

这里我们通过 persistence.enabled 来判断是否需要开启持久化数据,如果开启则需要看用户是否直接提供了一个存在的 PVC 对象,如果没有提供,则我们需要自己创建一个合适的 PVC 对象,如果不需要持久化,则直接使用 emptyDir:{} 即可,添加 templates/pvc.yaml 模板,内容如下所示:

yaml
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: {{ template "my-ghost.fullname" . }}
  labels:
    {{- include "my-ghost.labels" . | nindent 4 }}
spec:
  {{- if .Values.persistence.storageClass }}
  storageClassName: {{ .Values.persistence.storageClass | quote }}
  {{- end }}
  accessModes:
  - {{ .Values.persistence.accessMode | quote }}
  resources:
    requests:
      storage: {{ .Values.persistence.size | quote }}
{{- end -}}

其中访问模式、存储容量、StorageClass、存在的 PVC 都通过 Values 来指定,增加了灵活性。

对应的 values.yaml 配置部分我们可以给一个默认的配置:

yaml
## 是否使用 PVC 开启数据持久化
persistence:
  enabled: true
  ## 是否使用 storageClass,如果不适用则补配置
  # storageClass: "xxx"
  ##
  ## 如果想使用一个存在的 PVC 对象,则直接传递给下面的 existingClaim 变量
  # existingClaim: your-claim
  accessMode: ReadWriteOnce  # 访问模式
  size: 1Gi  # 存储容量

定制

增加更改更新策略

除了上面的这些主要的需求之外,还有一些额外的定制需求,比如用户想要配置更新策略,因为更新策略并不是一层不变的,这里和之前不太一样,我们需要用到一个新的函数 toYaml

yaml
{{- if .Values.updateStrategy }}
strategy: {{ toYaml .Values.updateStrategy | nindent 4 }}
{{- end }}

意思就是我们将 updateStrategy 这个 Values 值转换成 YAML 格式,并保留4个空格。

增加容忍

添加其他的配置,比如是否需要添加 nodeSelector、容忍、亲和性这些,这里我们都是使用 toYaml 函数来控制空格,如下所示:

yaml
{{- if .Values.nodeSelector }}
nodeSelector: {{- toYaml .Values.nodeSelector | nindent 8 }}
{{- end -}}
{{- with .Values.affinity }}
affinity: {{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations: {{- toYaml . | nindent 8 }}
{{- end }}

使用 toYamlindent 2 来处理缩进:

toYaml 函数将 .Values.affinity 对象转换为 YAML 格式的字符串,并保持内部结构的缩进。

indent 2 则将整个 affinity 配置块向右缩进2个空格。

增加镜像仓库 Secret

接下来当然就是镜像的配置了,如果是私有仓库还需要指定 imagePullSecrets

yaml
{{- if .Values.image.pullSecrets }}
imagePullSecrets:
{{- range .Values.image.pullSecrets }}
- name: {{ . }}
{{- end }}
{{- end }}
containers:
- name: ghost
  image: {{ printf "%s:%s" .Values.image.name .Values.image.tag }}
  imagePullPolicy: {{ .Values.image.pullPolicy | quote }}
  ports:
  - containerPort: 2368

对应的 Values 值如下所示:

yaml
image:
  name: ghost
  tag: latest
  pullPolicy: IfNotPresent
  ## 如果是私有仓库,需要指定 imagePullSecrets
  # pullSecrets:
  #   - myRegistryKeySecretName

然后就是 resource 资源声明,这里我们定义一个默认的 resources 值,同样用 toYaml 函数来控制空格:

yaml
resources:
{{ toYaml .Values.resources | indent 10 }}

增加探针模板

最后是健康检查部分,虽然我们之前没有做 livenessProbe,但是我们开发 Chart 模板的时候就要尽可能考虑周全一点,这里我们加上存活性和可读性、启动三个探针,并且根据 livenessProbe.enabledreadinessProbe.enabled 以及 startupProbe.enabled 三个 Values 值来判断是否需要添加探针,探针对应的参数也都通过 Values 值来配置:

yaml
{{- if .Values.startupProbe.enabled }}
startupProbe:
  httpGet:
    path: /
    port: 2368
  initialDelaySeconds: {{ .Values.startupProbe.initialDelaySeconds }}
  periodSeconds: {{ .Values.startupProbe.periodSeconds }}
  timeoutSeconds: {{ .Values.startupProbe.timeoutSeconds }}
  failureThreshold: {{ .Values.startupProbe.failureThreshold }}
  successThreshold: {{ .Values.startupProbe.successThreshold }}
{{- end }}
{{- if .Values.livenessProbe.enabled }}
livenessProbe:
  httpGet:
    path: /
    port: 2368
  initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }}
  periodSeconds: {{ .Values.livenessProbe.periodSeconds }}
  timeoutSeconds: {{ .Values.livenessProbe.timeoutSeconds }}
  failureThreshold: {{ .Values.livenessProbe.failureThreshold }}
  successThreshold: {{ .Values.livenessProbe.successThreshold }}
{{- end }}
{{- if .Values.readinessProbe.enabled }}
readinessProbe:
  httpGet:
    path: /
    port: 2368
  initialDelaySeconds: {{ .Values.readinessProbe.initialDelaySeconds }}
  periodSeconds: {{ .Values.readinessProbe.periodSeconds }}
  timeoutSeconds: {{ .Values.readinessProbe.timeoutSeconds }}
  failureThreshold: {{ .Values.readinessProbe.failureThreshold }}
  successThreshold: {{ .Values.readinessProbe.successThreshold }}
{{- end }}

默认的 values.yaml 文件如下所示:

yaml
replicaCount: 1
image:
  name: ghost
  tag: latest
  pullPolicy: IfNotPresent

node_env: production
url: ghost.k8s.local

service:
  type: ClusterIP
  port: 80

ingress:
  enabled: true
  ingressClass: nginx

## 是否使用 PVC 开启数据持久化
persistence:
  enabled: true
  ## 是否使用 storageClass,如果不适用则补配置
  # storageClass: "xxx"
  ##
  ## 如果想使用一个存在的 PVC 对象,则直接传递给下面的 existingClaim 变量
  # existingClaim: your-claim
  accessMode: ReadWriteOnce  # 访问模式
  size: 1Gi  # 存储容量

nodeSelector: {}

affinity: {}

tolerations: {}

resources: {}

startupProbe:
  enabled: false

livenessProbe:
  enabled: false

readinessProbe:
  enabled: false

验证Charts 包

现在我们再去更新 Release:

bash
$ helm upgrade --install my-ghost ./my-ghost -n default
Release "my-ghost" has been upgraded. Happy Helming!
NAME: my-ghost
LAST DEPLOYED: Tue Jul 16 21:32:53 2024
NAMESPACE: default
STATUS: deployed
REVISION: 1
TEST SUITE: None

$ helm ls -n default
NAME            NAMESPACE       REVISION        UPDATED                                 STATUS          CHART           APP VERSION
my-ghost        default         1               2024-07-16 21:32:53.509865463 +0800 CST deployed        my-ghost-0.1.0  1.16.0

$ kubectl get pods -n default
NAME                        READY   STATUS    RESTARTS   AGE
my-ghost-69d8995464-n2h6r   1/1     Running   0          2m46s

$ kubectl get pvc -n default
NAME      STATUS   VOLUME   CAPACITY   ACCESS MODES   STORAGECLASS   AGE
my-ghost  Bound  pvc-ed6232e2-96ba-4948-a462-c2b57faefb10  1Gi    RWO   longhorn   92s

$ kubectl get ingress -n default
NAME       CLASS   HOSTS             ADDRESS        PORTS   AGE
my-ghost   nginx   ghost.k8s.local   192.168.18.7   80      2m31s

访问 http://ghost.k8s.local/

到这里我们就基本完成了这个简单的 Helm Charts 包的开发,当然以后可能还会有新的需求,我们需要不断去迭代优化。