Project Management Качество, Definition of Done и релизный менеджмент
0%

Качество, Definition of Done и релизный менеджмент

Качество, Definition of Done и релизный менеджмент

Есть два вопроса, на которые команда отвечает десятки раз в неделю, и от честности этих ответов зависит почти всё остальное в проекте:

  1. «Это готово?»
  2. «Это можно выкатывать?»

Обычно на первый отвечают словами «ну, в целом да, осталось дотестировать», а на второй — «давай в пятницу вечером, чтобы если что, в выходные починим». Обе фразы — симптом одного и того же: у команды нет операционального определения готовности и нет управляемого процесса доставки. Качество в такой системе — это не свойство продукта, а результат везения и героизма отдельных людей.

Эта статья — про то, как перевести оба вопроса из области мнений в область проверяемых фактов. Мы разберём экономику дефектов (почему поздняя проверка стоит на порядок дороже ранней), конструкцию Definition of Done, автоматические quality gates, стратегии релиза, фича-флаги, обратимые миграции и откаты. Всё это — механика, которая позже измеряется метриками из статьи про DORA и инженерные метрики; здесь мы строим то, что там будем мерить.


Часть 1. Что такое качество и почему его нельзя «добавить в конце»

Два разных смысла слова «качество»

Джозеф Джуран в «Quality Control Handbook» развёл два определения, которые в IT постоянно путают:

  • Conformance to specification — соответствие спецификации. «Работает так, как написано в требованиях». Это проверяет тестирование.
  • Fitness for use — пригодность к использованию. «Решает реальную задачу пользователя». Это проверяет только сам пользователь.

Продукт может быть безупречен по первому определению и бесполезен по второму. Обратное тоже бывает: кривой прототип, который решает боль, ценнее вылизанного продукта, который никому не нужен. Управление качеством — это управление обоими смыслами: pipeline отвечает за conformance, а обратная связь от пользователей и продуктовая аналитика — за fitness. Вторую половину подробнее разбирает трек продуктового управления.

Внутреннее и внешнее качество

Мартин Фаулер в статье «Is High Quality Software Worth the Cost?» проводит ещё одну важную границу:

  • Внешнее качество — то, что видит пользователь: отсутствие багов, скорость, UX. За него можно осознанно платить временем: «выкатим с известным ограничением, потом починим».
  • Внутреннее качество — архитектура, тесты, читаемость кода, наблюдаемость. Пользователь его не видит никогда. И именно поэтому его так легко резать под давлением сроков.

Ключевой тезис Фаулера: экономия на внутреннем качестве окупается только на очень коротком горизонте — недели, максимум пары месяцев. Дальше кривая переворачивается: скорость команды с плохим внутренним качеством падает быстрее, чем растёт от «сэкономленного» времени. То есть внутреннее качество — не «инвестиция в будущее ценой настоящего», а условие сохранения текущей скорости.

Практический вывод для менеджера: внутреннее качество нельзя выносить в бэклог как задачу, за которую конкурируют с фичами. Оно должно быть встроено в определение готовности любой задачи — иначе оно всегда проиграет приоритизации.

Экономика позднего обнаружения

Классические цифры Барри Боэма («Software Engineering Economics», 1981) и отчёта NIST 2002 года показывают: стоимость исправления дефекта растёт примерно экспоненциально по фазе обнаружения.

Стоимость исправления дефекта по фазам

Точные множители спорны — их много раз критиковали за методологию (см. разбор «The Leprechauns of Software Engineering» Лорана Боссавита, где показано, что первоисточники цифр слабее, чем принято думать). Но форма кривой не спорна, и объясняется она просто, без всякой статистики:

  1. Когда дефект найден через 10 минут после написания кода, у автора весь контекст в голове. Правка стоит минуты.
  2. Через две недели контекст утерян. Нужно заново разобраться в коде, воспроизвести баг, написать тест. Стоимость — часы.
  3. В проде добавляется всё остальное: инцидент, коммуникация с пользователями, хотфикс-релиз, откат данных, постмортем, репутационный ущерб. Стоимость — дни и деньги.

Отсюда принцип shift-left: сдвигать обнаружение проблем как можно раньше по конвейеру. Не «тестировать больше», а «тестировать раньше и дешевле».

Built-in quality вместо фазы тестирования

Отсюда следует главное организационное решение: тестирование — не фаза после разработки, а свойство каждого шага. Модель «разработали → отдали в QA → QA нашли 200 багов → чинили месяц» плоха не тем, что там есть QA, а тем, что она создаёт длинную петлю обратной связи и большой батч непроверенной работы. Работа QA при этом смещается: от «искать баги руками» к «проектировать проверки, автоматизировать их и находить то, что автоматика найти не может» — исследовательское тестирование, тестирование удобства, edge-cases на реальных данных.


