Один undefined и 80 минут CI: почему мы перенесли рендер ansible на Go
Расскажу, как рефакторинг 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-рантайм.