Инженерная практика Эксплуатация и эволюция: жизнь системы после релиза
0%

Эксплуатация и эволюция: жизнь системы после релиза

Эксплуатация и эволюция: жизнь системы после релиза

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

Разграничение сразу, чтобы не тратить ваше время. Трек sre разбирает надёжность как дисциплину — шестнадцать глав про SLO, дежурства, ёмкость и режимы отказа. Глава Наблюдаемость и дежурства — про инструменты и их настройку. Легаси-код — про технику безопасных изменений. Технический долг — про то, как долг выглядит из кресла руководителя. Здесь — взгляд прикладного инженера: какие решения вы принимаете сами, каким языком разговариваете с продуктом и что делаете руками, когда система уже работает.

Стоимость владения: почему сопровождение — большая часть жизни системы

Разработка нового кода — короткий яркий эпизод в биографии системы; всё остальное время её сопровождают. Цифра «60–80 % стоимости жизненного цикла приходится на сопровождение» кочует по презентациям без источника, поэтому назовём источники. Барри Боэм в «Software Engineering Economics» (1981) на данных проектов TRW получил 40–60 %. Беннет Линц и Бартон Суонсон в исследовании 487 организаций («Software Maintenance Management», 1980) показали не только объём, но и структуру: больше половины усилий по «сопровождению» — это вообще не починка багов, а доработка под новые требования. Роберт Гласс в «Facts and Fallacies of Software Engineering» (2002) сводит накопленные оценки в диапазон 40–80 % и добавляет ключевое наблюдение: около 60 % этих расходов — развитие функциональности, а не исправление ошибок.

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

  • Непрерывное изменение. Система класса E — та, что решает задачу реального мира, — обязана меняться, иначе становится всё менее полезной. Мир не стоит на месте, и «замороженная» система деградирует относительно него, даже если ни строки в ней не поменялось.
  • Растущая сложность. С каждым изменением сложность растёт, если специально не тратить силы на её снижение. Это не лень команды, а свойство процесса.
  • Убывающее качество. Воспринимаемое качество падает, если система не адаптируется к изменениям среды.

Второй закон — прямое обоснование того, почему бюджет на рефакторинг не роскошь: без него сложность растёт монотонно. ISO/IEC/IEEE 14764 делит сопровождение на четыре вида, и это деление полезно не для отчётности, а чтобы честно называть, чем вы заняты:

Вид Что это Пример Кто обычно инициирует
Корректирующее починка обнаруженных дефектов падение при пустом списке заказов поддержка, пользователи
Адаптивное подстройка под изменившееся окружение база подняла мажорную версию, библиотека выкинула API внешние обстоятельства
Совершенствующее новые требования и улучшение характеристик новый фильтр в отчёте, ускорение выгрузки продукт
Превентивное снижение будущей стоимости изменений покрытие тестами, разбиение монстра, обновление зависимостей инженеры

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

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

Наблюдаемость: как увидеть систему, которую нельзя остановить

Отладчик в проде поставить нельзя. Перезапустить с логами уровня DEBUG — обычно тоже нельзя. Остаётся то, что система рассказала о себе сама, пока работала: это и есть телеметрия, а способность по ней восстановить внутреннее состояние — наблюдаемость. Термин пришёл из теории управления: Рудольф Калман в 1960 году назвал систему наблюдаемой, если её внутреннее состояние восстановимо по внешним измерениям за конечное время. В инженерной практике определение мягче и полезнее: система наблюдаема, если вы можете задать ей новый, заранее не предусмотренный вопрос и получить ответ, не выкатывая новую версию.

Три сигнала не взаимозаменяемы. У каждого своя работа и своя слепая зона:

Сигнал Отвечает на вопрос Чего не может Стоимость
Метрики «Плохо ли сейчас и насколько» сказать, какому пользователю и почему низкая, растёт по кардинальности
Логи «Что именно случилось в этом конкретном случае» дать общую картину без агрегации высокая, линейна по объёму
Трассировки «Где в цепочке сервисов ушло время и что чей вызов» показать редкий случай, если он не попал в выборку средняя, зависит от семплирования

Типичный рабочий цикл: метрика показала, что плохо → трассировка показала, в каком сервисе → лог показал, что именно там произошло. Каждый сигнал сужает поиск, но переход между ними работает, только если они связаны общим идентификатором. Устройство сигналов, кардинальность и семплирование разобраны в главах Мониторинг и Наблюдаемость распределённых систем; дальше — только то, что делает руками прикладной инженер.

Структурированные логи и почему print в проде бесполезен

print(f"user {uid} failed to pay") в проде — это не лог, а мусор. Причины конкретные:

  • Нельзя искать по полю. Вы можете грепать подстроку, но не можете спросить «все ошибки оплаты для тарифа business за последний час с длительностью больше секунды».
  • Нельзя агрегировать. Посчитать долю неудач по причине — это регулярка поверх свободного текста, которая сломается при первой же правке формулировки.
  • Нет контекста. В print нет версии сборки, инстанса, идентификатора запроса. В момент разбора инцидента вы не поймёте, к какому выкату относится строка.
  • Нет уровня и семплирования. Один цикл по десяти тысячам позиций заказа кладёт диск и счёт за хранение.

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

# app/observability.py — обвязка: структурные логи + контекст запроса + трассировка.
import contextvars, logging, uuid
import structlog
from opentelemetry import trace
from opentelemetry.trace import SpanKind, Status, StatusCode

# Идентификатор запроса живёт в contextvars: виден любому коду ниже по стеку,
# в том числе внутри async-задач, и не требует таскать его параметром через десять слоёв.
request_id_var: contextvars.ContextVar[str] = contextvars.ContextVar("request_id", default="-")
tracer = trace.get_tracer("checkout")


def add_request_context(_logger, _method, event_dict):
    """Процессор structlog: подмешивает в КАЖДОЕ событие корреляционные поля."""
    event_dict["request_id"] = request_id_var.get()
    ctx = trace.get_current_span().get_span_context()
    if ctx.is_valid:
        # Те же trace_id/span_id, что и в трассировке, — это и есть шов между сигналами.
        event_dict["trace_id"] = format(ctx.trace_id, "032x")
        event_dict["span_id"] = format(ctx.span_id, "016x")
    return event_dict


structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        add_request_context,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.processors.dict_tracebacks,   # исключение как поля, а не простыня текста
        structlog.processors.JSONRenderer(),    # в проде JSON, локально ConsoleRenderer
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
)
log = structlog.get_logger()

Дальше — обработчик запроса. Здесь видно главное: идентификатор запроса приходит снаружи, если клиент или шлюз его прислал, и генерируется, только если не прислал. Иначе сквозная связь рвётся на первом же сервисе.

# app/api.py — один запрос: корреляция, спан, доменные атрибуты, честная ошибка.
async def handle_checkout(request, order_id: str, user_id: str):
    # Свой заголовок для человекочитаемой корреляции; W3C traceparent обрабатывает
    # инструментация OpenTelemetry сама и связывает спаны между сервисами.
    request_id_var.set(request.headers.get("X-Request-Id") or uuid.uuid4().hex)

    with tracer.start_as_current_span("checkout", kind=SpanKind.SERVER) as span:
        # order_id уникален, но в спане это допустимо: трейсы не индексируются как метрики.
        span.set_attribute("order.id", order_id)
        span.set_attribute("user.tier", await tier_of(user_id))  # business/free — 2 значения
        log.info("checkout.started", order_id=order_id)
        try:
            with tracer.start_as_current_span("payment.authorize"):
                auth = await payments.authorize(order_id)   # дочерний спан: видна его доля времени
            with tracer.start_as_current_span("inventory.reserve"):
                await inventory.reserve(order_id)
        except PaymentDeclined as exc:
            # Отказ платежа — НЕ ошибка сервиса. Логируем как бизнес-событие, спан оставляем
            # успешным, иначе SLO будет считать чужие отказы своими.
            log.info("checkout.declined", order_id=order_id, reason=exc.code)
            span.set_attribute("checkout.declined_reason", exc.code)
            return response(402, {"reason": exc.code})
        except Exception as exc:
            span.record_exception(exc)
            span.set_status(Status(StatusCode.ERROR))
            log.error("checkout.failed", order_id=order_id, exc_info=True)
            raise
        log.info("checkout.completed", order_id=order_id, auth_id=auth.id)
        return response(200, {"auth_id": auth.id})

Что даёт эта конструкция на практике. В инциденте вы находите в логе строку с request_id, копируете trace_id и одним переходом открываете весь путь запроса через четыре сервиса — с длительностью каждого шага. Обратно тоже работает: увидели медленный трейс — вытащили по trace_id все логи всех сервисов по этому запросу. Без общего идентификатора эти два действия превращаются в сопоставление таймстемпов вручную, а это и есть та работа, из-за которой инциденты длятся часами. Стандарт на формат идентификатора — W3C Trace Context: заголовок traceparent вида 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01. Его понимают все современные библиотеки, и именно поэтому свой самодельный X-Trace — плохая идея: он не проедет через чужой шлюз, брокер или SDK.

Кардинальность: откуда берётся счёт за мониторинг

Метрика с метками — это не одна метрика: каждая уникальная комбинация значений меток создаёт отдельный временной ряд, а система хранения платит за каждый ряд. Считайте так: http_requests_total{route, method, status, instance} при 20 маршрутах, 4 методах, 6 кодах и 30 инстансах даёт до 14 400 рядов — нормально. Добавьте user_id со ста тысячами значений — и вы получите миллиарды потенциальных рядов и упавший Prometheus. Правило прикладного инженера простое: в метки идёт только то, что имеет ограниченный, заранее известный набор значений. Идентификаторы пользователей, заказов, сессий, полные URL с параметрами, тексты ошибок и IP-адреса в метках не живут — им место в логах и трассировках, которые устроены как поиск, а не как индекс по рядам.

OpenTelemetry сегодня — точка консолидации: единый API и SDK для трёх сигналов, единый протокол OTLP и словарь семантических соглашений (http.route, db.system, service.name). Практическая ценность — не в модности, а в развязке: код инструментируется один раз, а бэкенд хранения меняется правкой конфигурации коллектора. Как это разворачивают и сколько стоит — в главе про наблюдаемость трека devops.

Мониторинг против наблюдаемости

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

Наблюдаемость позволяет задать новый вопрос по уже собранным данным. «Почему медленно только у пользователей с корпоративным тарифом, только на мобильном клиенте, только после вчерашнего выката?» Такой вопрос никто не предусматривал. Если в телеметрии есть нужные измерения и они связаны, ответ получается за пять минут. Если нет — нужно дописать код, выкатить и ждать, пока проблема повторится. Практический критерий зрелости: сколько раз за последний квартал вам пришлось выкатывать релиз, чтобы понять причину уже случившейся проблемы. Если часто — вы мониторите, но не наблюдаете. Разворот этой мысли в тезис «известные неизвестные против неизвестных неизвестных» подробно разобран у Чарити Мейджорс в «Observability Engineering» и в главе Мониторинг.

Алерты, которые работают

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

Первое: алерт на симптом у пользователя, а не на технический показатель. CPU 90 % — это не проблема, это, возможно, эффективная утилизация железа. Проблема — когда пользователь не может оформить заказ. Плохие алерты: загрузка процессора, объём свободной памяти, число потоков, длина очереди сама по себе. Хорошие: доля неуспешных запросов, задержка на 99-м перцентиле, падение числа успешных оплат в минуту, растущее отставание обработки очереди относительно допустимого.

Плохой алерт Что с ним не так Чем заменить
CPU > 90 % пять минут не факт, что кому-то плохо доля ответов 5xx > 1 % за 5 минут
Свободно меньше 15 % диска срабатывает ночью, ничего не ломая прогноз заполнения: диск кончится за 4 часа
Сервис не отвечает на /health в кластере из 30 подов это норма жизни доля здоровых реплик ниже кворума
Ошибка в логе с уровнем ERROR одна ошибка — не инцидент скорость сжигания бюджета ошибок

Исключение из правила — «предсказуемо превратится в симптом»: сертификат истекает через сутки, диск закончится за четыре часа. Такие алерты допустимы, но должны идти в тикет, а не в пейджер. Второе правило: каждый алерт требует действия. Если на алерт нельзя сделать ничего осмысленного прямо сейчас — это не алерт, а дашборд. Практический тест: у алерта есть ранбук с конкретными шагами. Нет ранбука — алерт недоделан. Нарушение этих двух правил приводит к отказу, который не виден ни в одном мониторинге: усталость от оповещений. Механика арифметическая. Если из десяти ночных срабатываний девять — шум, дежурный за месяц обучается открывать телефон с мыслью «наверное, опять ерунда». В этот момент система перестаёт работать, хотя все её компоненты «зелёные»: сигнал есть, а получателя у него больше нет. Именно поэтому число алертов на дежурство — метрика, которую надо мерить и снижать, а «известная проблема, все привыкли» — самый дорогой из технических долгов.

SLI, SLO и бюджет ошибок — коротко и по делу

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

  • SLI — измеримый показатель качества с точки зрения пользователя. Например: доля запросов к /checkout, завершившихся успешно быстрее 300 мс.
  • SLO — целевое значение SLI на окне. Например: 99,9 % за 30 дней.
  • Бюджет ошибок — то, что остаётся: 0,1 % от месячного трафика можно провалить. Это не «допустимый брак», а валюта. Бюджет цел — команда катит рискованные изменения. Бюджет сожжён — приоритет уходит в надёжность, и это решение принято заранее, а не в разгар спора.

Ценность конструкции в том, что она превращает разговор «нам кажется, что стало хуже» в «мы сожгли 78 % бюджета за девять дней, релиз функции переносится». Подробно — SLI и SLO, Бюджет ошибок и Алерты, где разобраны в том числе оповещения по скорости сжигания бюджета.

