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

6un1fbikehqxfki1yxpdebcxy08.png


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

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

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

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


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

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

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

Hello, {{ user.name }}

Практики шаблонизирования понравились не только веб-разработчикам — спустя время их стали применять в IaC-инструментах вроде Ansible, Puppet и Terraform. Например, при заполнение конфигурационных файлов. Для этого понадобится файл, в котором описаны характеристики каждого из серверов: CPU, 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 (Access Control List, список управления доступом) не нужны, то вместо пустого массива мы получим 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


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

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

ptwt-pcef2msq7duvzrwfkt4gbo.png


Персональная боль: шаблонизаторы в 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? Поделитесь своим опытом в комментариях.

© Habrahabr.ru