Deep Engineering

MEASUREMENT

bench/probes/model.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/sre/health-checks
How to run it
python3 bench/probes/model.py    > bench/probes/runs/model.txt
python3 bench/probes/practice.py > bench/probes/runs/practice.txt

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Модель для урока «Health-checks»

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

Файл Что делает
model.py балансировщик и три реплики в четырёх режимах: без проб, только liveness, liveness с readiness и liveness, завязанный на общую базу
practice.py ответы к задачам урока: медленные ответы в трёх режимах и цена каскада

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

python3 bench/probes/model.py    > bench/probes/runs/model.txt
python3 bench/probes/practice.py > bench/probes/runs/practice.txt

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

Отношения: readiness сокращает число медленных ответов по сравнению с одним liveness; liveness, проверяющий общую зависимость, добавляет отказов сверх тех, что стоила сама авария. Абсолютные числа заданы допущениями (10 запросов на шаг, горизонт 60 секунд).

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

Настоящего оркестратора, сети, разогрева процессов и разных порогов у разных проб.

Script

158 lines
"""Health-checks: три разные проверки, которые зовут одним словом. Модель.

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

ДОПУЩЕНИЯ:
  1. Время дискретно: шаг 100 мс, горизонт 60 секунд.
  2. Три реплики, балансировщик раздаёт запросы по кругу между теми, кого
     считает готовыми. Поток постоянный: 10 запросов на шаг.
  3. Одна реплика с 10-й секунды становится «больной»: отвечает в десять раз
     медленнее, но отвечает. Это не отказ, а деградация.
  4. Проба выполняется раз в секунду; порог — три подряд неудачных ответа.
  5. Перезапуск реплики занимает 5 секунд, всё это время она не обслуживает.
  6. Общая зависимость (база) недоступна с 20-й по 30-ю секунду.

ЧТО СРАВНИВАЕТСЯ. Четыре режима: без проб; только liveness; liveness плюс
readiness; и liveness, завязанный на общую зависимость. Считаются медленные и
неуспешные ответы.

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

import os
import sys

STEP_MS = 100
HORIZON_S = 60
RPS_PER_STEP = 10
REPLICAS = 3
SICK_FROM_S = 10
SLOW_FACTOR = 10
DB_DOWN_FROM_S = 20
DB_DOWN_TO_S = 30
PROBE_EVERY_S = 1
FAIL_THRESHOLD = 3
RESTART_S = 5


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


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


def simulate(mode: str) -> dict[str, int]:
    """Один прогон модели.

    mode:
      none            — проб нет, балансировщик считает живыми всех;
      liveness        — проба перезапускает больную реплику;
      liveness+readiness — плюс вывод из ротации без перезапуска;
      liveness-on-db  — liveness проверяет общую зависимость.
    """
    ready = [True] * REPLICAS
    restart_until = [0] * REPLICAS
    fails = [0] * REPLICAS
    slow = 0
    failed = 0

    steps = HORIZON_S * 1000 // STEP_MS
    for step in range(steps):
        now_s = step * STEP_MS / 1000
        db_down = DB_DOWN_FROM_S <= now_s < DB_DOWN_TO_S

        # Пробы: раз в секунду
        if step % (PROBE_EVERY_S * 1000 // STEP_MS) == 0:
            for i in range(REPLICAS):
                if now_s < restart_until[i]:
                    continue
                sick = i == 0 and now_s >= SICK_FROM_S
                # Что именно считает проба неудачей — и есть предмет урока.
                if mode == "none":
                    bad = False
                elif mode == "liveness":
                    bad = sick
                elif mode == "liveness+readiness":
                    bad = sick
                elif mode == "liveness-on-db":
                    bad = sick or db_down
                else:
                    raise ValueError(mode)

                fails[i] = fails[i] + 1 if bad else 0
                if fails[i] >= FAIL_THRESHOLD:
                    fails[i] = 0
                    if mode == "liveness+readiness":
                        ready[i] = False  # вывели из ротации, не перезапуская
                    else:
                        restart_until[i] = now_s + RESTART_S
                        ready[i] = False

        for i in range(REPLICAS):
            if now_s >= restart_until[i] and restart_until[i] != 0 and not ready[i]:
                if mode != "liveness+readiness":
                    ready[i] = True

        serving = [i for i in range(REPLICAS) if ready[i] and now_s >= restart_until[i]]
        if not serving:
            failed += RPS_PER_STEP
            continue

        for n in range(RPS_PER_STEP):
            replica = serving[n % len(serving)]
            sick = replica == 0 and now_s >= SICK_FROM_S
            if db_down:
                failed += 1
            elif sick:
                slow += 1

    return {"slow": slow, "failed": failed, "total": steps * RPS_PER_STEP}


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"{REPLICAS} replicas, {RPS_PER_STEP} requests per {STEP_MS} ms step, {HORIZON_S} s horizon")
    print(f"replica 1 degrades from {SICK_FROM_S} s; the shared database is down {DB_DOWN_FROM_S}-{DB_DOWN_TO_S} s")

    results = {}
    for mode in ("none", "liveness", "liveness+readiness", "liveness-on-db"):
        results[mode] = simulate(mode)

    show("REQUESTS SERVED SLOWLY OR NOT AT ALL")
    print(f"  {'mode':>20} {'slow':>10} {'failed':>10} {'total':>10}")
    for mode, res in results.items():
        print(f"  {mode:>20} {res['slow']:>10} {res['failed']:>10} {res['total']:>10}")

    show("WHAT THE THREE MODES ACTUALLY CHANGE")
    row("no probes: slow answers", results["none"]["slow"])
    row("liveness only: slow answers", results["liveness"]["slow"])
    row("liveness + readiness: slow answers", results["liveness+readiness"]["slow"])
    row(
        "liveness tied to the database: failures",
        results["liveness-on-db"]["failed"],
    )
    row(
        "the same failures without that tie",
        results["liveness"]["failed"],
    )
    print()
    extra = results["liveness-on-db"]["failed"] - results["liveness"]["failed"]
    row("failures added by tying liveness to the database", extra)
    print()
    print("  The outage itself is the same length in both runs. The extra")
    print("  failures are the cascade: a liveness probe that checks a shared")
    print("  dependency restarts every replica at once, and the service stays")
    print("  down after the database is already back.")


if __name__ == "__main__":
    main()