Часто в IT-компаниях разработчики используют шаблонизаторы в YAML, JSON и Terraform, управляя параметрами конфигураций, ACL-списками и другими сущностями. Но у такого подхода много подводных камней: шаблоны не всегда корректно отрабатывают и превращают код в спагетти. Особенно если приспичило добавить десятки вложенных условий.

В этой статье рассказываем, откуда соблазн «программировать» в YAML и JSON и почему этого лучше не делать. А еще делимся полезными инструментами, которые помогут избавиться от зловредной привычки. Подробности под катом!

Используйте навигацию, чтобы выбрать интересующий блок:

От Jinja до генератора конфигураций
Почему шаблонизаторы не подходят для YAML и JSON
Персональная боль: шаблонизаторы в Terraform
Альтернативы Terraform

От Jinja до генератора конфигураций


Если вы занимались веб-разработкой, то наверняка встречались с шаблонизаторами — например, с Jinja для Django, Blade для Laravel или ERB, которые используют Ruby, Ruby on Rails и Puppet.

Проще говоря, шаблонизатор — это специальное ПО, которое автоматически генерирует код по заданным правилам, на основании некого трафарета.

Представьте: вам нужно настроить для посетителей вашего веб-сайта HTML-страницы с персонализированным приветствием. Если начнете создавать их вручную, то потратите много времени на изменение бесчисленного количества имен в документах. Но этот процесс можно автоматизировать с помощью шаблонизаторов: он обрабатывает пользовательские запросы и отправляет ответ со стороны сервера, в нашем случае подставляет в приветствие никнейм. Это выглядит примерно так:

<h1>Hello, {{ user.name }}</h1>


Практики шаблонизирования понравились не только веб-разработчикам — спустя время их стали применять в IaC-инструментах вроде Ansible, Puppet и Terraform. Например, при заполнении конфигураций.

Приведем пример: вы — компания, которая работает с большим количеством серверов. Процесс их создания нужно автоматизировать: завести какой-то файлик с конфигурациями, в котором будут описаны характеристики каждого из серверов — например, GPU, RAM или HDD. Кажется, что можно использовать шаблонизатора, чтобы он самостоятельно составил YAML/JSON-конфигурацию, но здесь появляются подводные камни.

Почему шаблонизаторы не подходят для YAML и JSON


Важно разделять данные и код


YAML и JSON — отличные инструменты для декларирования и представления данных, но не нужно их шаблонизировать. До сих пор осталось много конфигураций на JSON, которые не переведены на тот же HCL (язык разметки и конфигураций).

Допустим, что у вас есть JSON, который описывает работу системы сборки образов Packer в OpenStack:

{
…
“builders”: [
   {
	“flavor”: “{{ user.flavor_id }}”,
	“use_blockstorage_volume”: “{{ user.blockstorage_volume }}”,
	“volume_type”: “{{ user.volume_type }}”
}
…
}

Есть две опции — use_blockstorage_volume и volume_type. Если значение первой — ложь, Packer откажется запускаться и выведет сообщение: «В конфигурации есть лишняя опция — volume_type».

Самый простой вариант решения проблемы — добавить условие: если blockstorage активен, тогда мы выводим его тип. Посмотрим, как это будет выглядеть на ERB.

{
…
“builders”: [
   {
	“flavor”: “{{ user.flavor_id }}”,
	“use_blockstorage_volume”: <%=  @blockstorage_enable %>,
<% if @blockstorage_enable -%>
	“volume_type”: “<%= @blockstorage_type %?>”
<% end -%>
}
…
}

В некоторых случаях код отработает корректно, а в некоторых — нет. Причина кроется в коварной запятой после опции use_blockstorage_volume. Если он окажется неактивен и опция volume_type не добавится в конфигурацию, запятая будет лишней, а Packer выведет ошибку.

Нужно дополнительно обрабатывать данные


Бывают ситуации, когда нужно описать много структур данных в одном формате, а после — сохранить в YAML-файл. Для решения подобных задач также используют шаблонизаторы. Но далеко не все внешние инструменты, которые в результате должны считать ваш YAML-файл, понимают структуры шаблонов.

Результат: описанные через шаблоны структуры системы воспринимают как невалидные объекты. Допустим, есть структура, где мы перечисляем IP-адреса серверов для ACL:

---
acl_servers_list:
  - 172.1.2.3/32
  - 192.168.0.0/24
  - foo.bar.com

Самое простое, что приходит в голову, — вместо того, чтобы вводить код вручную, описывать все адреса по шаблону, в котором будем проходить по массиву данных и выводить список серверов. В итоге у нас получится такой шаблон:

---
acl_servers_list:
<%= @servers.each do |server| %> 
  - <%= server %>
<% end %>

Первое время шаблон будет работать. Но если мы перенесем модуль из Production-окружения, например, в Development- или Stage-среду, где ACL не нужны, вместо пустого массива мы получим null. 

acl_servers_list: 
==
acl_servers_list: ~
acl_servers_list: NULL

Такой результат мы получим в YAML.

На этом этапе неизвестно, как ваш язык обработает этот объект. Поэтому, если ACL не содержит адресов, нужно корректно записывать в YAML-файл «пустые скобочки» в виде значения для объявления пустого массива:

<% if @servers %>
acl_servers_list:
<%= @servers.each do |server| %> 
- <%= server %>
<% end %>
<% else %>
acl_servers_list: []
<% end %>

Встречаются нечитабельные шаблоны


Лет 10-20 назад разработчики конфигурировали серверы на Bash. Это было неудобно, поскольку они не могли делиться с другими специалистами своими наработками. Каждый писал в своем стиле, а некоторые специалисты и вовсе разрабатывали отдельные инструменты, которые, по сути, делали одно и то же, но по-разному.

С появлением Ansible, Puppet и Salt проблема уменьшилась: у разработчиков появилась возможность переиспользовать чужой код и наработки, что привело к унификации и распространению кода и знаний.

Конфигурирование Ansible осуществляется с помощью YAML-структур или файлов. Но при этом заполняемость самих YAML-файлов, калькулирование математических операций или подстановки переменных осуществляются через шаблонизатор Jinja. Бывают случаи, когда даже сходу тяжело определить, на чем инженер конфигурирует систему: на Ansible- или Jinja-конфигурации.

- debug:
        msg<b>: </b>'{{ message }}'
      vars:
        foo<b>: </b>'FOO'
        foobar<b>: </b>'{{ foo + "bar" }}'
        message<b>: </b>'This is {{ foobar }}'

Работа с Ansible — это «программирование» на языке Ansible или шаблонизирование YAML через Jinja?

В примере выше получается, что мы принимаем одну переменную foo, подставляем ее в шаблон Jinja и получаем значение другой переменной foobar. Чего в этой конфигурации больше: Ansible или Jinja? Философский вопрос. Но согласитесь, что выглядит запутанно.

Есть еще один пример — из Kubespray. В нем разработчики подставляют в Jinja другой Jinja-шаблон, чтобы его интерпретировать внутри Ansible-конфигурации. Из-за этого появляются проблемы с экранированием скобок и код выглядит примерно так:

- name: prep_download | Set image pull/info command for docker
  set_fact:
    image_pull_command: "{{ docker_bin_dir }}/docker pull"
    image_info_command: "{{ docker_bin_dir }}/docker images -q | xargs -i {{ '{{' }} docker_bin_dir }}/docker inspect -f {% raw %}'{{ '{{' }} if .RepoTags }}{{ '{{' }} join .RepoTags \",\" }}{{ '{{' }} end }}{{ '{{' }} if .RepoDigests }},{{ '{{' }} join .RepoDigests \",\" }}{{ '{{' }} end }}' {% endraw %} {} | tr '\n' ','"
  when: container_manager == 'docker'

А теперь представьте, что спустя время вам нужно будет оперативно отрефакторить этот код или в команду придет новый человек, которому нужно будет разобраться с потоком мыслей коллеги. Вряд ли это получится.

В JSON и YAML сложно «программировать» разветвленную логику


Вы можете написать что-то элементарное с применением конструкций if/else в Jinja-шаблонах. Даже описать сложную логику, но как потом эти все эти условия читать, тестировать и рефакторить?

- name: set dns facts
 set_fact:
   resolvconf: >-
     {%- if resolvconf.rc == 0 -%}true(&- else -%}false{%- endif -%}
   bogus_domains: |-
      (% for d in I 'default.svc.' + dns_domain, 'svc.' + dns_domain ] + searchdomains |default( []) -%)
      {{ dns_domain }}.{{ d }}./{{ d }}.{{ d }}. /com.{{ d }}./
      {%- endfor %}
   cloud resolver: "{{ ['169.254.169.254') if cloud provider is defined and cloud provider == 'gce' else
                       ['169.254.169.253'] if cloud_provider is defined and cloud_provider == 'aws' else
                       []}}"