Часть 2. Definition of Done: контракт готовности

Определение

Definition of Done (DoD) — формальный, проверяемый список условий, которым должна удовлетворять любая единица работы, чтобы считаться завершённой. Ключевые слова: формальный (записан, а не в головах), проверяемый (можно ответить да/нет без спора) и любая (применяется ко всем задачам одинаково).

Scrum Guide 2020 формулирует это жёстко: DoD — это «формальное описание состояния Инкремента, когда он соответствует стандартам качества продукта». И дальше: работа, не удовлетворяющая DoD, не может быть представлена на Sprint Review и не входит в Инкремент. То есть DoD — это не пожелание, а условие существования результата.

DoD против Acceptance Criteria против DoR

Самая частая путаница в командах. Разведём:

Definition of Ready Acceptance Criteria Definition of Done
Область вход в работу конкретная история все истории одинаково
Кто автор команда + владелец продукта владелец продукта команда (инженерные стандарты)
Пример «есть макет, зависимости выявлены» «при неверном пароле показать ошибку и не блокировать аккаунт до 5 попытки» «покрыто тестами, прошло ревью, задеплоено в staging»
Меняется редко у каждой истории свои раз в несколько месяцев, осознанно
Проверяет достаточно ли информации, чтобы начать сделали ли то, что нужно сделали ли как надо

Мнемоника: AC — про «что», DoD — про «как». История считается завершённой, только когда выполнены и её acceptance criteria, и общий DoD.

К Definition of Ready есть обоснованная критика: жёсткий DoR легко превращается в мини-водопад («история не берётся в спринт, пока аналитик не оформит её по шаблону из 12 полей»), что убивает совместное уточнение. Разумный компромисс — DoR как эвристика для обсуждения, а не как ворота с охраной. Про то, как это связано с планированием, — в статье об оценке и планировании.

Многоуровневый DoD

В любой нетривиальной системе один список не работает: условия для отдельной истории и для пользовательского релиза различаются. Поэтому DoD строят слоями — каждый внешний слой добавляет условия к внутреннему.

Многоуровневый Definition of Done

Смысл слоёв — в честности. Если у вас один DoD уровня истории, а перед релизом всё равно нужны нагрузочное тестирование, release notes и проверка отката, то эта работа существует, но нигде не запланирована. В LeSS её называют undone work — и она всегда всплывает в самый неудачный момент, порождая ритуал «стабилизационного спринта». Подробнее про то, как это масштабируется, — в статье про фреймворки масштабирования.

Жизненный цикл единицы работы через призму DoD

Обратите внимание на две стрелки в Undone Work. Они существуют в любой реальной команде. Задача менеджмента — не запретить их, а сделать так, чтобы переход туда был явным решением с записанной ценой, а не тихим срезанием угла.

DoD как код

Устный DoD деградирует за месяц. Написанный на вики — за квартал. Работает только тот, который проверяется автоматически. Начните с YAML-описания, где каждый пункт помечен способом проверки:

# quality/dod.yml — единый источник правды о готовности
version: 3
levels:
  story:
    - id: code-review
      text: "Изменения одобрены минимум одним инженером, не автором"
      check: automated        # проверяется правилом защиты ветки
    - id: unit-tests
      text: "Новая логика покрыта тестами; покрытие изменённых строк >= 80%"
      check: automated        # diff-coverage gate в CI
    - id: static-analysis
      text: "Линтер и статический анализатор без новых нарушений"
      check: automated
    - id: observability
      text: "Для новых путей исполнения добавлены структурные логи и метрики"
      check: manual           # пункт чек-листа в шаблоне merge request
    - id: ac-demo
      text: "Acceptance criteria продемонстрированы владельцу продукта"
      check: manual
  feature:
    - id: e2e
      text: "Сквозной сценарий проходит на staging"
      check: automated
    - id: flag
      text: "Фича-флаг создан, протестирован в обоих положениях, есть план его удаления"
      check: manual
    - id: docs
      text: "Пользовательская документация и справка поддержки обновлены"
      check: manual
  release:
    - id: load-test
      text: "Нагрузочный тест на профиле прод-трафика без деградации p99"
      check: automated
    - id: rollback-drill
      text: "План отката выполнен на стенде в этом релизном цикле"
      check: manual
    - id: slo-alerts
      text: "Алерты на SLO новых компонентов заведены и протестированы"
      check: automated

Дальше — маленький валидатор, который CI запускает на merge request. Он не заменяет людей, но убирает у пунктов статус «мы вроде бы это делаем»:

