Перейти к содержимому

Оркестрация сценарием

Это руководство — про управление тем, как именно состояние раскатывается по душам: в каком порядке, с какой степенью параллелизма, на каких хостах. Первичная раскатка (Первый сервис на душах) применяла состояние ко всем хостам сразу; здесь добавляются оркестрационные ключи, которые превращают «применить везде» в «применить волнами с проверкой здоровья между ними».

Справочная грамматика оркестрации — DSL → Scenario; здесь — практические рецепты на её основе.

Сценарий получает весь набор блоков задач Destiny (module:, params:, when:, register:, onchanges:, …) плюс оркестрационную дельту — ключи, которых у Destiny нет:

КлючНазначение
on:Стабильный таргет: на каких хостах выполнять (по covens — реальным стабильным меткам). Опущен → все члены инкарнации.
where:Волатильный фильтр: отобрать хосты по живой проверке или стабильному факту.
serial:Волновое (rolling) исполнение: по N хостов за раз.
run_once:Выполнить шаг ровно на одном хосте таргета.
apply:Вызвать изолированный Destiny вместо инлайн-module:.

Порядок резолва строгий: сначала on: сужает множество по базе (стабильно), затем where: фильтрует получившееся по живым данным.

Опустите on: — шаг идёт на всех членов инкарнации (членство хоста в инкарнации — самостоятельное отношение, не coven). on: нужен только чтобы сузить таргет: on: keeper или до реального под-coven — стабильной метки, назначенной при онбординге (on: [baremetal]). Covens — это реальные метки (кластер, проект, окружение, датацентр); имя инкарнации к ним не относится.

Стабильные факты хоста доступны как soulprint.self.<путь> и известны из реестра — их можно использовать в where: без probe:

# Настроить sysctl только на Debian-семействе
- name: Tune kernel on Debian-family hosts
module: core.sysctl.present
where: soulprint.self.os.family == "debian"
params:
name: net.core.somaxconn
value: "1024"

Доступные стабильные факты — soulprint.self.os.family, soulprint.self.os.arch, soulprint.self.sid, soulprint.self.network.primary_ip, soulprint.self.covens и т.д. (полный набор — DSL → Scenario).

probe-роль: волатильная роль через живую проверку

Заголовок раздела «probe-роль: волатильная роль через живую проверку»

Стабильные covens сознательно не несут волатильную роль хоста (кто сейчас master, кто replica) — роль меняется от прогона к прогону, а Coven обязан быть стабильным. Чтобы таргетировать по живой роли, её узнают probe-шагом непосредственно перед таргетингом.

Probe — это обычный read-only шаг (core.exec.run или core.cmd.shell) с register: и changed_when: false. Чтобы безопасно таргетировать разрушительную операцию по результату probe, probe обязан утверждать полноту ответа через failed_when::

# probe: узнать живую роль каждого хоста (on: опущен — весь incarnation)
- name: Detect actual replication role per host
module: core.cmd.shell
register: node_role
changed_when: false
failed_when: size(register.node_role) < incarnation.host_count
params:
cmd: "the-service role | head -1"

failed_when: size(register.node_role) < incarnation.host_count гарантирует, что ответили все хосты. Без этой проверки частичный probe (не все хосты ответили) молча применил бы разрушительную операцию только к «ответившей» части. С проверкой неполный probe делает шаг упавшим, прогон останавливается, состояние не коммитится.

Дальше таргетируем по результату probe:

# применить только к текущим репликам
- name: Act only on the current replicas
module: core.service.restarted
where: register.node_role.stdout == "replica"
params:
name: the-service

serial: бьёт хосты таргета на последовательные волны: внутри волны — параллельно, волны — строго одна за другой. Падение в волне останавливает раскатку (следующие волны не стартуют).

# по 2 хоста за раз
serial: 2
# по 25% таргета за волну
serial: "25%"

serial: можно ставить на block: — тогда весь блок (несколько задач) прокатывается по одному набору хостов целиком, прежде чем стартует следующая волна. Это и есть идиома «волна = {изменить, проверить здоровье}».

run_once: true выполняет шаг ровно на одном хосте таргета (первом по SID) — для операций, которые достаточно сделать единожды на кластер (инициализация, failover, миграция данных):

- name: Trigger failover once, on the current master
module: core.cmd.shell
where: register.node_role.stdout == "master"
run_once: true
params:
cmd: "the-service failover"

serial: и run_once: взаимоисключающи.

Сложим всё вместе — типовая операция «обновить конфиг и перезапустить сервис волнами, только там, где конфиг реально изменился, с проверкой здоровья между хостами». Здесь работают сразу: probe-роль, where:, serial: на блоке, onchanges: и retry/until.

scenario/rolling-restart/main.yml
input:
max_memory:
type: string
default: "512mb"
state_changes:
sets:
max_memory: "${ input.max_memory }" # фиксируем применённый конфиг — это желаемое состояние, а не разовый knob рестарта
tasks:
# 1. probe: живая роль каждого хоста (on: опущен — весь incarnation)
- name: Detect actual replication role per host
module: core.cmd.shell
register: node_role
changed_when: false
failed_when: size(register.node_role) < incarnation.host_count
params:
cmd: "the-service role | head -1"
# 2. rolling по одному хосту: перерендерить конфиг, рестартнуть ТОЛЬКО при
# изменении конфига, дождаться здоровья — и лишь потом следующий хост
- name: Rolling config update on replicas, one host at a time
where: register.node_role.stdout == "replica"
serial: 1
block:
- name: Render service config
module: core.file.rendered
register: svc_conf
params:
path: /etc/the-service/the-service.conf
template: templates/the-service.conf.tmpl
vars:
max_memory: "${ input.max_memory }"
mode: "0640"
# рестарт срабатывает ТОЛЬКО если рендер конфига сообщил changed=true
- name: Restart the service because config changed
module: core.service.restarted
onchanges: [svc_conf]
timeout: 30s
params:
name: the-service
# health-gate перед следующей волной: бюджет времени = count × delay
- name: Wait until the host is healthy again
module: core.exec.run
changed_when: false
retry:
count: 12
delay: 5s
until: contains(register.self.stdout, "status:ok")
failed_when: '!contains(register.self.stdout, "status:ok")'
params:
cmd: the-service
args: ["healthcheck"]
# 3. мастер ПОСЛЕДНИМ: запускается только после всех реплик выше. run_once
# берёт единственного текущего мастера (probe в момент failover может вернуть
# больше одного). В проде сначала делают failover — промоутят свежую реплику,
# потом рестартят бывшего мастера; здесь он рестартится на месте — последним.
- name: Update and restart the master last, on its own
where: register.node_role.stdout == "master"
run_once: true
block:
- name: Render service config on the master
module: core.file.rendered
register: master_conf
params:
path: /etc/the-service/the-service.conf
template: templates/the-service.conf.tmpl
vars:
max_memory: "${ input.max_memory }"
mode: "0640"
- name: Restart the master because config changed
module: core.service.restarted
onchanges: [master_conf]
timeout: 30s
params:
name: the-service

Что здесь происходит по шагам:

  1. probe с failed_when-полнотой узнаёт живую роль всех хостов — без этого rolling мог бы молча обойти невидимую часть душ.
  2. serial: 1 на блоке катит весь блок (рендер → рестарт → health) по одному хосту за раз; следующий хост стартует, только когда предыдущий прошёл health-gate.
  3. onchanges: [svc_conf] перезапускает сервис только там, где конфиг реально изменился: если рендер вернул changed=false, рестарт пропускается. Идемпотентность сохраняется — холостой прогон не дёргает живой сервис.
  4. retry + until держит health-gate: до 12 попыток с шагом 5s (бюджет — минута); если хост не оздоровился — шаг падает, и rolling останавливается, не уронив остальные хосты.
  5. Мастер — последним. Все реплики обновляются до того, как финальная задача трогает мастера: run_once: берёт одного текущего мастера (по тому же probe). Мастер никогда не рестартят в середине раскатки — реплики, которые с него синкаются, встали бы. В проде правильный ход — сначала failover: промоутнуть здоровую реплику в мастера, затем рестартнуть бывшего мастера (теперь реплику); в рецепте он рестартится на месте, чтобы форма была видна яснее.

Что сценарий пишет в incarnation.state, объявляется в state_changes. Коммит состояния — барьер: сценарий дожидается завершения всех задач на всех хостах прогона, и только потом state_changes коммитятся. Если хоть одна задача хоть на одном хосте упала — состояние не коммитится, инкарнация переходит в error_locked. Это инвариант: зафиксированное состояние всегда соответствует факту на хостах.