Инцидент по шагам

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

Ключевое правило: сначала восстанови сервис, потом ищи причину. Оно противоречит инстинкту инженера — хочется понять, что происходит. Но пока вы понимаете, счётчик потерь тикает. Смягчение и диагностика — разные задачи, и вторая почти всегда может подождать: причину можно искать на копии данных, на стенде, по сохранённой телеметрии. Отсюда прикладное следствие: откат — первое действие, а не последнее. Если за последний час был выкат, а сейчас плохо — откатывайтесь, не разбираясь, виноват ли он. Вероятность, что виноват именно он, высокая, стоимость отката низкая, а время дорогое. Это работает только при условии, что откат отрепетирован и занимает минуты, — а это уже требование к конвейеру из главы CI/CD и к стратегиям выката из Стратегии релиза. Отдельно проверьте, что откат возможен: миграция базы, удалившая колонку, откату не поддаётся — про это ниже, в разделе про expand/contract.

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

Роли. Даже в маленькой команде разделение обязательно, иначе один человек одновременно чинит, отвечает в чате и объясняет поддержке — и делает всё три плохо:

  • Командующий инцидентом — не чинит руками. Он держит картину, принимает решения, назначает задачи, следит за временем. Это самая недооценённая роль.
  • Оператор — тот, кто выполняет действия в системе. Он один, чтобы изменения не накладывались друг на друга.
  • Коммуникатор — пишет статусы наружу с фиксированной частотой (раз в 15–30 минут даже если новостей нет). Отсутствие новостей воспринимается хуже, чем плохие новости.
  • Ведущий журнал — фиксирует «в 14:32 откатили сервис оплат». Через сутки никто не вспомнит порядок событий, а без него постмортем превращается в художественную литературу.

Роли и процедуры разобраны глубоко в Реакция на инцидент и Дежурства; взгляд со стороны руководителя — в Дежурства и инциденты.

Постмортем без поиска виноватых

Постмортем — документ, который превращает потраченные впустую часы в знание. Его единственная цель — чтобы такое не повторилось, и достигается она только при одном условии: никто не наказан за то, что рассказал, как было на самом деле. Безвинностность здесь — не про доброту, а про качество данных. Человек, который знает, что за ответом последует разбор его компетентности, отвечает осторожно. Он не скажет «я запустил скрипт на проде, потому что был уверен, что это стенд» — он скажет «произошла ошибка конфигурации». В первой формулировке есть системный дефект (стенд и прод неразличимы в приглашении терминала — это чинится). Во второй нет ничего. Обвинение обменивает информацию на иллюзию контроля.

Второй аргумент сильнее. Если инженер смог одной командой уронить прод, дефект не в инженере — дефект в системе, которая это позволила. Уволите его — придёт другой, у которого будет ровно та же возможность. Сидни Деккер называет это переходом от «истории первой» (кто-то ошибся) к «истории второй» (почему это действие в тот момент выглядело разумным). Ричард Кук в «How Complex Systems Fail» формулирует ещё жёстче: в сложных системах катастрофа всегда требует нескольких одновременных отказов, поэтому «единственной коренной причины» не существует — её выбирают, а не находят. Практику ввёл в оборот Джон Оллспоу в «Blameless PostMortems and a Just Culture». Рабочая структура документа:

  1. Кратко — что видел пользователь, сколько это длилось, какой масштаб. Три предложения, понятные не-инженеру.
  2. Влияние в числах — сколько запросов, сколько пользователей, сколько денег, сколько процентов бюджета ошибок сожжено. Не «было плохо», а «14 % оплат не проходили 47 минут».
  3. Хронология — по минутам, с двумя отдельными отметками: когда началось и когда мы узнали. Разрыв между ними — это качество ваших алертов, и он важнее всего остального.
  4. Что произошло технически — цепочка условий, а не одна причина.
  5. Что сработало и что помешало — сюда попадает и «дашборд не открывался, потому что он в том же кластере».
  6. Действия — самое главное.

Про действия отдельно, потому что здесь постмортемы чаще всего умирают. Действие бесполезно, если у него нет владельца-человека (не «команда платформы»), срока и тикета в том же трекере, где обычная работа. «Улучшить мониторинг» — не действие. «Добавить алерт на глубину пула соединений, порог 80 %, ответственный Иванов, до 30 июля, тикет OPS-431» — действие. Хороший показатель здоровья процесса: доля действий из постмортемов, выполненных в срок; если она ниже половины, разборы пишутся ради галочки, и лучше честно перестать их писать, чем поддерживать ритуал. Подробно про механику разбора — Постмортемы; про то, как написать этот документ так, чтобы его прочли, — Постмортем в треке технического письма.