"""Проверка DoD уровня story для merge request.

Сложность: O(n) по числу пунктов DoD и O(m) по числу изменённых строк diff.
Память: O(n + m). На практике время работы определяется вызовами API, а не вычислениями.
"""
from dataclasses import dataclass
import sys
import yaml


@dataclass(frozen=True)
class Item:
    id: str
    text: str
    check: str  # "automated" | "manual"


@dataclass(frozen=True)
class MergeRequest:
    approvals: int                 # число одобрений от не-авторов
    diff_coverage: float           # покрытие изменённых строк, 0..1
    new_lint_violations: int
    checked_manual: frozenset[str]  # id пунктов, отмеченных галочкой в описании MR


def load_level(path: str, level: str) -> list[Item]:
    with open(path, encoding="utf-8") as f:
        data = yaml.safe_load(f)
    return [Item(**it) for it in data["levels"][level]]


# Реестр автоматических проверок: id пункта -> предикат.
AUTOMATED = {
    "code-review": lambda mr: mr.approvals >= 1,
    "unit-tests": lambda mr: mr.diff_coverage >= 0.80,
    "static-analysis": lambda mr: mr.new_lint_violations == 0,
}


def evaluate(items: list[Item], mr: MergeRequest) -> list[tuple[Item, bool]]:
    """Возвращает список (пункт, выполнен) в порядке объявления в DoD."""
    result = []
    for item in items:
        if item.check == "automated":
            predicate = AUTOMATED.get(item.id)
            # Пункт объявлен автоматическим, но проверки нет — это ошибка конфигурации,
            # а не повод считать его выполненным. Fail closed.
            passed = predicate(mr) if predicate else False
        else:
            passed = item.id in mr.checked_manual
        result.append((item, passed))
    return result


def main() -> int:
    mr = MergeRequest(
        approvals=1,
        diff_coverage=0.86,
        new_lint_violations=0,
        checked_manual=frozenset({"ac-demo"}),  # observability забыли отметить
    )
    report = evaluate(load_level("quality/dod.yml", "story"), mr)

    failed = [item for item, ok in report if not ok]
    for item, ok in report:
        print(f"{'PASS' if ok else 'FAIL'}  [{item.check[:4]}] {item.id}: {item.text}")
    if failed:
        print(f"\nDoD не выполнен: {len(failed)} из {len(report)} пунктов.")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())

Два принципиальных решения в этом коде:

  • Fail closed. Если пункт помечен как автоматический, но проверки для него нет, он считается проваленным. Иначе DoD тихо разлагается: кто-то добавил пункт, забыл проверку, и год все думают, что он работает.
  • Ручные пункты остаются в том же списке. Их нельзя автоматизировать сегодня, но их видно рядом с автоматическими — и на ретроспективе понятно, что стоит автоматизировать следующим.

Антипаттерны DoD

  • DoD-фикция. Список из 15 пунктов, который никто не читал после воркшопа. Лечится сокращением до 5–7 пунктов, которые команда реально готова соблюдать всегда.
  • DoD как дубина. Менеджер использует DoD, чтобы отчитывать команду. Результат — команда занижает DoD или врёт. DoD принадлежит команде; менеджер отвечает за то, чтобы у команды было время его соблюдать.
  • Разный DoD у разных людей. «У Пети без тестов, потому что он быстрый» — гарантированный путь к тому, что через год модуль Пети нельзя трогать.
  • DoD, зависящий от внешней команды. Пункт «отдел безопасности провёл аудит» превращает готовность в лотерею. Либо втягивайте компетенцию в команду, либо выносите пункт на уровень релиза с явным SLA от смежников. Про управление такими зависимостями — в статье о рисках.
  • «Done» без деплоя. Если «готово» означает «код в ветке», между готовностью и ценностью остаётся невидимый разрыв, который никто не измеряет.

Часть 3. Quality gates: конвейер, который не пускает плохое дальше

DoD, который проверяется людьми, — это чек-лист. DoD, который проверяется конвейером, — это система. Схема типичного пайплайна с воротами:

Порядок ворот определяется стоимостью, а не важностью

Главный принцип проектирования конвейера: дешёвые и быстрые проверки идут первыми. Не потому что они важнее, а потому что вероятность потратить дорогой ресурс (30-минутный E2E-прогон, время ревьюера) на заведомо сломанный код должна быть минимальной.

Формально: если проверка $i$ стоит $c_i$ и отсеивает долю дефектов $p_i$, оптимальный порядок — по убыванию $p_i / c_i$. Это тот же жадный принцип, что в задаче о планировании с весами, и он же лежит в основе WSJF-приоритизации из оценки и планирования.

