Почему не стоит шаблонизировать YAML и JSON?

«Хватит программировать в YAML и JSON!»: проблемы шаблонизирования

Анастасия Ербанова
Анастасия Ербанова Технический писатель
11 января 2024

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

Изображение записи

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

От 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: '{{ message }}'
      vars:
        foo: 'FOO'
        foobar: '{{ foo + "bar" }}'
        message: '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.

Автор: Алексей Степаненко, ведущий системный администратор.