- name: check if early DNS configuration stage
 set_fact:
   dns_early: >-
     {%- if kubelet_configured.stat.exists -%}false{%- else -%}true{%- endif -%}
- name: target resolv.conf files
 set_fact:
   resolvconffile: /etc/resolv.conf
   base: >=
      {%- if resolvconf|bool -%}/etc/resolvconf/resolv.conf.d/base{%- endif -%}
   head: >-
      {S- if resolvconf bool -S/etc/resolvconf/resolv.conf.d/head<%- endif -S}
 when: not ansible_os_family in ['Flatcar Container Linux by Kinvolk"] and not is_fedora_coreos

Когда начинается сложная логика, невольно система управления конфигурациями сравнивается с языком программирования. В случае с языками мы можем использовать декомпозицию, юнит-тестирование, выносить части логики в отдельные функции с последующим отлаживанием и тестированием.

Давайте заглянем в блок кода ниже, в котором присутствуют подстановки, условия и циклы. Тестировать конфигурацию без прогона всего плейбука невозможно. И непонятно, какие есть состояния и мы можем описать тест-кейс с передаваемыми данными на вход и ожидаемыми на выходе, а не кучей детерминированных состояний.


Персональная боль: шаблонизаторы в Terraform


Terraform — это программа управления инфраструктурой, конфигурация которой описывается на HCL — языке, полностью совместимом с JSON. То есть, описав конструкцию на JSON, он может перевести ее в Terraform и обратно. Поэтому все проблемы работы с YAML и JSON касаются Terraform, который также пытается уместить в себе работу с данными и кодом.

Из-за того, что используется структуры управления данными, для написания вариативной логики вводятся искусственные и неответственные итераторы, генераторы по массивам и словарям, чтобы получить новые данные или трансформировать текущие. Это не добавляет читаемости и понятности, но решается гораздо легче на популярным языке программирования.

variable "rules" {
   default = {
       example0 = [{
           description         = "rule description 0"
           to_port             = 80
           from_port           = 80
           cidr_ blocks        = ['10.0.0.0/16"]
       },{
           description         = "rule description 1'
           to_port             = 80
           from_port           = 80
           cidr_blocks         = ['10.1.0.0/16"]
       }]
       example1 = [] # пустой массив удаляет правила
   }
}

locals {
   rules = {
       for k,v in var.rules:
           k => [
               for i in vi
               merge ({
                   ipv6_cidr_blocks = null
                   prefix list ids  = null
                   security_groups  = null
                   protocol         = "tcp"
                   self             = null
               }, i)
           ]
   }
}

В этом примере мы описываем структуру с ACL для файрвола и правила, как его смержить. Интересно описано поле ingress с конкатенацией и большим набором правил. И почему не написать отдельную функцию c документацией, понятными входами и оговоренными выходами, которую можно удобно тестировать? Не пытайтесь ответить, это риторический вопрос.

resource "aws_security_group" "this" {
   for_each    = var.groups
   name        = each. key # ключ верхнего уровня — имя группы безопасности
descrapcion     = each.vatue.descrocion
ingress = contains (keys (local.rules), each.key) ? local.rules [each.key) : []   #Список карт с атрибутами правил

Еще один пример, или как в Terraform подставить переменную в переменную


Ниже — пример файла, через который мы настраиваем регион и зону доступности при создании виртуальных машин в OpenStack:

region = “ru-1”
zone = “ru-1a”

Кажется, в примере есть избыточность и дублирование значений. Первое что приходить в голову — шаблонировать:

region = "ru-1"
zone = "${region}a"

Но так сделать нельзя. Terraform не позволяет подставить одну переменную в другую. Однако можно объявить шаблон, который будет интерполировать переменные в нужном месте:

region = "ru-1"
t = template($v){"<%= v%>a"}

resource openstack_disk disk_1{
	zone = t($region)
	...
}

Отсюда вопрос: мы управляем конфигурациями через Terraform или шаблонизатор? Снова решаем проблемы из-за того, что нам не хватает управления логикой в структурах хранения данных, таких как JSON или YAML? Непонятно.

Чего не хватает в Terraform


Перечислить необходимые доделки просто — резюмируем наш опыт шаблонизирования в JSON и переносим его на Terraform.

Нет нормальных управляющих конструкций

Это мы уже обсуждали в предыдущем разделе, проблема остается актуальной и для Terraform.

Нельзя нативно подать на вход разные данные без смены окружения

Здесь все совсем печально: вы находитесь в стейджинге и хотите протестировать конфигурацию для продакшена. Подаете часть данных на вход шаблона — получаете ошибку. Получается, что для каждого окружения нужно создавать свой шаблон. Нельзя создать одну «черную коробку» для описания проекта в разных окружениях.

Альтернативы Terraform


YAML и JSON — хорошие форматы хранения данных и сериализации объектов. Но, к сожалению, при добавление логики мы получаем больше проблем. Поэтому код и логика должны быть отделены друг от друга. Так и здесь используется эта альтернатива — писать код со всеми принятыми практиками, а на выходе средствами самого языка формируется валидный JSON или YAML. В таком случае не будет проблем с пустым массивом и null-объектом или запятыми в JSON.

Пример с Terraform показывает, что в индустрии есть аналогичные мысли по разделению языков программирования и результатов. Что на выходе позволяет получить более привычные инструменты для решения задач и легче интегрировать в сложные системы.

Python-Terrascript


С проблемой «программирования-шаблонизирования» Terraform и JSON сталкивались не только мы, но и разработчики Python-Terrascript. Они написали модуль, который позволяет в коде Python декларативно описывать конфигурации и генерировать JSON-манифесты, которые после преобразуются в объекты Terraform. Выглядит это примерно так:

{
  "provider": {
"aws": [ {
       "version": "~> 2.0",
       "region": "us-east-1"
      }
] },
 "resource": {
    "aws_vpc": {


     "example": {
        "cidr_block": "10.0.0.0/16"
} }
} }

import terrascript
import terrascript.provider as provider
import terrascript.resource as resource


config = terrascript.Terrascript()
config += provider.aws(version='~> 2.0', region='us-east-1')


config += resource.aws_vpc('example', cidr_block='10.0.0.0/16')


with open('config.tf.json', 'wt') as fp: 
    fp.write(str(config))

Pulumi


Другие энтузиасты пошли дальше и создали новый IaaC-инструмент — Pulumi. Он позволяет генерировать YAML-конфигурации с помощью Go, Python, JavaScript, TypeScript, C#, Java и Kotlin. При этом инструмент мультиоблачный — его можно использовать для тестирования облачных провайдеров, поддерживающих Terraform.

func main() { pulumi.Run(osCreateServer) }
func osCreateServer(ctx *pulumi.Context) error {
   net, err := networking.LookupNetwork(ctx, &networking.LookupNetworkArgs{
       Name: "nat"
   }, nil)
   instance, err := compute.NewInstance(ctx, "test", &compute.InstanceArgs{
       Name:   pulumi. StringPtr("test-server2"),
       FlavorName: pulumi. String("SL1.1-1024-8"),
       ImageName: pulumi. String("Ubuntu 20.04 LTS 64-bit"),


       Networks: compute.InstanceNetworkArray{
               &compute.InstanceNetworkArgs{       //В исходном примере добавляется два раза
                   Uuid: pulumi.StringPtr(net.Id),
               },


       },
   })
   server1Ports := instance.Networks.ApplyT(
       func (nets []compute.InstanceNetwork) (string) {
               fmt.Printin("len:". len(nets))
               ip1 := *nets[01.FixedlpV4
               ip2 := *nets[11.FixedIpV4
               return fmt.Sprintf(°ip1: %s, ip2: %s", ip1, ip2)
       }.
   ).(pulumi.StringOutput)
}

Пример декларации операционной системы, Pulumi на Go.

Проще говоря, Pulumi позволяет описывать конфигурации, тестировать инфраструктуру как код на нормальном «человеческом» языке программирования, без шаблонизирования YAML или JSON.

Что думаете насчет шаблонизаторов и Terraform вы? Может, используете альтернативы, отличные от Python-Terrascript и Pulumi? Поделитесь своим опытом в комментариях.

Комментарии (13)


  1. saboteur_kiev
    11.01.2024 15:04
    +8

    Лет 10-20 назад разработчики конфигурировали серверы на Bash. Это было неудобно, поскольку они не могли делиться с другими специалистами своими наработками.

    Почему не могли? На баш можно писать простые вменяемые конструкции. На баше можно обрабатывать json/cvs файлы или работать с базой, чтобы хранить в удобном виде.
    Основная проблема это разнообразие дистрибутивов, пакет менеджеров, зоопарка версий.. Поэтому оркестраторы которые брали на себя поддержку всего этого дела стали популярными.

    Но да, огромный минус json/yaml это очень неудобная отладка, и слишком часто начав шаблонизировать, люди их усложняют, в результате разобрать на 100% что там происходит - очень неудобно и долго. Потому что неудобная отладка.

    P.S. А еще добавьте в список helm


    1. johndow
      11.01.2024 15:04
      +7

      helm обязательно надо добавить. шаблонизировать один yaml другим yaml`ом со всеми его приколами с отступами и переносами строк - это прям "удовольствие"


  1. Vermut666
    11.01.2024 15:04
    +1

    Я бы сказал, что для Терраформа на такой случай есть CDKTF (https://developer.hashicorp.com/terraform/cdktf). В нем можно создавать нужные конфигурации на любимом языке. Почему-то люди особенно любят TypeScript для этого. Не читал, но осуждаю.

    Недавно вышла 0.20.


  1. chernov7
    11.01.2024 15:04
    -7

    Полезная статья! Нашел, что искал. Спасибо!


  1. eugenezarechkin
    11.01.2024 15:04

    В рельсах к слову есть такая вещь как json builder. Шикарный шаблонизатор для JSON. Все проблемы с запятыми как раз решаются использованием подходящего шаблонизатора. Очень странно юзать шаблонизатор для XML/HTML (ERB) для JSON ,)


  1. nin-jin
    11.01.2024 15:04
    -5

    Переписал ваши примеры на простеньком DSL на базе формата Tree:

    Транслятор этого в JSON пишется в пару строк.

    Но вы меня не слушайте, я всё вру.


    1. mayorovp
      11.01.2024 15:04

      Ну и как тут понять где переменная, а где константа?


      1. nin-jin
        11.01.2024 15:04
        -1

        А какая разница?


  1. jurikolo
    11.01.2024 15:04
    +1

    Пошёл искать, что за зверь такой - Python-Terrascript, а он не развивается уже несколько лет. Так себе инструмент для сравнения.
    Да и в целом проблема не такая большая, как описывается в статье. Да, в некоторых случаях приходится писать некрасивый код и костылять для Terraform, но в целом описание инфраструктуры достаточно наглядное и понятное.


  1. Tamerlan666
    11.01.2024 15:04

    - debug:
    msg<b>: </b>'{{ message }}'
    vars:
    foo<b>: </b>'FOO'
    foobar<b>: </b>'{{ foo + "bar" }}'
    message<b>: </b>'This is {{ foobar }}'

    Вот это вообще какой-то странный пример, непонятно для чего нужный. Я такое в своей практике ни разу не встречал. При работе в Ansible шаблонизировать yaml и json нужды нет вообще. Это нативные для ансибла форматы. При необходимости, вывод в нужный формат делается одним фильтром вида to_nice_json или to_nice_yaml.


  1. wizard_s
    11.01.2024 15:04
    +2

    Глоток свежего воздуха прямо. Страшный сон - шаблоны с макросами, подключаемыми из других шаблонов, в которых тоже макросы. И это невозможно отлаживать.
    А потом кому-то не хватило возможностей шаблонизатора и внутри шаблона появляются куски кода


  1. primetalk
    11.01.2024 15:04

    С успехом применял Jsonnet для сложных конфигураций. Очень простой язык в функциональном стиле, тотальный (гарантированно возвращающий одинаковый результат при тех же входных данных). Является надмножеством json. В частности, хорошо поддерживается k8s.


    1. mayorovp
      11.01.2024 15:04

      Термин "тотальный" означает не это.