Расскажу, как рефакторинг vars-слоя в нашем ansible-хозяйстве упёрся в guard-пайплайн, который бежал дольше, чем автор правил замечания ревью, — и к чему это привело: рендер 85 спеков двух сред на Go за 3,5 секунды с побайтовым паритетом и вклад в upstream.

Про то, как эта инфраструктура устроена в целом — один бинарник, systemd, пакеты и ansible вместо Kubernetes, — я уже писал в «Эксплуатации без K8s»; здесь фокус на конфиг-слое и его страховке.

TL;DR

  • IaC стал страшным — глобальный словарь kinescope на 834 строки, 210 из которых — порты чужих сервисов; одна vault-ссылка роняла 21 роль и 84 спека из 85.
  • Страховка рефакторинга — render-check: рендер всех спеков baseline против рабочего дерева, сравнение байт-в-байт.
  • ansible как движок рендера не вытянул — ~80 минут на полный прогон (340 запусков плейбука).
  • go-ansible/template как SDK — Go-рендер всего корпуса за ~3,5 секунды, побайтово идентичный ansible; перед переключением — пилот на 70 пар env, ноль расхождений.
  • Вклад в upstream — RegisterFilter (PR #41, вошёл в v0.33.0), из ревью вырос RegisterTest (PR #42).
  • ansible остаётся организацией, меняется исполнитель: следующий кандидат — деплой приложений на том же SDK.

С чего всё началось: IaC, который стал страшным

У нас видеоплатформа Kinescope, инфраструктура декларативная: каждый сервис описан спек-файлом (YAML в specs/), из которого генерируются environment-файлы (/etc/<svc>/environment) и secrets через Jinja2-шаблоны. Весь этот мир живёт в ansible: роли, инвентари, свой IaC-слой, который строился годами.

И вот с этим слоем случилось то, что рано или поздно случается с любым IaC, растущим без жёстких конвенций. Он стал страшным. Первый нарушенный принцип — Locality of Behavior (LoB, Карсон Гросс): поведение системы должно быть видно в месте её описания. У нас — наоборот: порт и конфиг сервиса лежат за сотни строк от его описания, в глобальном словаре. Второй — единый источник правды (Single Source of Truth): значение, принадлежащее сервису, имело владельца, не совпадающего с потребителем, — правка одного сервиса требовала трогать общий файл. Третий — композиция вместо копипаста: новое окружение собиралось копией оверлея соседа, а не переопределением только отличий. Главный симптом — ад в vars/.

Точный диагноз дал инцидент. Прогон ansible-playbook playbooks/proxmox.yml --tags ops_exporter упал с AnsibleUndefinedVariable: 'acme_manager_cloudflare_api_token' is undefined при обращении к kinescope.ops.exporter.monitoring_port. Закрыли точечно, добавив переменную в vars. Но корень был системный.

Мы разобрали vars/kinescope.yml — глобальный словарь kinescope на 834 строки — по владельцам:

  • порты, 210 строк — владелец: сервис, его спек;
  • конфигурация сервисов (debug, бакеты, лимиты, URL внешних API), около 350 строк — снова сервис;
  • ссылки-прокси на секреты и версии, пара десятков;
  • адреса БД и NATS, около 50 строк — уже переехали в resources: спеков;
  • домены и общие хосты, около 30 строк — единственная настоящая per-env дельта.

То есть словарь, через который проходит каждый прогон, на девять десятых состоит из чужих данных.

Последствия у этой простыни характерные. На ansible-core 2.16 обращение к любому kinescope.x.y шаблонизирует словарь целиком — и одна нерезолвленная vault-ссылка в инвентаре, где этого секрета нет, роняет 21 роль и 84 спека из 85. Оверлеи vars/<env>/kinescope.yml умеют только перекрывать: prod-оверлей — 799 строк и 355 листов (ключей), реально отличаются 62, ещё 42 — буквальные копии базы. 29 из 66 плейбуков несут копипасту pre_tasks с четырёхшаговым include/merge. Завести новый сервис значило — внести правку в общую простыню.

Мы сели и написали план рефакторинга: единый источник правды по портам — спек сервиса, генерируемая проекция ports.yml вместо ручной простыни, секреты только в vault и подстановкой в месте использования, удаление merge_yaml и оверлеев. План хороший, по шагам, каждый шаг самодостаточен.

Оставалась одна проблема: как доказать, что рефакторинг ничего не меняет.

Страховка: render-check

Рефакторить конфиг-слой, не имея способа проверить, что отрендеренный вывод не изменился, — это миграция на надежде. У нас 84 спека из 85 читают kinescope.*, и правка vars может тихо поменять порт в конфиге соседа. Поэтому первой задачей плана стал guard-пайплайн render-check: берём baseline (merge-base MR), рендерим все спеки, рендерим те же спеки из рабочего дерева и сравниваем результат байт-в-байт. Любой diff — красный пайплайн, и автор разбирается, осознанно это изменение или авария.

Логика честная. Реализация — нет.

Ansible стреляет себе в ногу: 80 минут на тест

Свой рендер мы уже делали руками — харнес повторял механику роли kinescope_service: два прохода шаблона, spec_import, merge_yaml, vault. Не писать же это заново — мы взяли ansible как движок: bash-скрипт поднимал git worktree на baseline, и для каждого спека запускался ansible-playbook с плейбуком рендера. По процессу на спек, на каждую сторону сравнения, на каждую среду. Полный прогон — 85 спеков × 2 среды × 2 стороны, порядка 340 запусков плейбука.

Каждый запуск платит за старт Python-интерпретатора, загрузку ansible, парсинг инвентаря и vault-файлов — всё это на порядки дороже самого рендера, который занимает миллисекунды. Батчинг был последовательным: следующий спек ждал окончания предыдущего. Сумма — около 80 минут на полный прогон по production и staging.

Для guard-пайплайна в MR это приговор. Проверка не влезает в цикл ревью: MR открыт, автор правит замечания, а guard от прошлого коммита ещё бежит. Люди начинали игнорировать красный guard или ретраить его наугад — а guard, который игнорируют, хуже, чем его отсутствие.

Итак, первый налог — время. Второй копился тише: зависимость от python-модулей, включая те, что должны стоять на целевых хостах. Каждая версия python — отдельная матрица совместимости, каждый модуль — точка отказа в непопулярный момент. Тогда это был фоновый шум; острое — 80 минут в MR.

go-ansible/template как SDK

Писать собственную реализацию Jinja2 с ansible-семантикой мы не хотели — это месяцы. И тут вовремя обнаружился проект github.com/go-ansible/template: Go-реализация ansible-семантики шаблонизации поверх gonja. Jinja2-совместимый синтаксис, StrictUndefined как в ansible, библиотека фильтров ansible — а вот регистрации своих фильтров на тот момент в нём не было. Для нас это был не «шаблонизатор», а SDK: мы построили на нём рендер-пайплайн — инвентарь и vars загружаются в память, merge_yaml-семантика воспроизводится фикспоинтом, пять кастомных фильтров (hosts_port, hosts_addrs, each_hostvars, set_flags, vault_base64) портированы с Python на Go, и один процесс отрендерил весь корпус.

Что библиотека даёт: семантику шаблона. Чего не даёт: семантику переменных ansible. Ключевое отличие — ansible резолвит вложенные шаблоны при lookup переменной. Когда вы обращаетесь к some_var, а её значение — строка {{ other_var }}, ansible сначала шаблонизирует её, и вы получаете значение other_var. Gonja подставляет текст значения и идёт дальше, вложенные {{ }} остаются сырыми. Это различие пронизывает весь порт, и о нём — половина граблей.

Пять наших фильтров упёрлись в деталь: в версии библиотеки на тот момент набор фильтров собирался внутри New() за неэкспортированными полями, и добавить свой без форка пакета было нельзя. Форк тащить не хотелось, так что мы отправили в upstream PR #41 — RegisterFilter, тонкое делегирование в FilterSet gonja: ошибка на дубликате имени вместо тихой замены, регистрация до первого рендера по контракту. Мейнтейнер проекта tannevaled сверил утверждения с исходниками gonja, заметил в ревью, что Register неатомарен (Exists отпускает лок до записи — нам не грозит, регистрируем на старте из одной горутины), и влил PR 7 октября 2026-го. API вошёл в релиз v0.33.0, на который мы и переехали; из того же ревью родился компаньон #42 с RegisterTest — jinja-тесты is в ansible живут рядом с фильтрами, а их набор был закрыт той же стеной. Контракт «регистрация из одной горутины до первого рендера» теперь зафиксирован в документации вместе с обоими API.

Грабли

Рекурсивный lookup и фикспоинт

В ansible значения переменных шаблонизируются лениво, при обращении, сколько бы вложенности ни было. В gonja — один проход. Решение: рендерить до стабилизации. Для слоёв vars это фикспоинт, максимум 5 проходов, как в нашем merge_yaml:

func (p *Pipeline) resolveLayer(layer map[string]any) {
	for i := 0; i < 5; i++ {
		resolved := walkTemplate(layer, p.scope(nil), nil, nil)
		out := resolved.(map[string]any)
		// Snapshot before mutating: layer and out can alias after a
		// pass, and comparing mutated state to itself stops the loop
		// after one pass, leaving chained refs half-resolved.
		stable := equalYAML(layer, out)
		for k, v := range out {
			layer[k] = v
		}
		if stable {
			return
		}
	}
}

Тонкость, на которой мы споткнулись: снапшот до мутации. Слои и результат прохода могут алиасить один и тот же map, и если мутировать слой до сравнения, сравниваешь изменённое состояние с самим собой — цикл завершается после первого прохода, а цепочки ссылок остаются полурезолвленными. Симптом был неприятный: часть значений резолвилась, часть нет, зависело от порядка ключей. Для рендера самого спека та же идея, до трёх итераций: вложенные ссылки, всплывшие в выводе (например, to_json над списком с vault-ссылками), резолвятся на втором проходе.

keep_raw не везде

Merge-файл vars шаблонился без keep_raw, и расхождение семантик merge_yaml дало нерезолвленные ссылки внутри JSON: to_json сериализовал сырые {{ }} до того, как те успевали раскрыться. Лечится точным раскладом: групповые переменные шаблонятся без keep_raw (в ansible они раскрываются свободно при lookup), merged-файл — с ним, vault-имена попадают в keep_raw-глобы. Без точного воспроизведения этих правил побайтового паритета не получить.

Panic на пустом raw

Gonja падает в panic (index out of range) на пустом блоке {% raw %}{% endraw %} — и такие значения в нашем корпусе нашлись. Паника на одном спеке не должна ронять весь пайплайн:

func (p *Pipeline) safeRender(src string, scope map[string]any) (out string, err error) {
	defer func() {
		if r := recover(); r != nil {
			err = fmt.Errorf("template panic: %v", r)
		}
	}()
	return p.eng.Render(src, scope)
}

Паника превращается в ошибку, спек попадает в FAIL-множество — ровно как в ansible, где он падает с нормальным сообщением.

trim_blocks

В ansible trim_blocks=True по умолчанию: перевод строки сразу после блочного тега съедается. В go-ansible/template настройки нет, а разница в один перевод строки — это проваленный байт-в-байт. Эмулируем текстовой заменой, заодно раскрывая литерал {{ '\n' }} (gonja не разворачивает escape-последовательности в строковых литералах, а три спека им пользуются):

trimBlocks := func(s string) string {
	s = strings.ReplaceAll(s, "{{ '\\n' }}", "\n")
	return strings.ReplaceAll(s, "%}\n", "%}")
}

Да, грубо. Но семантика ansible здесь тоже текстовая, и замена покрывает наш корпус целиком.

Секреты: base64 от шифротекста

Модель секретов у нас такая: plaintext в env-файле отсутствует. Значение едет на хост как base64 от шифротекста ansible-vault (фильтр vault_base64), сервис расшифровывает сам. Рендеру plaintext почти нигде не нужен — кроме случаев, когда vault-значение участвует в других шаблонах.

В Go мы реализовали ansible-vault 1.1 AES256 напрямую: PBKDF2-HMAC-SHA256 на 10000 итераций даёт 80-байтовый ключ, который режется на AES-ключ, HMAC-ключ и IV:

derived := pbkdf2.Key([]byte(password), salt, 10000, 80, sha256.New)
aesKey, hmacKey, iv := derived[:32], derived[32:64], derived[64:80]

mac := hmac.New(sha256.New, hmacKey)
mac.Write(data)
if !hmac.Equal(mac.Sum(nil), storedHMAC) {
	return "", fmt.Errorf("vault HMAC mismatch (wrong password?)")
}
stream := cipher.NewCTR(block, iv)
stream.XORKeyStream(plaintext, data)

Вышло проще, чем возиться с сабпроцессами: тип VaultString с методом String() зеркалит AnsibleVaultEncryptedUnicode из ansible — рендер отдаёт plaintext, vault_base64 потребляет шифротекст. Вся vault-магия уместилась в один файл на 140 строк, без python в рантайме.

Паритет и числа

На Go-движок мы не перешли на вере. Сначала пилот: Go-рендер сравнивался с ansible-бейзлайном байт-в-байт — 70 пар «спек × среда» (production 23 + staging 47 — все, где ansible-рендер вообще существует), ноль расхождений. Параллельно — юнит-тесты на байт-совместимость фильтров и 11 тестов классификации diff-чекера (IDENTICAL / DIFF / TREE-FAIL / BOTH-FAIL и прочие вердикты). Только после этого дефолтный движок переехал на Go; ansible остался флагом --engine ansible как запасной путь.

Полезный побочный эффект: паритет сделал накопившиеся проблемы видимыми. В корпусе набралось 20 спеков в статусе BOTH-FAIL — исторические падения, которые старый пайплайн тихо пропускал («и база упала, и дерево упало»). Часть падает на lazy-eval ansible, часть — gonja не может распарсить один for-цикл. Теперь это явный список, чиним отдельной волной.

Сами числа. Полный прогон guard-а: ~80 минут → ~3,5 секунды на все 85 спеков обеих сред. Аналогия, не метрика: guard, который раньше бежал дольше, чем человек правит замечания ревьюера, теперь укладывается во время набора «git push». Render-check стал синхронным шагом MR: нажал rerun — через несколько секунд ответ, интерактивный цикл правок не разрывается. А рефакторинг vars, ради которого всё затевалось, получил страховку, которой можно пользоваться по сто раз в день.

Что оказалось важнее ускорения

Когда цифры сошлись, оказалось, что главное — не в них. Мы начинали с утилитарной задачи — ускорить один guard-пайплайн, — а получили работающий Go-слой, который воспроизводит ansible-семантику на нашем корпусе с точностью до байта. Инвентарь, merge vars с фикспоинтом, vault, кастомные фильтры, двухпроходный рендер — всё это не «скрипт для проверки», а SDK для наших инструментов.

Оба налога — время и python-зависимость — этот слой снимает. Go убирает зависимость от конкретных версий python и набора модулей — и на CI-раннере, и на целевых хостах. Потребление ресурсов падает на порядки: один процесс вместо сотен запусков плейбука. Свой модуль на Go пишется быстрее и предсказуемее, чем ansible-плагин с py-обвязкой, а вместе с модулем в деплой естественно встраивается observability: структурированные логи и метрики шагов, чего плейбук толком не даёт.

Сам ansible как способ организации никуда не денется: роли, инвентари, спеки, декларативная модель — остаются, менять их мы не собирались и не собираемся. Меняется исполнитель. template — один из восьми компонентов проекта go-ansible: автор проекта развивает его в целую библиотеку с измеренным побайтовым паритетом (vault, inventory, vars, playbook, cli и остальные), а мы — потребители SDK. Шаблонизация была первым, самым изолированным и побайтово проверяемым куском; дальше с тем же SDK мы планируем деплой приложений и запуск уже существующих ansible-плейбуков, не таща python-рантайм.