company_banner

Продвинутая 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.


    Читайте также в нашем блоге:

    Флант
    DevOps-as-a-Service, Kubernetes, обслуживание 24×7

    Comments 18

      +1

      Проблема большинства helm чартов в том, что они совершенно нечитаемы. Авторы все делают правильно, ведь для клиента чаще всего важен только файл values.yaml. Но весь вот этот обвяз из include, define и остальной кучи шаблонизации создает мультизлаковую кашу, которую дебажить — одно "удовольствие". Я могу ошибаться, но мне кажется, что такая гибкость в большинстве случаев не нужна, если чарт приватный внутренний. Как по мне, лучше пусть будет один простой и очевидный if else для, допустим, прода и прочих окружений, чем эта вся эта мишура.

        +6
        Действительно, если к чартам особых требований нет, то лучше держать всё максимально простым. Но если однотипных чартов становится много + сложность каждого отдельного чарта растёт, то в любом случае у тебя получится куча шаблонизации. Просто эта сложность не будет вынесена в include/define'ы, а будет в каждом чарте повторяться снова и снова. Каждый раз реализована по-новому, каждый раз с новыми багами/фичами, и без возможности всё это централизованно править.

        Реализация некоторой логики в шаблонах вообще получается слишком сложной, чтобы каждый раз её повторять, поэтому либо выносишь её в «функцию», либо не пишешь вообще и миришься с ограничениями.

        В остальном у меня не меньше горит с helm-шаблонов, особенно сложных. Собственно всё это на самом деле для того чтобы упростить работу с ними, а не усложнить.
          +1
          и без возможности всё это централизованно править.

          ну, это не совсем так. sed по пачке репозиториев или search&replace в современных редакторов очень мощны. Настолько, что даже Сысоев рекомендовал в конфигурации nginx писать максимально ясно и развернуто, а не пытаться сворачивать блоки и увеличивать сложность конфета для понимания...

            +2
            На практике если логика продублирована — в большинстве случаев это значит что она изначально была реализована по-разному (и вероятно разными людьми), а потом ещё и правилась, и тоже по-разному, в разноё время и разными людьми. В итоге начинается дрифт этой логики/конфигурации, и простым sed'ом уже не пройдешься.

            Конечно, если логики не много, и пишется она небольшим кол-вом людей, то часто проще дублировать и не париться.
              0
              Он это рекомендует, наверное, последние лет 10)
            +4

            Что хуже — нет каких-то стандартов. Где-то имена образов подставляются как единый параметр, в других же чартах — отдельно репо, отдельно имиджнейм, отдельно тег (и это правильно). Последним подходом, например, пользуется битнами, за что им респект.

              +4
              Всё так, у всех свои практики складываются, стандартизации нет. При этом подводных в helm-шаблонах полно, поэтому пока выработаешь какие-то действительно работающие лучшие практики, шишек набьешь кучу.

              Думал может в отдельной статье поделиться практиками по структуре чартов/values, которые показали себя рабочими по крайней мере у нас, может кому полезно будет.
                +2
                Полностью согласен. Все городят то, что им удобно. Но ведь это и хорошо — мы видим разные подходы и совершенствуем свой. Битнами вообще красавцы, хотя и у них есть спорные моменты, в основном в образах.
                  0
                  хотя и у них есть спорные моменты, в основном в образах.

                  Это больше вкусовщина.
              +1

              Мне вот не хватает рекурсивной проверки по ключу. Приходится лопатить нечто подобное:


              {{ if .Values.key1 }}
              {{ if .Values.key1.key2 }}
              {{ if .Values.key1.key2.key3 }}
               - bla.bla-{{ .Values.key1.key2.key3 }}
              {{ end }}
              {{ end }}
              {{ end }}

              Хотелось бы сразу:


              {{ if .Values.key1.key2.key3 }}
               - bla.bla-{{ .Values.key1.key2.key3 }}
              {{ end }}
                0
                А это боль, да. И проблема в том, что вот это {{ if .Values.key1.key2.key3 }} особо то никак и не обернешь в include. Единственный вариант который в голову приходил — это сделать define, в который аргументом пробрасывается строка типа ".Values.key1.key2.key3", а внутри это как-то разбивать (по точкам?) на массив и по очереди проверять каждый ключ на существование.
                Но всё это довольно чудно и, вероятно, требует прилично логики чтобы все исключения обработать, так что реализовывать не стал.
                  +3

                  Функция dig в sprig есть для этого. Если в качестве дефолта поставить пустое значение, то можно в if использовать.


                  http://masterminds.github.io/sprig/dicts.html

                    0
                    Найс, разве что только в списке функций в третьем хельме не вижу этого, видимо не завезли в него, но это не точно. Зато в werf должно работать.
                  0
                  Поделитесь бест практис, пожалуйста
                  Если у меня есть несколько сервисов которые могут разворачиваться независимо друг от друга (в разных комбинациях в зависимости от инсталляции), но в целевом варианте интегрируются и работают друг с другом — с точки зрения хелм — это отдельный чарт под каждый микросервис, или один чарт с зависимыми чартами? Какой сервис выбрать основным, а какой зависимым, если нет прямой зависимости?
                  Или есть третий вариант?
                    0

                    третий вариант — вы никогда в "реальной" среде не будете разворачивать сервисы вместе — а фактически единицей развертывания является реальный один сервис. Убер (амбрелла) чарт в этом случае будет стопором для развертывания, например, нескольких версий одного сервиса для разных окружений или изменения версии одного сервиса, т.к. это ответственность одной команды…


                    еще раз — один хельм чарт — это одна единица развертывания (=один микросервис). Может быть один продукт (=несколько микросервисов + БД+ еще что-то), но тут думать нужно о границах, чтобы не напороть больше проблем, чем преимуществ

                      0
                      да вот именно эти границы и интересуют — я не имел в виду поднять из чарта что-то огромное — разговор о, например бекенде и фронтенде, который разрабатывается одной командой. просто в зависимости от инсталляции разворачиваться может и бек и фронт, а может только бек. а если еще добавляются ингресс/егресс истио с его конфигами — это отдельные чарты?
                      если бек и фронт сделать отдельными чартами, а в перспективе может это еще поделится на пяток микросервисов, читай чартов — есть ли какой-то оркестратор чартов?
                        0

                        Ответы на Ваши вопросы


                        1. Конфиги инфры (истио объекты, ингрессы, егерем, сетевые политики) — это очевидно неотъемлемая часть чарта приложения или сервиса. Потому что без него они смысла не имеют. Если приложение может быть задеалоено в разные окружения, то через флаги values.yaml вы можете переключать включение или отключение конкретной настройки. Например, нет истио — он в чарты есть, но мы отключаем istio related ресурсы
                        2. Оркестратора чартов нет. Это всего лишь способ упаковки приложения и части ресурсов, с ним связанных. Для управления и деплоя можно посмотреть на spinnaker или GitOps тулинги вроде FluxCD (с объектом HelmRelease, реализованным через helm controller) или ArgoCD.
                        3. Если чарты сделаны по принципу per microservice, а не per product, тогда опять возвращаемся к идее с umbrella chart, в нем же могут быть описаны инфра штуки из п.1 (что является атрибуцией не самого сервиса, а их совокупности), но мне такой подход, как я уже сказал, не очень нравится.

                        просто в зависимости от инсталляции разворачиваться может и бек и фронт, а может только бек.

                        Опять же — values.yaml, в котором мы выбираем комбинацию компонентов (бек и фронт или только бек, или только фронт) и условные блоки в темплейтах

                          +1
                          Для сопровождения взаимосвязанной группы чартов можно использовать helmfile

                    Only users with full accounts can post comments. Log in, please.