
Всем привет! Меня зовут Рома, я DevOps в команде «Платформа» в Банки.ру.
Одна из наших задач: развитие системы управления релизами сервисов (которых у нас уже больше 600). Мы не раз сталкивались с ситуацией, когда на первый взгляд небольшое изменение процесса, затрагивающее сразу много сервисов, приводило к необходимости править сотни файлов. В этой статье я расскажу, как мы нашли способ упростить и ускорить эту работу, а заодно навести порядок в репозитории.
Проблема: почему назрела необходимость в изменениях
Начну с того, откуда вообще взялся Ansible, почему, например, не Helm или какой-нибудь GitOps? Мы уже писали про то, как устроены наши тестовые среды: они гибридные, с некоторым количеством старых сервисов на виртуальных машинах, взаимодействующих с приложениями в кубере. Когда мы только внедряли Ansible, то переезжали с ruby-шного Capistrano на Ansistrano — это набор плейбуков, который позволяет прозрачно и удобно управлять релизом приложений на большое количество окружений.
Ansible — система управления конфигурациями, которая реализует принцип «инфраструктура как код» (IaC). С ее помощью можно сетапить различные удаленные хосты, сервера и так далее. Мы ее используем, чтобы доставлять наши сервисы кластерам Kubernetes, переходили на неё долго и трудно.
Когда у нас появился кубер, мы решили не менять принцип, и сделали еще один репозиторий со своим динамическим инвентарем и модулем kubernetes.core.k8s.
Раньше для каждого сервиса мы создавали одинаковый набор файлов:
плейбук, в котором описывали, что нужно сделать — причем, во многих из них отличия в коде были минимальные;
group_vars, в которых мы указывали параметры и переменные;
темплейты манифестов и разные вспомогательные файлы.
Управлять такой системой было достаточно легко, когда сервисов было 100. Одним из важных плюсов мы считали, что ошибка в одном манифесте не влияет строго на контекст другого сервиса. Когда их число перевалило за 300, мы начали менять точку зрения.
При внесении масштабных правок, например, интеграции статического анализа, каких-то уведомлений, был велик шанс ошибиться и забыть про какой-нибудь плейбук. Такие ситуации уже возникали, и, хотя больших проблем они не успели доставить, стало очевидно, что пора пересмотреть подход.
Решение: темплейты в Ansible и новый подход к конфигурированию сервисов
Чтобы перестать множить почти идентичные файлы, мы начали шаблонизировать плейбуки с помощью темплейтов.
Templates — в Ansible одна из сущностей, позволяющая гибко конфигурировать выполнение сценариев в зависимости от условий. Для обработки темплейтов используется язык Jinja2 с логикой, очень похожей на Python.

