Про Helm и работу с ним «в общем» мы рассказали в прошлой статье. Теперь подойдём к практике с другой стороны — с точки зрения создателя чартов (т.е. пакетов для Helm). И хотя эта статья пришла из мира эксплуатации, она получилась больше похожей на материалы о языках программирования — такова уж участь авторов чартов. Итак, чарт — это набор файлов…
Файлы чарта можно разделить на две группы:
- Файлы, необходимые для генерации манифестов Kubernetes-ресурсов. К ним относятся шаблоны из директории
templates
и файлы со значениями (значения по умолчанию хранятся вvalues.yaml
). Также к данной группе относятся файлrequirements.yaml
и директорияcharts
— всё это используется для организации вложенных чартов. - Сопроводительные файлы, содержащие информацию, которая может быть полезна при поиске чартов, знакомстве с ними и их использовании. Большая часть файлов этой группы является необязательной.
Подробнее о файлах обеих групп:
-
Chart.yaml
— файл с информацией о чарте; -
LICENSE
— необязательный текстовый файл с лицензией чарта; -
README.md
— необязательный файл с документацией; -
requirements.yaml
— необязательный файл со списком чартов-зависимостей; -
values.yaml
— файл со значениями по умолчанию для шаблонов; -
charts/
— необязательная директория со вложенными чартами; -
templates/
— директория с шаблонами манифестов Kubernetes-ресурсов; templates/NOTES.txt
— необязательный текстовый файл с примечанием, которое выводится пользователю при инсталяции и обновлении.
Чтобы лучше разобраться в содержимом этих файлов, можно обратиться к официальному руководству разработчика чарта или поискать соответствующие примеры в официальном репозитории.
Создание чарта по большому счёту сводится к организации правильно оформленного набора файлов. И главная сложность в этом «оформлении» — использование достаточно продвинутой системы шаблонов для достижения нужного результата. Для рендера манифестов Kubernetes-ресурсов используется стандартный Go-шаблонизатор, расширенный функциями Helm.
Напоминание: Разработчики Helm анонсировали, что в следующей крупной версии проекта — Helm 3 — появится поддержка Lua-скриптов, которые можно будет использовать одновременно с Go-шаблонами. Останавливаться подробнее на этом моменте не буду — об этом (и других изменениях в Helm 3) можно почитать здесь.
К примеру, вот так в Helm 2 выглядит шаблон Kubernetes-манифеста Deployment'а блога на WordPress из прошлой статьи:
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
name: {{ template "fullname" . }}
labels:
app: {{ template "fullname" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
spec:
replicas: {{ .Values.replicaCount }}
template:
metadata:
labels:
app: {{ template "fullname" . }}
chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
release: "{{ .Release.Name }}"
spec:
{{- if .Values.image.pullSecrets }}
imagePullSecrets:
{{- range .Values.image.pullSecrets }}
- name: {{ . }}
{{- end}}
{{- end }}
containers:
- name: {{ template "fullname" . }}
image: "{{ .Values.image.registry }}/{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy | quote }}
env:
- name: ALLOW_EMPTY_PASSWORD
{{- if .Values.allowEmptyPassword }}
value: "yes"
{{- else }}
value: "no"
{{- end }}
- name: MARIADB_HOST
{{- if .Values.mariadb.enabled }}
value: {{ template "mariadb.fullname" . }}
{{- else }}
value: {{ .Values.externalDatabase.host | quote }}
{{- end }}
- name: MARIADB_PORT_NUMBER
{{- if .Values.mariadb.enabled }}
value: "3306"
{{- else }}
value: {{ .Values.externalDatabase.port | quote }}
{{- end }}
- name: WORDPRESS_DATABASE_NAME
{{- if .Values.mariadb.enabled }}
value: {{ .Values.mariadb.db.name | quote }}
{{- else }}
value: {{ .Values.externalDatabase.database | quote }}
{{- end }}
- name: WORDPRESS_DATABASE_USER
{{- if .Values.mariadb.enabled }}
value: {{ .Values.mariadb.db.user | quote }}
{{- else }}
value: {{ .Values.externalDatabase.user | quote }}
{{- end }}
- name: WORDPRESS_DATABASE_PASSWORD
valueFrom:
secretKeyRef:
{{- if .Values.mariadb.enabled }}
name: {{ template "mariadb.fullname" . }}
key: mariadb-password
{{- else }}
name: {{ printf "%s-%s" .Release.Name "externaldb" }}
key: db-password
{{- end }}
- name: WORDPRESS_USERNAME
value: {{ .Values.wordpressUsername | quote }}
- name: WORDPRESS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ template "fullname" . }}
key: wordpress-password
- name: WORDPRESS_EMAIL
value: {{ .Values.wordpressEmail | quote }}
- name: WORDPRESS_FIRST_NAME
value: {{ .Values.wordpressFirstName | quote }}
- name: WORDPRESS_LAST_NAME
value: {{ .Values.wordpressLastName | quote }}
- name: WORDPRESS_BLOG_NAME
value: {{ .Values.wordpressBlogName | quote }}
- name: WORDPRESS_TABLE_PREFIX
value: {{ .Values.wordpressTablePrefix | quote }}
- name: SMTP_HOST
value: {{ .Values.smtpHost | quote }}
- name: SMTP_PORT
value: {{ .Values.smtpPort | quote }}
- name: SMTP_USER
value: {{ .Values.smtpUser | quote }}
- name: SMTP_PASSWORD
valueFrom:
secretKeyRef:
name: {{ template "fullname" . }}
key: smtp-password
- name: SMTP_USERNAME
value: {{ .Values.smtpUsername | quote }}
- name: SMTP_PROTOCOL
value: {{ .Values.smtpProtocol | quote }}
ports:
- name: http
containerPort: 80
- name: https
containerPort: 443
livenessProbe:
httpGet:
path: /wp-login.php
{{- if not .Values.healthcheckHttps }}
port: http
{{- else }}
port: https
scheme: HTTPS
{{- end }}
{{ toYaml .Values.livenessProbe | indent 10 }}
readinessProbe:
httpGet:
path: /wp-login.php
{{- if not .Values.healthcheckHttps }}
port: http
{{- else }}
port: https
scheme: HTTPS
{{- end }}
{{ toYaml .Values.readinessProbe | indent 10 }}
volumeMounts:
- mountPath: /bitnami/apache
name: wordpress-data
subPath: apache
- mountPath: /bitnami/wordpress
name: wordpress-data
subPath: wordpress
- mountPath: /bitnami/php
name: wordpress-data
subPath: php
resources:
{{ toYaml .Values.resources | indent 10 }}
volumes:
- name: wordpress-data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ .Values.persistence.existingClaim | default (include "fullname" .) }}
{{- else }}
emptyDir: {}
{{ end }}
{{- if .Values.nodeSelector }}
nodeSelector:
{{ toYaml .Values.nodeSelector | indent 8 }}
{{- end -}}
{{- with .Values.affinity }}
affinity:
{{ toYaml . | indent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{ toYaml . | indent 8 }}
{{- end }}
Теперь — об основных принципах и особенностях шаблонизации в Helm. Большая часть приведённых ниже примеров взята из чартов официального репозитория.
Шаблонизация
Шаблоны: {{ }}
Всё, что связано с шаблонизацией, оборачивается в двойные фигурные скобки. Текст вне фигурных скобок при рендере остаётся неизменным.
Значение контекста: .
При рендере файла или partial'а (подробнее о переиспользовании шаблонов рассказывается в следующих разделах статьи) прокидывается значение, которое становится доступным внутри через переменную контекста — точку. При передаче в качестве аргумента структуры точка используется для доступа к полям и методам этой структуры.
Значение переменной изменяется в процессе рендера в зависимости от контекста, в котором она используется. Большинство блочных операторов переопределяет переменную контекста внутри основного блока. Основные операторы и их особенности будут рассмотрены ниже, после знакомства с базовой структурой Helm.
Базовая структура Helm
При рендере манифестов в шаблоны прокидывается структура со следующими полями:
- Поле
.Values
— для доступа к параметрам, которые определяются при инсталяции и обновлении релиза. К ним относятся значения опций--set
,--set-string
и--set-file
, а также параметры файлов со значeниями, файлvalues.yaml
и файлы, соответствующие значениям опций--values
:containers: - name: main image: "{{ .Values.image }}:{{ .Values.imageTag }}" imagePullPolicy: {{ .Values.imagePullPolicy }}
.Release
— для использования данных релиза о выкате, инсталяции или обновлении, имени релиза, namespace и значений ещё нескольких полей, которые могут пригодиться при генерации манифестов:metadata: labels: heritage: "{{ .Release.Service }}" release: "{{ .Release.Name }}" subjects: - namespace: {{ .Release.Namespace }}
.Chart
— для доступа к информации о чарте. Поля соответствуют содержимому файлаChart.yaml
:labels: chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
- Структура
.Files
— для работы с хранящимися в директории чарта файлами; со структурой и доступными методами можно ознакомиться по ссылке. Примеры:data: openssl.conf: | {{ .Files.Get "config/openssl.conf" | indent 4 }}
data: {{ (.Files.Glob "files/docker-entrypoint-initdb.d/*").AsConfig | indent 2 }}
.Capabilities
— для доступа к информации о кластере, в котором выполняется выкат:{{- if .Capabilities.APIVersions.Has "apps/v1beta2" }} apiVersion: apps/v1beta2 {{- else }} apiVersion: extensions/v1beta1 {{- end }}
{{- if semverCompare "^1.9-0" .Capabilities.KubeVersion.GitVersion }} apiVersion: apps/v1 {{- else }}
Операторы
Начнём, конечно, с операторов if
, else if
и else
:
{{- if .Values.agent.image.tag }}
image: "{{ .Values.agent.image.repository }}:{{ .Values.agent.image.tag }}"
{{- else }}
image: "{{ .Values.agent.image.repository }}:v{{ .Chart.AppVersion }}"
{{- end }}
Оператор range
предназначен для работы с массивами и картами. Если в качестве аргумента передаётся массив и он содержит элементы, то для каждого элемента последовательно выполняется блок (при этом значение внутри блока становится доступным через переменную контекста):
{{- range .Values.ports }}
- name: {{ .name }}
port: {{ .containerPort }}
targetPort: {{ .containerPort}}
{{- else }}
...
{{- end}}
{{ range .Values.tolerations -}}
- {{ toYaml . | indent 8 | trim }}
{{ end }}
Для работы с картами предусмотрен синтаксис с переменными:
{{- range $key, $value := .Values.credentials.secretContents }}
{{ $key }}: {{ $value | b64enc | quote }}
{{- end }}
Похожее поведение — у оператора with
: eсли переданный аргумент существует, то выполняется блок, а переменная контекста в блоке соответствует значению аргумента. Например:
{{- with .config }}
config:
{{- with .region }}
region: {{ . }}
{{- end }}
{{- with .s3ForcePathStyle }}
s3ForcePathStyle: {{ . }}
{{- end }}
{{- with .s3Url }}
s3Url: {{ . }}
{{- end }}
{{- with .kmsKeyId }}
kmsKeyId: {{ . }}
{{- end }}
{{- end }}
Для переиспользования шаблонов может быть задействована связка из define [name]
и template [name] [variable]
, где переданное значение становится доступным через переменную контекста в блоке define
:
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ template "kiam.serviceAccountName.agent" . }}
...
{{- define "kiam.serviceAccountName.agent" -}}
{{- if .Values.serviceAccounts.agent.create -}}
{{ default (include "kiam.agent.fullname" .) .Values.serviceAccounts.agent.name }}
{{- else -}}
{{ default "default" .Values.serviceAccounts.agent.name }}
{{- end -}}
{{- end -}}
Пара особенностей, которые стоит учитывать при использовании define
, или, проще говоря, partial'ов:
- Объявленные partial'ы являются глобальными и могут использоваться во всех файлах директории
templates
. - Основной чарт компилируется вместе с зависимыми чартами, поэтому при существовании двух одноимённых partial'ов будет использоваться последний загруженный. При именовании partial'а принято добавлять имя чарта для избежания подобных конфликтов:
define "chart_name.partial_name"
.
Переменные: $
Помимо работы с контекстом можно хранить, изменять и переиспользовать данные, используя переменные:
{{ $provider := .Values.configuration.backupStorageProvider.name }}
...
{{ if eq $provider "azure" }}
envFrom:
- secretRef:
name: {{ template "ark.secretName" . }}
{{ end }}
При рендере файла или partial'а $
имеет такое же значение, что и точка. Но в отличие от переменной контекста (точки), значение $
не изменяется в контексте блочных операторов, что позволяет одновременно работать со значением контекста блочного оператора и базовой структурой Helm (или значением, переданным в partial, если говорить об использовании $
внутри partial'а). Иллюстрация отличия:
context: {{ . }}
dollar: {{ $ }}
with:
{{- with .Chart }}
context: {{ . }}
dollar: {{ $ }}
{{- end }}
template:
{{- template "flant" .Chart -}}
{{ define "flant" }}
context: {{ . }}
dollar: {{ $ }}
with:
{{- with .Name }}
context: {{ . }}
dollar: {{ $ }}
{{- end }}
{{- end -}}
В результате обработки этого шаблона получится следующее (для наглядности в выводе структуры заменены на соответствующие псевдоимена):
context: #Базовая структура helm
dollar: #Базовая структура helm
with:
context: #.Chart
dollar: #Базовая структура helm
template:
context: #.Chart
dollar: #.Chart
with:
context: habr
dollar: #.Chart
А вот реальный пример использования данной особенности:
{{- if .Values.ingress.enabled -}}
{{- range .Values.ingress.hosts }}
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
name: {{ template "nats.fullname" $ }}-monitoring
labels:
app: "{{ template "nats.name" $ }}"
chart: "{{ template "nats.chart" $ }}"
release: {{ $.Release.Name | quote }}
heritage: {{ $.Release.Service | quote }}
annotations:
{{- if .tls }}
ingress.kubernetes.io/secure-backends: "true"
{{- end }}
{{- range $key, $value := .annotations }}
{{ $key }}: {{ $value | quote }}
{{- end }}
spec:
rules:
- host: {{ .name }}
http:
paths:
- path: {{ default "/" .path }}
backend:
serviceName: {{ template "nats.fullname" $ }}-monitoring
servicePort: monitoring
{{- if .tls }}
tls:
- hosts:
- {{ .name }}
secretName: {{ .tlsSecret }}
{{- end }}
---
{{- end }}
{{- end }}
Отступы
При разработке шаблонов могут оставаться лишние отступы: пробелы, табуляции, переводы строк. С ними файл попросту выглядит более читабельным. Можно либо отказаться от них, либо использовать специальный синтаксис для удаления отступов вокруг используемых шаблонов:
{{- variable }}
обрезает предшествующие пробелы;{{ variable -}}
обрезает последующие пробелы;{{- variable -}}
— оба варианта.
Пример файла, результатом обработки которого будет строка habr flant helm
:
habr
{{- " flant " -}}
helm
Встроенные функции
Со всеми функциями, встроенными в шаблон, можно ознакомиться по следующей ссылке. Здесь же я расскажу только о некоторых из них.
Функция index
предназначена для доступа к элементам массива или карт:
definitions.json: |
{
"users": [
{
"name": "{{ index .Values "rabbitmq-ha" "rabbitmqUsername" }}",
"password": "{{ index .Values "rabbitmq-ha" "rabbitmqPassword" }}",
"tags": "administrator"
}
]
}
Функция принимает произвольное количество аргументов, что позволяет работать с вложенными элементами:
$map["key1"]["key2"]["key3"] => index $map "key1" "key2" "key3"
Например:
httpGet:
{{- if (index .Values "pushgateway" "extraArgs" "web.route-prefix") }}
path: /{{ index .Values "pushgateway" "extraArgs" "web.route-prefix" }}/#/status
{{- end }}
Булевые операции реализованы в шаблонизаторе как функции (а не как операторы). Все аргументы для них вычисляются при передаче:
{{ if and (index .Values field) (eq (len .Values.field) 10) }}
...
{{ end }}
При отсутствии поля field
рендер шаблона завершится с ошибкой (error calling len: len of untyped nil
): второе условие проверяется, несмотря на то, что первое не выполнилось. Стоит взять это на заметку, а подобные запросы решать за счёт разбиения на несколько проверок:
{{ if index . field }}
{{ if eq (len .field) 10 }}
...
{{ end }}
{{ end }}
Pipeline — это уникальная функция Go-шаблонов, позволяющая объявлять выражения, которые выполняются подобно конвейеру в shell. Формально конвейер представляет собой цепочку команд, разделенных символом |
. Команда может быть простым значением или вызовом функции. Результат каждой команды передаётся в качестве последнего аргумента следующей команде, а результатом конечной команды в конвейере является значение всего конвейера. Примеры:
data:
openssl.conf: |
{{ .Files.Get "config/openssl.conf" | indent 4 }}
data:
db-password: {{ .Values.externalDatabase.password | b64enc | quote }}
Дополнительные функции
Sprig — библиотека, состоящая из 70 полезных функций для решения широкого спектра задач. Из соображений безопасности в Helm исключены функции env
и expandenv
, которые предоставляли бы доступ к переменным окружения Tiller.
Функция include
, как и стандартная функция template
, используется для переиспользования шаблонов. В отличие от template
, функцию можно использовать в pipeline, т.е. передавать результат в другую функцию:
metadata:
labels:
{{ include "labels.standard" . | indent 4 }}
{{- define "labels.standard" -}}
app: {{ include "hlf-couchdb.name" . }}
heritage: {{ .Release.Service | quote }}
release: {{ .Release.Name | quote }}
chart: {{ include "hlf-couchdb.chart" . }}
{{- end -}}
Функция required
даёт разработчикам возможность объявлять обязательные значения, необходимые для рендеринга шаблона: если значение существует, при рендере шаблона оно используется, в противном же случае рендер завершается с указанным разработчиком сообщением об ошибке:
sftp-user: {{ required "Please specify the SFTP user name at .Values.sftp.user" .Values.sftp.user | b64enc | quote }}
sftp-password: {{ required "Please specify the SFTP user password at .Values.sftp.password" .Values.sftp.password | b64enc | quote }}
{{- end }}
{{- if .Values.svn.enabled }}
svn-user: {{ required "Please specify the SVN user name at .Values.svn.user" .Values.svn.user | b64enc | quote }}
svn-password: {{ required "Please specify the SVN user password at .Values.svn.password" .Values.svn.password | b64enc | quote }}
{{- end }}
{{- if .Values.webdav.enabled }}
webdav-user: {{ required "Please specify the WebDAV user name at .Values.webdav.user" .Values.webdav.user | b64enc | quote }}
webdav-password: {{ required "Please specify the WebDAV user password at .Values.webdav.password" .Values.webdav.password | b64enc | quote }}
{{- end }}
Функция tpl
позволяет рендерить строку как шаблон. В отличие от template
и include
, функция позволяет выполнять шаблоны, которые передаются в переменных, а также рендерить шаблоны, хранящиеся не только в директории templates
. Как это выглядит?
Выполнение шаблонов из переменных:
containers:
{{- with .Values.keycloak.extraContainers }}
{{ tpl . $ | indent 2 }}
{{- end }}
… а в values.yaml
имеем следующее значение:
keycloak:
extraContainers: |
- name: cloudsql-proxy
image: gcr.io/cloudsql-docker/gce-proxy:1.11
command:
- /cloud_sql_proxy
args:
- -instances={{ .Values.cloudsql.project }}:{{ .Values.cloudsql.region }}:{{ .Values.cloudsql.instance }}=tcp:5432
- -credential_file=/secrets/cloudsql/credentials.json
volumeMounts:
- name: cloudsql-creds
mountPath: /secrets/cloudsql
readOnly: true
Рендер файла, хранящегося вне директории templates
:
apiVersion: batch/v1
kind: Job
metadata:
name: {{ template "mysqldump.fullname" . }}
labels:
app: {{ template "mysqldump.name" . }}
chart: {{ template "mysqldump.chart" . }}
release: "{{ .Release.Name }}"
heritage: "{{ .Release.Service }}"
spec:
backoffLimit: 1
template:
{{ $file := .Files.Get "files/job.tpl" }}
{{ tpl $file . | indent 4 }}
… в чарте, по пути files/job.tpl
, имеется следующий шаблон:
spec:
containers:
- name: xtrabackup
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy | quote }}
command: ["/bin/bash", "/scripts/backup.sh"]
envFrom:
- configMapRef:
name: "{{ template "mysqldump.fullname" . }}"
- secretRef:
name: "{{ template "mysqldump.fullname" . }}"
volumeMounts:
- name: backups
mountPath: /backup
- name: xtrabackup-script
mountPath: /scripts
restartPolicy: Never
volumes:
- name: backups
{{- if .Values.persistentVolumeClaim }}
persistentVolumeClaim:
claimName: {{ .Values.persistentVolumeClaim }}
{{- else -}}
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ template "mysqldump.fullname" . }}
{{- else }}
emptyDir: {}
{{- end }}
{{- end }}
- name: xtrabackup-script
configMap:
name: {{ template "mysqldump.fullname" . }}-script
На этом знакомство с азами шаблонизации в Helm подошло к концу…
Заключение
В статье рассказано о структуре Helm-чартов и подробно разобрана главная сложность в их создании — шаблонизация: основные принципы, синтаксис, функции и операторы Go-шаблонизатора, дополнительные функции.
Как начать со всем этим работать? Поскольку Helm — это уже целая экосистема, всегда можно посмотреть на примеры чартов схожих пакетов. Например, если вы хотите запаковать новый message queue, взгляните на публичный чарт RabbitMQ. Конечно, никто не обещает вам идеальных реализаций в уже существующих пакетах, однако они отлично подойдут как отправная точка. Остальное же приходит с практикой, в которой вам помогут команды отладки helm template
и helm lint
, а также запуск инсталяции с опцией --dry-run
.
Для получения более обширного представления о разработке Helm-чартов, лучших практиках и используемых технологиях предлагаю ознакомиться с материалами по следующим ссылкам (все на английском языке):
- Руководство разработчика чартов;
- Лучшие практики;
- Документация Go-шаблонизатора;
- Документация библиотеки Sprig;
- Официальный репозиторий чартов (как база знаний и реализованных лучших практик).
А в конце очередного материала про Helm прикрепляю опрос, который поможет лучше понять, какие ещё статьи о Helm ждут (или не ждут?) читатели Хабра. Спасибо за внимание!
P.S.
Читайте также в нашем блоге:
- «Практическое знакомство с пакетным менеджером для Kubernetes — Helm»;
- «Пакетный менеджер для Kubernetes — Helm: прошлое, настоящее, будущее»;
- «Практика с dapp. Часть 2. Деплой Docker-образов в Kubernetes с помощью Helm»;
- «Сборка и дeплой приложений в Kubernetes с помощью dapp и GitLab CI»;
- «Лучшие практики CI/CD с Kubernetes и GitLab» (обзор и видео доклада).
Автор: Алексей Игрычев