«Хватит программировать в YAML и JSON!»: проблемы шаблонизирования
Рассказываем, откуда соблазн «программировать» в 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.
Автор: Алексей Степаненко, ведущий системный администратор.