Блок в старом коде:
tasks:
- name: Import Db tasks
import_tasks: postgres-config.yml
vars:
db_user: '{{ service_name }}'
db_pass: '{{ service_secret.service_name.db_pass }}'
db_host: '{{ pg_host }}'
db_name: '{{ service_name }}'Блок в новом коде:
tasks:
{% if database_init_tasks is defined and import_database_tasks_enabled | default(true) %}
- name: Import Db tasks
import_tasks: {{ database_init_tasks.import_tasks | default("postgres-config.yml") }}
{% if database_init_tasks.vars is defined %}
vars:
{{ database_init_tasks.vars | to_nice_yaml(indent=2) | trim | indent(8) }}
{% endif %}
{% endif %}Если отбросить в уме новые конструкции, то видно, что код мало изменился. За счет этого адаптация к переменам проходит безболезненно.
В этой новой системе плейбуки и манифесты для кубера формируются через общие темплейты, а не создаются отдельно для каждого сервиса. Точкой конфигурирования сервисов становятся group_vars. В них можно прописать все параметры, переменные, и даже указать специальный темплейт, если общий по какой-то причине не подходит.
Пример group_vars одного из наших сервисов:
custom_templates:
service: '{{ k8s_project }}'
database_init_tasks:
enabled: true
ensure_rabbitmq_correct_state:
enabled: true
header_request_proto: 'https'
passport_pub_key_enabled: true
k8s_cronjob:
enabled: true
custom_template_directory: '{{ k8s_project }}'
configs_and_secrets:
- name: '{{ k8s_project }}-secrets'
k8s_kind: Secret
files:
- name: .env.local
uniq_data_name: secrets
- name: '{{ k8s_project }}-config'
k8s_kind: ConfigMap
data:
CPU_LIMIT: '{{ cpu_lim }}'
SERVICE_NAME: '{{ service_name }}'
SERVICE_PORT: '{{ service_port | default("8080") }}'
SERVICE_READ_TIMEOUT: '{{ service_read_timeout }}'
SERVICE_WRITE_TIMEOUT: '{{ service_write_timeout }}'Теперь посмотрим на темплейты плейбука и манифестов, которые используют эти переменные. Возьмем блок кода из темплейта плейбука, с помощью которого мы деплоим конфиги и секреты:
{% if configs_and_secrets is defined %}
{% for config_or_secret in configs_and_secrets %}
{% if config_or_secret.files is defined %}
- name: Generate {{ config_or_secret.name }} data
set_fact:
{% for config_or_secret_file in config_or_secret.files %}
{{ config_or_secret_file.uniq_data_name }}: "{% raw %}{{ lookup('template', '{% endraw %}{{ config_or_secret_file.custom_path | default(k8s_project) }}/{{ config_or_secret_file.name }}{% raw %}.j2') }}{% endraw %}"
{% endfor %}
{% endif %}
{% endfor %}
- name: Deploy {{ k8s_project }} configs and secrets
k8s:
namespace: "{{ k8s_namespace }}"
definition: "{% raw %}{{ lookup('template', 'common-templates/php/{% endraw %}{{ custom_templates.configs_and_secrets | default('default') }}{% raw %}/k8s-configs-secrets.yml.j2') }}{% endraw %}"
validate:
fail_on_error: yes
state: "{% raw %}{{ state | default('present') }}{% endraw %}"
environment:
K8S_AUTH_KUBECONFIG: "{{ k8s_conf }}"
{% endif %}В первой половине описана логика считывания данных из файла (кстати, ниже рассмотрим случай, когда конфиги и секреты получаем не из отдельного файла, а просто перечислением переменных). Во второй половине кода описан деплой манифеста с configMap и Secret.
Как получить из этого на первый взгляд сложного текста понятный код плейбука? Просто сгенерировать ansible template:
---
- hosts: localhost
tasks:
- name: Generate playbook template
template:
src: "{{ playbook_dir }}/templates/common-templates/php/k8s_universal_php_deploy.yml.j2"
dest: "{{ playbook_dir }}/k8s_php_deploy.yml"И получаем playbook k8s_php_deploy.yml (точнее часть с конфигами):
- name: Generate SERVICE_NAME-secrets data
set_fact:
secrets: "{{ lookup('template', 'SERVICE_NAME/.env.local.j2') }}"
- name: Deploy SERVICE_NAME configs and secrets
k8s:
namespace: "Stage"
definition: "{{ lookup('template', 'common-templates/php/default/k8s-configs-secrets.yml.j2') }}"
validate:
fail_on_error: yes
state: "{{ state | default('present') }}"
environment:
K8S_AUTH_KUBECONFIG: "path/to/kubeconfig"Обратите внимание, что не все куски кода ansible templates ушли. Мы специально оборачиваем некоторые части кода в структуру “{% raw %}some code{% endraw %}”, т. к. некоторые переменные (например, факты “secrets”) мы получаем при преобразовании темплейта, и не можем сразу их использовать.
Теперь посмотрим на темплейт манифеста k8s-configs-secrets.yml.j2:
{% for item in configs_and_secrets %}
---
apiVersion: v1
kind: {{ item.k8s_kind }}
metadata:
name: {{ item.name }}
namespace: {{ k8s_namespace }}
{% if item.k8s_kind == 'Secret' %}
type: Opaque
stringData:
{% else %}
data:
{% endif %}
{% if item.files is defined %}
{% for file in item.files %}
{{ file.name | basename }}: |-
{{ vars[file.uniq_data_name] | indent }}
{% endfor %}
{% elif item.data is defined %}
{% for key, value in item.data.items() %}
{{ key }}: "{{ value }}"
{% endfor %}
{% endif %}
{% endfor %}И после преобразования темплейта манифест будет выглядеть так:
---
apiVersion: v1
kind: Secret
metadata:
name: 'SERVICE_NAME-secrets'
namespace: 'Stage'
type: Opaque
stringData:
.env.local: |-
APP_ENV=Stage
APP_DEBUG=1
APP_SECRET=superStrongSecret
DATABASE_URL="postgresql://db_url"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: 'SERVICE_NAME-config'
namespace: 'Stage'
type: Opaque
data:
CPU_LIMIT: '100'
SERVICE_NAME: 'SERVICE_NAME'
SERVICE_PORT: '8080'
SERVICE_READ_TIMEOUT: '1s'
SERVICE_WRITE_TIMEOUT: '5s'Получаем два блока, как и было задано выше в group_vars сервиса.
В итоге мы можем одним темплейтом k8s-configs-secrets.yml.j2 задеплоить как секреты, так и configMap. Как переменные из файла, так и просто перечислив их в group_vars.
Похожим образом у нас описаны и все остальные kubernetes манифесты.
Результаты и планы на будущее
Самое интересное. Внедрять эти изменения мы начали в июле 2023 года. Тогда у нас было 405 сервисов, и для них было написано 405 плейбуков и 1749 темплейтов, а на сегодня осталось 133 плейбука и 822 темплейта (при этом количество сервисов увеличилось в 1.5 раза!). Последних стало меньше за счет унификации, а число первых мы хотим сокращать и дальше, чтобы в идеале оставить меньше десятка:

Профит, который мы уже получили:
уменьшилось количество файлов, которые нужно ревьювить в пулл-реквестах (без шаблонов было бы уже >600 плейбуков и ~2500 темплейтов)
проще дебажить проблемные сервисы на одинаковых темплейтах, потому что разница при деплое только в group_vars’ах;
проще вносить глобальные изменения (например, интеграция Istio Service Mesh или обновление образа nginx-symfony).

Если посмотреть немного дальше в будущее, то мы видим 2 варианта, что с этим всем делать:
Развивать этот подход и дальше.
Отказаться от Ansible и перейти на Helm-чарты. Это стандарт индустрии, но для этого нам придется переписать все пайплайны. На данный момент мы решили, что проще будет разобраться с темплейтами Ansible, а потом, если понадобится, мигрировать на Helm. Это в любом случае будет удобнее сделать, когда мы удалим все лишние файлы.
На этом у меня все! Спасибо, что прочитали, я надеюсь, статья оказалась для вас полезной. Если у вас есть вопросы про Ansible, пишите в комментариях, постараюсь на все ответить.