Технический долг: честное определение

Половина споров о техдолге идёт из-за того, что под этим словом каждый понимает своё; уберём два неверных понимания. Техдолг — это не плохой код. Небрежный код — просто небрежный код, и оправдывать его словом «долг» нечестно. Уорд Каннингем, который придумал метафору в отчёте на OOPSLA 1992, потом объяснял прямо: он имел в виду ситуацию, когда вы осознанно выпускаете код, отражающий ваше текущее, ещё неполное понимание задачи, чтобы быстрее получить обратную связь. Долг здесь — разрыв между вашей моделью предметной области и реальностью.

Техдолг — это не «код, который надо переписать красивее». Рабочее определение, которым удобно пользоваться: техдолг — это разрыв между текущей формой системы и той формой, которая нужна ей сейчас. Ключевое слово — «сейчас». Модуль, написанный пять лет назад под другие требования, не был плохим — требования изменились. Долг возникает не только когда система портится, но и когда мир вокруг неё уходит вперёд (второй и седьмой законы Лемана).

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

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

Как сделать долг видимым

Долг, которого нет в трекере, не существует для планирования — а значит, никогда не будет починен. Три работающих приёма. Первый — реестр долга рядом с кодом: не презентация и не вики-страница, которую никто не открывает, а файл в репозитории, который меняется через pull request и обсуждается на ревью.

# docs/debt/registry.yaml — реестр, который читают, потому что он в репозитории
- id: DEBT-014
  title: Расчёт скидок продублирован в трёх местах
  where: [billing/pricing.py, api/cart.py, jobs/report.py]
  # Боль — не «некрасиво», а измеримый факт из жизни команды.
  pain: >
    За квартал 4 бага из-за расхождения формул; любая правка тарифа требует
    синхронной правки трёх мест и ручной регрессии на 2 часа.    
  interest_per_month: "около 3 дней инженера"     # проценты
  principal: "5 дней на вынос в общий сервис"     # основная сумма
  trigger: продуктовая задача в области тарифов   # чинить не отдельно, а вместе с ней
  owner: ivanov
  quadrant: reckless-deliberate

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

Бюджет на каждый спринт вместо «недели рефакторинга». Выделенная неделя раз в квартал не работает по трём причинам: её отменяют первой при любом сдвиге сроков; за неделю нельзя закончить крупное изменение, а незаконченное хуже неначатого; и главное — она создаёт иллюзию, что качество можно делать отдельно от работы. Постоянные 15–20 % ёмкости каждого спринта дают тот же объём за квартал, но непрерывно и без разрешения на каждый раз.

Правило бойскаута с ограничением. Трогаете файл — оставьте его чуть чище. С важной оговоркой: улучшение должно быть в границах вашей задачи, иначе pull request на три строки превращается в неревьюабельные восемьсот. Каталог того, что именно улучшать, — Запахи кода и рефакторинг; как выбить на это время и как разговаривать с бизнесом — Технический долг.

Работа с легаси

Определение Майкла Физерса из «Working Effectively with Legacy Code» (2004) звучит вызывающе: легаси — это код без тестов. Не старый код, не чужой код, не код на устаревшем языке. Логика такая: код без тестов нельзя менять уверенно — каждое изменение это ставка, а не операция. Возраст тут ни при чём: код, написанный вами вчера без тестов, уже легаси. Отсюда следует порядок действий, обратный интуитивному: хочется сначала переписать, потом покрыть, а правильно — наоборот.

Характеризующие тесты. Прежде чем менять непонятный код, зафиксируйте его текущее поведение — включая то, которое кажется неправильным. Вы пишете тест не на то, как должно быть, а на то, как есть: подаёте вход, смотрите фактический выход, записываете его как ожидаемый. Такой тест ничего не утверждает о корректности, он утверждает «после моей правки поведение не изменилось». Странности, которые вы обнаружите (округление в меньшую сторону в третьем знаке, пустая строка вместо null), — не баги по умолчанию: возможно, на них уже кто-то опирается. Это закон Хайрама: при достаточном числе пользователей любое наблюдаемое поведение системы становится чьим-то контрактом.

Шов (seam) — место, где можно изменить поведение программы, не редактируя код в этом месте. Параметр конструктора вместо new внутри метода, интерфейс вместо конкретного класса, переопределяемый метод-фабрика, точка внедрения через конфигурацию. Швы — это то, что превращает нетестируемый код в тестируемый минимальными правками. Каталог техник разрыва зависимостей — в Легаси-код; как выделять границы в старой системе по-доменному — DDD в легаси.

Удушающая смоковница

Как заменить систему целиком, если останавливать её нельзя. Strangler Fig — метафора Фаулера: фикус-душитель прорастает вокруг дерева-хозяина, постепенно замещает его, и когда старое дерево отмирает, новое уже стоит само. Механика — пять шагов, каждый из которых обратим.

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

Почему не «перепишем с нуля». Джоэл Спольски в «Things You Should Never Do, Part I» назвал это «худшей стратегической ошибкой» на примере переписывания Netscape, стоившего компании три года. Аргумент не про эстетику, а про информацию: уродливый код часто уродлив потому, что содержит знание. Каждая странная ветка — это чей-то отчёт о баге, обходной путь для конкретного браузера, требование регулятора. Переписывая с нуля, вы выбрасываете вместе с кодом весь этот накопленный опыт и обязуетесь пройти те же грабли заново — только теперь под давлением, потому что старая система всё это время требует поддержки и вы платите за две. Добавьте сюда то, что переписывание не даёт ценности до самого конца, и вы поймёте, почему такие проекты закрывают на 70 % готовности.

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

Миграции и удаление: как выключить старое

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

# api/deprecation.py — прежде чем удалять, измерь. Счётчик использования,
# предупреждение потребителю и дата отключения в одном месте.
from datetime import datetime, timezone
from prometheus_client import Counter

# Метка client_id — низкой кардинальности: это ваши известные потребители,
# а не произвольные пользователи. Именно она отвечает на вопрос «кому звонить».
DEPRECATED_HITS = Counter("api_deprecated_endpoint_hits_total",
                          "Обращения к устаревшим эндпоинтам", ["endpoint", "client_id"])
SUNSET = datetime(2026, 10, 1, tzinfo=timezone.utc)


async def deprecated_v1_orders(request):
    client = request.headers.get("X-Client-Id", "unknown")
    DEPRECATED_HITS.labels(endpoint="/v1/orders", client_id=client).inc()
    resp = await handle_orders_v1(request)
    resp.headers["Deprecation"] = "true"                    # RFC 9745: ресурс устарел
    # RFC 8594: дата, после которой ресурса не станет. Формат — HTTP-date.
    resp.headers["Sunset"] = SUNSET.strftime("%a, %d %b %Y %H:%M:%S GMT")
    resp.headers["Link"] = '</v2/orders>; rel="successor-version"'
    return resp

Порядок вывода из эксплуатации, который работает:

  1. Объявить устаревшим и дать замену. Без готовой замены объявление — просто раздражение.
  2. Измерять использование по потребителям. Метрика с меткой клиента отвечает на вопрос «кому писать», а не «сколько ещё вызовов».
  3. Написать потребителям адресно — не рассылкой в общий канал, а конкретным командам с цифрами их трафика и датой отключения.
  4. Провести учения на отказ: выключить эндпоинт на 15 минут в согласованное окно. Это находит потребителей, которых нет ни в одном списке, — гораздо дешевле, чем найти их в момент финального удаления. Механика таких учений — Хаос и учения.
  5. Отключить, но не удалять — оставить возможность вернуть за минуту (флаг, а не откат релиза).
  6. Удалить код и данные через оговорённый срок. Именно код: закомментированный блок или ветка if False — не удаление, а ещё один долг.

Эволюция без остановки

Всё вышеперечисленное упирается в одно требование: менять систему, не выключая её. Три техники, которые прикладной инженер применяет постоянно.

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

Expand/contract (параллельное изменение) — способ выполнить несовместимое изменение серией совместимых. Три фазы: расширяем (добавляем новое, не трогая старое), мигрируем (переводим читателей и писателей), сжимаем (удаляем старое). Классический пример — переименование колонки, которое «в лоб» ломает прод в момент выката:

-- ФАЗА 1: EXPAND. Только добавление. Откат безопасен, старый код не замечает изменений.
ALTER TABLE orders ADD COLUMN total_amount_cents BIGINT;
-- Заполняем существующие строки пачками, чтобы не держать долгую блокировку.
UPDATE orders SET total_amount_cents = ROUND(total * 100)
 WHERE total_amount_cents IS NULL AND id BETWEEN :lo AND :hi;

-- ФАЗА 2: MIGRATE. Код версии N пишет в ОБА поля и читает старое.
-- Код версии N+1 пишет в оба и читает новое. Обе версии живут одновременно.
-- Здесь же — сверка: расхождение полей должно быть нулевым.
SELECT count(*) FROM orders WHERE total_amount_cents <> ROUND(total * 100);

-- ФАЗА 3: CONTRACT. Только после того, как ни один инстанс не читает старое поле.
-- Между фазой 2 и 3 должно пройти БОЛЬШЕ, чем время жизни самого старого клиента.
ALTER TABLE orders DROP COLUMN total;

Ценность в том, что после каждой фазы система работоспособна и откатываема. Цена — временное дублирование и дисциплина довести до третьей фазы: незавершённый expand/contract и есть тот самый долг, который потом никто не может объяснить. Приём описан у Данило Сато как Parallel Change.

Теневой трафик и сравнение ответов. Когда вы заменяете компонент, тесты доказывают, что новый код проходит придуманные вами сценарии. Прод проверяет его настоящими. Схема: запрос обрабатывает старая реализация и отдаёт ответ пользователю, параллельно тот же вход уходит в новую, ответы сравниваются, расхождения пишутся в отчёт. Пользователь ничего не замечает — новый путь не влияет на ответ. За неделю такого прогона вы находите то, что не нашли бы за месяц придумывания тест-кейсов. Инструментальный образец — GitHub Scientist. Две оговорки: теневой путь не должен иметь побочных эффектов (никаких повторных списаний — см. идемпотентность), и он удваивает нагрузку на зависимости, что надо учесть по ёмкости (Масштабирование). Все три техники опираются на управление поведением без выката — флаги, конфигурацию и разделение окружений из главы Окружения, конфигурация и секреты.

Типичные ошибки

Алерт на технический показатель без симптома. Пейджер звонит на CPU, диск и рестарты подов, а падение конверсии оплат замечает продукт через два часа. Лечится переписыванием алертов от пользователя внутрь, а не от инфраструктуры наружу.

«Известная проблема, все привыкли». Алерт, который срабатывает каждую ночь и который все игнорируют, — это не проблема мониторинга, это сломанный процесс. Либо чините причину, либо удаляйте алерт. Третьего состояния быть не должно: молчаливое согласие с шумом обучает команду игнорировать и настоящие сигналы.

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

Рефакторинг без тестов. «Я просто аккуратно перепишу» — самая дорогая фраза в этой главе. Без характеризующих тестов вы не рефакторите, а переписываете вслепую, и обнаружите это в проде.

Долг, который никто не считает. Пока долг живёт в головах, он всегда проигрывает продуктовым задачам, у которых есть оценка и срок. Реестр с болью и процентами превращает «нам бы отрефакторить» в аргумент, который можно положить рядом с продуктовой задачей и сравнить.

Система, которую никто не удаляет. Сервис, у которого два запроса в сутки и оба от мониторинга. Эндпоинт v1, который «вроде кто-то использует». Флаг, включённый на 100 % два года назад. Каждый из них требует обновлений безопасности, попадает в каждый аудит и участвует в каждой миграции. Заведите правило: у каждой временной конструкции с рождения есть дата смерти.

Мини-итог главы

  • Большая часть жизни системы и её стоимости — после первого релиза, и основная часть этой работы — развитие функциональности, а не починка багов. Законы Лемана дают инженерное обоснование бюджета на качество: сложность растёт сама, снижается только осознанным усилием.
  • Три сигнала телеметрии не заменяют друг друга: метрики говорят, что плохо, трассировки — где, логи — что именно. Их связывает общий идентификатор запроса, и без него разбор инцидента превращается в сопоставление таймстемпов.
  • Мониторинг отвечает на заранее заданные вопросы, наблюдаемость позволяет задать новый без релиза. Проверьте себя по числу релизов «чтобы понять причину».
  • Алерт — просьба разбудить человека: только на симптом у пользователя, только с ранбуком, только если есть что сделать. Шум в пейджере — отказ системы, невидимый в мониторинге.
  • Порядок инцидента: обнаружение → смягчение → диагностика → устранение → разбор. Сначала верните сервис; откат — первое действие, если он отрепетирован. Постмортем без поиска виноватых даёт больше информации, потому что люди говорят правду; действие без владельца, срока и тикета — не действие.
  • Техдолг — разрыв между текущей и нужной формой системы. Приоритет задают проценты, а не основная сумма. Делайте долг видимым и платите по нему каждый спринт, а не раз в квартал.
  • Легаси — код без тестов. Сначала характеризующие тесты и швы, потом изменения. Замена по частям через удушающую смоковницу почти всегда бьёт переписывание с нуля.
  • Удаление — полноценная инженерная работа: объявить, измерить использование, предупредить адресно, провести учения на отказ, отключить, удалить. Менять систему на ходу позволяют совместимость, expand/contract и теневой трафик со сравнением ответов.

Мини-итог всего трека