Пирамида тестов и её честная критика

Классическая пирамида (много юнит-тестов, меньше интеграционных, совсем мало E2E) — эвристика про стоимость обратной связи, а не догма. У неё есть два известных возражения:

  • «Testing Trophy» Кента Доддса: в веб-разработке основная масса должна приходиться на интеграционные тесты, потому что юнит-тесты с моками проверяют в основном ваши моки.
  • Тесты, привязанные к реализации, вредны. Тест, который ломается при каждом рефакторинге, не защищает поведение, а препятствует изменению. Хороший критерий: тест должен ломаться только тогда, когда меняется наблюдаемое поведение.

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

Flaky-тесты: почему они убивают ворота

Нестабильный тест — самый дорогой актив в CI, потому что он разрушает сам смысл ворот. Посчитаем. Пусть в наборе $n$ тестов, каждый независимо падает ложно с вероятностью $f$. Вероятность полностью зелёного прогона:

$$P(\text{зелёный}) = (1 - f)^n$$

def green_run_probability(n: int, flake_rate: float) -> float:
    """Вероятность, что прогон из n тестов зелёный при доле ложных падений flake_rate."""
    return (1.0 - flake_rate) ** n


for n in (200, 1_000, 5_000):
    for f in (0.0001, 0.001, 0.01):
        p = green_run_probability(n, f)
        print(f"n={n:5d}  f={f:<7} P(зелёный)={p:6.1%}  ожид. перезапусков={1 / p - 1:5.1f}")

Результат отрезвляющий:

Тестов f = 0,01 % f = 0,1 % f = 1 %
200 98,0 % 81,9 % 13,4 %
1 000 90,5 % 36,8 % 0,004 %
5 000 60,7 % 0,7 % ≈ 0 %

При 1000 тестов и всего 1 % нестабильности зелёный прогон — событие практически невозможное. Команда начинает перезапускать pipeline «на удачу», и в этот момент ворота перестают существовать: красный сигнал больше не означает поломку, значит, его перестают читать. Это классическая десенсибилизация к алертам, ровно как в мониторинге.

Что с этим делать:

  1. Измерять flake rate как продуктовую метрику: доля прогонов, где тест упал, а повторный запуск на том же коммите прошёл.
  2. Карантин: нестабильный тест автоматически выводится из блокирующего набора и заводится как дефект с владельцем и сроком. Не «удаляется навсегда» — иначе карантин станет свалкой.
  3. Бюджет: если в карантине больше N тестов, слияния новых фич останавливаются. Так делают в Google — подробно в главе про CI в «Software Engineering at Google» (глава 23, доступна бесплатно).

Часть 4. Дефекты: учёт, приоритизация, метрики

Severity против Priority

Две независимые оси, которые постоянно склеивают в одну:

  • Severity — техническая тяжесть последствий. Свойство дефекта. Определяет инженер или QA.
  • Priority — срочность исправления. Свойство бизнес-контекста. Определяет владелец продукта.

Опечатка на главной странице лендинга — низкая severity, но может быть высочайший priority. Падение сервиса для трёх внутренних пользователей раз в месяц — высокая severity, низкий priority. Склеивание осей приводит к вечному спору «это баг первого приоритета!» вместо разговора о рисках.

Метрики качества, которые не врут

"""Метрики качества по релизам. Все — O(n) по числу дефектов, память O(1) на метрику."""
from dataclasses import dataclass
from datetime import date


@dataclass(frozen=True)
class Defect:
    id: str
    found_in: str        # "dev" | "review" | "ci" | "staging" | "production"
    severity: int        # 1 — критический, 4 — косметический
    opened: date
    closed: date | None


def escaped_defect_rate(defects: list[Defect]) -> float:
    """Доля дефектов, дошедших до прода. Главная метрика эффективности ворот.

    Не путать с «числом багов в проде»: абсолютное число зависит от объёма релиза,
    а доля — от качества конвейера.
    """
    if not defects:
        return 0.0
    escaped = sum(1 for d in defects if d.found_in == "production")
    return escaped / len(defects)


def defect_removal_efficiency(defects: list[Defect]) -> float:
    """DRE = доля дефектов, пойманных ДО прода. Классика Каперса Джонса.

    Ориентир зрелой команды: 0.85–0.95. Значение 1.0 почти всегда означает,
    что в проде баги просто не заводят как дефекты.
    """
    return 1.0 - escaped_defect_rate(defects)


def phase_containment(defects: list[Defect]) -> dict[str, float]:
    """Распределение обнаружения по фазам: показывает, где именно течёт конвейер."""
    total = len(defects) or 1
    phases = ("dev", "review", "ci", "staging", "production")
    return {p: sum(1 for d in defects if d.found_in == p) / total for p in phases}


