Продвинутая Helm-шаблонизация: выжимаем максимум
Стандартной Helm-библиотеки и привычных подходов к написанию Helm-чартов обычно хватает для автоматизации несложных приложений. Но когда количество и сложность Helm-чартов растут, то минималистичных Go-шаблонов и неоднозначной стандартной Helm-библиотеки быстро перестаёт хватать. В этой статье речь пойдет о том, как сделать ваши Helm-шаблоны гораздо более гибкими и динамичными, реализуя свои собственные «функции» в Helm, а также эксплуатируя tpl
.
NB. Всё описанное было проверено с werf, но так как шаблоны в этой утилите практически идентичны Helm-шаблонам, то и всё нижеприведенное будет полностью или почти полностью совместимо с обычным Helm обеих версий (v2 и v3).
А теперь разберем, как получить от Helm-шаблонов всё, что можно… и даже всё, что нельзя!
1. Include/define-шаблоны как полноценные функции
В Helm-чартах регулярно используется функция define
для того, чтобы выносить часто используемые части шаблонов в одно общее место. В большинстве случаев применение define
(и сопутствующего ей include
) ограничивается вынесением в define’ы простых фрагментов шаблонов: аннотаций, лейблов, имен ресурсов.
На самом же деле define’ы можно использовать ещё и как полноценные функции, эффективно абстрагируя за ними нашу логику.
1.1. Передача аргументов
Хотя include
принимает только один аргумент, ничто не мешает пробросить список с несколькими аргументами:
{{- include "testFunc" (list $val1 $val2) }}
… и потом, внутри шаблона, получить аргументы вот так:
{{- define "testFunc" }}
{{- $arg1 := index . 0 }}
{{- $arg2 := index . 1 }}
Полный пример:
{{- define "testFunc" }}
{{- $arg1 := index . 0 }}
{{- $arg2 := index . 1 }}
# Объединим аргументы в одну строку и вернем результат:
{{ print $arg1 $arg2 }}
{{- end }}
---
{{- $val1 := "foo" }}
{{- $val2 := "bar" }}
{{- include "testFunc" (list $val1 $val2) }}
# ==> string "foobar"
1.2. Передача текущего и глобального контекста
Глобальный контекст ($
) — словарь, который содержит в себе все встроенные объекты, в том числе объект Values
. Текущий контекст (.
) по умолчанию указывает на глобальный контекст, но пользователь может менять, на какую переменную текущий контекст указывает.
Внутри шаблона текущим (и глобальным) контекстом становится аргумент, переданный в include
, в нашем случае — список из аргументов. Но таким образом внутри шаблона нам теперь ничего кроме списка аргументов не доступно, даже $.Values. Исправить это можно, передав через список аргументов также и контексты:
{{- include "testFunc" (list $ . $arg) }}
Теперь остаётся восстановить глобальный контекст, который был за пределами include’а, чтобы к нему снова можно было обращаться через $
:
{{- define "testFunc" }}
{{- $ := index . 0 }}
…, а также восстановить текущий контекст, чтобы обращаться к нему, как и раньше, через точку:
{{- with index . 1 }}
В конечном итоге всё будет выглядеть так:
.helm/values.yaml:
-------------------------------------------------------------
key: "value"
-------------------------------------------------------------
.helm/templates/testFunc.yaml:
-------------------------------------------------------------
{{- define "testFunc" }}
{{- $ := index . 0 }}
{{- $stringArg := index . 2 }}
{{- with index . 1 }}
# И вот мы имеем доступ к "реальным" глобальному
# и относительному контекстам, прямо как за пределами
# include/define:
{{ cat $stringArg $.Values.key .Values.key }}
{{- end }}
{{- end }}
---
{{- $arg := "explicitlyPassed" }}
{{- include "testFunc" (list $ . $arg) }}
# ==> string "explicitlyPassed value value"
1.3. Передача опциональных аргументов
Есть несколько способов сделать передачу опциональных аргументов в шаблон. Самый гибкий и удобный из них — передавать в списке аргументов также и словарь с опциональными аргументами:
{{- include "testFunc" (list $requiredArg (dict "optionalArg2" "optionalValue2")) }}
А теперь добавим немного магии в шаблон, чтобы корректно обрабатывать отсутствие опциональных аргументов:
{{- define "testFunc" }}
...
{{- $optionalArgs := dict }}
{{- if ge (len .) 2 }}{{ $optionalArgs = index . 1 }}{{ end }}
После этого можно обращаться к опциональным аргументам через {{ $optionalArgs.optionalArg2 }}
. Полный пример:
{{- define "testFunc" }}
{{- $requiredArg := index . 0 }}
{{- $optionalArgs := dict }}
{{- if ge (len .) 2 }}{{ $optionalArgs = index . 1 }}{{ end }}
# Проверяем на наличие опциональных аргументов
# и используем их, если нашли:
{{- if hasKey $optionalArgs "optionalArg1" }}
{{- cat "Along with" $requiredArg "we have at least" $optionalArgs.optionalArg1 }}
{{- else if hasKey $optionalArgs "optionalArg2" }}
{{- cat "Along with" $requiredArg "we have" $optionalArgs.optionalArg2 }}
{{- else }}
{{- cat "We only have" $requiredArg }}
{{- end }}
{{- end }}
---
{{- $requiredArg := "requiredValue" }}
# Вызовем шаблон без опциональных аргументов:
{{- include "testFunc" (list $requiredArg) }}
# ==> string "We only have requiredValue"
# А теперь - с одним из двух опциональных аргументов:
{{- include "testFunc" (list $requiredArg (dict "optionalArg2" "optionalValue2")) }}
# ==> string "Along with requiredValue we have optionalValue2"
1.4. Вложенные include’ы, рекурсия
Также, находясь внутри шаблона, есть возможность делать include
других шаблонов. В том числе, мы можем рекурсивно вызывать тот же шаблон, внутри которого мы сейчас находимся, — всё как с функциями в полноценных языках:
{{- define "testFunc" }}
{{- $num := . }}
{{- if lt $num 10 }}
# Делаем include другого шаблона:
{{- include "print" $num }}
# Рекурсивно вызываем тот же шаблон, внутри которого
# мы сейчас находимся:
{{- include "testFunc" (add 1 $num) }}
{{- end }}
{{- end }}
{{- define "print" }}
{{- print . }}
{{- end }}
---
{{- include "testFunc" 0 }}
# ==> string "0123456789"
1.5. Возвращение из шаблонов полноценных типов данных
include
работает предельно просто: на место {{ include }}
просто подставляется текст
, который был отрендерен внутри шаблона. По умолчанию возможность вернуть из шаблона что-то кроме строки отсутствует. Таким образом, мы не можем, например, вернуть список или словарь в словаре, чтобы потом пройтись по ним циклом и получить их значения. Но есть возможность это обойти с помощью сериализации.
Сериализуем данные в JSON (или YAML) внутри шаблона:
{{- define "returnJson" }}
{{- $result := dict "key1" (dict "nestedKey1" "nestedVal1") }}
{{- $result | toJson }}
{{- end }}
При вызове этого шаблона наши сериализованные данные вернутся как строка. Убедимся в этом:
{{ include "returnJson" . | typeOf }}
# ==> string "string"
А теперь сделаем десериализацию полученной из шаблона строки и проверим, какой тип данных мы получили:
{{- include "returnJson" . | fromJson | typeOf }}
# ==> string "map[string]interface {}"
Таким образом, во втором случае мы получаем не просто строку, а полноценный словарь с вложенным в него другим словарем. С ним можно работать обычными для словарей функциями:
{{- include "returnJson" . | fromJson | values }}
# ==> string "[map[nestedKey1:nestedVal1]]"
Такую сериализацию, очевидно, можно применять для любых типов: списков, словарей, булевых значений и т. п., в том числе вложенных друг в друга.
1.6. Include в условиях if-блоков и в функции ternary
Всё, что возвращает include
в условиях блоков if
, так и остаётся строкой, не преобразовывается в булевые значения и другие типы. То есть, если мы возвращаем из шаблона true
, оно становится строкой "true"
. Любая непустая строка эквивалентна в условии if-блока булевому true
. Поэтому, если мы возвращаем false
, то оно тоже превращается в непустую строку "false"
, которая тоже становится булевым true
.
Избежать превращения false
в true
в условии if-блока можно, если ничего не возвращать из шаблона — вместо того, чтобы возвращать false
. В таком случае из шаблона вернется пустая строка, которая в условии if-блока станет нам булевым false
:
{{- define "returnPseudoBoolean" }}
{{- if eq . "pleaseReturnTrue" }}
true
{{- else if eq . "pleaseReturnFalse" }}
{{- end }}
{{- end }}
Так мы можем делать include’ы, которые будут работать в условиях if-блоков:
{{- if include "returnPseudoBoolean" "pleaseReturnTrue" }}
{{- print "Первый if вернёт True" }}
{{- end }}
# ==> string "Первый if вернёт True"
{{- if include "returnPseudoBoolean" "pleaseReturnFalse" }}
{{- else }}
{{- print "Второй if вернёт False" }}
{{- end }}
# ==> string "Второй if вернёт False"
Другое дело — функция ternary
: она как раз ожидает реальные булевые значения, а не строки. Вернуть булевые значения из шаблона можно, дополнительно прогнав вывод шаблона через функцию empty
. Получится аналог того, что происходит под капотом условий if-блоков:
{{- ternary "Сработало True" "Сработало False" (include "returnBoolean" "pleaseReturnTrue" | not | empty) }}
# ==> string "Сработало True"
2. Эффективное использование функции tpl
Функция tpl
— мощный инструмент для шаблонизации там, где она была невозможна. Она показала себя полезной прежде всего для шаблонизации значений в values.yaml
. Но у этой функции есть несколько ограничений, не дающих ей полностью раскрыться. Разберем эти ограничения и способы их обхода.
2.1. Сделаем обёртку для Values
Чтобы не дублировать каждый раз логику, в которую мы сейчас обернём нашу функцию tpl
, можно вынести эту логику в шаблон. Назовём его "value"
и будем использовать как обертку для всех наших Values
. Так что теперь вместо {{ $.Values.key }}
везде будет использоваться {{ include "value" (list $ . $.Values.key }}
.
Сам шаблон получится таким:
.helm/values.yaml:
-------------------------------------------------------------
key1: "Значение ключа key2: {{ $.Values.key2 }}"
key2: "value2"
-------------------------------------------------------------
.helm/templates/test.yaml:
-------------------------------------------------------------
{{- define "value" }}
# Сразу пробросим контексты, они понадобятся нам позже:
{{- $ := index . 0 }}
{{- $val := index . 2 }}
{{- with index . 1 }}
{{- tpl $val $ }}
{{- end }}
{{- end }}
---
{{- include "value" (list $ . $.Values.key1) }}
# ==> String "Значение ключа key2: value2"
Пока что мы просто передаём в функцию tpl
третий аргумент шаблона. Этот аргумент — простое Value
. Ничего кроме этого не делаем и получаем результат.
NB: Обработку остальных типов данных (не строк) в шаблоне "value"
мы реализовывать не будем. Используя конструкции вида {{- if kindIs "map" $val }}
, вы можете сами попробовать реализовать специфичные для разных типов данных обработки.
2.2. Передача текущего контекста
Функция tpl
требует, чтобы единственным аргументом, который ей передаётся, был словарь с объектом Template
. Таким словарём является глобальный контекст ($
), его-то обычно и передают как аргумент в функцию tpl
. И здесь мы не можем использовать трюк с передачей в качестве единственного аргумента списка с несколькими вложенными аргументами, т. к. в этом списке не будет необходимого объекта Template
. И всё же есть несколько способов передать вместе с глобальным контекстом и текущий контекст — рассмотрим самый простой.
Первый шаг — создадим новый ключ в глобальном контексте, значением которого будет наш текущий контекст, и после этого передадим глобальный контекст в tpl
. Таким образом {{- tpl $val $ }}
превратится в это:
{{- tpl $val (merge (dict "RelativeScope" .) $) }}
Теперь локальный контекст доступен с помощью {{ $.RelativeScope }}
в нашей строке-шаблоне $val
, которая передаётся в функцию tpl
для рендеринга.
Второй шаг — обернём строку-шаблон $val
в блок with
, который «восстановит» текущий контекст и позволит обращаться к нему через привычную точку:
{{- tpl (cat "{{- with $.RelativeScope -}}" $val "{{- end }}") (merge (dict "RelativeScope" .) $) }}
И теперь можно использовать не только глобальный, но и относительный контекст в наших values.yaml
:
.helm/values.yaml:
-------------------------------------------------------------
key1: "Значение ключа key2: {{ .key2 }}"
key2: "value2"
-------------------------------------------------------------
.helm/templates/test.yaml:
-------------------------------------------------------------
{{- define "value" }}
{{- $ := index . 0 }}
{{- $val := index . 2 }}
{{- with index . 1 }}
{{- tpl (cat "{{- with $.RelativeScope -}}" $val "{{- end }}") (merge (dict "RelativeScope" .) $) }}
{{- end }}
{{- end }}
---
# Изменим текущий контекст:
{{- with $.Values }}
# Попробуем использовать относительный путь к key1:
{{- include "value" (list $ . .key1) }}
{{- end }}
# ==> String "Значение ключа key2: value2"
Доступ к текущему контексту полезен, например, при генерации YAML-фрагментов в цикле, где очень часто нужен доступ именно к контексту в текущей итерации.
Так же, как мы пробросили текущий контекст, можно передавать и любых другие дополнительные аргументы в функцию tpl
. Надо только прикрепить аргументы к глобальному контексту и передать глобальный контекст в функцию tpl
.
2.3. Проблемы с производительностью tpl
У функции tpl
есть известные проблемы с производительностью (Issue). Поэтому вызов tpl
для каждого Value
, даже когда это не нужно, может сильно замедлить рендеринг больших чартов. Чтобы избежать ненужных запусков функции tpl
, можно просто добавить проверку на наличие {{
в строке-шаблоне. Если фигурных скобок в строке-шаблоне не будет, то значение вернётся из шаблона «value» как есть, без передачи значения в функцию tpl
:
{{- define "value" }}
{{- $ := index . 0 }}
{{- $val := index . 2 }}
{{- with index . 1 }}
{{- if contains "{{" $val }}
{{- tpl (cat "{{- with $.RelativeScope -}}" $val "{{- end }}") (merge (dict "RelativeScope" .) $) }}
{{- else }}
{{- $val }}
{{- end }}
{{- end }}
{{- end }}
---
{{- with $.Values }}
{{- include "value" (list $ . .key1) }}
{{- end }}
При этом, если фигурные скобки в строке-шаблоне будут присутствовать, то она, как и прежде, будет пропущена через функцию tpl
. Такое простое решение может ускорить рендеринг шаблонов в десятки раз.
3. Отладка
С увеличением количества логики в чартах отладка может сильно усложниться. Помимо банальных helm render
и helm lint
есть ещё и функция fail
, которая во многих случаях является лучшей альтернативой простому {{ $valueToDump }}
. Функция fail
не требует того, чтобы чарты рендерились без ошибок, и может использоваться в любом месте, сразу же давая результат, без необходимости передавать его в манифест. Нужно лишь, чтобы рендеринг добрался до вызова этой функции.
Сделать дамп текущего контекста:
{{- fail (toYaml $.Values) }}
# ==> "key1: val1
# key2: val2
# ...."
Вариант для дебага циклов/рекурсий (порядок не гарантирован, но при желании можно упорядочить по timestamp):
{{- range (list "val1" "val2") }}
{{- $_ := set $.Values.global (toString now) (toYaml .) }}
{{- end }}
{{ fail (toYaml $.Values.global) }}
# ==> "2020-12-12 19:52:10.750813319 +0300 MSK m=+0.202723745: |
# val1
# 2020-12-12 19:52:10.750883773 +0300 MSK m=+0.202794200: |
# val2"
Схожим образом можно сохранить любые промежуточные результаты, которые потом отобразить разом с помощью функции fail
:
{{- $_ := set $.Values.global "value1" $val1 }}
{{- $_ := set $.Values.global "value2" $val2 }}
{{ fail (toYaml $.Values.global) }}
# ==> "value1: val1
# value2: val2"
Вместо заключения
Генерация YAML шаблонизаторами, да ещё и не самыми удачными, да ещё и шаблонизаторами общего толка, которые не понимают YAML, — это, по моему скромному мнению, значит, что мы свернули куда-то не туда. YAML не был предназначен для того, чтобы он генерировался как текст из шаблона, и мне тоже очень грустно от того, насколько повсеместной (и неуместной) эта практика стала. Как бы там ни было, часто приходится работать с тем, что имеем, и в этой статье были показаны способы выжать из Helm-шаблонов максимум гибкости и динамики.
Чувствуете, что даже этого не хватает, чтобы держать чарты обслуживаемыми и расширяемыми? Тогда, возможно, стоит задуматься о том, чтобы генерировать YAML программно, подставляя генерируемые манифесты подобному тому, как это делается в ПО, отвечающем за развертывание. Можно привести cdk8s как пример программной генерации YAML: пусть оно ещё и весьма сырое, саму идею наглядно демонстрирует. И пока светлое будущее без YAML-шаблонизации не наступило, нам не должно быть стыдно за то, что мы эксплуатируем шаблонизаторы, которые уже очень давно эксплуатируют нас.
P.S.
Читайте также в нашем блоге: