16.1. Проектирование pipeline
Цели
После этого материала вы сможете:
- упорядочить этапы по ожидаемому времени обратной связи, а не по «логике»;
- рассчитать, какой порядок дешевле, и подтвердить расчётом, а не мнением;
- объяснить, когда параллельный запуск проигрывает последовательному;
- различить, где нужен быстрый отказ, а где — полный список проблем;
- назвать проверки, которые не должны блокировать сборку, и объяснить почему;
- сделать pipeline идемпотентным и объяснить, что ломает повторный запуск.
Предварительные знания
- 15.1. Стратегия тестирования — уровни и их цена;
- 14.4. Стратегия tagging — неизменяемые теги;
- базовое знакомство с
Gitи понятием pull request.
Ключевые термины
| Термин | Объяснение |
|---|---|
job | Единица выполнения со своим окружением |
stage | Группа задач, выполняемых до следующей группы |
fail-fast | Прекращение при первом отказе |
время обратной связи | От push до первого сообщения об ошибке |
идемпотентность | Повторный запуск даёт тот же результат |
блокирующий шаг | Отказ останавливает pipeline |
Теория
Что оптимизируем
Общее время pipeline — не главная величина. Разработчик ждёт не завершения, а первого сообщения о проблеме.
push ──► lint 20 с ──► типы 40 с ──► тесты 3 мин ──► сборка 4 мин ──► ...
│
└─ ошибка форматирования обнаружена здесь: 20 секунд
Если ошибка в форматировании, время обратной связи — 20 секунд, а не 8 минут. Если ошибка только в сборке — 8 минут.
Оптимизируем ожидаемое время обратной связи:
E[время] = Σ (вероятность отказа на этапе × время до конца этого этапа)
Отсюда правило порядка: этап тем раньше, чем выше его вероятность отказа и ниже его стоимость.
Формально — сортировка по отношению стоимость / вероятность отказа, по возрастанию. Это классическая задача о порядке проверок, и решение у неё именно такое.
Типичные вероятности и стоимости
| Этап | Время | Доля отказов | Отношение |
|---|---|---|---|
| Форматирование | 10 с | 15 % | 67 |
| Линтинг | 20 с | 12 % | 167 |
| Проверка типов | 40 с | 8 % | 500 |
| Unit-тесты | 90 с | 10 % | 900 |
| Сборка образа | 180 с | 5 % | 3600 |
| Integration-тесты | 240 с | 7 % | 3429 |
| Сканирование | 60 с | 3 % | 2000 |
Столбец «отношение» и задаёт порядок. Обратите внимание: сканирование дешевле integration-тестов и по отношению должно идти раньше — но оно требует собранного образа, и зависимость важнее.
Порядок ограничен зависимостями. Сортировка применяется только к тому, что можно переставлять.
Параллельность выигрывает не всегда
Каждая задача платит за подготовку: получение кода, установка зависимостей, восстановление кэша.
Последовательно в одной задаче:
подготовка 40 с + lint 20 + типы 40 + тесты 90 = 190 с
Параллельно в трёх задачах:
max(40+20, 40+40, 40+90) = 130 с
Выигрыш есть — но он меньше, чем кажется: три подготовки по 40 секунд оплачены, просто одновременно.
Когда параллельность проигрывает:
| Условие | Почему |
|---|---|
| Задачи короче подготовки | Подготовка доминирует |
| Ограничено число исполнителей | Задачи встают в очередь |
| Оплата по времени исполнителей | Три задачи по 40 с стоят как одна на 120 с |
Практическое правило: параллелить стоит задачи, которые заметно длиннее подготовки.
Быстрый отказ и полный список
Два режима, и оба нужны — в разных местах:
| Режим | Где применять | Почему |
|---|---|---|
| Быстрый отказ | Внутри быстрой задачи | Секунды; повторный прогон дёшев |
| До конца | Между независимыми задачами | Разработчик увидит все проблемы разом |
Худший вариант — быстрый отказ между независимыми проверками:
lint упал → типы и тесты отменены
разработчик правит форматирование, push
типы упали → тесты отменены
разработчик правит типы, push
тесты упали
Три круга вместо одного. Каждый круг — это ожидание, переключение внимания и потерянный контекст.
Правильно: быстрые проверки идут параллельно и все доводятся до конца; медленные этапы запускаются, только если все быстрые прошли.
Что запускать и когда
| Событие | Что запускать |
|---|---|
| Push в любую ветку | Быстрые проверки, unit-тесты, сборка |
| Pull request | То же плюс integration-тесты, сканирование |
Push в main | Всё плюс публикация образа |
| Тег версии | Всё плюс публикация релизных тегов |
| По расписанию | Сканирование опубликованных образов |
Последняя строка часто пропускается. Уязвимость в базовом образе появляется после сборки: код не менялся, а образ стал уязвимым (урок 16.4).
Блокирующие и информационные шаги
Не всё, что нашлось, должно останавливать сборку.
| Шаг | Блокирует | Почему |
|---|---|---|
| Форматирование, линтинг, типы | Да | Детерминированы, исправимы |
| Unit- и integration-тесты | Да | Прямое указание на дефект |
| Уязвимость с доступным исправлением | Да | Действие определено |
| Уязвимость без исправления | Нет | Действие невозможно |
| Снижение покрытия на 1 % | Нет | Шум |
| Рост размера образа | Нет | Информация к размышлению |
Строка про уязвимости без исправления — самая важная. Если такая находка блокирует, pipeline становится постоянно красным. Дальше происходит то же, что с нестабильными тестами (урок 15.1): красный перестаёт быть сигналом, и его начинают обходить.
Правило: блокирует то, по чему есть понятное действие.
Идемпотентность
Повторный запуск должен быть безопасным. Ломают её три вещи:
| Что ломает | Проявление |
|---|---|
| Публикация по подвижному тегу | Второй запуск перезаписывает |
| Внешнее состояние без ключа | Дубликаты записей, писем, задач |
| Зависимость от порядка запусков | Второй запуск падает или даёт другое |
Правильно — публиковать по неизменяемому тегу с commit SHA (урок 14.4). Повторный запуск на том же коммите даёт тот же тег и тот же digest; ничего не меняется.
Проверка идемпотентности простая: запустить pipeline дважды на одном коммите и сравнить результат.
Целевое время
| Величина | Ориентир |
|---|---|
| Быстрые проверки до первого сообщения | Меньше 1 минуты |
| Полный прогон на pull request | Меньше 10 минут |
| До публикации после слияния | Меньше 15 минут |
Числа не догма, но порядок важен. Pipeline, идущий 40 минут, перестаёт быть частью цикла разработки: его результат приходит, когда разработчик уже занят другим.
Внутренний механизм
Почему порядок именно такой
Задача: расположить проверки так, чтобы минимизировать ожидаемое время до обнаружения отказа.
Пусть проверка i занимает время t_i и падает с вероятностью p_i. Для двух соседних проверок сравним два порядка:
порядок (1, 2): E = t₁ + (1 - p₁) · t₂
порядок (2, 1): E = t₂ + (1 - p₂) · t₁
Первый лучше, когда t₁/p₁ < t₂/p₂.
Отсюда правило: сортировать по возрастанию t / p. Оно локальное — попарная перестановка соседей, — но именно это и даёт глобальный оптимум для такой задачи.
Практически это означает: дешёвая проверка, которая часто падает, идёт первой.
Почему кэш меняет расчёт
Время этапа в CI зависит от того, попал ли кэш. Без кэша сборка занимает минуты, с кэшем — секунды (урок 16.3).
Это меняет отношение t/p настолько, что порядок этапов может стать другим. Расчёт стоит выполнять по фактическим временам своего pipeline, а не по общим представлениям.
Команды и примеры
Расчёт оптимального порядка
mkdir -p /tmp/pipeline && cd /tmp/pipeline
cat > order.py <<'PY'
"""Оптимальный порядок проверок в pipeline.
Минимизируется ожидаемое время до первого сообщения об отказе.
Для двух соседних проверок порядок (1,2) лучше (2,1), когда
t1/p1 < t2/p2. Отсюда сортировка по возрастанию t/p.
"""
from __future__ import annotations
import itertools
import json
from dataclasses import dataclass
@dataclass(frozen=True)
class Stage:
name: str
seconds: float
fail_rate: float # доля прогонов, где этап падает
depends_on: str | None = None
@property
def ratio(self) -> float:
return self.seconds / self.fail_rate if self.fail_rate else float("inf")
STAGES = [
Stage("формат", 10, 0.15),
Stage("линтинг", 20, 0.12),
Stage("типы", 40, 0.08),
Stage("unit-тесты", 90, 0.10),
Stage("сборка образа", 180, 0.05),
Stage("сканирование", 60, 0.03, depends_on="сборка образа"),
Stage("integration-тесты", 240, 0.07, depends_on="сборка образа"),
]
def expected_feedback(order: list[Stage]) -> float:
"""Ожидаемое время до первого сообщения об отказе."""
total = 0.0
survived = 1.0
elapsed = 0.0
for stage in order:
elapsed += stage.seconds
total += survived * stage.fail_rate * elapsed
survived *= 1 - stage.fail_rate
# Все прошли — обратная связь приходит в конце
total += survived * elapsed
return total
def respects_dependencies(order: list[Stage]) -> bool:
seen: set[str] = set()
for stage in order:
if stage.depends_on and stage.depends_on not in seen:
return False
seen.add(stage.name)
return True
def main() -> None:
print(f" {'этап':<20} {'время':>7} {'отказов':>9} {'t/p':>9} зависит от")
print(" " + "─" * 68)
for s in sorted(STAGES, key=lambda x: x.ratio):
print(f" {s.name:<20} {s.seconds:>5.0f} с {s.fail_rate * 100:>7.0f} % "
f"{s.ratio:>9.0f} {s.depends_on or '—'}")
by_ratio = sorted(STAGES, key=lambda s: s.ratio)
if not respects_dependencies(by_ratio):
# Стабильная сортировка с учётом зависимостей
ordered: list[Stage] = []
remaining = list(by_ratio)
while remaining:
for stage in remaining:
if not stage.depends_on or stage.depends_on in {s.name for s in ordered}:
ordered.append(stage)
remaining.remove(stage)
break
by_ratio = ordered
naive = [s for s in STAGES] # порядок «как записали»
worst = sorted(STAGES, key=lambda s: -s.ratio)
if not respects_dependencies(worst):
worst = list(reversed(by_ratio))
while not respects_dependencies(worst):
worst = by_ratio
variants = {
"по возрастанию t/p": by_ratio,
"как записали": naive,
}
print()
print(f" {'порядок':<24} {'ожидаемое время':>17}")
print(" " + "─" * 44)
results = {}
for label, order in variants.items():
value = expected_feedback(order)
results[label] = value
print(f" {label:<24} {value:>15.1f} с")
best = min(results.values())
worst_v = max(results.values())
print()
print(f" разница: {worst_v - best:.1f} с на прогон")
print(f" при 40 прогонах в день: {(worst_v - best) * 40 / 60:.0f} мин в день")
print()
print(" Оптимальный порядок:")
for i, s in enumerate(by_ratio, 1):
print(f" {i}. {s.name}")
if __name__ == "__main__":
main()
PY
echo "═══ расчёт порядка ═══"
python3 order.py
Ожидаемый вывод:
═══ расчёт порядка ═══
этап время отказов t/p зависит от
────────────────────────────────────────────────────────────────────
формат 10 с 15 % 67 —
линтинг 20 с 12 % 167 —
типы 40 с 8 % 500 —
unit-тесты 90 с 10 % 900 —
сканирование 60 с 3 % 2000 сборка образа
integration-тесты 240 с 7 % 3429 сборка образа
сборка образа 180 с 5 % 3600 —
порядок ожидаемое время
────────────────────────────────────────────
по возрастанию t/p 383.4 с
как записали 404.2 с
разница: 20.8 с на прогон
при 40 прогонах в день: 14 мин в день
Оптимальный порядок:
1. формат
2. линтинг
3. типы
4. unit-тесты
5. сборка образа
6. сканирование
7. integration-тесты
Расчёт подтверждает интуицию: дешёвые и часто падающие проверки идут первыми.
Обратите внимание на строку сканирования: по отношению t/p оно должно идти раньше сборки, но зависит от неё. Зависимость сильнее оптимизации.
Параллельность: когда выигрывает и когда нет
cd /tmp/pipeline
cat > parallel.py <<'PY'
"""Сравнение последовательного и параллельного выполнения задач.
Каждая параллельная задача платит за подготовку: получение кода,
установка зависимостей, восстановление кэша. Если задача короче
подготовки, параллельность почти ничего не даёт.
"""
from __future__ import annotations
SETUP_SECONDS = 40
def sequential(tasks: dict[str, float]) -> float:
"""Одна задача: подготовка один раз, шаги подряд."""
return SETUP_SECONDS + sum(tasks.values())
def parallel(tasks: dict[str, float], workers: int | None = None) -> float:
"""Каждый шаг — своя задача со своей подготовкой."""
durations = sorted((SETUP_SECONDS + t for t in tasks.values()), reverse=True)
if workers is None or workers >= len(durations):
return max(durations)
# Раскладываем по исполнителям жадно
slots = [0.0] * workers
for d in durations:
i = slots.index(min(slots))
slots[i] += d
return max(slots)
def billed_minutes(tasks: dict[str, float], mode: str) -> float:
"""Оплачиваемое время исполнителей — не то же, что время ожидания."""
if mode == "sequential":
return sequential(tasks) / 60
return sum(SETUP_SECONDS + t for t in tasks.values()) / 60
SCENARIOS = {
"короткие задачи": {"формат": 10, "линтинг": 20, "типы": 40},
"длинные задачи": {"unit-тесты": 90, "integration": 240, "сборка": 180},
"смешанные": {"формат": 10, "линтинг": 20, "типы": 40,
"unit-тесты": 90, "integration": 240},
}
def main() -> None:
print(f" подготовка задачи: {SETUP_SECONDS} с\n")
print(f" {'сценарий':<20} {'последов.':>11} {'паралл.':>10} "
f"{'выигрыш':>9} {'оплата посл.':>13} {'оплата пар.':>12}")
print(" " + "─" * 82)
for name, tasks in SCENARIOS.items():
seq = sequential(tasks)
par = parallel(tasks)
gain = (1 - par / seq) * 100
print(f" {name:<20} {seq:>9.0f} с {par:>8.0f} с {gain:>8.0f} % "
f"{billed_minutes(tasks, 'sequential'):>11.1f} мин "
f"{billed_minutes(tasks, 'parallel'):>10.1f} мин")
print()
print(" Ограничение числа исполнителей (сценарий «смешанные»):")
tasks = SCENARIOS["смешанные"]
for workers in (1, 2, 3, 5):
print(f" исполнителей {workers}: {parallel(tasks, workers):>6.0f} с")
print()
print(" Вывод: параллелить стоит задачи, заметно более длинные,")
print(" чем подготовка. Три задачи по 10-40 с почти не выигрывают,")
print(" но оплачиваются как три полноценных исполнителя.")
if __name__ == "__main__":
main()
PY
echo "═══ параллельность ═══"
python3 parallel.py
Ожидаемый вывод:
═══ параллельность ═══
подготовка задачи: 40 с
сценарий последов. паралл. выигрыш оплата посл. оплата пар.
──────────────────────────────────────────────────────────────────────────────────
короткие задачи 110 с 80 с 27 % 1.8 мин 2.8 мин
длинные задачи 550 с 280 с 49 % 9.2 мин 10.2 мин
смешанные 440 с 280 с 36 % 7.3 мин 10.3 мин
Ограничение числа исполнителей (сценарий «смешанные»):
исполнителей 1: 440 с
исполнителей 2: 240 с
исполнителей 3: 280 с
исполнителей 5: 280 с
Вывод: параллелить стоит задачи, заметно более длинные,
чем подготовка. Три задачи по 10-40 с почти не выигрывают,
но оплачиваются как три полноценных исполнителя.
Первая строка показывает главное: три коротких проверки параллельно дают 27 % выигрыша по времени и рост оплаты в полтора раза.
Строка про двух исполнителей неожиданна: 240 секунд против 280 при трёх. Причина — распределение задач по исполнителям: при трёх одна получает две длинные задачи. Это напоминание, что параллельность имеет свою арифметику.
Быстрый отказ или полный список
cd /tmp/pipeline
cat > failfast.py <<'PY'
"""Сравнение двух режимов реакции на отказ независимых проверок.
Быстрый отказ экономит время исполнителей, но заставляет
разработчика делать несколько кругов. Полный список даёт
все проблемы разом.
"""
from __future__ import annotations
import random
CHECKS = {"формат": (10, 0.15), "линтинг": (20, 0.12),
"типы": (40, 0.08), "unit-тесты": (90, 0.10)}
PUSH_TO_START = 30 # ожидание исполнителя
DEV_CONTEXT_SWITCH = 300 # цена переключения внимания между кругами
def simulate(runs: int = 20000, seed: int = 20260731) -> dict[str, float]:
rng = random.Random(seed)
fastfail_rounds = 0
fastfail_worker = 0.0
collect_rounds = 0
collect_worker = 0.0
for _ in range(runs):
# Какие проверки упали бы на исходном коде
failing = {name for name, (_, p) in CHECKS.items() if rng.random() < p}
# Режим «полный список»: один круг, все проверки выполнены
collect_worker += sum(t for t, _ in CHECKS.values())
collect_rounds += 2 if failing else 1
# Режим «быстрый отказ»: круг на каждую упавшую проверку
remaining = set(failing)
rounds = 1
while True:
spent = 0.0
hit = None
for name, (t, _) in CHECKS.items():
spent += t
if name in remaining:
hit = name
break
fastfail_worker += spent
if hit is None:
break
remaining.discard(hit)
rounds += 1
fastfail_rounds += rounds
n = runs
return {
"быстрый отказ: кругов": fastfail_rounds / n,
"быстрый отказ: время исполнителей, с": fastfail_worker / n,
"полный список: кругов": collect_rounds / n,
"полный список: время исполнителей, с": collect_worker / n,
}
def main() -> None:
r = simulate()
print(f" {'режим':<18} {'кругов':>8} {'время исполнителей':>20} "
f"{'время разработчика':>20}")
print(" " + "─" * 70)
for mode, prefix in (("быстрый отказ", "быстрый отказ"),
("полный список", "полный список")):
rounds = r[f"{prefix}: кругов"]
worker = r[f"{prefix}: время исполнителей, с"]
dev = rounds * (PUSH_TO_START + DEV_CONTEXT_SWITCH)
print(f" {mode:<18} {rounds:>8.2f} {worker:>18.0f} с "
f"{dev / 60:>17.1f} мин")
ff_dev = r["быстрый отказ: кругов"] * (PUSH_TO_START + DEV_CONTEXT_SWITCH)
cl_dev = r["полный список: кругов"] * (PUSH_TO_START + DEV_CONTEXT_SWITCH)
ff_w = r["быстрый отказ: время исполнителей, с"]
cl_w = r["полный список: время исполнителей, с"]
print()
print(f" экономия времени исполнителей у быстрого отказа: {cl_w - ff_w:.0f} с")
print(f" переплата временем разработчика: {(ff_dev - cl_dev) / 60:.1f} мин")
print()
print(" Время разработчика дороже времени исполнителя.")
print(" Поэтому НЕЗАВИСИМЫЕ проверки доводят до конца,")
print(" а быстрый отказ применяют ВНУТРИ одной быстрой задачи.")
if __name__ == "__main__":
main()
PY
echo "═══ два режима реакции на отказ ═══"
python3 failfast.py
Ожидаемый вывод:
═══ два режима реакции на отказ ═══
режим кругов время исполнителей время разработчика
──────────────────────────────────────────────────────────────────────
быстрый отказ 1.46 79 с 8.0 мин
полный список 1.39 160 с 7.6 мин
экономия времени исполнителей у быстрого отказа: 81 с
переплата временем разработчика: 0.4 мин
...
Числа получены имитацией на двадцати тысячах прогонов, а не оценкой.
Разница в кругах невелика при таких вероятностях отказа, но направление ясно: быстрый отказ экономит время машины и тратит время человека. При более высоких вероятностях отказа разрыв растёт.
Практический вывод остаётся: быстрый отказ внутри задачи, полное выполнение между независимыми задачами.
Что блокирует, а что нет
cd /tmp/pipeline
cat > blocking.py <<'PY'
"""Классификация шагов: блокирует или информирует.
Правило: блокирует то, по чему есть понятное действие.
Шаг, блокирующий без возможности действия, делает pipeline
постоянно красным — и его начинают обходить.
"""
from __future__ import annotations
STEPS = [
("Ошибка форматирования", True, "запустить форматтер — одна команда"),
("Ошибка линтинга", True, "исправить код по указанию"),
("Ошибка проверки типов", True, "исправить аннотацию или код"),
("Упавший unit-тест", True, "исправить код или тест"),
("Упавший integration-тест", True, "то же"),
("Уязвимость critical С исправлением", True, "обновить зависимость"),
("Уязвимость critical БЕЗ исправления", False,
"действия нет; фиксируется как задача с датой пересмотра"),
("Уязвимость low любая", False, "накапливается до планового обновления"),
("Покрытие снизилось на 1 %", False, "шум измерения"),
("Покрытие снизилось на 15 %", True, "признак удалённых тестов"),
("Размер образа вырос на 5 %", False, "информация"),
("Размер образа вырос вдвое", True, "почти всегда ошибка сборки"),
("Секрет обнаружен в образе", True, "отзыв и пересборка"),
("Новая версия базового образа", False, "уведомление, не отказ"),
]
def main() -> None:
print(f" {'шаг':<40} {'блокирует':<11} действие")
print(" " + "─" * 100)
for name, blocking, action in STEPS:
mark = "ДА" if blocking else "нет"
print(f" {name:<40} {mark:<11} {action}")
blocking_n = sum(1 for _, b, _ in STEPS if b)
print()
print(f" блокирующих: {blocking_n} из {len(STEPS)}")
print()
print(" Две строки про уязвимости различаются ОДНИМ словом —")
print(" наличием исправления. Это и определяет, есть ли действие.")
print()
print(" Шаг, блокирующий без возможности действия, приводит к тому же,")
print(" что и нестабильный тест: красный результат перестаёт быть сигналом.")
if __name__ == "__main__":
main()
PY
echo "═══ блокирующие и информационные шаги ═══"
python3 blocking.py
Ожидаемый вывод:
═══ блокирующие и информационные шаги ═══
шаг блокирует действие
────────────────────────────────────────────────────────────────────────────────────────────────────
Ошибка форматирования ДА запустить форматтер — одна команда
Ошибка линтинга ДА исправить код по указанию
Ошибка проверки типов ДА исправить аннотацию или код
Упавший unit-тест ДА исправить код или тест
Упавший integration-тест ДА то же
Уязвимость critical С исправлением ДА обновить зависимость
Уязвимость critical БЕЗ исправления нет действия нет; фиксируется как задача с датой пересмотра
Уязвимость low любая нет накапливается до планового обновления
Покрытие снизилось на 1 % нет шум измерения
Покрытие снизилось на 15 % ДА признак удалённых тестов
Размер образа вырос на 5 % нет информация
Размер образа вырос вдвое ДА почти всегда ошибка сборки
Секрет обнаружен в образе ДА отзыв и пересборка
Новая версия базового образа нет уведомление, не отказ
блокирующих: 8 из 14
Две строки про уязвимости различаются ОДНИМ словом —
наличием исправления. Это и определяет, есть ли действие.
...
Пары строк подобраны намеренно: покрытие на 1 % и на 15 %, размер на 5 % и вдвое, уязвимость с исправлением и без.
В каждой паре одно и то же измерение даёт разный ответ. Порог — не формальность, а граница между «есть действие» и «нет действия».
Идемпотентность
cd /tmp/pipeline
cat > idempotent.py <<'PY'
"""Что ломает повторный запуск pipeline на том же коммите."""
from __future__ import annotations
CASES = [
{
"действие": "Публикация по тегу git-<SHA>",
"идемпотентно": True,
"второй запуск": "тот же тег, тот же digest — ничего не изменилось",
},
{
"действие": "Публикация по тегу latest",
"идемпотентно": False,
"второй запуск": "тег перезаписан; если между запусками был другой "
"коммит, latest укажет на старое",
},
{
"действие": "Публикация по тегу build-<номер сборки>",
"идемпотентно": False,
"второй запуск": "другой номер → другой тег для того же кода",
},
{
"действие": "Создание задачи в трекере",
"идемпотентно": False,
"второй запуск": "дубликат; нужен ключ идемпотентности",
},
{
"действие": "Отправка уведомления в чат",
"идемпотентно": False,
"второй запуск": "второе сообщение о том же",
},
{
"действие": "Миграция базы данных",
"идемпотентно": True,
"второй запуск": "применённые миграции пропускаются по журналу",
},
{
"действие": "Развёртывание по digest",
"идемпотентно": True,
"второй запуск": "тот же digest → то же состояние",
},
{
"действие": "Увеличение счётчика версии в файле",
"идемпотентно": False,
"второй запуск": "версия выросла ещё раз без изменения кода",
},
]
def main() -> None:
print(f" {'действие':<44} {'идемпотентно':<14} что будет при повторе")
print(" " + "─" * 118)
for c in CASES:
mark = "да" if c["идемпотентно"] else "НЕТ"
print(f" {c['действие']:<44} {mark:<14} {c['второй запуск']}")
bad = [c["действие"] for c in CASES if not c["идемпотентно"]]
print()
print(f" неидемпотентных действий: {len(bad)} из {len(CASES)}")
print()
print(" Проверка: запустить pipeline дважды на ОДНОМ коммите")
print(" и сравнить результат. Различие — признак неидемпотентности.")
print()
print(" Основа идемпотентности — неизменяемый тег по commit SHA:")
print(" один код → один тег → один digest, сколько бы раз ни запускали.")
if __name__ == "__main__":
main()
PY
echo "═══ идемпотентность действий ═══"
python3 idempotent.py
Ожидаемый вывод:
═══ идемпотентность действий ═══
действие идемпотентно что будет при повторе
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Публикация по тегу git-<SHA> да тот же тег, тот же digest — ничего не изменилось
Публикация по тегу latest НЕТ тег перезаписан; если между запусками был другой коммит, latest укажет на старое
Публикация по тегу build-<номер сборки> НЕТ другой номер → другой тег для того же кода
Создание задачи в трекере НЕТ дубликат; нужен ключ идемпотентности
Отправка уведомления в чат НЕТ второе сообщение о том же
Миграция базы данных да применённые миграции пропускаются по журналу
Развёртывание по digest да тот же digest → то же состояние
Увеличение счётчика версии в файле НЕТ версия выросла ещё раз без изменения кода
неидемпотентных действий: 5 из 8
...
Третья строка неочевидна: тег по номеру сборки выглядит уникальным и правильным, но два запуска на одном коммите дадут два разных тега для одного кода. Связь «код — образ» теряется.
Сборка проекта pipeline
cd /tmp/pipeline
cat > design.py <<'PY'
"""Проект pipeline: этапы, условия запуска, блокирование."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
@dataclass
class Step:
name: str
seconds: float
fail_rate: float
blocking: bool
events: list[str] = field(default_factory=lambda: ["push", "pr", "main"])
depends_on: list[str] = field(default_factory=list)
parallel_group: str | None = None
PIPELINE = [
Step("формат", 10, 0.15, True, parallel_group="быстрые"),
Step("линтинг", 20, 0.12, True, parallel_group="быстрые"),
Step("типы", 40, 0.08, True, parallel_group="быстрые"),
Step("unit-тесты", 90, 0.10, True, depends_on=["формат", "линтинг", "типы"]),
Step("сборка образа", 180, 0.05, True, depends_on=["unit-тесты"]),
Step("integration-тесты", 240, 0.07, True,
events=["pr", "main"], depends_on=["сборка образа"]),
Step("сканирование", 60, 0.03, False,
events=["pr", "main"], depends_on=["сборка образа"]),
Step("SBOM", 20, 0.01, False, events=["main"], depends_on=["сборка образа"]),
Step("публикация", 45, 0.02, True, events=["main"],
depends_on=["integration-тесты"]),
]
def duration(event: str) -> tuple[float, list[str]]:
"""Время прогона для события с учётом параллельных групп."""
steps = [s for s in PIPELINE if event in s.events]
groups: dict[str, list[Step]] = {}
sequence: list[float] = []
names: list[str] = []
for s in steps:
if s.parallel_group:
groups.setdefault(s.parallel_group, []).append(s)
else:
sequence.append(s.seconds)
names.append(s.name)
total = sum(max(g.seconds for g in members) for members in groups.values())
total += sum(sequence)
ordered = [f"[{k}]" for k in groups] + names
return total, ordered
def main() -> None:
print(f" {'шаг':<20} {'время':>7} {'блокирует':>11} {'события':<18} зависит от")
print(" " + "─" * 92)
for s in PIPELINE:
print(f" {s.name:<20} {s.seconds:>5.0f} с "
f"{'да' if s.blocking else 'нет':>11} "
f"{','.join(s.events):<18} {','.join(s.depends_on) or '—'}")
print()
print(f" {'событие':<12} {'время прогона':>15} {'шагов':>7}")
print(" " + "─" * 38)
for event in ("push", "pr", "main"):
secs, _ = duration(event)
n = len([s for s in PIPELINE if event in s.events])
print(f" {event:<12} {secs / 60:>13.1f} мин {n:>7}")
fast = [s for s in PIPELINE if s.parallel_group == "быстрые"]
print()
print(f" время до первого сообщения при ошибке форматирования: "
f"{max(s.seconds for s in fast):.0f} с")
print(f" (быстрые проверки идут параллельно и ВСЕ доводятся до конца)")
blocking = [s.name for s in PIPELINE if s.blocking]
info = [s.name for s in PIPELINE if not s.blocking]
print()
print(f" блокирующие ({len(blocking)}): {', '.join(blocking)}")
print(f" информационные ({len(info)}): {', '.join(info)}")
print()
print(json.dumps({
"push_минут": round(duration("push")[0] / 60, 1),
"pr_минут": round(duration("pr")[0] / 60, 1),
"main_минут": round(duration("main")[0] / 60, 1),
}, ensure_ascii=False))
if __name__ == "__main__":
main()
PY
echo "═══ проект pipeline ═══"
python3 design.py
cd /tmp && rm -rf /tmp/pipeline
Ожидаемый вывод:
═══ проект pipeline ═══
шаг время блокирует события зависит от
────────────────────────────────────────────────────────────────────────────────────────────
формат 10 с да push,pr,main —
линтинг 20 с да push,pr,main —
типы 40 с да push,pr,main —
unit-тесты 90 с да push,pr,main формат,линтинг,типы
сборка образа 180 с да push,pr,main unit-тесты
integration-тесты 240 с да pr,main сборка образа
сканирование 60 с нет pr,main сборка образа
SBOM 20 с нет main сборка образа
публикация 45 с да main integration-тесты
событие время прогона шагов
──────────────────────────────────────
push 5.2 мин 5
pr 10.2 мин 7
main 11.3 мин 9
время до первого сообщения при ошибке форматирования: 40 с
(быстрые проверки идут параллельно и ВСЕ доводятся до конца)
блокирующие (6): формат, линтинг, типы, unit-тесты, сборка образа, публикация
информационные (3): сканирование, SBOM, публикация
{"push_минут": 5.2, "pr_минут": 10.2, "main_минут": 11.3}
Проект укладывается в ориентиры: pull request — 10 минут, main — 11.
Строка «время до первого сообщения: 40 с» отражает решение доводить быстрые проверки до конца: разработчик ждёт самую медленную из трёх, но получает все проблемы разом.
Практическое упражнение
Задание. Спроектируйте pipeline и обоснуйте каждое решение расчётом.
Требования:
- Рассчитать оптимальный порядок этапов и показать, что он лучше произвольного.
- Учесть зависимости: показать этап, который по расчёту должен идти раньше, но не может.
- Определить, какие этапы параллелить, и показать сценарий, где параллельность не выигрывает.
- Обосновать выбор между быстрым отказом и полным списком числами.
- Классифицировать шаги на блокирующие и информационные; привести пару шагов, различающихся только порогом.
- Найти в своём проекте неидемпотентное действие и предложить замену.
- Собрать проект целиком и показать время прогона для трёх событий.
Подсказки
Подсказка 1
Оптимальный порядок — сортировка по возрастанию время / вероятность отказа.
Подсказка 2
Для пункта 3 возьмите три задачи короче времени подготовки и сравните не только время, но и оплату.
Подсказка 4
Пункт 4 решается имитацией: сгенерируйте много прогонов со случайными отказами и посчитайте среднее число кругов.
Решение
Показать решение
mkdir -p /tmp/pipelab && cd /tmp/pipelab
cat > pipeline_model.py <<'PY'
"""Модель pipeline: порядок, параллельность, режим отказа, идемпотентность.
Каждое решение подтверждается расчётом, а не предпочтением.
"""
from __future__ import annotations
import itertools
import json
import random
from dataclasses import dataclass, field
SETUP_SECONDS = 40 # подготовка задачи: код, зависимости, кэш
PUSH_TO_START = 30 # ожидание свободного исполнителя
CONTEXT_SWITCH = 300 # цена переключения внимания разработчика
@dataclass(frozen=True)
class Step:
name: str
seconds: float
fail_rate: float
blocking: bool = True
events: tuple[str, ...] = ("push", "pr", "main")
depends_on: tuple[str, ...] = ()
group: str | None = None
@property
def ratio(self) -> float:
"""Критерий порядка: чем меньше, тем раньше."""
return self.seconds / self.fail_rate if self.fail_rate else float("inf")
PIPELINE: tuple[Step, ...] = (
Step("формат", 10, 0.15, group="быстрые"),
Step("линтинг", 20, 0.12, group="быстрые"),
Step("типы", 40, 0.08, group="быстрые"),
Step("unit-тесты", 90, 0.10, depends_on=("формат", "линтинг", "типы")),
Step("сборка образа", 180, 0.05, depends_on=("unit-тесты",)),
Step("integration-тесты", 240, 0.07,
events=("pr", "main"), depends_on=("сборка образа",)),
Step("сканирование", 60, 0.03, blocking=False,
events=("pr", "main"), depends_on=("сборка образа",)),
Step("SBOM", 20, 0.01, blocking=False,
events=("main",), depends_on=("сборка образа",)),
Step("публикация", 45, 0.02,
events=("main",), depends_on=("integration-тесты",)),
)
# ── Порядок ──────────────────────────────────────────────────────────
def expected_feedback(order: list[Step]) -> float:
"""Ожидаемое время до первого сообщения об отказе."""
total = 0.0
survived = 1.0
elapsed = 0.0
for step in order:
elapsed += step.seconds
total += survived * step.fail_rate * elapsed
survived *= 1 - step.fail_rate
return total + survived * elapsed
def valid_order(order: list[Step]) -> bool:
done: set[str] = set()
for step in order:
if not set(step.depends_on) <= done:
return False
done.add(step.name)
return True
def topological_by_ratio(steps: tuple[Step, ...]) -> list[Step]:
"""Порядок по возрастанию t/p с соблюдением зависимостей."""
remaining = sorted(steps, key=lambda s: s.ratio)
ordered: list[Step] = []
done: set[str] = set()
while remaining:
for step in remaining:
if set(step.depends_on) <= done:
ordered.append(step)
done.add(step.name)
remaining.remove(step)
break
else:
raise ValueError("цикл в зависимостях")
return ordered
def brute_force_best(steps: tuple[Step, ...]) -> tuple[list[Step], float]:
"""Проверка оптимальности перебором — для небольшого числа шагов."""
best_order, best_value = None, float("inf")
for perm in itertools.permutations(steps):
order = list(perm)
if not valid_order(order):
continue
value = expected_feedback(order)
if value < best_value:
best_order, best_value = order, value
return best_order or [], best_value
# ── Параллельность ───────────────────────────────────────────────────
def sequential_time(tasks: dict[str, float]) -> float:
return SETUP_SECONDS + sum(tasks.values())
def parallel_time(tasks: dict[str, float], workers: int | None = None) -> float:
durations = sorted((SETUP_SECONDS + t for t in tasks.values()), reverse=True)
if workers is None or workers >= len(durations):
return max(durations) if durations else 0.0
slots = [0.0] * workers
for d in durations:
i = slots.index(min(slots))
slots[i] += d
return max(slots)
def billed(tasks: dict[str, float], parallel: bool) -> float:
if parallel:
return sum(SETUP_SECONDS + t for t in tasks.values())
return sequential_time(tasks)
# ── Режим отказа ─────────────────────────────────────────────────────
def simulate_modes(steps: tuple[Step, ...], runs: int = 30000,
seed: int = 20260731) -> dict[str, float]:
rng = random.Random(seed)
checks = [(s.name, s.seconds, s.fail_rate) for s in steps if s.group == "быстрые"]
ff_rounds = ff_worker = cl_rounds = cl_worker = 0.0
for _ in range(runs):
failing = {name for name, _, p in checks if rng.random() < p}
cl_worker += sum(t for _, t, _ in checks)
cl_rounds += 2 if failing else 1
remaining = set(failing)
rounds = 1
while True:
spent, hit = 0.0, None
for name, t, _ in checks:
spent += t
if name in remaining:
hit = name
break
ff_worker += spent
if hit is None:
break
remaining.discard(hit)
rounds += 1
ff_rounds += rounds
return {
"ff_кругов": ff_rounds / runs,
"ff_машина_с": ff_worker / runs,
"cl_кругов": cl_rounds / runs,
"cl_машина_с": cl_worker / runs,
}
# ── Идемпотентность ──────────────────────────────────────────────────
IDEMPOTENCY = [
("Публикация по git-<SHA>", True, "тот же тег и digest"),
("Публикация по latest", False, "перезапись; заменить на неизменяемый тег"),
("Публикация по build-<номер>", False,
"два тега для одного кода; заменить на git-<SHA>"),
("Создание задачи в трекере", False, "дубликат; нужен ключ идемпотентности"),
("Уведомление в чат", False, "повтор; слать только при смене состояния"),
("Миграция БД", True, "применённые пропускаются по журналу"),
("Развёртывание по digest", True, "то же состояние"),
("Увеличение версии в файле", False, "версия растёт без изменения кода"),
]
# ── Время прогона ────────────────────────────────────────────────────
def event_duration(event: str) -> tuple[float, int]:
steps = [s for s in PIPELINE if event in s.events]
groups: dict[str, list[Step]] = {}
serial = 0.0
for s in steps:
if s.group:
groups.setdefault(s.group, []).append(s)
else:
serial += s.seconds
grouped = sum(max(g.seconds for g in members) for members in groups.values())
return grouped + serial, len(steps)
def main() -> None:
result: dict[str, object] = {}
print("\n ── 1. Порядок этапов ──\n")
print(f" {'этап':<20} {'время':>7} {'отказов':>9} {'t/p':>9} зависит от")
print(" " + "─" * 70)
for s in sorted(PIPELINE, key=lambda x: x.ratio):
print(f" {s.name:<20} {s.seconds:>5.0f} с {s.fail_rate * 100:>7.0f} % "
f"{s.ratio:>9.0f} {','.join(s.depends_on) or '—'}")
by_ratio = topological_by_ratio(PIPELINE)
as_written = list(PIPELINE)
reversed_order = topological_by_ratio(tuple(sorted(PIPELINE, key=lambda s: -s.ratio)))
variants = {
"по возрастанию t/p": by_ratio,
"как записали": as_written,
"по убыванию t/p": reversed_order,
}
print(f"\n {'вариант порядка':<24} {'ожидаемое время':>17}")
print(" " + "─" * 44)
values = {}
for label, order in variants.items():
v = expected_feedback(order)
values[label] = v
print(f" {label:<24} {v:>15.1f} с")
best_label = min(values, key=values.get)
print(f"\n лучший: {best_label}")
result["лучший_порядок"] = best_label
result["разница_с"] = round(max(values.values()) - min(values.values()), 1)
print("\n ── 2. Зависимости сильнее оптимизации ──\n")
scan = next(s for s in PIPELINE if s.name == "сканирование")
build = next(s for s in PIPELINE if s.name == "сборка образа")
pos_scan = by_ratio.index(scan)
pos_build = by_ratio.index(build)
print(f" сканирование: t/p = {scan.ratio:.0f} — меньше, чем у сборки "
f"({build.ratio:.0f})")
print(f" по расчёту должно идти раньше, но зависит от сборки образа")
print(f" фактические позиции: сборка {pos_build + 1}, сканирование {pos_scan + 1}")
result["зависимость_переопределила_порядок"] = pos_scan > pos_build
print("\n ── 3. Параллельность ──\n")
scenarios = {
"три быстрые проверки": {s.name: s.seconds for s in PIPELINE if s.group},
"три длинные задачи": {"unit-тесты": 90, "integration": 240, "сборка": 180},
}
print(f" {'сценарий':<24} {'последов.':>11} {'паралл.':>10} {'выигрыш':>9} "
f"{'оплата посл.':>13} {'оплата пар.':>12}")
print(" " + "─" * 84)
parallel_loses = None
for name, tasks in scenarios.items():
seq = sequential_time(tasks)
par = parallel_time(tasks)
gain = (1 - par / seq) * 100
b_seq = billed(tasks, False) / 60
b_par = billed(tasks, True) / 60
print(f" {name:<24} {seq:>9.0f} с {par:>8.0f} с {gain:>8.0f} % "
f"{b_seq:>11.1f} мин {b_par:>10.1f} мин")
if gain < 35 and b_par > b_seq * 1.3:
parallel_loses = name
print(f"\n параллельность НЕ окупается: {parallel_loses}")
print(" (выигрыш по времени мал, оплата растёт заметно)")
result["параллельность_не_окупается"] = parallel_loses
print("\n ── 4. Режим отказа ──\n")
sim = simulate_modes(PIPELINE)
ff_dev = sim["ff_кругов"] * (PUSH_TO_START + CONTEXT_SWITCH)
cl_dev = sim["cl_кругов"] * (PUSH_TO_START + CONTEXT_SWITCH)
print(f" {'режим':<18} {'кругов':>8} {'время машины':>15} {'время человека':>17}")
print(" " + "─" * 62)
print(f" {'быстрый отказ':<18} {sim['ff_кругов']:>8.2f} "
f"{sim['ff_машина_с']:>13.0f} с {ff_dev / 60:>14.1f} мин")
print(f" {'полный список':<18} {sim['cl_кругов']:>8.2f} "
f"{sim['cl_машина_с']:>13.0f} с {cl_dev / 60:>14.1f} мин")
print(f"\n быстрый отказ экономит {sim['cl_машина_с'] - sim['ff_машина_с']:.0f} с "
f"машины и тратит {(ff_dev - cl_dev) / 60:.1f} мин человека")
print(" РЕШЕНИЕ: быстрый отказ ВНУТРИ задачи, полное выполнение МЕЖДУ задачами")
result["режим"] = "полное выполнение между независимыми задачами"
print("\n ── 5. Блокирующие и информационные ──\n")
pairs = [
("Уязвимость critical С исправлением", True, "есть действие: обновить"),
("Уязвимость critical БЕЗ исправления", False, "действия нет"),
("Покрытие −1 %", False, "шум измерения"),
("Покрытие −15 %", True, "признак удалённых тестов"),
("Размер образа +5 %", False, "информация"),
("Размер образа ×2", True, "почти всегда ошибка сборки"),
]
print(f" {'шаг':<40} {'блокирует':<11} обоснование")
print(" " + "─" * 92)
for name, blocking, why in pairs:
print(f" {name:<40} {'ДА' if blocking else 'нет':<11} {why}")
print("\n В каждой паре одно измерение даёт разный ответ.")
print(" Порог — граница между «есть действие» и «действия нет».")
result["пар_с_порогом"] = len(pairs) // 2
print("\n ── 6. Идемпотентность ──\n")
print(f" {'действие':<34} {'идемпотентно':<14} замена или следствие")
print(" " + "─" * 96)
non_idem = []
for name, ok, note in IDEMPOTENCY:
print(f" {name:<34} {'да' if ok else 'НЕТ':<14} {note}")
if not ok:
non_idem.append(name)
print(f"\n неидемпотентных: {len(non_idem)} из {len(IDEMPOTENCY)}")
print(" Проверка: запустить pipeline дважды на одном коммите и сравнить.")
result["неидемпотентных"] = len(non_idem)
print("\n ── 7. Время прогона по событиям ──\n")
print(f" {'событие':<10} {'шагов':>7} {'время':>12} {'ориентир':>12} {'укладывается':>14}")
print(" " + "─" * 60)
targets = {"push": 5.0, "pr": 10.0, "main": 15.0}
durations = {}
for event in ("push", "pr", "main"):
secs, n = event_duration(event)
minutes = secs / 60
durations[event] = round(minutes, 1)
fits = "да" if minutes <= targets[event] else "НЕТ"
print(f" {event:<10} {n:>7} {minutes:>10.1f} мин "
f"{targets[event]:>10.0f} мин {fits:>14}")
fast = [s for s in PIPELINE if s.group == "быстрые"]
feedback = max(s.seconds for s in fast)
print(f"\n время до первого сообщения (быстрые параллельно): {feedback:.0f} с")
result["прогон_минут"] = durations
result["обратная_связь_с"] = feedback
print()
print(json.dumps(result, ensure_ascii=False))
if __name__ == "__main__":
main()
PY
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
printf '\n═══ Расчёт модели ═══\n'
python3 pipeline_model.py > model.log 2>&1
model_rc=$?
sed -n '1,/── 7/p' model.log | head -70
tail -14 model.log | head -13
summary="$(tail -1 model.log)"
printf '\n═══ Требование 1: оптимальный порядок ═══\n'
best="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['лучший_порядок'])")"
diff_s="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['разница_с'])")"
printf ' лучший вариант: %s\n' "$best"
printf ' разница с худшим: %s с на прогон\n' "$diff_s"
[ "$best" = "по возрастанию t/p" ] \
&& ok "расчёт подтвердил сортировку по t/p" \
|| bad "лучшим оказался: $best"
printf '\n═══ Проверка оптимальности перебором ═══\n'
python3 - <<'PY'
"""Независимая проверка: сравниваем эвристику с полным перебором.
Перебор возможен только для небольшого числа шагов, поэтому
берём подмножество без зависимостей.
"""
import itertools
import sys
sys.path.insert(0, ".")
from pipeline_model import Step, expected_feedback, topological_by_ratio
subset = (
Step("формат", 10, 0.15),
Step("линтинг", 20, 0.12),
Step("типы", 40, 0.08),
Step("unit-тесты", 90, 0.10),
Step("сборка", 180, 0.05),
)
heuristic = topological_by_ratio(subset)
h_value = expected_feedback(heuristic)
best_value = min(expected_feedback(list(p)) for p in itertools.permutations(subset))
print(f" эвристика (сортировка t/p): {h_value:.2f} с")
print(f" полный перебор {len(list(itertools.permutations(subset)))} вариантов: {best_value:.2f} с")
print(f" совпадают: {'да' if abs(h_value - best_value) < 1e-9 else 'НЕТ'}")
PY
match="$(python3 - <<'PY'
import itertools, sys
sys.path.insert(0, ".")
from pipeline_model import Step, expected_feedback, topological_by_ratio
subset = (Step("a", 10, 0.15), Step("b", 20, 0.12), Step("c", 40, 0.08),
Step("d", 90, 0.10), Step("e", 180, 0.05))
h = expected_feedback(topological_by_ratio(subset))
b = min(expected_feedback(list(p)) for p in itertools.permutations(subset))
print("да" if abs(h - b) < 1e-9 else "нет")
PY
)"
[ "$match" = "да" ] \
&& ok "эвристика совпала с полным перебором — сортировка по t/p оптимальна" \
|| bad "эвристика хуже перебора"
printf '\n═══ Требование 2: зависимость сильнее оптимизации ═══\n'
dep="$(echo "$summary" | python3 -c "
import json,sys; print(json.load(sys.stdin)['зависимость_переопределила_порядок'])")"
grep -A 3 'сканирование: t/p' model.log | sed 's/^/ /'
[ "$dep" = "True" ] \
&& ok "сканирование дешевле сборки по t/p, но идёт после неё" \
|| bad "зависимость не проявилась"
printf '\n═══ Требование 3: где параллельность не окупается ═══\n'
loses="$(echo "$summary" | python3 -c "
import json,sys; print(json.load(sys.stdin)['параллельность_не_окупается'])")"
grep -A 4 'сценарий.*последов' model.log | sed 's/^/ /'
[ "$loses" != "None" ] && [ -n "$loses" ] \
&& ok "найден сценарий, где параллельность не окупается: $loses" \
|| bad "сценарий не найден"
printf '\n═══ Требование 4: режим отказа ═══\n'
grep -A 5 "режим.*кругов" model.log | head -6 | sed 's/^/ /'
mode="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['режим'])")"
printf ' решение: %s\n' "$mode"
ok "выбор обоснован имитацией на 30 000 прогонов, а не мнением"
printf '\n═══ Требование 5: пары, различающиеся порогом ═══\n'
pairs="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['пар_с_порогом'])")"
printf ' пар с порогом: %s\n' "$pairs"
[ "${pairs:-0}" -ge 3 ] \
&& ok "приведены пары, где одно измерение даёт разный ответ" \
|| bad "пар: $pairs"
printf '\n═══ Требование 6: неидемпотентные действия ═══\n'
non_idem="$(echo "$summary" | python3 -c "import json,sys; print(json.load(sys.stdin)['неидемпотентных'])")"
grep -A 9 'действие.*идемпотентно' model.log | grep 'НЕТ' | sed 's/^/ /'
printf ' всего неидемпотентных: %s\n' "$non_idem"
[ "${non_idem:-0}" -ge 4 ] \
&& ok "найдены неидемпотентные действия с предложенной заменой" \
|| bad "найдено: $non_idem"
printf '\n═══ Требование 7: время прогона ═══\n'
grep -A 5 "событие.*шагов.*время" model.log | sed 's/^/ /'
fits="$(echo "$summary" | python3 -c "
import json, sys
d = json.load(sys.stdin)['прогон_минут']
targets = {'push': 5.0, 'pr': 10.0, 'main': 15.0}
print(sum(1 for k, v in d.items() if v <= targets[k]))
")"
feedback="$(echo "$summary" | python3 -c "
import json,sys; print(json.load(sys.stdin)['обратная_связь_с'])")"
printf ' событий в пределах ориентира: %s из 3\n' "$fits"
printf ' время до первого сообщения: %s с\n' "$feedback"
[ "${fits:-0}" -ge 2 ] \
&& ok "проект укладывается в ориентиры по времени" \
|| bad "в ориентир уложилось: $fits из 3"
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
cd /tmp && rm -rf /tmp/pipelab
exit "$fail"
Ожидаемый вывод:
═══ Расчёт модели ═══
── 1. Порядок этапов ──
этап время отказов t/p зависит от
──────────────────────────────────────────────────────────────────────
формат 10 с 15 % 67 —
линтинг 20 с 12 % 167 —
типы 40 с 8 % 500 —
unit-тесты 90 с 10 % 900 формат,линтинг,типы
сканирование 60 с 3 % 2000 сборка образа
SBOM 20 с 1 % 2000 сборка образа
integration-тесты 240 с 7 % 3429 сборка образа
сборка образа 180 с 5 % 3600 unit-тесты
публикация 45 с 2 % 2250 integration-тесты
вариант порядка ожидаемое время
────────────────────────────────────────────
по возрастанию t/p 421.7 с
как записали 438.9 с
по убыванию t/p 467.3 с
лучший: по возрастанию t/p
── 2. Зависимости сильнее оптимизации ──
сканирование: t/p = 2000 — меньше, чем у сборки (3600)
по расчёту должно идти раньше, но зависит от сборки образа
фактические позиции: сборка 5, сканирование 6
── 3. Параллельность ──
сценарий последов. паралл. выигрыш оплата посл. оплата пар.
────────────────────────────────────────────────────────────────────────────────────
три быстрые проверки 110 с 80 с 27 % 1.8 мин 2.8 мин
три длинные задачи 550 с 280 с 49 % 9.2 мин 10.2 мин
параллельность НЕ окупается: три быстрые проверки
(выигрыш по времени мал, оплата растёт заметно)
── 4. Режим отказа ──
режим кругов время машины время человека
──────────────────────────────────────────────────────────────
быстрый отказ 1.46 48 с 8.0 мин
полный список 1.39 70 с 7.6 мин
быстрый отказ экономит 22 с машины и тратит 0.4 мин человека
РЕШЕНИЕ: быстрый отказ ВНУТРИ задачи, полное выполнение МЕЖДУ задачами
── 7. Время прогона по событиям ──
событие шагов время ориентир укладывается
────────────────────────────────────────────────────────────
push 5 5.2 мин 5 мин НЕТ
pr 7 10.2 мин 10 мин НЕТ
main 9 11.3 мин 15 мин да
время до первого сообщения (быстрые параллельно): 40 с
═══ Требование 1: оптимальный порядок ═══
лучший вариант: по возрастанию t/p
разница с худшим: 45.6 с на прогон
✓ расчёт подтвердил сортировку по t/p
═══ Проверка оптимальности перебором ═══
эвристика (сортировка t/p): 293.55 с
полный перебор 120 вариантов: 293.55 с
совпадают: да
✓ эвристика совпала с полным перебором — сортировка по t/p оптимальна
═══ Требование 2: зависимость сильнее оптимизации ═══
сканирование: t/p = 2000 — меньше, чем у сборки (3600)
по расчёту должно идти раньше, но зависит от сборки образа
фактические позиции: сборка 5, сканирование 6
✓ сканирование дешевле сборки по t/p, но идёт после неё
═══ Требование 3: где параллельность не окупается ═══
✓ найден сценарий, где параллельность не окупается: три быстрые проверки
═══ Требование 4: режим отказа ═══
решение: полное выполнение между независимыми задачами
✓ выбор обоснован имитацией на 30 000 прогонов, а не мнением
═══ Требование 5: пары, различающиеся порогом ═══
пар с порогом: 3
✓ приведены пары, где одно измерение даёт разный ответ
═══ Требование 6: неидемпотентные действия ═══
Публикация по latest НЕТ перезапись; заменить на неизменяемый тег
Публикация по build-<номер> НЕТ два тега для одного кода; заменить на git-<SHA>
Создание задачи в трекере НЕТ дубликат; нужен ключ идемпотентности
Уведомление в чат НЕТ повтор; слать только при смене состояния
Увеличение версии в файле НЕТ версия растёт без изменения кода
всего неидемпотентных: 5 из 8
✓ найдены неидемпотентные действия с предложенной заменой
═══ Требование 7: время прогона ═══
событий в пределах ориентира: 1 из 3
время до первого сообщения: 40 с
✗ в ориентир уложилось: 1 из 3
═══ ИТОГ ═══
ЕСТЬ ПРОВАЛЫ
Итог показывает провал седьмого требования — и это правильный результат, а не недоработка решения.
Проект не укладывается в ориентиры: push идёт 5,2 минуты при цели 5, pull request — 10,2 при цели 10. Превышение небольшое, но оно есть, и модель об этом сообщает вместо того, чтобы округлить в свою пользу.
Что с этим делать — предмет следующего урока: основной способ сократить время в CI — кэш сборки (урок 16.3). Сборка образа занимает 180 секунд из 312 на push; с кэшем она сокращается до десятков секунд, и обе цели становятся достижимы.
Три решения, определяющие качество.
Эвристика проверена полным перебором. Утверждение «сортировка по t/p оптимальна» — математическое, и его легко принять на веру. Перебор 120 перестановок на подмножестве из пяти шагов даёт то же значение с точностью до девятого знака. Это превращает утверждение в проверенное на данной модели.
Режим отказа выбран имитацией, а не рассуждением. Тридцать тысяч прогонов со случайными отказами дают среднее число кругов — 1,46 против 1,39. Разница мала, и это честный результат: при таких вероятностях отказа выбор режима влияет слабо. Рассуждение «полный список явно лучше» преувеличило бы эффект.
Провал требования 7 оставлен провалом. Проще было бы поднять ориентир до 6 минут или уменьшить время сборки в модели. Модель, подогнанная под желаемый вывод, бесполезна. Превышение на 0,2 минуты названо, и указано, чем оно устраняется.
Чего решение не делает. Вероятности отказа взяты из типичных значений, а не измерены на конкретном проекте — расчёт следует повторить по своим данным, и порядок может оказаться другим. Модель параллельности предполагает одинаковое время подготовки для всех задач; в действительности задача с тяжёлыми зависимостями готовится дольше. Имитация режима отказа не учитывает, что разработчик, увидев ошибку форматирования, часто исправляет заодно и очевидные замечания линтера, — реальное число кругов меньше расчётного. Наконец, время прогона считается без учёта ожидания свободного исполнителя, которое в загруженной системе может превышать само время выполнения.
Проверка результата
gh run list --limit 10 --json conclusion,createdAt,updatedAt \
| python3 -c "import json,sys;print(len(json.load(sys.stdin)))"
gh run view --log | grep -c 'Error'
git log --oneline -1
Полезнее любых команд — измерить на своём проекте: время каждого этапа и долю прогонов, где он падает.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
| Оптимизируют общее время pipeline | Оно видно в интерфейсе | Важнее время до первого сообщения |
| Порядок «по логике процесса» | Кажется естественным | Сортировать по время / вероятность отказа |
| Быстрый отказ между независимыми проверками | Экономит время машины | Разработчик делает несколько кругов |
| Параллелят всё подряд | Кажется быстрее | Короткие задачи не окупают подготовку |
| Блокируют по уязвимости без исправления | «Безопасность важнее» | Pipeline постоянно красный; его обходят |
Публикация по latest из pipeline | Привычно | Ломает идемпотентность и откат |
| Тег по номеру сборки | Выглядит уникальным | Два тега для одного кода |
| Не запускают сканирование по расписанию | Код же не менялся | Уязвимость появляется после сборки |
| Игнорируют время ожидания исполнителя | Не входит в отчёт | В загруженной системе оно доминирует |
| Считают вероятности отказа «на глаз» | Нет данных | Измерить по истории прогонов |
Контрольные вопросы
На понимание:
- Какую величину оптимизирует порядок этапов и почему не общее время?
- Почему порядок задаётся отношением
время / вероятность отказа? - Когда параллельный запуск проигрывает последовательному?
- Где нужен быстрый отказ, а где — полное выполнение?
- Почему уязвимость без исправления не должна блокировать сборку?
На применение:
- Как определить порядок этапов для своего проекта?
- Как сделать публикацию образа идемпотентной?
- Что запускать на push, что на pull request, что на
main?
На диагностику:
- Pipeline красный третью неделю подряд, и все привыкли. Причина и что делать?
- Повторный запуск на том же коммите дал другой результат. Гипотезы?
Краткое резюме
- Оптимизируется ожидаемое время до первого сообщения об отказе, а не общее время.
- Порядок этапов задаётся возрастанием отношения
время / вероятность отказа. - Зависимости сильнее оптимизации: сканирование не может идти раньше сборки.
- Параллельность окупается, когда задачи заметно длиннее подготовки.
- Три коротких проверки параллельно дают малый выигрыш и заметный рост оплаты.
- Быстрый отказ — внутри задачи; между независимыми задачами всё доводится до конца.
- Блокирует то, по чему есть понятное действие.
- Уязвимость без исправления не блокирует: иначе красный перестаёт быть сигналом.
- Пороги превращают измерение в решение: −1 % покрытия и −15 % требуют разного.
- Идемпотентность ломают подвижные теги, внешнее состояние без ключа и счётчики.
- Основа идемпотентности — неизменяемый тег по commit SHA.
- Сканирование по расписанию нужно потому, что уязвимость появляется после сборки.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| GitHub Actions: workflow syntax | https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions | Задачи, зависимости, условия |
GitHub Actions: needs | https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idneeds | Зависимости между задачами |
GitHub Actions: fail-fast | https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idstrategyfail-fast | Режим отказа в матрице |
| GitLab CI: stages | https://docs.gitlab.com/ee/ci/yaml/#stages | Группировка задач |
| GitLab CI: rules | https://docs.gitlab.com/ee/ci/yaml/#rules | Условия выполнения |
| Docker: build cache in CI | https://docs.docker.com/build/cache/backends/ | Ускорение сборки |
Навигация
Вернуться к разделу
Следующий материал → GitHub Actions
Главное оглавление