def mttr_days(defects: list[Defect], severity: int = 1) -> float | None:
    """Среднее время до закрытия дефектов заданной severity, в днях.

    Медиану считать полезнее среднего: распределение времени починки
    почти всегда с тяжёлым правым хвостом.
    """
    closed = [(d.closed - d.opened).days for d in defects
              if d.severity == severity and d.closed is not None]
    return sum(closed) / len(closed) if closed else None

Отдельно про опасные метрики, которые ломают поведение при попытке ими управлять (закон Гудхарта в чистом виде):

  • Число найденных багов на тестировщика. Приводит к дроблению одного дефекта на пять карточек.
  • Процент покрытия кода как цель. Приводит к тестам без ассертов. Покрытие полезно как диагностика непокрытых мест, вредно как KPI. Разумный компромисс — покрытие изменённых строк (diff coverage) как ворота, а не общее покрытие как цель.
  • Ноль открытых багов. Приводит к массовому закрытию с резолюцией «не воспроизводится».

Подробнее про то, как метрики деформируют поведение, — в статье про DORA и в разделе о мотивации статьи про команду и коммуникации.


Часть 5. Релизный менеджмент

Главная идея: размер батча

Интуиция менеджера обычно такая: «релизы рискованные, значит, надо релизить реже и тщательнее готовиться». Это ровно наоборот. Разберём почему.

Пусть в релизе $n$ изменений, каждое независимо ломает прод с вероятностью $q$. Вероятность проблемного релиза: $1-(1-q)^n$ — растёт с размером батча. Но хуже другое: при отказе вы не знаете, какое из $n$ изменений виновато. Стоимость диагностики растёт примерно как $O(n)$ при линейном поиске и в лучшем случае как $O(\log n)$ при бисекции. А риск отката растёт ещё быстрее: откатить один релиз с 200 изменениями означает откатить 199 работающих.

Релиз раз в квартал Релиз ежедневно
Изменений в батче ~500 ~5
Вероятность инцидента высокая низкая
Время локализации причины часы–дни минуты
Стоимость отката огромная (тянет всё) почти нулевая
Стресс команды пик перед релизом ровный фон
Обратная связь от пользователей через 3 месяца завтра

Это тот же аргумент про размер партии, что в статье про Kanban и поток: маленькие частые батчи снижают и время цикла, и риск одновременно. Данные из «Accelerate» (Форсгрен, Хамбл, Ким) это подтверждают эмпирически: элитные команды деплоят чаще И имеют меньшую долю неудачных изменений. Это не компромисс между скоростью и качеством — это одна и та же переменная.

Стратегии релиза: где вы находитесь

Ключ к правому верхнему квадранту — не «релизить смелее», а отделить деплой от релиза и построить автоматические механизмы обнаружения и отката. Без них рост частоты действительно двигает вас в правый нижний квадрант.

Ветвление: release branches против trunk-based

Что видно на схеме: долгоживущая релизная ветка порождает двойную работу — каждый фикс нужно применить и туда, и в main, а обратные слияния конфликтуют тем сильнее, чем дольше живёт ветка. Это цена, которую платят за возможность «заморозить» состояние.

Альтернатива — trunk-based development: короткоживущие ветки (часы, максимум день), всё сливается в trunk, релиз — это тег на trunk, а незавершённая функциональность скрыта фича-флагами. Каноническое описание — trunkbaseddevelopment.com Пола Хаммонда и Стива Смита; исследование в «Accelerate» показывает статистически значимую связь trunk-based практик с производительностью доставки.

Trunk-based не бесплатен. Он требует:

  • быстрого и надёжного CI (иначе сломанный trunk блокирует всех);
  • дисциплины разбиения работы на маленькие безопасные шаги;
  • инфраструктуры фича-флагов и дисциплины их удаления;
  • культуры, где сломать trunk — не преступление, а сигнал починить конвейер.

Если этого нет, release branches — честный промежуточный вариант. Плохо не иметь релизных веток, плохо иметь их и не двигаться в сторону сокращения их жизни.

Deploy ≠ Release: фича-флаги

Самый недооценённый рычаг в релизном менеджменте — разведение двух событий:

  • Deploy — код оказался на проде. Техническое событие, риск управляется инфраструктурой.
  • Release — функциональность стала доступна пользователям. Бизнес-событие, риск управляется продуктом.

Фича-флаг делает их независимыми. Следствия огромны: код едет в прод ежедневно маленькими кусками (низкий риск деплоя), а включение фичи — отдельное решение, обратимое за секунды без пересборки.

