Deep Engineering

ЗАМЕР

bench/rollout/strategies.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/interview/sre/rollout-rollback
Как запустить
python3 bench/rollout/strategies.py > bench/rollout/runs/strategies.txt
python3 bench/rollout/practice.py   > bench/rollout/runs/practice.txt

Запись прогона

Модель для урока «Выкатка и откат»

Это симуляция, а не замер: стратегии выкатки описываются документацией конкретных продуктов, кластера в среде замеров нет. Допущения перечислены в докстринге strategies.py.

Файл Что делает
strategies.py четыре стратегии выкатки при двух способах обнаружения дефекта — по числу накопленных ошибок и по их доле
practice.py ответы к задачам урока: задетые запросы у канарейки при обоих детекторах и у rolling

Запуск из корня репозитория:

python3 bench/rollout/strategies.py > bench/rollout/runs/strategies.txt
python3 bench/rollout/practice.py   > bench/rollout/runs/practice.txt

Что в модели содержательно

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

Чего в модели нет

Неравномерного потока, ретраев, частичных дефектов (проявляющихся только на части данных) и стоимости самой выкатки для системы.

Скрипт

164 строк
"""Выкатка и откат: сколько запросов задето при четырёх стратегиях. Модель.

ЧТО ЭТО ЗА ФАЙЛ. Симуляция выкатки с дефектом — без кластера и без сети.
Выводы верны настолько, насколько верны допущения (ADR-017), и допущения
названы здесь.

ДОПУЩЕНИЯ:
  1. Время дискретно: шаг 1 секунда, горизонт 30 минут.
  2. Поток постоянный: 100 запросов в секунду.
  3. Новая версия ломает 5 % запросов, которые на неё попадают. Дефект есть с
     первой секунды её работы и не зависит от нагрузки.
  4. Обнаружение: автоматика замечает беду, когда накопилось 200 ошибок, и
     тратит на решение ещё 60 секунд. Раньше этого никто не смотрит.
  5. Откат у каждой стратегии свой: 5 с у blue-green, 300 с у rolling (те же
     десять реплик по одной), 30 с у канарейки, мгновенно у флага. Значения
     стоят в rollback_seconds() и ниоткуда не выводятся — это допущение.
  6. Ни ретраев, ни очередей: считаются только задетые запросы.

СТРАТЕГИИ. rolling — десять реплик по 30 с каждая; blue-green — переключение
всего трафика разом; canary — 5 % на пять минут, потом всё; feature-flag —
1 %, 10 %, 100 % с мгновенным откатом.

ЗАПУСК: python3 bench/rollout/strategies.py
Вывод: runs/strategies.txt
"""

import os
import sys

RPS = 100
HORIZON_S = 30 * 60
DEFECT_SHARE = 0.05
DETECT_ERRORS = 200
DECIDE_S = 60
RATE_WINDOW_S = 30


def show(title: str) -> None:
    print()
    print(title)
    print("-" * len(title))


def row(label: str, value: object) -> None:
    print(f"  {label:<40} {value}")


def share_on_new(strategy: str, t: int) -> float:
    """Доля трафика на новой версии в секунду t от начала выкатки."""
    if strategy == "blue-green":
        return 1.0
    if strategy == "rolling":
        # Десять реплик, по одной каждые 30 секунд.
        return min(1.0, (t // 30 + 1) / 10)
    if strategy == "canary":
        return 0.05 if t < 300 else 1.0
    if strategy == "feature-flag":
        if t < 300:
            return 0.01
        if t < 600:
            return 0.10
        return 1.0
    raise ValueError(strategy)


def rollback_seconds(strategy: str) -> int:
    """Сколько занимает возврат трафика на старую версию."""
    return {"blue-green": 5, "rolling": 300, "canary": 30, "feature-flag": 0}[strategy]


def simulate(strategy: str, detector: str = "count") -> dict[str, int]:
    """Прогон одной стратегии с одним из двух способов обнаружения.

    detector="count" — тревога, когда накопилось DETECT_ERRORS ошибок;
    detector="rate"  — тревога, когда доля ошибок на новой версии держится
                       выше порога RATE_WINDOW_S секунд, независимо от их
                       абсолютного числа.
    """
    errors = 0
    affected = 0
    detected_at = -1
    rolled_back_at = -1
    bad_seconds = 0

    for t in range(HORIZON_S):
        if rolled_back_at >= 0 and t >= rolled_back_at:
            share = 0.0
        else:
            share = share_on_new(strategy, t)

        broken = RPS * share * DEFECT_SHARE
        errors += broken
        affected += broken

        if share > 0 and broken > 0:
            bad_seconds += 1
        alarm = (
            errors >= DETECT_ERRORS
            if detector == "count"
            else bad_seconds >= RATE_WINDOW_S
        )
        if detected_at < 0 and alarm:
            detected_at = t
            rolled_back_at = t + DECIDE_S + rollback_seconds(strategy)

    return {
        "affected": int(affected),
        "detected_at": detected_at,
        "rolled_back_at": rolled_back_at,
        "share_at_detection": share_on_new(strategy, detected_at) if detected_at >= 0 else 0.0,
    }


def main() -> None:
    print(f"Python {sys.version.split()[0]} · Linux {os.uname().release}")
    print("model, not a measurement: assumptions are in the docstring")
    print(f"{RPS} requests per second, the new version breaks {DEFECT_SHARE:.0%} of what reaches it")
    print(f"detection needs {DETECT_ERRORS} errors, then {DECIDE_S} s to decide")

    show("1. DETECTION BY ERROR COUNT: 200 ERRORS RAISE THE ALARM")
    print(f"  {'strategy':>14} {'affected':>10} {'detected, s':>13} {'rolled back, s':>16}")
    results = {}
    for strategy in ("blue-green", "rolling", "canary", "feature-flag"):
        res = simulate(strategy, "count")
        results[strategy] = res
        print(
            f"  {strategy:>14} {res['affected']:>10} {res['detected_at']:>13}"
            f" {res['rolled_back_at']:>16}"
        )
    print()
    print("  A counting detector needs a fixed number of errors, so a small")
    print("  canary reaches it late: the alarm waits for the sample to grow.")

    show("2. DETECTION BY ERROR RATE: 30 SECONDS OF A BAD SHARE")
    print(f"  {'strategy':>14} {'affected':>10} {'detected, s':>13} {'rolled back, s':>16}")
    by_rate = {}
    for strategy in ("blue-green", "rolling", "canary", "feature-flag"):
        res = simulate(strategy, "rate")
        by_rate[strategy] = res
        print(
            f"  {strategy:>14} {res['affected']:>10} {res['detected_at']:>13}"
            f" {res['rolled_back_at']:>16}"
        )
    print()
    print("  A rate detector does not wait for volume, so the strategies")
    print("  finally differ the way they are supposed to.")

    show("3. THE SAME STRATEGY UNDER THE TWO DETECTORS")
    print(f"  {'strategy':>14} {'by count':>10} {'by rate':>10} {'times fewer':>13}")
    for strategy in ("blue-green", "rolling", "canary", "feature-flag"):
        a, b = results[strategy]["affected"], by_rate[strategy]["affected"]
        print(f"  {strategy:>14} {a:>10} {b:>10} {a / max(1, b):>12.1f}x")
    print()
    row("canary: traffic share at detection, by count", f"{results['canary']['share_at_detection']:.0%}")
    row("canary: traffic share at detection, by rate", f"{by_rate['canary']['share_at_detection']:.0%}")
    print()
    print("  The defect is identical in all eight runs. What differs is how much")
    print("  traffic stood on the new version when the problem became visible -")
    print("  and a small canary only helps if the detector can see it there.")


if __name__ == "__main__":
    main()