Конвейер как платформенный сервис: общее против «у каждого своё»
Первая сцена. Компания на тридцать продуктовых команд, около двухсот пятидесяти инженеров. Приходит требование: все образы, попадающие в прод, должны быть подписаны, к каждому нужен SBOM, базовый образ — только из внутреннего реестра. Платформенная команда садится считать объём работ и обнаруживает пятнадцать разных конвейеров: четыре поколения GitLab CI, два Jenkins (один без владельца), самописный скрипт на Python, который запускается с ноутбука тимлида, и три репозитория, где сборка живёт в Makefile, а деплой — в чужом аккаунте облака. Одиннадцать недель ходьбы по командам. Четыре команды к дедлайну не успевают, им выписывают исключение «до конца квартала», исключение живёт два года.
Вторая сцена, та же компания через год. Платформа сделала вывод и построила единый конвейер: один шаблон, все на нём. Шаблон — тысяча двести строк YAML, сорок входных параметров, среди них skip_tests, legacy_mode, custom_dockerfile_path и extra_kaniko_args. Флаг skip_tests: true стоит у семи команд, причём у трёх — со времён «мы временно, до конца спринта». Прогон на пул-реквесте занимает сорок семь минут. Три команды тихо завели свои workflow, потому что «в общий не влезаем», и на вопрос «почему не пришли» отвечают: приходили, ответ был «через два спринта».
Обе сцены — про одну и ту же ошибку в постановке вопроса. Вопрос «общий конвейер или у каждого свой» неразрешим, потому что он неправильный. Правильный: что именно в конвейере общее по своей природе, а что принадлежит команде и никогда не станет общим. Первая сцена — цена того, что общими не сделали инварианты. Вторая — цена того, что общими попытались сделать шаги.
Эта глава — про конвейер как платформенный сервис: у него есть пользователи, SLO, стоимость и метрики принятия. Про технику CI как таковую — конвейеры и их основы, современные CI-платформы, стратегии выката и тесты в CI в соседних треках; здесь мы не пересказываем, как настроить кэш, а разбираем, кто им владеет, во сколько он обходится и почему команды уходят.
Почему конвейер — самый заметный сервис платформы
У платформы много поверхностей, но конвейер бьёт по нервам сильнее всех по трём причинам.
Частота. С каталогом инженер сталкивается раз в квартал, с шаблоном сервиса — раз в полгода, с конвейером — по десять раз в день. Любой дефект здесь умножается на частоту. Тридцать секунд лишнего ожидания в шаге, который выполняется шестьсот раз в день, — это пять часов в сутки по компании.
Позиция в потоке. Конвейер стоит ровно между «я закончил» и «это работает у пользователей». Когда он сломан, останавливается не одна задача, а вся поставка: невозможно ни выкатить фичу, ни выкатить исправление того, что уже сломалось. Отсюда правило, которое стоит проговорить до первого инцидента: недоступность конвейера — это инцидент того же класса, что и недоступность прода, потому что она блокирует восстановление прода. Механика инцидентов и дежурств — в треке SRE.
Видимость авторства. Тесты пишет команда, а красный крестик показывает платформа. Из этого рождается вечный спор «вы всё время падаете» против «это ваши флаки», и без механизма атрибуции этот спор не разрешается никогда — к нему вернёмся отдельно.
Есть и четвёртое, менее очевидное: конвейер — единственное место, через которое гарантированно проходит каждое изменение. Это делает его соблазнительным местом для всего: проверок безопасности, лицензионного аудита, сбора метрик, согласований, отчётности перед регулятором. Соблазн понятен, и он же — главный механизм превращения золотого пути в забор, о котором шла речь в главе про золотой путь. Каждая новая обязательная проверка кажется бесплатной для того, кто её добавляет, и стоит минут для всех, кто через неё проходит.
Разделение владения: субстрат, инварианты, содержание, маршрут
Полезно разложить конвейер на четыре разных по природе слоя. Они по-разному масштабируются, по-разному ломаются и требуют разных владельцев.
- Субстрат — раннеры, очередь, сеть, кэш, реестр артефактов, выдача секретов и доступов. Это инфраструктура с эффектом масштаба: содержать её тридцатью командами по отдельности бессмысленно и дорого.
- Инварианты — что должно быть истинно про любой артефакт, попадающий в прод: подписан, есть SBOM, базовый образ из разрешённых, известен владелец, есть запись в каталоге. Это про гарантии компании, не про удобство.
- Содержание — что именно собирается и как тестируется. Это предметная область команды. Платформа, которая решает, какие юнит-тесты запускать у сервиса платежей, взяла на себя ответственность, которую не сможет нести.
- Маршрут — через какие окружения и в каком порядке едет изменение, кто и когда одобряет, как откатывается. Здесь общее и частное перемешаны: сама механика выката общая, а политика «кто нажимает» — командная, потому что дежурит команда.
Из этой раскладки следует главный технический вывод главы, который спасает от обеих сцен во вступлении:
Инварианты нужно проверять на приёмке, а не шагом конвейера.
Если «образ подписан» — это шаг sign в общем шаблоне, то любая команда, ушедшая со своего пути, автоматически теряет гарантию, и вам приходится делать конвейер обязательным, то есть забором. Если «образ подписан» — это правило на входе в реестр и в кластер (admission-контроль), то команде можно разрешить собственный конвейер: гарантия проверяется там, где артефакт применяется, а не там, где он произведён. Свобода на входе, строгость на выходе.
# Инвариант, живущий на приёмке, а не в чужом конвейере.
# Kyverno: в прод-namespace попадают только подписанные образы из внутреннего реестра.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce # блокируем, а не только предупреждаем
rules:
- name: verify-signature
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["prod-*"]
verifyImages:
- imageReferences: ["registry.internal/*"]
attestors:
- entries:
- keyless:
# доверяем только identity нашего CI, а не «кому угодно из sigstore»
subject: "https://gitlab.internal/platform/ci/*"
issuer: "https://gitlab.internal"
Такое правило работает одинаково для образа, собранного общим конвейером, и для образа, собранного командой самостоятельно. Именно это превращает «у каждого своё» из угрозы в допустимый режим. Документация: Kyverno verifyImages, Sigstore keyless signing, уровни цепочки поставки — SLSA.
Таблица владения, которую полезно согласовать письменно и повесить рядом с документацией конвейера:
| Слой | Кто владеет | Кто чинит в 3 часа ночи | Что происходит при неправильном владельце |
|---|---|---|---|
| Раннеры, очередь, кэш | платформа | платформа | у каждой команды свой пул: пятикратная переплата, никто не обновляет образы раннеров |
| Реестр, подпись, SBOM | платформа | платформа | пятнадцать конвейеров и одиннадцать недель на внедрение подписи |
| Выдача доступов в облако | платформа | платформа | долгоживущие ключи в переменных CI, компрометация без срока давности |
| Шаги сборки | команда | команда | платформа отлаживает чужой Dockerfile и становится узким местом |
| Набор и пороги тестов | команда | команда | платформа отвечает за чужой флаки-тест, при этом не может его исправить |
| Инварианты артефакта | платформа | платформа | инвариант держится на дисциплине, то есть не держится |
| Маршрут выката | общий механизм, командная политика | команда | «одобряет платформа» превращает её в отдел согласований |
| Классификация падений | платформа | платформа | спор «ваши флаки против вашего CI» не заканчивается никогда |
Отдельно проговорите границы поддержки. Формулировка, которая работает: «платформа отвечает за то, что ваш конвейер запустился, получил ресурсы, кэш, секреты и опубликовал артефакт; платформа не отвечает за то, что ваш код собирается и тесты зелёные». Без этой строчки платформенная команда за полгода превращается в общий отдел отладки чужих сборок — самый частый способ израсходовать её и не получить ничего. Это прямое следствие разговора о когнитивной нагрузке и границах в Team Topologies.
Три способа сделать общее — и почему выигрывает композиция
Общее можно раздавать четырьмя разными способами, и они отличаются на порядок по цене владения и обратимости.
одинаково всем
или похоже, но по-разному?"} B -->|"буквально одинаково"| C{"Должно ли обновляться
централизованно
без участия команды?"} B -->|"похоже, но детали разные"| D["Компонент с узким контрактом
+ точки расширения.
Команда собирает свой конвейер
из ваших кубиков"] C -->|"да, это инвариант"| E["Не в шаблон конвейера,
а в приёмку:
реестр, admission, политика"] C -->|"нет, достаточно
версии и обновления PR-ом"| F["Версионируемый компонент
с закреплённой версией
+ автообновление ботом"] D --> G{"Число входов
растёт с каждой
новой командой?"} F --> G G -->|"да"| H["Признак объединения
трёх разных задач.
Расщепить по типу нагрузки"] G -->|"нет, стабильно"| I["Здоровый общий компонент"] A --> J{"Нужно один раз
дать старт,
дальше не вмешиваться?"} J -->|"да"| K["Скаффолд: сгенерировали
конвейер в репозиторий
и разошлись"]
| Форма | Что связывает вас с пользователем | Цена владения | Обратимость | Где ломается |
|---|---|---|---|---|
| Скаффолд, генерация при создании сервиса | момент генерации | низкая | высокая: файлы у команды | дрейф — через год тридцать разных копий |
| Библиотека шагов, компоненты, составные действия | версия, закреплённая командой | средняя | средняя: можно отцепиться | требует настоящего версионирования и changelog |
| Единый монолитный шаблон с параметрами | каждый прогон | высокая | низкая | растёт флагами, ломает всех сразу |
| Генерация конвейера из контракта сервиса | каждый прогон | очень высокая | очень низкая | отлаживать приходится сгенерированный YAML |
| Свой CI-движок | всё | запредельная | никакой | оправдано в единицах компаний мира |
Практическое правило: композиция вместо наследования. Монолитный шаблон — это наследование: команда наследует всё поведение и настраивает его флагами. Библиотека компонентов — композиция: команда берёт нужные кубики и складывает свой конвейер. Наследование даёт быстрый старт и медленную смерть от флагов; композиция требует дисциплины в проектировании контрактов и живёт годами.
Плохо — монолит, у которого растёт число входов:
# Шаблон платформы, версия «мы уже не помним»
# Признаки болезни видны без чтения кода: имена команд внутри общего шаблона
.build-and-deploy:
variables:
LANGUAGE: ""
BUILD_TOOL: ""
SKIP_TESTS: "false"
RUN_E2E: "false"
CUSTOM_DOCKERFILE: ""
EXTRA_KANIKO_ARGS: ""
LEGACY_DEPLOY: "false"
# ... ещё 33 переменные
script:
- if [ "$LANGUAGE" = "go" ]; then make build; fi
- if [ "$LANGUAGE" = "java" ] && [ "$BUILD_TOOL" = "gradle" ]; then ./gradlew build; fi
- if [ "$CI_PROJECT_NAME" = "checkout" ]; then ./hacks/checkout-only.sh; fi # приговор
Хорошо — узкие версионируемые компоненты, которые команда складывает сама:
# .gitlab-ci.yml продуктовой команды: конвейер собран из кубиков платформы
include:
- component: gitlab.internal/platform/ci/go-build@2.4.1
inputs: { go_version: "1.23", test_command: "make test" }
- component: gitlab.internal/platform/ci/container-image@3.1.0
inputs: { context: ".", base: "registry.internal/base/distroless:2024-11" }
- component: gitlab.internal/platform/ci/deploy@1.9.2
inputs: { service: "checkout", strategy: "canary" }
# Свой шаг команды — рядом, без разрешения платформы и без форка компонента
contract-tests:
stage: test
needs: ["go-build"]
script: ["make pact-verify"]
Версии закреплены явно, обновления приезжают ботом (Renovate умеет это и для CI-компонентов), ломающее изменение обязано сопровождаться кодмодом от того, кто ломает. Аналогично устроены переиспользуемые workflow в GitHub Actions, компоненты в GitLab и shared libraries в Jenkins. Важная деталь, которую забывают: компонент без закрепления версии — это не компонент, а глобальная переменная; ссылка на @main означает, что вы ломаете тридцать команд одним мержем.
Как распознать, что общий шаблон уже не общий
Диагноз ставится по данным, а не по ощущениям. Соберите фактически заданные входы по всем репозиториям и посчитайте:
"""Диагностика общего шаблона конвейера: живы ли его входы и есть ли вообще общий случай.
На вход — по одному словарю фактически заданных входов на репозиторий."""
from collections import Counter
from typing import Iterable, Mapping, Any
def flag_report(repos: Iterable[Mapping[str, Any]], declared: list[str]) -> dict:
used = Counter() # сколько репозиториев задаёт каждый вход
combos = Counter() # какие комбинации входов встречаются
n = 0
for cfg in repos:
n += 1
keys = tuple(sorted(k for k in cfg if k in declared))
combos[keys] += 1
used.update(keys)
dead = [k for k in declared if used[k] == 0] # никто не задаёт — удалять
single = [k for k, c in used.items() if c == 1] # ровно один пользователь — это хук, а не флаг
top_combo, top_count = combos.most_common(1)[0] if combos else ((), 0)
return {
"репозиториев": n,
"объявлено входов": len(declared),
"мёртвых входов": dead,
"входов ради одной команды": single,
"уникальных комбинаций": len(combos),
"доля самой частой комбинации": round(top_count / n, 2) if n else 0.0,
}
Время O(n·k) по числу репозиториев и объявленных входов, память O(k + c), где c — число уникальных комбинаций. Читается результат так:
{'репозиториев': 96, 'объявлено входов': 40, 'мёртвых входов': ['legacy_mode', 'extra_kaniko_args', ...],
'входов ради одной команды': ['custom_dockerfile', 'skip_lint', 'jvm_agent'],
'уникальных комбинаций': 71, 'доля самой частой комбинации': 0.09}
Семьдесят одна уникальная комбинация на девяносто шесть репозиториев и самая популярная — девять процентов. Общего случая нет: под одним шаблоном живут три-четыре разные задачи. Это ровно тот механизм объединения полей, который разобран в главе про абстракции: одна абстракция поверх трёх разных потребностей всегда вырождается в объединение всех полей. Здоровая картина выглядит иначе — пять-восемь комбинаций, самая частая покрывает 40–60 %.
Что делать с диагнозом. Не «переписать шаблон», а расщепить по типам нагрузки: HTTP-сервис, батч по расписанию, консьюмер очереди, библиотека, фронтенд-приложение, мобильная сборка. У каждого свой узкий контракт и своя честная документация; общая реализация — внизу, в компонентах субстрата (кэш, публикация, подпись). Общая реализация дешевле общего интерфейса. Мёртвые входы удаляются сразу, входы «ради одной команды» превращаются в объявленную точку расширения, доступную всем.
Конвейер — это прод: SLO, атрибуция, дежурство
Платформенная команда, которая не измеряет свой конвейер, узнаёт о его состоянии из мемов в общем чате. Конвейер — сервис, у него должны быть SLI и SLO по тем же правилам, что описаны в главе про SLI и SLO, и бюджет ошибок, который останавливает разработку новых фич платформы.
| SLI | Определение | Ориентир для старта | Почему именно так |
|---|---|---|---|
| Ожидание раннера | p95 времени от постановки в очередь до старта job | < 30 с в рабочие часы | ожидание — чистая потеря, оно не производит ничего |
| Длительность PR-прогона | p50 и p95 успешных прогонов на пул-реквесте | p50 < 10 мин, p95 < 20 мин | 10 минут — граница, после которой человек уходит из контекста |
| Доступность выката | доля рабочего времени, когда команда может выкатить в прод | 99,5 % за 30 дней | считается по возможности выкатиться, а не по аптайму сервера CI |
| Падения по вине платформы | доля прогонов, упавших не из-за кода команды | < 1 % | это и есть тот самый спорный показатель, см. ниже |
| Флаки | доля прогонов, зелёных при перезапуске без изменений кода | < 1,5 % | владелец — команда, но измерять обязана платформа |
| Время восстановления конвейера | MTTR инцидентов самого CI | < 30 мин в рабочие часы | остановка конвейера блокирует и починку прода |
Обратите внимание на строчку про доступность. Считать её как аптайм сервера — самый распространённый способ обмануть себя: сервер жив, а выкатиться нельзя, потому что реестр не отдаёт слои, кончились раннеры или протух токен. Правильный знаменатель — рабочие минуты, в которые команда, захотевшая выкатиться, смогла бы это сделать. Проверяется синтетическим прогоном: раз в пять минут платформа сама собирает и выкатывает игрушечный сервис по полному маршруту и записывает результат.
Атрибуция падений — обязательный механизм, а не приятное дополнение
Пока падения не классифицированы, любой разговор о качестве конвейера — это обмен впечатлениями. Классификатор должен быть автоматическим, встроенным в конвейер и работать по одному жёсткому правилу: неопознанное падение по умолчанию относится на платформу. Правило кажется несправедливым и именно поэтому работает: оно создаёт у платформы стимул улучшать распознавание, а не прятать неудобное в корзину «прочее».
"""Классификация причины падения прогона. Порядок правил важен: первое совпадение выигрывает."""
PLATFORM = "platform" # виновата платформа: инфраструктура, кэш, реестр, доступы
USER = "user" # виноват код или конфигурация команды
FLAKY = "flaky" # тот же коммит зелёный при перезапуске
EXTERNAL = "external" # внешняя зависимость: чужой реестр, чужой API
RULES = [
(PLATFORM, ("runner system failure", "no space left on device", "context deadline exceeded",
"error dialing backend", "failed to pull image from registry.internal",
"oidc token exchange failed", "cache upload failed")),
(EXTERNAL, ("dial tcp: lookup registry-1.docker.io", "429 too many requests",
"npm ERR! network", "proxy.golang.org: 502")),
(USER, ("compilation error", "test failed", "lint:", "policy violation:",
"manifest validation failed")),
]
def classify(log_tail: str, rerun_same_sha_passed: bool | None) -> str:
"""log_tail — последние строки лога; rerun_same_sha_passed — известен ли исход перезапуска."""
if rerun_same_sha_passed is True:
return FLAKY # тот же код, другой результат
low = log_tail.lower()
for label, needles in RULES:
if any(n.lower() in low for n in needles):
return label
return PLATFORM # неопознанное — на себя, это дисциплинирует
Событие с результатом отправляйте в общий поток телеметрии по семантическим соглашениям OpenTelemetry для CI/CD — чужая схема дешевле своей, и она сразу совместима с расчётом DORA-метрик и с инструментами вроде Four Keys. Минимальный набор атрибутов: идентификатор конвейера и задания, сервис, тип нагрузки, версия компонента платформы, длительность по шагам, метка причины падения.
Жизненный цикл прогона с точки зрения атрибуции удобно держать перед глазами:
даже если формально прогон не упал Running --> Passed Running --> Failed state Failed { [*] --> Unclassified Unclassified --> UserFault: сигнатура кода или конфигурации Unclassified --> PlatformFault: сигнатура инфраструктуры Unclassified --> ExternalFault: сигнатура внешней зависимости Unclassified --> PlatformFault: не распознано - по умолчанию на платформу } Failed --> Rerun: перезапуск того же коммита Rerun --> Flaky: зелёный без изменений кода Rerun --> Confirmed: снова красный Flaky --> Quarantine: превышена квота нестабильности Quarantine --> [*]: тест выключен, заведена задача владельцу Passed --> [*] Confirmed --> [*]
Карантин флаки-тестов — отдельная тема, где платформа и команда должны разойтись правильно: платформа даёт механизм (детект, статистика, автоматический карантин, отчёт владельцу), команда чинит. Если карантин будет чинить платформа, у команд исчезнет стимул. Google описывал свою механику и порядок цифр в «Flaky Tests at Google»; практическая часть — в главе про тесты в CI.
Очередь, мощность и деньги
Раннеры — общий ограниченный ресурс, а значит, в системе есть очередь, и у очереди есть свойства, о которых надо думать заранее.
Шумный сосед. Одна команда с матрицей в двести job на каждый коммит съедает пул, остальные двадцать девять ждут. Каждая команда при этом действует разумно со своей колокольни — ускоряет собственную обратную связь; сумма разумных локальных решений даёт общую деградацию. Это учебный случай локальной оптимизации: пока метрика есть только у команды, оптимизировать общий ресурс некому. Лечится квотами по группам и отдельными классами обслуживания, а не просьбами в чате. Разумный минимум — три класса: hotfix (приоритетный, всегда есть свободная ёмкость), main (сборки основной ветки, деплой), pr (пул-реквесты, вытесняемые). Прогон на пул-реквесте, вытесненный ради хотфикса, — это правильное поведение системы, и это надо объявить, а не делать молча.
Пиковость. Нагрузка на CI повторяет рабочий день и особенно — вечер вторника и четверга перед релизом. Ёмкость под пик, оплачиваемая круглосуточно, — типичная переплата; автоскейлинг раннеров (Actions Runner Controller, autoscaling executors в GitLab) решает это, но добавляет холодный старт, который надо мерить и держать в пределах десятков секунд.
Экономика ожидания. Это тот самый счёт, без которого платформа превращается в налог. Посчитаем честно на нашей компании: шестьсот прогонов в день, сокращение p50 с 47 до 16 минут.
Триста десять часов календарного ожидания в сутки — цифра, которую нельзя предъявлять как экономию, и это принципиальный момент честного счёта. Инженер, ждущий сборку, не сидит без дела: он переключается на ревью, читает задачу, отвечает в чате. Но переключение контекста не бесплатно, а самое дорогое ожидание — то, что стоит между «нашёл причину инцидента» и «исправление в проде». Рабочая практика: берите консервативный коэффициент блокирующего ожидания 0,3–0,5 и обосновывайте его вслух. Триста десять часов × 0,4 ≈ 124 часа в сутки, примерно 2700 часов в месяц, порядка шестнадцати человеко-месяцев. Против этого ставится честная стоимость: рост счёта за раннеры и кэш, скажем, с 6000 до 11 000 USD в месяц, плюс полтора инженера платформы на поддержку. Сделка очевидно хорошая — но она стала очевидной только после того, как обе стороны названы. Тот же расчёт при трёх продуктовых командах и шестидесяти прогонах в день даёт экономию, не покрывающую и половины ставки одного инженера, — и это правильный повод не строить платформенный конвейер, а обойтись общим шаблоном (подробно — в главе про небольшую компанию).
Приоритизировать улучшения по ощущениям бессмысленно; полезнее разложить их по частоте и боли:
Первым всегда идёт ожидание: пул раннеров, кэш, параллельность. Это дёшево и мгновенно заметно. Отбор тестов по изменённым файлам и удалённое кэширование сборки (Bazel remote caching и аналоги) дают больший выигрыш, но требуют дисциплины в описании зависимостей — это квартальная работа, а не спринт. Собственный портал запусков и собственный язык описания конвейеров стоят дорого и почти не влияют на поток — про них дальше.
CD как сервис: кто нажимает кнопку и где живут права
Continuous delivery добавляет к конвейеру то, чего нет в сборке: необратимость. Отсюда три вещи, которые платформа обязана дать как сервис, и одна, которую обязана не забрать.
Даёт: единый механизм выката (одна и та же механика для канареек, поэтапного расширения и отката), артефакт как единицу поставки (собрали один раз — двигаем по окружениям, а не пересобираем на каждом), автоматическую проверку после выката (сравнение показателей канарейки с базой). Не забирает: решение о выкате. Кнопку нажимает команда, потому что дежурит команда. Платформа, которая согласовывает релизы, становится отделом согласований и первым узким местом; подробности про стратегии — devops про CD и SRE про безопасность релиза.
Отдельный вопрос — где живут прод-права. Классическая ошибка: у CI есть постоянные ключи с правами администратора в облаке, потому что «иначе неудобно деплоить». Это делает конвейер самой привлекательной мишенью в компании: тот, кто может изменить workflow в любом репозитории, может всё. Здоровая схема: у CI нет постоянных прав вообще, он получает короткоживущий токен через федерацию OIDC под конкретный сервис и конкретное окружение (про OIDC в GitHub Actions), а лучше — вообще не ходит в прод: он публикует артефакт и обновляет декларацию, а выкат делает контроллер внутри кластера, который сам тянет изменения (GitOps-подход). Тогда компрометация CI не даёт прямого доступа в прод. Смежное — управление секретами и безопасность цепочки поставки.
а не в шаге конвейера — правило одно
и для общего конвейера, и для своего Dev->>CI: правка базового образа, повтор CI->>Reg: публикация Reg-->>CD: новый тег доступен Dev->>CD: выкатить canary 5 % CD->>Prod: применить, следить за показателями Prod-->>CD: ошибки 5xx выше базы в 3 раза CD->>CD: автоматический откат CD-->>Dev: откат выполнен, причина и ссылки на дашборд Note over Dev,Plat: платформа не участвовала в решении —
она дала механизм и гарантию Dev->>Plat: эскалация только если сломался сам механизм
Золотой путь в CI и процедура для тех, кому он не подходит
Есть команды, которым общий конвейер не подходит не из вредности, а по существу. Три честных примера, которые встречаются почти в каждой компании:
- Мобильная сборка. Нужны macOS-раннеры, подпись сертификатами Apple, выкладка в TestFlight, прогоны на устройствах. Пересечение с бэкендовым конвейером — примерно ноль, кроме реестра артефактов и секретов.
- ML-обучение. Прогон на GPU идёт четыре часа, артефакт — модель весом в десятки гигабайт, «тесты» — метрики качества на валидационной выборке. Общий шаблон с таймаутом в час не применим в принципе.
- Крупный монорепозиторий фронтенда. Смысл конвейера — в отборе затронутых пакетов и удалённом кэше; общий шаблон, собирающий всё целиком, ухудшает время прогона в разы.
Попытка затащить эти три случая в общий шаблон и есть тот самый механизм появления сорока флагов. Правильная реакция — объявленная процедура съезда, устроенная так же, как в главе про золотой путь, но с конкретикой конвейера:
| Уровень | Что делает команда | Что остаётся обязательным | Что даёт платформа |
|---|---|---|---|
| 0. На пути | компоненты платформы + свои шаги в объявленных точках | всё по умолчанию | полная поддержка, обновления PR-ами |
| 1. Своя сборка | свои шаги сборки и тестов, публикация через компонент платформы | подпись, SBOM, базовый образ, каталог | поддержка публикации и выката, консультации |
| 2. Свой конвейер | всё своё, включая раннеры | инварианты на приёмке: подпись, SBOM, владелец, запись в каталоге, события в телеметрию | доступ к реестру, секретам, OIDC, шаблоны политик |
| 3. Полный выход | своя инфраструктура доставки | только периметр безопасности и учёт расходов | ничего, кроме консультаций; регистрируется в реестре исключений со сроком пересмотра |
Ключевое свойство этой лестницы: спуск на уровень ниже не отключает платформу. Команда со своим конвейером всё равно получает секреты, попадает в каталог, шлёт события в общую телеметрию и видна в метриках DORA. Как только съезд означает потерю всего сразу, вы получаете теневую платформу: свой аккаунт в облаке, свои алерты, никакого аудита. Именно это описано во второй сцене вступления.
Каждый съезд регистрируется: причина, срок пересмотра, владелец со стороны команды. Реестр исключений с нулём записей при тридцати командах означает не порядок, а слепоту. И самое важное для платформы: реестр исключений — это её бэклог. Три команды с одинаковой причиной съезда — это заявка на новый компонент, а не три нарушителя.
Типовые провалы
Обёртка над облачным CI ради обёртки. Платформа делает ci build поверх GitHub Actions с собственным форматом описания. Проверка та же, что для любой абстракции (глава 05): сколько решений пользователь перестал принимать? Если обёртка транслирует те же поля с другими именами — она не убрала ни одного решения и стоит дорого: свой транслятор ошибок (без него пользователь видит чужие сообщения о вашем YAML), отставание от фич апстрима на месяцы, невозможность найти ответ в интернете, потому что термины ваши. Обёртка оправдана ровно тогда, когда за одним полем стоит несколько решений: strategy: canary вместо двухсот строк описания шагов выката — оправдана, image_name вместо image — нет.
Портал запусков, которым никто не пользуется. Инженер живёт в редакторе, терминале и пул-реквесте. Веб-интерфейс, дублирующий кнопку «перезапустить job», проигрывает по всем параметрам той кнопке, что уже есть в пул-реквесте. Портал начинает быть нужен там, где в PR ничего нет: сквозная история артефакта («этот образ собран из какого коммита, кем подписан, в каких окружениях крутится»), кросс-сервисный релиз, откат с одной кнопки, поиск владельца в три часа ночи. Мерьте не просмотры, а действия: если 95 % трафика — «посмотреть статус», вам нужна интеграция в мессенджер и в PR, а не портал. Backstage здесь — фреймворк, который надо собирать, обновлять и сопровождать, а не готовый продукт (что такое Backstage); заводить его ради витрины конвейера обычно не окупается.
Одна абстракция поверх трёх разных потребностей. Разобрано выше через число комбинаций входов. Признак-индикатор, который видно без анализа: в общем шаблоне встречается имя конкретного репозитория или команды.
Конвейер как склад политик. За два года в обязательную часть добавлено четырнадцать проверок: лицензии, SAST, DAST, секреты, зависимости, качество кода, соглашение об именах, отчёт для аудита. Суммарно двадцать минут на каждом пул-реквесте, половина отчётов не читается никем. Лечение: у каждой обязательной проверки должен быть владелец, сформулированный риск и дата пересмотра; тяжёлые проверки уезжают из PR-прогона в ночной или в предрелизный; блокирует только то, что действительно блокирующе. Подробнее — безопасность в конвейере и глава 10.
Платформа чинит чужие тесты. Начинается с доброты («давайте поможем»), заканчивается тем, что двое из шести платформенных инженеров заняты отладкой чужих сборок, а бэклог платформы стоит. Границы поддержки объявляются письменно, флаки уходят в карантин автоматически, задача на исправление заводится владельцу сервиса.
Миграция всех на новый CI без причины и без кодмода. «Мы переезжаем с Jenkins на новый CI» — это отъём двух-трёх недель у каждой из тридцати команд, то есть порядка года чистого инженерного времени. Такая миграция оправдана только с посчитанной выгодой, и правило то же, что в главе про миграции: кто ломает, тот и пишет автоматический перенос. Ручная миграция, разосланная письмом, растягивается на год и оставляет вечный хвост.
Свой CI-движок. Соблазн возникает, когда чужой инструмент не даёт нужного. Оценка честной цены: оркестратор задач, изоляция, кэш, артефакты, UI, права, интеграции, отказоустойчивость — это годы работы отдельной команды, и всё это время вы конкурируете с инструментами, в которые вложены сотни человеко-лет. Компаний, где это оправдано, в мире единицы.
Цена владения инструментами
Никакой инструмент не делает платформу; каждый добавляет обязательства. Полезно называть их вслух до внедрения — фильтром здесь работает эссе Дэна Маккинли «Choose Boring Technology».
| Инструмент | Что реально даёт | Чем платите | Когда оправдан |
|---|---|---|---|
| Управляемый CI (GitHub Actions, GitLab CI) | не содержите control plane, экосистема готовых шагов | лимиты, стоимость минут, синтаксис как форма привязки, сторонние действия как риск цепочки поставки | почти всегда — стартовая точка по умолчанию |
| Jenkins | зрелость, любая интеграция, полный контроль | плагины и их совместимость, обновления, безопасность, дефицит желающих это поддерживать | есть исторический парк и люди, которые его знают |
| Tekton, Argo Workflows | конвейер как объекты Kubernetes, единая модель с остальной платформой | вы становитесь оператором CI: раннеры, хранилище, UI, отладка в терминах CRD | уже есть зрелая экспертиза по Kubernetes и нужен свой control plane |
| Dagger и подобные | одинаковый прогон локально и в CI, конвейер на языке программирования | ещё один слой и язык между инженером и CI, своя отладка | сборка сложная и «работает в CI, не работает локально» — регулярная боль |
| Bazel и удалённый кэш | серьёзное сокращение времени в монорепозитории | описание зависимостей, миграция сборки, выделенные люди | большой монорепозиторий, счёт времени сборки идёт на часы |
| Backstage | каркас портала и каталога | приложение на TypeScript, которое вы собираете и обновляете; плагины разного качества | каталог уже наполняется автоматически, нужна витрина |
Общее правило приёмки инструмента то же, что и для абстракции: назовите, какое пользовательское решение он убирает и во сколько человеко-часов в год обойдётся его сопровождение. Если ответ на первый вопрос — «он современный», сделки нет.
Метрики принятия конвейера
Полнота функций не измеряет ничего. Измеряет принятие — и знаменатель здесь важнее числителя. Требования к метрике здесь ровно продуктовые (метрики в продуктовом треке): она должна меняться от ваших действий, не считаться по собственным данным и не улучшаться от оттока недовольных. Общий подход к метрикам платформы разобран в главе про опыт разработчика; ниже — специфика конвейера.
| Метрика | Как считать | Ловушка знаменателя |
|---|---|---|
| Доля деплоев через платформенный конвейер | события деплоя из кластера и реестра, а не из вашего же CI | если считать по своим прогонам, метрика равна единице по построению |
| Репозиториев со своим конвейером | скан всех репозиториев организации на файлы CI, не ссылающиеся на компоненты | «мы про них не знали» — самый частый источник расхождения |
| Доля прогонов на актуальной версии компонента | телеметрия прогонов по версии | форк компонента выглядит как использование, если смотреть по имени |
| Время до принятия новой версии | медиана дней от релиза компонента до его появления у 80 % команд | среднее прячет хвост из десяти команд, застрявших год назад |
| Тикеты «почини мой пайплайн» | доля от всех обращений в платформу | рост означает, что диагностика непонятна, а не что пользователи глупые |
| Флаг-обходы | число активных skip_* и их возраст |
флаг, включённый «на спринт» год назад, — это ваш долг, а не их |
Скан репозиториев стоит автоматизировать и запускать еженедельно — обход не приходит к вам сам:
# Ищем конвейеры, которые не используют компоненты платформы, и датируем их
for repo in $(gh repo list my-org --limit 1000 --json name -q '.[].name'); do
gh api "repos/my-org/$repo/contents/.github/workflows" --jq '.[].name' 2>/dev/null |
while read -r wf; do
if ! gh api "repos/my-org/$repo/contents/.github/workflows/$wf" --jq '.content' |
base64 -d | grep -q 'my-org/platform-ci/'; then
echo "$repo/$wf — свой конвейер, последнее изменение: $(gh api "repos/my-org/$repo/commits?path=.github/workflows/$wf&per_page=1" --jq '.[0].commit.committer.date')"
fi
done
done
Каждая найденная строка — не нарушение, а заявка на функциональность, которую вам не подали. Разговор начинается с вопроса «что в общем конвейере вам не подошло», а не «почему вы не на платформе».
И главный счёт, который платформенная команда обязана вести сама: сколько чужого времени сэкономлено. Три инженера платформы — это примерно 450 часов в месяц, не пошедших в продукт. Сокращение p50 прогона, снижение доли падений по вине платформы и исчезновение тикетов «выдайте раннер» переводятся в часы других команд по формуле из раздела про экономику. Если такой счёт не ведётся и не показывается, платформа незаметно превращается в налог — и первым это заметит не руководство, а инженер, который заведёт себе свой workflow.
Мини-итог
- Вопрос «общий конвейер или свой» неразрешим. Разрешимый вопрос: что общее по природе — субстрат и инварианты; что своё — содержание шагов и решение о выкате.
- Инварианты проверяются на приёмке (реестр, admission), а не шагом конвейера. Только так съезд с общего пути перестаёт означать выход из гарантий, а путь не превращается в забор.
- Композиция вместо наследования: версионируемые компоненты с закреплёнными версиями и автообновлением, а не один шаблон с флагами. Число входов, растущее с каждой новой командой, — диагноз, а не особенность.
- Общее у трёх разных нагрузок — это общая реализация, а не общий интерфейс. Расщепляйте по типу нагрузки, оставляя общими кэш, публикацию и подпись.
- Конвейер — прод платформы: свои SLI и SLO, синтетический прогон, дежурство, атрибуция падений с правилом «неопознанное на себя».
- Ожидание — самая большая статья расходов конвейера, но считать его в экономию нужно с консервативным коэффициентом и рядом со счётом за раннеры. Обе стороны обязаны быть названы.
- Мобильные, ML- и монорепозиторные сборки не влезают в общий конвейер по существу. Для них нужна процедура съезда с сохранением платформенных гарантий, а не сорок первый флаг.
- Инструменты — Jenkins, Tekton, Bazel, Backstage — имеют цену владения. Называйте, какое решение инструмент убирает и сколько стоит его сопровождение в год.
- Принятие измеряется по знаменателю из внешних источников: события деплоя, скан репозиториев, возраст флагов-обходов. Свой конвейер в чужом репозитории — это заявка на функциональность.
Источники
- GitLab. CI/CD components; GitHub. Reusing workflows; Jenkins. Shared libraries.
- OpenTelemetry. Semantic conventions for CI/CD — схема событий сборки и выката, которую дешевле принять, чем изобрести.
- SLSA — уровни гарантий цепочки поставки; Sigstore/cosign; Kyverno verifyImages.
- GitHub. Security hardening your deployments with OIDC — почему у CI не должно быть постоянных прав в проде.
- Google Testing Blog. Flaky Tests at Google and How We Mitigate Them, 2016.
- N. Forsgren, J. Humble, G. Kim. Accelerate. IT Revolution, 2018; DORA Research 2024 — в том числе неудобные результаты про платформы.
- M. Skelton, M. Pais. Team Topologies. IT Revolution, 2019 — границы ответственности платформенной команды.
- D. McKinley. Choose Boring Technology, 2015; Bazel remote caching; Actions Runner Controller; Renovate.
Что дальше
Мы разобрали конвейер как платформенный сервис: где проходит граница владения, почему инварианты живут на приёмке, во что обходится ожидание и по каким признакам общий шаблон перестаёт быть общим. Один сквозной мотив здесь остался недоговорённым — телеметрия. Атрибуция падений, синтетический прогон, метрики принятия и события выката бесполезны, если каждая команда собирает их по-своему: сравнить нельзя, инцидент разбирать нечем, а платформа не видит собственных пользователей. Следующая глава — про то, как сделать наблюдаемость общим сервисом и не построить при этом ещё один забор.