"""Минимальный, но продакшн-пригодный движок фича-флагов.

Ключевые свойства:
  * детерминированность — один и тот же пользователь всегда получает одно решение;
  * равномерность — хеш распределяет пользователей без перекоса;
  * независимость флагов — соль флага в хеше, иначе одни и те же 5% пользователей
    попадают в canary каждой фичи и получают весь риск системы.
Сложность: O(1) на проверку, O(1) памяти. Реально узкое место — доставка конфигурации.
"""
import hashlib
from dataclasses import dataclass, field


@dataclass(frozen=True)
class Flag:
    key: str
    enabled: bool = False          # глобальный рубильник (kill switch)
    percentage: float = 0.0        # доля пользователей, 0..1
    allowlist: frozenset[str] = field(default_factory=frozenset)  # всегда включено
    denylist: frozenset[str] = field(default_factory=frozenset)   # всегда выключено


def _bucket(flag_key: str, user_id: str) -> float:
    """Стабильное отображение (флаг, пользователь) -> [0, 1)."""
    digest = hashlib.sha256(f"{flag_key}:{user_id}".encode()).digest()
    return int.from_bytes(digest[:8], "big") / 2 ** 64


def is_enabled(flag: Flag, user_id: str) -> bool:
    if user_id in flag.denylist:      # denylist сильнее всего: аварийное исключение
        return False
    if not flag.enabled:              # kill switch — один вызов гасит фичу целиком
        return False
    if user_id in flag.allowlist:     # внутренние пользователи, dogfooding
        return True
    return _bucket(flag.key, user_id) < flag.percentage


if __name__ == "__main__":
    flag = Flag(key="new-checkout", enabled=True, percentage=0.05,
                allowlist=frozenset({"qa-01"}))
    users = [f"user-{i}" for i in range(100_000)]
    on = sum(is_enabled(flag, u) for u in users)
    print(f"включено у {on / len(users):.2%} пользователей")  # ~5%
    # Детерминированность: повторный вызов даёт тот же ответ
    assert is_enabled(flag, "user-42") == is_enabled(flag, "user-42")

Главная опасность фича-флагов — их накопление. Каждый флаг удваивает число путей исполнения: $k$ независимых флагов дают $2^k$ комбинаций, из которых тестируется хорошо если две. Флаг, проживший год, — это неудалённый мёртвый код и мина в логике. Дисциплина:

  • у каждого флага есть владелец и дата истечения прямо в конфигурации;
  • CI ругается на флаги старше 60–90 дней;
  • удаление флага — часть DoD фичи, а не «потом когда-нибудь»;
  • флаги релизные (временные) и операционные (kill switch, живут долго) разводятся явно.

Каноническое описание типов флагов — у Пита Ходжсона: «Feature Toggles (aka Feature Flags)».

Прогрессивная выкатка и автоматический откат

Три свойства, без которых canary — театр:

  1. Сравнение с одновременно работающей базовой линией, а не с историческими данными. Иначе вы измеряете суточную сезонность, а не эффект релиза.
  2. Метрики включают бизнес-показатели. Технически здоровый релиз, обнуливший конверсию в оплату, — провал, который error rate не покажет.
  3. Откат автоматический. Если решение принимает дежурный инженер ночью, среднее время отката измеряется десятками минут вместо секунд.

Обзор стратегий — в главе про release engineering «Site Reliability Engineering» (Google, доступна бесплатно).

Сравнение основных стратегий:

Стратегия Стоимость инфраструктуры Скорость отката Ограничение
Rolling update низкая минуты обе версии в проде одновременно — нужна совместимость
Blue-green двойная (два полных окружения) секунды дорого; проблема с состоянием и БД
Canary средняя секунды нужен зрелый мониторинг и статистика
Feature flag почти нулевая миллисекунды только для того, что скрыто за флагом
Shadow traffic высокая не применимо только чтение; побочные эффекты недопустимы

Откат невозможен без обратимых миграций

Самое частое место, где «у нас есть план отката» разбивается о реальность, — база данных. Откатить код за 30 секунд легко. Откатить миграцию, удалившую колонку, — нельзя: данные уже потеряны.

Решение — паттерн expand / contract (он же parallel change): любое несовместимое изменение схемы разбивается на серию совместимых шагов, между которыми старая и новая версии кода работают одновременно.

-- Задача: переименовать users.email -> users.email_address
-- Наивный вариант ломает откат: ALTER TABLE users RENAME COLUMN email TO email_address;
-- Старая версия кода после отката упадёт на несуществующей колонке.