Тринадцать глав складываются в одну картину — путь от запроса пользователя до системы, которая живёт годами. Всё начинается на клиенте (глава 01) — в недоверенной среде на чужом железе, где вы отвечаете за впечатление, но не за истину. Запрос уходит на сервер (02), где живёт бизнес-логика и принимаются решения, и упирается в слой данных (03) — самую консервативную часть системы, которая переживёт три поколения кода вокруг себя. Части системы разговаривают через интеграцию (04): контракты, синхронные и асинхронные вызовы, границы, за которыми начинается чужая ответственность. Как эти части расставлены в пространстве, определяет форма системы (05) — от модульного монолита до сервисов, и выбор здесь диктуется устройством команды не меньше, чем техникой.

Дальше — то, как это делается руками. Контроль версий (06) хранит не файлы, а историю решений. Рабочее окружение (07) и сборка с зависимостями (08) отвечают на вопрос воспроизводимости: та же команда даёт тот же результат у вас, у коллеги и на сервере сборки. Ворота качества (09) переносят обнаружение проблем влево, к моменту, когда правка стоит минуты, а не сутки. Конвейер (10) превращает изменение в выкатанную версию без ручных шагов и делает откат дешёвым. Конфигурация и окружения (11) отделяют то, что меняется между стендами, от того, что меняется вместе с кодом, — и дают возможность управлять поведением без релиза. Масштабирование (12) отвечает на рост нагрузки — но только после того, как измерено, что именно упирается.

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

Источники

  • Barry Boehm. Software Engineering Economics (1981) — первая систематическая оценка доли сопровождения; Robert Glass. Facts and Fallacies of Software Engineering (2002), факты 41–45 — сводка оценок и структура этих расходов.
  • Lehman’s laws of software evolution — почему система обязана меняться и почему сложность растёт сама. ISO/IEC/IEEE 14764 — стандарт сопровождения ПО и четыре вида работ.
  • OpenTelemetry и W3C Trace Context — нынешний стандарт телеметрии и формат сквозного идентификатора.
  • Charity Majors, Liz Fong-Jones, George Miranda. Observability Engineering (O’Reilly, 2022).
  • Google SRE Book — главы про алертинг, дежурства и постмортемы, откуда пошла современная практика.
  • John Allspaw. Blameless PostMortems and a Just Culture (2012).
  • Richard Cook. How Complex Systems Fail — восемнадцать тезисов, которые стоит перечитывать перед каждым разбором.
  • Martin Fowler. Technical Debt Quadrant, Strangler Fig Application, Parallel Change.
  • Michael Feathers. Working Effectively with Legacy Code (2004) — швы, характеризующие тесты, техники разрыва зависимостей. Рядом — Hyrum’s Law: почему нельзя безнаказанно менять «недокументированное» поведение.
  • Joel Spolsky. Things You Should Never Do, Part I (2000).
  • RFC 8594 (Sunset) и RFC 9745 (Deprecation) — как машиночитаемо объявить об отключении ресурса.
  • GitHub Scientist — эталонная реализация сравнения старой и новой реализации на живом трафике.

Что дальше

Это последняя глава трека — дальше дороги расходятся; выберите ту, что отвечает на ваш нынешний вопрос.

Глубже в надёжность и эксплуатациютрек sre. Здесь мы прошли эксплуатацию на уровне «что делает прикладной инженер»; шестнадцать глав трека разбирают то же самое как отдельную дисциплину: SLI и SLO с арифметикой, бюджеты ошибок, режимы отказа, ёмкость, деградация, учения. Идти туда стоит, если вы отвечаете за работающую систему и хотите перестать спорить о надёжности словами.

Глубже в инфраструктуру и поставкутрек devops. Контейнеры, Kubernetes, IaC, GitOps, облака и то, сколько всё это стоит. Отдельно — Наблюдаемость и дежурства, где всё из первой половины этой главы доведено до рабочих конфигураций Prometheus, Alertmanager и коллектора OpenTelemetry.

Глубже в форму системытрек architecture-patterns. Если вы поняли, что боль вашей системы не в коде и не в эксплуатации, а в том, как она разрезана на части, — вам сюда: монолит и микросервисы, событийные архитектуры, паттерны устойчивости, архитектурные решения и ADR.

Качество кода, принципы и работа с легаситрек principles: SOLID, связность и зацепление, принципы тестирования, эволюция контрактов — фундамент, который определяет, насколько дорого обходится каждое изменение через два года. Если завтра вам предстоит менять код, которого вы боитесь, начните прицельно с Легаси-кода и Запахов кода — там каталог техник, а не рассуждения.

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

Карьера и ролитрек sdlc-and-career. Куда всё это встраивается организационно: этапы жизненного цикла, роли в команде, грейды, рост, собеседования и первые 90 дней на новом месте.

Общая карта портала со всеми треками и рекомендуемыми маршрутами — Роадмап.

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

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

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

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