-- Шаг 1 (EXPAND). Релиз N. Добавляем новую колонку, ничего не ломая.
ALTER TABLE users ADD COLUMN email_address TEXT;

-- Шаг 2. Релиз N. Код пишет в обе колонки, читает пока из старой.
-- Обратная засыпка старых данных — батчами, чтобы не залочить таблицу.
UPDATE users SET email_address = email
WHERE email_address IS NULL AND id BETWEEN :lo AND :hi;

-- Шаг 3. Релиз N+1 (после того как засыпка завершена и проверена).
-- Код читает из новой колонки, продолжает писать в обе.
-- В этой точке откат к релизу N полностью безопасен.

-- Шаг 4 (CONTRACT). Релиз N+2, спустя период, гарантирующий,
-- что откат к N уже не понадобится (обычно 1-2 недели).
ALTER TABLE users DROP COLUMN email;

Правила, которые стоит зафиксировать в DoD уровня релиза:

  • миграции только аддитивные в том же релизе, что и код, который их использует;
  • удаление чего-либо — минимум через один релиз после того, как это перестали читать;
  • каждая миграция сопровождается оценкой времени блокировки на объёме прод-данных;
  • для больших таблиц — онлайн-инструменты (pg_repack, gh-ost, pt-online-schema-change).

Подробный каталог таких приёмов — в «Refactoring Databases» Скотта Эмблера и Прамодкумара Садаладжа.

Rollback против roll-forward

Два способа реагировать на плохой релиз:

  • Rollback — вернуть предыдущую версию. Быстро, предсказуемо, требует обратной совместимости данных и API. Стандарт по умолчанию.
  • Roll-forward — выкатить исправление вперёд. Требует очень быстрого конвейера (минуты от коммита до прода) и уверенности в диагнозе.

Практическое правило: rollback — рефлекс, roll-forward — решение. Во время инцидента приоритет — остановить ущерб, а не понять причину. Диагностика после восстановления. Если откат в вашей системе технически невозможен, это не «особенность архитектуры», а незакрытый риск первого приоритета.

Версионирование и release notes

Semantic Versioning 2.0.0 даёт контракт MAJOR.MINOR.PATCH: несовместимое изменение — обратно совместимая функциональность — исправление. Для приложений (не библиотек) semver часто избыточен — там разумнее CalVer (2026.07.3) или просто монотонный номер сборки. Важно другое: версия артефакта иммутабельна, и по ней однозначно восстанавливается коммит, зависимости и конфигурация. Без этого расследование инцидента превращается в археологию.

Release notes — не бюрократия, а входные данные для поддержки и для будущего вас. Минимум: что изменилось для пользователя, какие флаги включены, какие миграции применены, как откатить, кто дежурный. Хорошая практика — генерировать черновик из conventional commits, а редактировать руками.


Часть 6. Go / no-go: как принимать решение о релизе

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

Что должно быть на руках к моменту решения:

  1. Отчёт конвейера: все автоматические ворота релизного уровня зелёные.
  2. Список известных дефектов, вошедших в релиз, с явным решением по каждому.
  3. Подтверждение, что план отката исполним (в идеале — исполнялся на стенде в этом цикле).
  4. Дежурный назначен и доступен, окно выкатки не упирается в его конец рабочего дня.
  5. Понятно, какие метрики смотреть первые 30 минут и какой порог означает откат.

Про code freeze стоит сказать отдельно. Заморозка кода — это не мера качества, а компенсация отсутствия автоматических ворот. Она создаёт очередь готовой работы (растёт размер батча первого релиза после разморозки — то есть риск), провоцирует «давайте успеем протолкнуть до заморозки» (то есть спешку в самый опасный момент) и ничего не проверяет по существу. Если заморозка нужна из-за регуляторных требований — это законно; если из-за того, что «иначе страшно» — это симптом, который лечится конвейером.

Аналогично релиз в пятницу: сама по себе пятница не опасна. Опасны релизы, которые нельзя быстро откатить, и отсутствие людей, способных отреагировать. Команда, у которой откат занимает 30 секунд и работает автоматика, релизит в пятницу спокойно. Запрет на пятничные релизы — разумный костыль ровно до тех пор, пока вы чините причину.


Часть 7. Типичные ошибки

  1. DoD не включает деплой. «Готово» = «код в ветке». Между готовностью и ценностью остаётся невидимый разрыв, который не попадает ни в одну метрику.
  2. Отдельная фаза стабилизации. Регулярный «спринт стабилизации» — это признание, что предыдущие спринты производили не готовый продукт, а его черновик.
  3. QA как ворота вместо конвейера. Один человек, который «пропускает» релиз, — узкое место и единственная точка отказа; при этом он физически не может проверить всё.
  4. Игнорирование flaky-тестов. Приводит к культуре «перезапусти, обычно проходит», после чего красный CI перестаёт что-либо значить.
  5. Покрытие как KPI. Порождает тесты без ассертов и ложное чувство защищённости.
  6. Долгоживущие фича-ветки. Чем дольше живёт ветка, тем дороже слияние — стоимость растёт нелинейно, потому что растёт и объём изменений, и вероятность конфликта.
  7. Свалка фича-флагов. Через два года никто не знает, что произойдёт при выключении флага enable_new_billing_v2_final.
  8. Необратимые миграции. План отката существует на бумаге и не работает в момент, когда нужен.
  9. Ручной чек-лист релиза на 40 пунктов. Человек, который делает это в 40-й раз, пропускает пункты автоматически. Всё, что в чек-листе повторяется, должно быть скриптом.
  10. Метрики качества, используемые для оценки людей. Гарантированно приводят к игре с цифрами вместо улучшения качества.
  11. Нет владельца релиза. «Все отвечают» означает «никто не отвечает» в момент инцидента.
  12. Релиз как событие, а не как рутина. Чем реже вы это делаете, тем хуже вы это умеете.

Часть 8. Как это выглядит в проде

Google. Релизная инженерия — отдельная дисциплина с выделенными инженерами (SRE-книга, глава Release Engineering). Принципы: герметичные (воспроизводимые) сборки, все изменения проходят через ревью, автоматизация всего, что повторяется. Отдельно — политика по flaky-тестам и огромные вложения в скорость CI, потому что скорость обратной связи считается стратегическим активом.

Facebook/Meta. Исторический путь от еженедельных «push» к квази-непрерывной доставке описан в статье «Continuous Deployment at Facebook and OANDA» (Savor et al., ICSE 2016). Главный вывод исследования: рост частоты деплоев на два порядка не привёл к росту доли неудачных изменений — потому что параллельно рос уровень автоматизации и прогрессивных выкаток (сначала на сотрудников, потом на 2 % пользователей, потом на всех).

Etsy. Каноническая история про «деплой десятки раз в день» и культуру безвинных постмортемов. Ключевая практика — каждый новый инженер деплоит в прод в первый день работы: это одновременно и обучение, и проверка того, что конвейер действительно безопасен.

Amazon. Многие тысячи деплоев в день достигаются не героизмом, а декомпозицией: маленькие независимо развёртываемые сервисы, у каждого своя pipeline, свои флаги и своя ответственность команды. Это архитектурное решение в поддержку релизного процесса — см. трек архитектурных паттернов.

Регулируемые домены (финтех, медицина, авиация). Здесь встречается искреннее возражение: «у нас нельзя релизить часто, у нас аудит». На практике аудит требует прослеживаемости и контроля, а не редкости. Автоматизированный конвейер с иммутабельными артефактами, подписанными сборками и полным логом одобрений даёт аудитору больше, чем письмо с темой «согласовано» от начальника отдела. Требование разделения обязанностей закрывается обязательным ревью другим человеком, а не ручным деплоем.


Мини-итог

  • Качество бывает внешним (видно пользователю, можно осознанно занять в долг) и внутренним (не видно никогда, занимать нельзя — это условие сохранения скорости).
  • Стоимость дефекта растёт нелинейно с задержкой обнаружения, поэтому проверки двигают влево, а не наращивают в конце.
  • Definition of Done — формальный проверяемый контракт готовности, принадлежащий команде. Строится слоями (история → фича → релиз), максимально автоматизируется, fail closed. Всё, что не покрыто ни одним слоем, — undone work, то есть скрытый долг.
  • Quality gates выстраиваются по убыванию отношения «пойманные дефекты / стоимость проверки». Flaky-тесты уничтожают смысл ворот и требуют карантина с бюджетом.
  • Размер батча — главный рычаг релизного риска: маленькие частые релизы одновременно быстрее и безопаснее. Это не компромисс, а одна переменная.
  • Deploy ≠ release. Фича-флаги разводят техническое и продуктовое события; за флаги надо платить дисциплиной удаления.
  • Откат должен быть рефлексом. Он невозможен без обратимых миграций (expand/contract) и иммутабельных версионированных артефактов.
  • Code freeze и запрет пятничных релизов — костыли, компенсирующие отсутствие конвейера; допустимы как временная мера, вредны как постоянная политика.

Источники


Что дальше

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

Читайте дальше: DORA и метрики инженерной эффективности — четыре ключевые метрики (deployment frequency, lead time for changes, change failure rate, time to restore service), их корректный расчёт, ловушки интерпретации и связь с бизнес-результатами.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов