diff --git a/editorial/agent-rewrites/001.json b/editorial/agent-rewrites/001.json index e4aef68..48abfbe 100644 --- a/editorial/agent-rewrites/001.json +++ b/editorial/agent-rewrites/001.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-12-field-author-manifesto", "title": "Эксплуатационная инструкция: от симптома к проверенному откату", "excerpt": "Как описать опасную операцию так, чтобы оператор видел область сбоя, условия запуска, одно обратимое действие, сигнал остановки и критерий восстановления.", - "contentHtml": "

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

Типичный симптом нужно описать наблюдаемыми признаками: например, «5xx выше 5% на POST /payments в одном регионе», а не «сервис нездоров». Если инструкция не задаёт область, условие запуска и проверку результата, читатель может отключить здоровый трафик, выполнить команду на другой версии или вернуть конфигурацию без восстановления данных.

Тезис: инструкция описывает границы действия

Хороший runbook не перечисляет команды ради полноты. Он связывает симптом с областью, предварительным условием, одним изменением, обратным действием и наблюдаемым результатом. Каждый шаг отвечает на четыре вопроса: что должно быть истинно до команды, что изменится, какой вывод ожидается и когда нужно остановиться.

Такой порядок отделяет факт от гипотезы. Сначала оператор сохраняет сигнал и ограничивает scope. Затем меняет один рычаг. После этого ждёт заданное окно и сравнивает метрику с критерием. Если критерий не выполнен или появился новый риск, оператор запускает rollback по заранее описанному условию. Окончание команды не равно восстановлению сервиса.

Механизм проверяемой операции

Symptom фиксирует метрику, endpoint и время. Scope показывает, кого затронула проблема: регион, релиз, долю трафика или конкретный tenant. Precondition подтверждает доступ, версию, наличие backup или сохранённого dashboard. Action меняет одно состояние. Rollback возвращает его при указанном условии. Verification задаёт метрику и окно наблюдения.

Эти поля защищают от разных ошибок. Без scope оператор расширит локальный сбой до всей системы. Без precondition он применит команду к неправильной версии. Несвязанные команды усложнят причинность: после них нельзя понять, что помогло. Без verification текст заканчивается слишком рано — на синтаксически успешном вызове, а не на подтверждённом результате.

Модальность тоже должна быть явной. Слова «проверьте», «убедитесь» и «при необходимости» ничего не задают, пока рядом нет объекта и критерия. В терминах RFC 2119 обязательное условие можно пометить как MUST, допустимое исключение — как SHOULD, необязательную диагностику — как MAY. В русской инструкции достаточно написать «обязательно», «рекомендуется» или «можно», если правило остаётся однозначным.

Перед изменением состояния сохраните наблюдаемый факт. Нужен минимальный набор, по которому можно сравнить до и после: scope, версия, временное окно и исходная метрика. Для опасной команды укажите право доступа, namespace и способ увидеть diff. Не вставляйте секреты, настоящие hostname и локальные alias, которых читатель не сможет проверить.

Симптом, причина, проверка и действие
СимптомВероятная причинаПроверкаДействие
5xx выше порога на одном endpointНовая версия или флаг затрагивает ограниченный scopeСравнить release, регион, долю трафика и error rate за одинаковое окноОграничить воздействие флагом; при ухудшении вернуть его
Timeout растёт, 5xx не меняетсяЗависимость отвечает медленно или исчерпан ресурсСопоставить p95/p99, trace и лимиты пула с baselineНе повторять запросы вслепую; остановить изменение и проверить зависимость
409 при повторной операцииСостояние уже изменено или нарушена идемпотентностьПроверить operation id, запись состояния и время первого вызоваНе выполнять повторно; выбрать безопасный путь чтения или ручного разбора
После отката ошибка остаётсяОткатил один рычаг, но причина вне его scopeСравнить метрики до, после action и после rollbackОстановиться, зафиксировать результат и передать расследование владельцу
Команда завершилась успешноИзменился только control plane, data plane ещё не восстановленПроверить пользовательский сигнал и заданное окно наблюденияНе закрывать инцидент до прохождения verification
Петля эксплуатационной инструкции: симптом и предварительное условие ведут к одному действию, затем к проверке метрики и условному откату.
Операция считается завершённой после наблюдаемой проверки. Если сигнал не достиг критерия, инструкция возвращает оператора к безопасной остановке или откату.

Минимальный рабочий пример

Ниже — учебная проверка карточки runbook. Функция принимает шесть полей и отклоняет объект без содержательного rollback или наблюдаемой verification. Она не проверяет права, shell, облако и реальную исполнимость команды. Её задача — поймать структурный пробел до публикации инструкции.

import { validateRunbookCard } from './upgrade-2027-12.mjs'; const card = validateRunbookCard({ symptom: '5xx выше 5 процентов на POST /payments', scope: 'region eu-west, release 42, 10 percent traffic', precondition: 'есть доступ к flag и сохранён dashboard за 15 минут', action: 'отключить flag payments-v2 для 10 процентов трафика', rollback: 'вернуть flag payments-v2 после проверки результата', verification: 'проверить error rate и p95 в течение 10 минут' }); console.log(card.ok, card.order.join(' -> ')); // true symptom -> scope -> precondition -> action -> rollback -> verification

В примере строка с результатом показывает порядок полей, а не разрешение выполнить операцию. В настоящем runbook команда должна ссылаться на локальный механизм изменения и на конкретный сигнал. Если эти сведения нельзя проверить, их нужно оставить как явно обозначенные placeholders, а не заполнять вымышленными значениями.

Порядок редакторской проверки

  1. Запишите симптом в первых двух абзацах: метрика, объект, временное окно и цена ошибки. Не начинайте с инструмента или команды.
  2. Сузьте scope до endpoint, региона, версии, доли трафика или другой проверяемой границы. Слово «все» требует отдельного доказательства.
  3. Перед действием перечислите precondition: доступ, версия, backup, lock, сохранённые метрики и разрешённый объём воздействия.
  4. Оставьте одно изменение на шаг. Рядом укажите ожидаемый output и способ увидеть diff, чтобы результат можно было отличить от совпадения.
  5. Опишите rollback как действие и условие. Формулировка «откатить при проблеме» не говорит, что считать проблемой и когда начинать возврат.
  6. Задайте verification: метрика, порог, сегмент и окно наблюдения. Сравните результат с тем же scope, который был зафиксирован до изменения.
  7. Добавьте соседние ветки для timeout, 409 и ошибки после отката. Один симптом не должен автоматически вести к одному и тому же действию.
  8. Проверьте учебный пример на принятом и неполном объекте. После этого удалите команды, которые нельзя безопасно воспроизвести.

Ограничения

Структурная проверка не знает, кому разрешён доступ, как устроены shell и облако, есть ли backup, lock и реальные пороги. Она не выполняет команду и не доказывает, что rollback действительно возвращает исходное состояние. Поэтому runbook нельзя считать разрешением менять production: approval, окно операции и владельца изменения задаёт локальный процесс.

NIST SP 800-61 задаёт общий цикл работы с инцидентом: подготовку, обнаружение, анализ, containment, восстановление и действия после инцидента. Документ не знает ваших сервисных зависимостей, команд и порогов остановки. RFC 2119 помогает различить обязательное и рекомендуемое действие, но не тестирует исполнимость конкретной команды.

Не добавляйте в текст вымышленные замеры или производственные результаты ради гладкого примера. Учебная карточка должна оставаться учебной и не содержать секретов, настоящих адресов и необратимых команд. Если значение неизвестно, лучше назвать, где его проверить, чем подменить неизвестное числом.

Критерий готовности

Инструкция готова, когда читатель может по первым абзацам определить симптом и scope, до команды проверить precondition, выполнить одно ограниченное действие, увидеть ожидаемый output, запустить rollback по явному условию и подтвердить восстановление метрикой за заданное окно. Если для любого шага нельзя назвать наблюдаемый результат, текст ещё не готов: вернитесь к условию, границе или проверке.

Проверяемые источники

" + "contentHtml": "

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

Типичный симптом нужно описать наблюдаемыми признаками: например, «5xx выше 5% на POST /payments в одном регионе», а не «сервис нездоров». Если инструкция не задаёт область, условие запуска и проверку результата, читатель может отключить здоровый трафик, выполнить команду на другой версии или вернуть конфигурацию без восстановления данных.

Тезис: инструкция описывает границы действия

Хороший runbook не перечисляет команды ради полноты. Он связывает симптом с областью, предварительным условием, одним изменением, обратным действием и наблюдаемым результатом. Каждый шаг отвечает на четыре вопроса: что должно быть истинно до команды, что изменится, какой вывод ожидается и когда нужно остановиться.

Такой порядок отделяет факт от гипотезы. Сначала оператор сохраняет сигнал и ограничивает scope. Затем меняет один рычаг. После этого ждёт заданное окно и сравнивает метрику с критерием. Если критерий не выполнен или появился новый риск, оператор запускает rollback по заранее описанному условию. Окончание команды не равно восстановлению сервиса.

Механизм проверяемой операции

Symptom фиксирует метрику, endpoint и время. Scope показывает, кого затронула проблема: регион, релиз, долю трафика или конкретный tenant. Precondition подтверждает доступ, версию, наличие backup или сохранённого dashboard. Action меняет одно состояние. Rollback возвращает его при указанном условии. Verification задаёт метрику и окно наблюдения.

Эти поля защищают от разных ошибок. Без scope оператор расширит локальный сбой до всей системы. Без precondition он применит команду к неправильной версии. Несвязанные команды усложнят причинность: после них нельзя понять, что помогло. Без verification текст заканчивается слишком рано — на синтаксически успешном вызове, а не на подтверждённом результате.

Модальность тоже должна быть явной. Слова «проверьте», «убедитесь» и «при необходимости» ничего не задают, пока рядом нет объекта и критерия. В терминах RFC 2119 обязательное условие можно пометить как MUST, допустимое исключение — как SHOULD, необязательную диагностику — как MAY. В русской инструкции достаточно написать «обязательно», «рекомендуется» или «можно», если правило остаётся однозначным.

Перед изменением состояния сохраните наблюдаемый факт. Нужен минимальный набор, по которому можно сравнить до и после: scope, версия, временное окно и исходная метрика. Для опасной команды укажите право доступа, namespace и способ увидеть diff. Не вставляйте секреты, настоящие hostname и локальные alias, которых читатель не сможет проверить.

Как выбрать рычаг изменения

Я выбираю самый узкий обратимый рычаг, который действительно покрывает scope. Это не универсальное правило: если ошибка уже записала несовместимые данные, одного feature flag недостаточно. Матрица ниже помогает назвать цену выбора до команды, а не после неудачного отката.

Рычаг, радиус и цена отката
РычагКогда подходитЦена и рискЧто проверить до запуска
Feature flagПроблема включается отдельным путём и данные совместимыМалый blast radius; нужна рабочая ветка выключения и владелец флагаScope флага, текущая версия и время распространения
Версионируемая конфигурацияОшибка в параметре, а код менять не нужноОткат обычно короткий, но конфигурация может приходить с задержкойDiff, порядок публикации и фактическое значение на инстансе
Предыдущий артефактПричина в коде и быстрый локальный рычаг не покрывает сбойРадиус больше и операция дольше; возможна несовместимость с миграцией данныхСовместимость схемы, артефакт и критерий остановки

Сигнал остановки должен быть отдельной строкой: например, «при росте 5xx в незатронутом scope или при появлении ошибок записи прекратить rollout и вернуть flag». Он не заменяет критерий восстановления. Остановка говорит, когда нельзя продолжать, а verification — когда допустимо завершить операцию.

Симптом, причина, проверка и действие
СимптомВероятная причинаПроверкаДействие
5xx выше порога на одном endpointНовая версия или флаг затрагивает ограниченный scopeСравнить release, регион, долю трафика и error rate за одинаковое окноОграничить воздействие флагом; при ухудшении вернуть его
Timeout растёт, 5xx не меняетсяЗависимость отвечает медленно или исчерпан ресурсСопоставить p95/p99, trace и лимиты пула с baselineНе повторять запросы вслепую; остановить изменение и проверить зависимость
409 при повторной операцииСостояние уже изменено или нарушена идемпотентностьПроверить operation id, запись состояния и время первого вызоваНе выполнять повторно; выбрать безопасный путь чтения или ручного разбора
После отката ошибка остаётсяОткатил один рычаг, но причина вне его scopeСравнить метрики до, после action и после rollbackОстановиться, зафиксировать результат и передать расследование владельцу
Команда завершилась успешноИзменился только control plane, data plane ещё не восстановленПроверить пользовательский сигнал и заданное окно наблюденияНе закрывать инцидент до прохождения verification
Петля эксплуатационной инструкции: симптом и предварительное условие ведут к одному действию, затем к проверке метрики и условному откату.
Операция считается завершённой после наблюдаемой проверки. Если сигнал не достиг критерия, инструкция возвращает оператора к безопасной остановке или откату.

Минимальный рабочий пример

Ниже — учебная проверка карточки runbook. Функция принимает шесть полей и отклоняет объект без содержательного rollback или наблюдаемой verification. Она не проверяет права, shell, облако и реальную исполнимость команды. Её задача — поймать структурный пробел до публикации инструкции.

function validateRunbookCard(card) {\n  const required = ['symptom', 'scope', 'precondition', 'action', 'rollback', 'verification'];\n  if (!card || typeof card !== 'object') return { ok: false, reason: 'runbook-must-be-object' };\n  const missing = required.filter((key) => typeof card[key] !== 'string' || card[key].trim().length < 10);\n  if (missing.length) return { ok: false, reason: 'runbook-fields-missing', missing };\n  if (!/rollback|откат|вернуть/i.test(card.rollback)) return { ok: false, reason: 'rollback-must-be-explicit' };\n  if (!/провер|verify|метрик|threshold/i.test(card.verification)) return { ok: false, reason: 'verification-must-be-observable' };\n  return { ok: true, order: required };\n}\n\nconst card = validateRunbookCard({\n  symptom: '5xx выше 5 процентов на POST /payments',\n  scope: 'region eu-west, release 42, 10 percent traffic',\n  precondition: 'есть доступ к flag и сохранён dashboard за 15 минут',\n  action: 'отключить flag payments-v2 для 10 процентов трафика',\n  rollback: 'вернуть flag payments-v2 после проверки результата',\n  verification: 'проверить error rate и p95 в течение 10 минут',\n});\nconsole.log(card.ok, card.order.join(' → '));\n// true symptom → scope → precondition → action → rollback → verification\n\nconst incomplete = { ...card };\ndelete incomplete.rollback;\nconsole.log(validateRunbookCard(incomplete).reason);\n// runbook-fields-missing

В примере первая строка показывает, что карточка прошла структурную проверку, а вторая — что пропущенное поле обнаруживается до публикации. Этот код не разрешает выполнять операцию: он не знает права, shell, облако, реальную команду и корректность порога. В настоящем runbook команда должна ссылаться на локальный механизм изменения и на конкретный сигнал. Если эти сведения нельзя проверить, их нужно оставить как явно обозначенные placeholders, а не заполнять вымышленными значениями.

Порядок редакторской проверки

  1. Запишите симптом в первых двух абзацах: метрика, объект, временное окно и цена ошибки. Не начинайте с инструмента или команды.
  2. Сузьте scope до endpoint, региона, версии, доли трафика или другой проверяемой границы. Слово «все» требует отдельного доказательства.
  3. Перед действием перечислите precondition: доступ, версия, backup, lock, сохранённые метрики и разрешённый объём воздействия.
  4. Оставьте одно изменение на шаг. Рядом укажите ожидаемый output и способ увидеть diff, чтобы результат можно было отличить от совпадения.
  5. Опишите rollback как действие и условие. Формулировка «откатить при проблеме» не говорит, что считать проблемой и когда начинать возврат.
  6. Задайте verification: метрика, порог, сегмент и окно наблюдения. Сравните результат с тем же scope, который был зафиксирован до изменения.
  7. Добавьте отдельный сигнал остановки и проверьте соседние ветки для timeout, 409 и ошибки после отката. Один симптом не должен автоматически вести к одному и тому же действию.
  8. Проверьте учебный пример на принятом и неполном объекте. После этого удалите команды, которые нельзя безопасно воспроизвести.

Ограничения

Структурная проверка не знает, кому разрешён доступ, как устроены shell и облако, есть ли backup, lock и реальные пороги. Она не выполняет команду и не доказывает, что rollback действительно возвращает исходное состояние. Поэтому runbook нельзя считать разрешением менять production: approval, окно операции и владельца изменения задаёт локальный процесс.

Архивная NIST SP 800-61 Revision 2 (2012) показывает цикл подготовки, обнаружения и анализа, containment, восстановления и действий после инцидента. NIST пометил Rev. 2 как withdrawn и заменил её Rev. 3 (2025), поэтому этот цикл здесь приведён как историческая модель, а не как текущая нормативная схема. Актуальная Rev. 3 связывает incident response с CSF 2.0, но по-прежнему не знает ваших сервисных зависимостей, команд и порогов остановки. RFC 2119 помогает различить обязательное и рекомендуемое действие, но не тестирует исполнимость конкретной команды.

Не добавляйте в текст вымышленные замеры или производственные результаты ради гладкого примера. Учебная карточка должна оставаться учебной и не содержать секретов, настоящих адресов и необратимых команд. Если значение неизвестно, лучше назвать, где его проверить, чем подменить неизвестное числом.

Критерий готовности

Инструкция готова, когда читатель может по первым абзацам определить симптом и scope, до команды проверить precondition, выполнить одно ограниченное действие, увидеть ожидаемый output, запустить rollback по явному условию и подтвердить восстановление метрикой за заданное окно. Если для любого шага нельзя назвать наблюдаемый результат, текст ещё не готов: вернитесь к условию, границе или проверке.

Проверяемые источники

" } diff --git a/editorial/agent-rewrites/002.json b/editorial/agent-rewrites/002.json index 746cae4..bbcb1a3 100644 --- a/editorial/agent-rewrites/002.json +++ b/editorial/agent-rewrites/002.json @@ -1 +1 @@ -{"index":2,"slug":"editorial-2027-12-mechanism-author-manifesto","title":"Web performance budget: как читать LCP, INP и CLS вместе","excerpt":"Разбираем, почему один показатель не объясняет скорость интерфейса, как разделить LCP, INP и CLS и превратить наблюдение в проверяемое действие.","contentHtml":"

Проблема начинается с отчёта «страница медленная». Такой симптом не говорит, что именно увидел пользователь: крупнейший блок появился поздно, клик обработался с задержкой или контент прыгнул под пальцем. Цена ошибки — чинить не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить загрузку, оставив интерфейс заблокированным длинной задачей.

\n

Причина — свести LCP, INP и CLS к одному среднему score. Эти метрики отвечают на разные вопросы и требуют разных разрезов данных. LCP описывает появление крупнейшего видимого элемента, INP — задержку взаимодействия, CLS — неожиданные сдвиги layout. Сначала нужно определить тип симптома и границу измерения, потом выбирать изменение в коде.

\n

Тезис: performance budget состоит из нескольких проверяемых границ

\n

Budget — это не красивое число в дашборде. Это набор порогов, сегментов, периода и действий владельца. Для каждого показателя нужно знать, какую часть опыта он описывает, где искать причину и что считать регрессией. Рекомендованные границы Core Web Vitals — LCP до 2500 мс, INP до 200 мс и CLS до 0,1. Они помогают классифицировать опыт, но не доказывают причину.

\n

Для полевых данных используйте percentile, а не только среднее. Например, p75 показывает значение, ниже которого находится 75 процентов наблюдений в выбранном сегменте. Смешивать в одной строке мобильные и десктопные устройства, разные версии и разные периоды нельзя: итог потеряет смысл. В budget явно запишите URL, устройство, соединение, release и окно наблюдения.

\n

Механизм: три метрики — три объекта диагностики

\n

LCP фиксирует момент, когда крупнейший контентный элемент стал видимым в пределах загрузки. На результат влияют TTFB, критический CSS, изображение, шрифт, сеть и работа браузера. Поэтому плохой LCP — сигнал разобрать цепочку, а не команда сразу добавить preload или сжать картинку.

\n

INP оценивает задержку после взаимодействий пользователя. Ищите конкретное interaction, обработчик и long task на main thread. Причиной может быть тяжёлый обработчик, сторонний скрипт, лишняя синхронная работа или слабое устройство. Быстрый LCP не означает, что интерфейс быстро отвечает после ввода.

\n

CLS суммирует неожиданные сдвиги layout. Типовые источники — изображение без зарезервированных размеров, поздняя реклама, вставленный сверху контент или изменение шрифта. Для проверки нужен shifted element и момент сдвига. Удаление одного долгого скрипта не исправит CLS, если браузер по-прежнему не знает размеры блока.

\n
Как переводить симптом в проверку
СимптомВероятная причинаПроверкаДействие
LCP выше 2500 мсTTFB, ресурс, CSS или шрифтelement timing, TTFB, waterfallУскорить критический путь и повторить замер
INP выше 200 мсlong task или тяжёлый handlerinteraction trace и main threadРазбить работу или перенести её после ответа
CLS выше 0,1Нет места под изображение или вставкуshifted element и layout traceЗарезервировать размер и стабилизировать layout
Среднее хорошее, p75 плохойДлинный хвост или смешанные сегментыp75 по URL, устройству и releaseРазделить сегменты и назначить владельца
Lab и field расходятсяРазная среда и состав трафикаСопоставить условия запускаНе подменять один тип данных другим
\n

Минимальный рабочий пример

\n

Классификатор ниже принимает уже собранные значения и возвращает статус каждой метрики. Он показывает механику budget, но не собирает браузерные данные и не считает percentile. Реальные значения нужно получать через подходящие API наблюдения, сохранять с контекстом и агрегировать по сегментам.

\n
function classifyWebVitals({ lcpMs, inpMs, cls }) {\n  if (![lcpMs, inpMs, cls].every((value) => Number.isFinite(value) && value >= 0)) {\n    return { ok: false, reason: 'vital-input-invalid' };\n  }\n\n  const lcp = lcpMs <= 2500 ? 'good' : lcpMs <= 4000 ? 'needs-improvement' : 'poor';\n  const inp = inpMs <= 200 ? 'good' : inpMs <= 500 ? 'needs-improvement' : 'poor';\n  const layout = cls <= 0.1 ? 'good' : cls <= 0.25 ? 'needs-improvement' : 'poor';\n  const statuses = [lcp, inp, layout];\n  const overall = statuses.includes('poor') ? 'poor'\n    : statuses.includes('needs-improvement') ? 'needs-improvement'\n    : 'good';\n\n  return { ok: true, lcp, inp, layout, overall };\n}\n\nconsole.log(classifyWebVitals({ lcpMs: 2180, inpMs: 240, cls: 0.08 }));\n// { ok: true, lcp: 'good', inp: 'needs-improvement', layout: 'good', overall: 'needs-improvement' }
\n

Порог в функции нужен для triage. Он не отвечает на вопрос «какой код виноват». Даже улучшение LCP после preload не доказывает, что preload был единственной причиной: могли измениться сервер, кэш или состав трафика.

\n
\"Матрица
Один score скрывает разные механизмы. Для каждой метрики нужен собственный объект проверки и действие.
\n

Порядок работы

\n
  1. Определите URL, сегмент, percentile и окно наблюдения. Не сравнивайте p75 мобильного трафика со средним по всем устройствам.
  2. Для плохого LCP найдите element и разделите TTFB, загрузку ресурса и отрисовку. Для INP найдите interaction и long task. Для CLS найдите shifted element.
  3. Сформулируйте один budget на релиз и один диагностический сигнал. Не блокируйте сборку по метрике, которую CI не может воспроизвести.
  4. Сопоставьте лабораторные и полевые данные отдельно. Lighthouse удобен для воспроизводимого CI, field data показывает реальное разнообразие сети и устройств.
  5. Измените один тяжёлый участок: критический ресурс, обработчик, размеры изображения или резервирование места.
  6. Повторите измерение тем же сегментом и окном. Улучшение LCP не закрывает автоматически INP и CLS.
\n

Ограничения и критерий готовности

\n

Локальный запуск классификатора не является field evidence. W3C LCP и Performance Timeline описывают API и объекты измерения, но не проверяют вашу агрегацию, sampling или дашборд. Порог web.dev — практическая рекомендация, а не гарантия UX и не причинная модель. Внешние скрипты, кэш, браузер, сеть и состав аудитории могут изменить результат.

\n

Оптимизация одной метрики может ухудшить другую. Сжатие изображения уменьшает передаваемый объём, но может добавить CPU-декодирование или снизить качество. Разделение JavaScript способно помочь INP, но добавить запросы и повлиять на LCP. Рядом с Core Web Vitals проверяйте размер ресурсов, long tasks и ошибки.

\n

Готовность наступает, когда для выбранного URL есть p75 LCP, INP и CLS по явно названным сегментам; для каждой плохой строки указан element, interaction или shifted element; изменение повторено тем же способом; соседние метрики не ухудшились. Если одного поля нет, вывод нужно назвать неполным, а не превращать его в общий score.

\n

Проверяемые источники

"} +{"index":2,"slug":"editorial-2027-12-mechanism-author-manifesto","title":"Web performance budget: как читать LCP, INP и CLS вместе","excerpt":"Разбираем, почему один показатель не объясняет скорость интерфейса, как разделить LCP, INP и CLS и превратить наблюдение в проверяемое действие.","contentHtml":"

Проблема начинается с отчёта «страница медленная». Такой симптом не говорит, что именно увидел пользователь: крупнейший блок появился поздно, клик обработался с задержкой или контент прыгнул под пальцем. Цена ошибки — чинить не тот участок: уменьшить JavaScript, пока главный баннер ждёт шрифт, или ускорить загрузку, оставив интерфейс заблокированным длинной задачей.

\n

Причина — свести LCP, INP и CLS к одному среднему score. Эти метрики отвечают на разные вопросы и требуют разных разрезов данных. LCP описывает появление крупнейшего видимого элемента, INP — задержку взаимодействия до следующей отрисовки, CLS — неожиданный сдвиг уже видимого layout. Сначала определите тип симптома и границу измерения, потом выбирайте изменение в коде.

\n

Тезис: performance budget состоит из нескольких проверяемых границ

\n

Budget — это не красивое число в дашборде. Это набор порогов, сегментов, периода и действий владельца. Для каждого показателя нужно знать, какую часть опыта он описывает, где искать причину и что считать регрессией. Рекомендованные границы Core Web Vitals — LCP до 2500 мс, INP до 200 мс и CLS до 0,1. Они помогают классифицировать опыт, но не доказывают причину.

\n

Для полевых данных используйте percentile, а не только среднее. Например, p75 показывает значение, ниже которого находится 75 процентов наблюдений в выбранном сегменте. Смешивать в одной строке мобильные и десктопные устройства, разные версии и разные периоды нельзя: итог потеряет смысл. В budget явно запишите URL, устройство, соединение, release и окно наблюдения.

\n

Механизм: три метрики — три объекта диагностики

\n

LCP фиксирует момент, когда крупнейший контентный элемент стал видимым в пределах загрузки. На результат влияют TTFB, критический CSS, изображение, шрифт, сеть и работа браузера. Поэтому плохой LCP — сигнал разобрать цепочку, а не команда сразу добавить preload или сжать картинку.

\n

INP оценивает задержку после взаимодействий пользователя. Ищите конкретное interaction, обработчик и long task на main thread. Причиной может быть тяжёлый обработчик, сторонний скрипт, лишняя синхронная работа или слабое устройство. Быстрый LCP не означает, что интерфейс быстро отвечает после ввода.

\n

CLS (Cumulative Layout Shift) отвечает на вопрос: насколько неожиданно сдвигался уже видимый контент? В актуальном определении CLS выбирает самое большое session window — окно, в котором сдвиги идут с интервалами менее секунды и общей длительностью не более пяти секунд, — и суммирует баллы внутри него. Поэтому «сумма всех сдвигов за страницу» — устаревшее упрощение. Типовые источники — изображение без зарезервированных размеров, поздняя реклама, вставленный сверху контент или изменение шрифта. Для разбора нужны layout-shift entries, source-элементы и момент сдвига.

\n
Как переводить симптом в проверку
СимптомВероятная причинаПроверкаДействие
LCP выше 2500 мсTTFB, ресурс, CSS или шрифтelement timing, TTFB, waterfallУскорить критический путь и повторить замер
INP выше 200 мсlong task или тяжёлый handlerinteraction trace и main threadРазбить работу или перенести её после ответа
CLS выше 0,1Нет места под изображение или вставкуSession window, source-элемент и layout traceЗарезервировать размер и стабилизировать layout
Среднее хорошее, p75 плохойДлинный хвост или смешанные сегментыp75 по URL, устройству и releaseРазделить сегменты и назначить владельца
Lab и field расходятсяРазная среда и состав трафикаСопоставить условия запускаНе подменять один тип данных другим
\n

Рабочий пример: сначала зафиксировать контекст

\n

Допустим, в отчёте checkout для мобильного сегмента p75 INP хуже порога, а LCP и CLS проходят. Это учебный payload, а не измерение реального сайта: он показывает, какие поля нельзя потерять при передаче результата между сбором, дашбордом и владельцем.

\n
{\n  'url': '/checkout',\n  'segment': { 'device': 'mobile', 'connection': '4g' },\n  'release': '2027.12.1',\n  'percentile': 75,\n  'metrics': { 'lcpMs': 2180, 'inpMs': 640, 'cls': 0.08 }\n}
\n

Здесь проблема не в общем score: LCP 2180 мс и CLS 0,08 проходят рекомендованные границы, а INP 640 мс — нет. Следующий вопрос — не «как ускорить страницу», а «какое взаимодействие сформировало p75 и чем занят main thread перед следующим кадром».

\n

Для triage можно использовать маленький классификатор. Он не измеряет браузер и не считает p75: функция принимает уже агрегированные значения одного URL и сегмента. Все пороги в коде — проектная копия опубликованных рекомендаций; при изменении официальных границ их нужно обновлять вместе с тестами.

\n
const BUDGET = {\n  lcpMs: { good: 2500, poor: 4000 },\n  inpMs: { good: 200, poor: 500 },\n  cls: { good: 0.1, poor: 0.25 },\n};\n\nfunction classify(value, limits) {\n  if (!Number.isFinite(value) || value < 0) return 'invalid';\n  if (value <= limits.good) return 'good';\n  if (value <= limits.poor) return 'needs-improvement';\n  return 'poor';\n}\n\nfunction classifyWebVitals({ lcpMs, inpMs, cls }) {\n  const status = {\n    lcp: classify(lcpMs, BUDGET.lcpMs),\n    inp: classify(inpMs, BUDGET.inpMs),\n    cls: classify(cls, BUDGET.cls),\n  };\n\n  if (Object.values(status).includes('invalid')) {\n    return { ok: false, reason: 'vital-input-invalid' };\n  }\n\n  const overall = Object.values(status).includes('poor') ? 'poor'\n    : Object.values(status).includes('needs-improvement')\n      ? 'needs-improvement'\n      : 'good';\n\n  return { ok: true, ...status, overall };\n}\n\nconsole.log(classifyWebVitals({ lcpMs: 2180, inpMs: 640, cls: 0.08 }));\n// { ok: true, lcp: 'good', inp: 'poor', cls: 'good', overall: 'poor' }
\n

Классификатор нужен для единого triage, но он не отвечает на вопрос о причине. Улучшение LCP после preload не доказывает, что preload был единственной причиной: могли измениться сервер, кеш или состав трафика. Не превращайте результат функции в автоматический rollback без проверяемой связи с release и без отдельного анализа trace.

\n
\"Матрица
Один score скрывает разные механизмы. Для каждой метрики нужен собственный объект проверки и действие.
\n

Flow: от строки в дашборде к изменению

\n
field data: p75 + сегмент + release\n  -> какая граница нарушена?\n  -> какой объект её объясняет: resource / interaction / shift?\n  -> какой один участок принадлежит владельцу?\n  -> изменение и повторный замер в том же сегменте\n  -> соседние метрики не ухудшились? да: оставить, нет: откатить
\n

Поток разделяет две операции. Метрика говорит, где болит пользовательский опыт. Trace или запись элемента показывает, что проверять в системе. Владелец изменения отвечает за участок кода или доставки, но не может объявить причину доказанной только по совпавшему времени.

\n

Порядок работы

\n
  1. Определите URL, сегмент, percentile и окно наблюдения. Не сравнивайте p75 мобильного трафика со средним по всем устройствам.
  2. Для плохого LCP найдите кандидат и разделите TTFB, загрузку ресурса и отрисовку. Для INP найдите interaction и разложите задержку на input, processing и presentation. Для CLS найдите session window и source-элементы.
  3. Сформулируйте один budget на релиз и один диагностический сигнал. Не блокируйте сборку по метрике, которую CI не может воспроизвести.
  4. Сопоставьте лабораторные и полевые данные отдельно. Lighthouse удобен для воспроизводимого CI, field data показывает реальное разнообразие сети и устройств.
  5. Измените один тяжёлый участок: критический ресурс, обработчик, размеры изображения или резервирование места. Зафиксируйте владельца и путь отката.
  6. Повторите измерение тем же сегментом и окном. Улучшение LCP не закрывает автоматически INP и CLS.
\n

Ограничения и критерий готовности

\n

Локальный запуск классификатора не является field evidence. Performance Timeline даёт браузерные примитивы для доступа к entries и PerformanceObserver, но не проверяет вашу sampling-логику, backend-агрегацию или дашборд. Полевой INP может отсутствовать, если на странице не было подходящего взаимодействия. LCP зависит от эвристик и прекращает поиск кандидатов после определённых действий пользователя; bfcache и same-document navigation требуют отдельной обработки.

\n

Оптимизация одной метрики может ухудшить другую. Сжатие изображения уменьшает передаваемый объём, но может добавить CPU-декодирование или изменить момент отрисовки. Разделение JavaScript способно помочь INP, но добавить запросы и повлиять на LCP. Резервирование места снижает CLS, но не исправляет медленный ресурс. Рядом с Core Web Vitals проверяйте размер ресурсов, long tasks, ошибки и пользовательский сценарий.

\n

Готовность наступает, когда для выбранного URL есть p75 LCP, INP и CLS по явно названным сегментам; для каждой плохой строки указан кандидат, interaction или source-элемент; изменение повторено тем же способом; соседние метрики не ухудшились. Если одного поля нет, вывод нужно назвать неполным, а не превращать его в общий score.

\n

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/003.json b/editorial/agent-rewrites/003.json index de80121..ef5b03f 100644 --- a/editorial/agent-rewrites/003.json +++ b/editorial/agent-rewrites/003.json @@ -1 +1,7 @@ -{"index":3,"slug":"editorial-2027-12-practice-author-manifesto","title":"Security headers: CSP и HSTS без иллюзии защиты","excerpt":"Как CSP и HSTS ограничивают браузер, почему nonce и HTTPS не заменяют исправление XSS и как вводить политики с проверяемым откатом.","contentHtml":"

Проблема становится видимой после XSS или downgrade-атаки: приложение отдаёт страницу по HTTPS, но разрешает произвольный inline-скрипт или продолжает открываться по HTTP. Симптомы можно увидеть в DevTools: CSP фиксирует нарушение или отсутствует вовсе, а HTTP-запрос сначала доходит до редиректа. Цена ошибки — выполнение чужого кода в контексте origin, утечка токена и потеря данных. Один security header не закрывает весь риск.

Ошибка начинается с копирования длинной строки заголовка без карты ресурсов и момента включения политики. CSP управляет тем, откуда страница может загружать и выполнять ресурсы. HSTS говорит браузеру обращаться к домену по HTTPS после получения политики через доверенный HTTPS-ответ. Ни один заголовок не исправляет серверный XSS, плохой сертификат, mixed content или секрет, который уже попал в JavaScript.

Тезис: заголовок — это граница, а не доказательство безопасности

Политику нужно проектировать от конкретной страницы. Сначала назовите нужные скрипты, стили, изображения, API и фреймы. Затем разрешите только эти источники и отдельно проверьте отрицательные сценарии. Так CSP становится исполняемым контрактом браузера, а не декоративной строкой в конфигурации.

default-src задаёт запасное правило для типов ресурсов, но не заменяет явные директивы. script-src управляет JavaScript. object-src 'none' закрывает загрузку plugin/object, если она не нужна. base-uri 'self' ограничивает изменение базового URL. Такая политика уменьшает поверхность атаки, но не превращает небезопасный sink в безопасный.

Механизм CSP

Браузер получает Content-Security-Policy вместе с ответом и сопоставляет каждую попытку загрузки или выполнения с директивой. При несовпадении ресурс блокируется в режиме enforce. В режиме Report-Only браузер отправляет нарушение для анализа, но не блокирует действие. Поэтому отчёт помогает собрать карту, но сам по себе не является исправлением.

Nonce нужен для конкретного inline-скрипта, который пока нельзя вынести в файл. Сервер генерирует непредсказуемое значение заново для каждого ответа, вставляет его в CSP и в атрибут нужного script. Статическая строка не даёт защиты: тот, кто её узнает, получает разрешение. Полный nonce не стоит писать в диагностические логи.

Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Inline-скрипт заблокированНет nonce или разрешение слишком узкоеСверить директиву script-src и атрибут nonceВынести код в файл или выдать свежий nonce только этому скрипту
В отчёте много нарушенийПолитика собрана без карты ресурсовСгруппировать отчёты по директиве, URL и типу ресурсаУдалить лишние источники и обосновать каждый оставшийся
Сайт всё ещё доступен по HTTPБраузер ещё не получил HSTS или первый переход был HTTPПроверить заголовок в HTTPS-ответе и цепочку переходовИсправить HTTPS и redirect; отдельно оценить требования preload
Поддомен перестал открыватьсяincludeSubDomains включили до проверки всех имёнПроверить сертификаты, HTTPS и endpoint каждого поддоменаРасширять область и max-age только после контролируемой проверки
После CSP остаётся XSSНебезопасный HTML или sink не исправленПроверить контекстное экранирование и места вроде innerHTMLИсправить источник XSS; CSP оставить дополнительным барьером
\"Граф
Два независимых слоя защиты: CSP управляет ресурсами страницы, HSTS — схемой соединения. Один заголовок не заменяет другой.

Минимальный рабочий пример

Ниже — учебная функция, которая собирает заголовки из nonce и срока HSTS. Она не устанавливает заголовки в серверном ответе и не проверяет шаблонизатор. Проверяемый результат показывает только форму политики: nonce присутствует в CSP, а HSTS содержит числовой срок и includeSubDomains.

import { buildSecurityHeaders } from './security-headers.mjs'; const result = buildSecurityHeaders({ nonce: '7c2f1b8e9a4d6f0c', hstsMaxAge: 31536000 }); if (!result.ok) throw new Error(result.reason); response.setHeader('Content-Security-Policy', result.headers['Content-Security-Policy']); response.setHeader('Strict-Transport-Security', result.headers['Strict-Transport-Security']);

В реальном шаблоне nonce должен попасть только в разрешённый inline-скрипт:

<script nonce=\"7c2f1b8e9a4d6f0c\">startApplication();</script>

Пример не доказывает, что приложение безопасно. Он проверяет сборку значения и показывает границу между генерацией заголовка и его применением. Тесты должны отдельно проверить отсутствие случайного unsafe-inline, соответствие nonce в двух местах и отправку заголовков только по нужному маршруту.

Механизм HSTS

HSTS начинает действовать после того, как браузер получил Strict-Transport-Security через доверенный HTTPS-ответ. Большой max-age не защищает самый первый HTTP-переход, если домен ещё неизвестен браузеру. Он также не исправляет сертификат или mixed content. Для защиты первого перехода существует preload с отдельными требованиями и риском: сначала нужно проверить готовность домена.

includeSubDomains распространяет правило на поддомены. Включайте его только после проверки всех имён, которые ещё нужны пользователям, API и административным инструментам. Если один поддомен не умеет HTTPS, браузер перестанет подключаться к нему по HTTP на весь срок политики. Возможность отката — часть безопасности, а не последняя строка runbook.

Порядок внедрения

  1. Соберите карту ресурсов страницы: scripts, inline-код, eval, стили, изображения, CDN, API, iframe и object. Зафиксируйте нужные источники до написания политики.
  2. Включите CSP в Report-Only. Соберите нарушения по директиве, URL и типу ресурса. Не называйте этот режим блокировкой.
  3. Удалите лишние источники. Inline-код вынесите в файл или замените на nonce. Оставшиеся исключения объясните рядом с конфигурацией.
  4. Переведите CSP в enforce на одной проверяемой странице. Сравните ошибки загрузки с разрешённым списком и проверьте отрицательные сценарии.
  5. Проверьте HTTPS для основного домена и поддоменов. Только после этого добавляйте HSTS, затем осторожно расширяйте max-age и область.
  6. Добавьте версионируемый откат и автоматическую проверку заголовков. Изменение не должно зависеть от ручной правки одного proxy.

Ограничения и критерий готовности

Nonce не санитизирует пользовательский HTML и не исправляет небезопасный sink. Если приложение передаёт строку в innerHTML, разрешённый bootstrap может помочь атакующему выполнить уже загруженный код. Нужны контекстное экранирование и безопасные API. CSP снижает последствия и обнаруживает часть нарушений, но не заменяет исправление причины.

Эта схема не проверяет совместимость целевых браузеров, CDN, service worker, iframe-политику, сертификаты, preload и настройки API-клиента. CSP Level 3 остаётся Working Draft, поэтому директивы и поддержку следует сверять с целевыми браузерами. RFC 6797 описывает HSTS, но не даёт защиты до первого безопасного ответа.

Готовность наступает, когда для конкретной страницы есть карта ресурсов, Report-Only нарушения разобраны, enforce проверен, HSTS подтверждён на каждом нужном домене, а откат воспроизводится. Дополнительный признак — автоматическая проверка заголовков ловит возврат широкого источника или исчезновение обязательной политики. Без этих проверок строка конфигурации остаётся предположением.

Проверяемые источники

"} +{ + "index": 3, + "slug": "editorial-2027-12-practice-author-manifesto", + "title": "Security headers: CSP и HSTS без иллюзии защиты", + "excerpt": "Как CSP и HSTS ограничивают браузер, почему nonce и HTTPS не заменяют исправление XSS и как вводить политики с проверяемым откатом.", + "contentHtml": "

Сбой обычно замечают в двух местах: DevTools показывает нарушение Content Security Policy (CSP), а переход на http:// сначала попадает на редирект вместо немедленной замены схемы. Если в этот момент просто добавить длинную строку заголовков, приложение может продолжить выполнять инъецированный код, а первый HTTP-запрос всё ещё может уйти без шифрования. Цена ошибки — украденная сессия, отправленная форма или недоступный поддомен, а не «потерянный зелёный чек».

\n

Главный вопрос не в том, какой шаблон заголовка скопировать, а в том, какую границу он действительно проводит. CSP ограничивает ресурсы и выполнение внутри страницы. HSTS учит браузер обращаться к уже известному хосту только по HTTPS. Ни один из них не исправляет XSS, не выдаёт сертификат и не делает безопасным секрет, который уже попал в JavaScript. Значит, внедрение нужно вести как проверяемое изменение поведения браузера: симптом → причина → отрицательный тест → откат.

\n

Две политики, два владельца

\n

CSP приходит в ответе страницы и применяется к её документу или связанному worker-контексту. Браузер сопоставляет попытку загрузки или выполнения с директивой: script-src отвечает за JavaScript, connect-src — за fetch/XHR и другие соединения, img-src — за изображения, а default-src задаёт запасное правило там, где нет более узкой директивы. object-src 'none' закрывает plugin-контент, если он приложению не нужен; base-uri 'self' ограничивает адреса, допустимые в элементе base.

\n

HSTS работает иначе. После корректного заголовка Strict-Transport-Security, полученного по TLS без ошибки, браузер сохраняет хост как известный HSTS-хост на срок max-age. При следующем обращении к нему по HTTP браузер сам меняет схему на HTTPS. Заголовок, пришедший по обычному HTTP, браузер обязан проигнорировать. Поэтому серверный redirect нужен для первого контакта и старых клиентов, но не заменяет HSTS для клиента, который ещё не знает домен.

\n
Что меняется после включения и кто должен это доказать
СлойДействие браузераЧего не делаетПроверка и владелец
CSPБлокирует ресурс или выполнение, не совпавшее с политикой; может только отправить отчётНе санитизирует HTML и не исправляет небезопасный innerHTMLНегативные сценарии страницы; владелец приложения
HSTSДля известного хоста заменяет HTTP на HTTPS и требует успешный TLSНе защищает первый HTTP-переход и не чинит сертификатыHTTPS-ответы и каждый поддомен; владелец платформы
RedirectПолучает ответ сервера с новой схемойНе скрывает исходный HTTP-запрос от сетевого посредникаЦепочка 3xx и канонический URL; владелец edge
Исправление XSSУбирает исполняемый ввод из приложенияНе заменяется заголовкомКонтекстное экранирование и безопасные API; владелец кода
\n
Схема двух независимых слоёв: карта ресурсов ведёт к CSP, HTTPS-ответ ведёт к HSTS, неизвестный ресурс блокируется или попадает в отчёт
Карта ресурсов и HTTPS-ответ входят в разные ветки. Их нельзя свести к одной «строке безопасности».
\n

Как CSP создаёт барьер, а не иллюзию

\n

Начните с одной страницы и составьте список фактических источников: скрипты, inline-блоки, стили, шрифты, изображения, API, iframe, service worker и plugin-контент. Затем выражайте этот список в директивах. Разрешение https: или широкого CDN может убрать сообщения в консоли, но одновременно разрешить больше серверов, чем нужно странице. Узкий список — не самоцель: каждую внешнюю зависимость нужно связать с конкретной загрузкой и владельцем.

\n

Content-Security-Policy-Report-Only позволяет сначала наблюдать нарушения без блокировки. Это полезный этап инвентаризации, но не защита: сломанный или вредоносный inline-скрипт продолжит выполняться. После разбора отчётов ту же политику переводят в Content-Security-Policy. Проверка должна включать отрицательный путь — например, попытку загрузить https://unknown.example/evil.js — иначе успешная загрузка штатного bundle ничего не доказывает.

\n

Nonce — исключение для конкретного inline-скрипта. Сервер создаёт новое случайное значение для каждого ответа, помещает его в script-src и в атрибут nonce нужного элемента. CSP Level 3 требует уникальное значение; спецификация рекомендует не менее 128 бит до кодирования и криптографически стойкий генератор. Статическая строка в конфигурации нарушает эту модель: её можно повторно использовать и предсказать.

\n

Nonce разрешает inline-скрипт, но не inline-обработчик события вроде onclick и не произвольный HTML. Если пользовательская строка попала в DOM через небезопасный sink, атакующий может использовать разрешённый bootstrap или другой разрешённый путь. Поэтому исправление XSS, контекстное экранирование и безопасные DOM-API остаются первым слоем, а CSP — ограничителем последствий.

\n

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

\n

Ниже — учебный файл security-headers.mjs. Он запускается в Node.js без сторонних пакетов, создаёт nonce и проверяет связь между политикой и HTML. Значения img-src, connect-src, frame-ancestors и годовой max-age — проектный пример, а не готовая политика для любого сайта.

\n
import { randomBytes } from 'node:crypto';\n\nfunction buildSecurityHeaders() {\n  const nonce = randomBytes(16).toString('base64');\n  const contentSecurityPolicy = [\n    \"default-src 'self'\",\n    \"base-uri 'self'\",\n    \"object-src 'none'\",\n    \"script-src 'self' 'nonce-\" + nonce + \"'\",\n    \"style-src 'self'\",\n    \"img-src 'self' data:\",\n    \"connect-src 'self'\",\n    \"frame-ancestors 'none'\",\n    \"form-action 'self'\",\n  ].join('; ');\n\n  return {\n    nonce,\n    headers: {\n      'Content-Security-Policy': contentSecurityPolicy,\n      'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',\n    },\n  };\n}\n\nconst { nonce, headers } = buildSecurityHeaders();\nconst bootstrap = \"<script nonce='\" + nonce + \"'>startApplication();</script>\";\nconst csp = headers['Content-Security-Policy'];\nconst nonceSource = \"'nonce-\" + nonce + \"'\";\n\nif (!csp.includes(nonceSource)) throw new Error('nonce is absent from CSP');\nif (!bootstrap.includes(\"nonce='\" + nonce + \"'\")) throw new Error('nonce is absent from HTML');\nif (csp.includes(\"'unsafe-inline'\")) throw new Error('broad inline permission detected');\n\nconsole.log({ headers, bootstrap });
\n

Запуск node security-headers.mjs должен вывести два заголовка и один bootstrap с одинаковым nonce. Это проверяет только сборку значения. Серверный адаптер всё ещё обязан отправить заголовки именно в HTTPS-ответе HTML и передать тот же nonce шаблону. При кэшировании страницы нужно проверить, что HTML и CSP не разъезжаются: сохранённый HTML со старым nonce при новом заголовке будет заблокирован, а повторно использованный nonce ослабит модель.

\n

Для страницы с внешним CDN добавьте конкретный origin только после проверки реального запроса, например https://cdn.example/asset.js, и повторите отрицательный тест. Не добавляйте 'unsafe-inline' или 'unsafe-eval' ради исчезновения одной ошибки сборки: сначала выясните, какой код создаёт inline или динамическое выполнение. Если библиотека требует это исключение, запишите риск, владельца и срок удаления рядом с конфигурацией.

\n

HSTS: что происходит до и после первого посещения

\n

У HSTS есть неудобная граница: политика появляется только после доверенного HTTPS-ответа, если браузер заранее не знает домен из своего preload-списка. Пользователь, впервые открывший http://example.test, сначала зависит от сети и server redirect. Посредник может изменить этот первый запрос или ответ до того, как браузер узнает о HSTS. Поэтому порядок такой: исправить TLS, включить редирект на HTTPS, проверить сертификат и только затем отдать HSTS в HTTPS-ответе.

\n

includeSubDomains расширяет политику на все поддомены. Это не декоративный флаг: отдельный API, старый кабинет, health endpoint или внешний инструмент может перестать открываться, если он не готов к HTTPS или имеет другой сертификат. Сначала соберите список имён и владельцев, затем проверьте их вручную и автоматикой. В учебном примере выше включение флага — осознанное значение для домена, где такая инвентаризация уже пройдена.

\n

max-age=0 позволяет сообщить браузеру по HTTPS, что политика хоста больше не должна считаться действующей. Это не мгновенный глобальный откат: клиент должен снова успешно соединиться по HTTPS, а другие клиенты могли ещё не получить новое значение. Чем дольше срок и шире область, тем дороже ошибка конфигурации. HSTS нельзя использовать как замену управляемому rollout и плану восстановления.

\n

Runbook перед переводом в enforce

\n
  1. Зафиксировать границы. Выпишите страницу, её домен, поддомены, типы ресурсов и владельцев. Отдельно отметьте inline-код, динамический eval, iframe, worker, CDN и кэш.
  2. Проверить TLS и redirect. Выполните curl -sS -D - -o /dev/null https://example.test/ и убедитесь, что HTTPS-ответ содержит ровно одну CSP и один HSTS. Для http:// проверьте код и Location; не принимайте redirect за доказательство HSTS.
  3. Собрать нарушения. Отправьте CSP в Report-Only на одной странице, откройте штатные сценарии и сгруппируйте сообщения по директиве, URL и типу ресурса. Фильтруйте шум, но не удаляйте повторяемые нарушения без объяснения.
  4. Сузить политику. Вынесите inline-код в файл или выдайте ему свежий nonce. Удалите широкие источники и лишние исключения. Для каждого оставшегося origin укажите запрос, владельца и тест.
  5. Проверить блокировку. Переведите CSP в enforce на тестовом маршруте. Убедитесь, что штатный сценарий работает, неизвестный script блокируется, запрещённый frame не встраивает страницу, а форма не уходит на непредусмотренный origin.
  6. Проверить поддомены. Для каждого имени под includeSubDomains проверьте сертификат, HTTPS-ответ, API-клиента и административные пути. Если список неполон, начните с области без флага и не расширяйте её по привычке.
  7. Закрепить контроль. Добавьте в CI проверку обязательных директив и отсутствия случайных unsafe-inline/unsafe-eval. В релизном плане укажите владельца, наблюдаемость нарушений и восстановление через версионируемую конфигурацию.
\n

Ограничения и критерий готовности

\n

Эта статья не обещает, что два заголовка закрывают всю модель угроз. CSP зависит от того, какие контексты и браузеры поддерживает продукт, как CDN переписывает ответы, где рендерится HTML и какие сервисы загружаются после навигации. Worker, iframe, кэш, service worker, API CORS и сертификаты нужно проверять отдельно. В частности, HSTS не заменяет настройку TLS, а CSP не контролирует логику сервера или уже украденный токен.

\n

В указанной датированной редакции W3C CSP Level 3 имеет статус Working Draft, поэтому директиву, поддержку браузеров и поведение конкретной версии следует сверять перед релизом. RFC 6797 — нормативная спецификация HSTS, но она не превращает первый HTTP-переход в защищённый. Если проект использует preload-список, рассматривайте его как отдельный операционный процесс с собственными условиями и стоимостью вывода домена.

\n

Готовность можно доказать коротким пакетом: HTML-ответ содержит согласованные CSP и HSTS по HTTPS; Report-Only нарушения разобраны; enforce блокирует искусственно добавленный неизвестный ресурс; штатный bootstrap проходит с новым nonce; каждый поддомен прошёл TLS-проверку; CI ловит ослабление политики. Если хотя бы один пункт неизвестен, безопаснее оставить конкретную границу в ограничении и назначить следующий тест, чем назвать заголовок защитой.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/004.json b/editorial/agent-rewrites/004.json index e233021..0c5b612 100644 --- a/editorial/agent-rewrites/004.json +++ b/editorial/agent-rewrites/004.json @@ -1 +1,7 @@ -{"index":4,"slug":"editorial-2027-11-field-mistakes-revisions","title":"Incident runbook: как пройти от симптома к проверенному rollback","excerpt":"Практический маршрут инцидента: зафиксировать симптом и границу влияния, проверить одну гипотезу, выполнить обратимое действие и убедиться, что восстановилась система, а не только один график.","contentHtml":"

Проблема во время инцидента начинается с фразы «сервис упал после релиза». Она смешивает наблюдение, время и причинность. На практике симптом может выглядеть иначе: HTTP 503, рост p95 latency, очередь сообщений или ошибки только у одного метода и в одном регионе.

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

Тезис: runbook должен ограничивать неопределённость

Хороший runbook не угадывает первопричину за оператора. Он превращает тревожный сигнал в короткий цикл: зафиксировать факт, сузить область, сформулировать гипотезу, выполнить одну обратимую проверку и измерить результат. Если гипотеза не подтверждается, инструкция должна вести к следующей проверке, а не подталкивать к тому же действию.

Начинайте с полей, которые можно заполнить без интерпретации: timestamp, affected endpoint, регион, версия, доля ошибок, latency, saturation и baseline. Запись «всё медленно» не задаёт ни области поиска, ни критерия улучшения. Запись «POST /payments, EU, 5xx 8%, p95 1,8 с, baseline — предыдущее окно» уже позволяет выбрать следующий шаг.

Механизм: сигнал, гипотеза, действие, восстановление

Статус 500, задержка и error rate описывают разные стороны одного события. Они могут иметь общий корень, но не обязаны. Поэтому классификация сигнала должна быть детерминированной и прозрачной: высокий приоритет получает отказ или превышение error threshold, затем проверяется latency threshold. Классификатор помогает выбрать срочность, но не доказывает причину.

Гипотеза связывает наблюдение с проверяемым признаком: «после изменения лимита pool выросло ожидание соединения; проверка — метрика pool wait в том же временном окне». В формулировке должны быть условие и ожидаемый сигнал. Гипотеза «виноват backend» слишком широкая: её нельзя опровергнуть одним локальным измерением.

Первое изменение должно уменьшать blast radius. Подойдут отключение feature flag, остановка нового consumer или перевод небольшой доли трафика на старую версию — если такой путь предусмотрен архитектурой. Перезапуск без фиксации метрик может убрать симптом и одновременно удалить полезный контекст.

\"Петля
Каждое действие в runbook должно иметь ожидаемый эффект, условие возврата и отдельную проверку результата.

Минимальный рабочий пример

Ниже — небольшая чистая функция. Она принимает HTTP status, latency и error rate, применяет два явно заданных порога и возвращает классификацию. Числа в примере учебные: функция не знает бизнес-критичность endpoint, не отправляет уведомления и не выбирает rollback.

function classifySignal({ status, latencyMs, errorRate, latencyLimitMs = 1000, errorLimit = 0.05 }) {\n  if (!Number.isInteger(status) || !Number.isFinite(latencyMs) || !Number.isFinite(errorRate)) {\n    return { ok: false, reason: 'signal-invalid' };\n  }\n  if (status >= 500 || errorRate >= errorLimit) {\n    return { ok: true, severity: 'high', reason: 'availability-or-error-threshold' };\n  }\n  if (latencyMs >= latencyLimitMs) {\n    return { ok: true, severity: 'medium', reason: 'latency-threshold' };\n  }\n  return { ok: true, severity: 'low', reason: 'signal-below-threshold' };\n}\n\nconsole.log(classifySignal({ status: 503, latencyMs: 820, errorRate: 0.08 }));\n// { ok: true, severity: 'high', reason: 'availability-or-error-threshold' }

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

Карта диагностики

Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаПервое действие
5xx растёт сразу после выкатаНовая версия или flag затронули часть трафикаСравнить версии, долю трафика и error rate по endpointОграничить flag или traffic split, затем повторить измерение
p95 растёт, 5xx не меняетсяОжидание соединения, CPU или внешнего ответаСопоставить latency breakdown, saturation и pool waitНе перезапускать всё; убрать один изменённый лимит, если он подтверждён
Очередь увеличивается после откатаConsumer остановлен или не справляется с потокомПроверить lag, rate обработки и ошибки consumerВернуть только совместимый consumer или остановить входной поток по policy
5xx снизился, операции не завершаютсяСервис доступен, но есть незавершённое состояниеСверить успешные операции, очередь, дубли и состояние данныхНе закрывать инцидент; запустить recovery-проверку и сохранить факты
Timeout при rollbackИзменение не применилось или control plane недоступенПроверить фактическую версию и audit log, а не только код командыОстановить новые изменения и выбрать заранее описанный ручной путь

В таблице указаны направления проверки, а не универсальные причины. Один симптом может иметь несколько объяснений. Оператор должен подтвердить выбранную ветку своим сигналом и записать результат рядом с timestamp.

Rollback не равен восстановлению

Rollback возвращает конфигурацию, feature flag или binary. Recovery означает, что система снова выполняет допустимую работу и данные остаются согласованными. Можно успешно вернуть старую версию и оставить очередь, двойные записи или несовместимые события. Поэтому после rollback проверяйте не только 5xx, но и lag, успешность операций, saturation и консистентность данных.

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

Критерий завершения должен быть измеримым. Формулировка «график выглядит лучше» не подходит. Нужны конкретные условия: error rate ниже согласованного порога, latency вернулась к допустимому диапазону, очередь не продолжает расти, а контрольная бизнес-операция проходит. Интервал наблюдения зависит от окна метрики и характера нагрузки; его задают заранее, а не в момент усталости.

Порядок действий

  1. Запишите timestamp, scope, версию и один измеримый симптом. Сохраните ссылку на dashboard и baseline.
  2. Проверьте границу влияния: регионы, методы, версии, долю трафика и затронутые зависимости.
  3. Сформулируйте одну гипотезу и одну проверку. Для проверки заранее назовите сигнал, который должен измениться.
  4. Выберите одно обратимое действие. Запишите ожидаемый эффект, риск и условие возврата до выполнения команды.
  5. После действия выдержите заданное окно и сравните error rate, latency, saturation, очередь и бизнес-сигнал.
  6. Если сигнал не улучшился, остановите ветку и зафиксируйте отрицательный результат. Не добавляйте второе изменение, пока не понятен эффект первого.
  7. После стабилизации сохраните timeline, фактическое состояние версии и оставшиеся риски. Не удаляйте временные изменения до проверки recovery.

Ограничения и критерий готовности

Этот маршрут не заменяет on-call график, права доступа, SLO, уведомления и локальную матрицу severity. Классификатор не видит traces, не отличает бизнес-критичный endpoint от второстепенного и не знает, можно ли безопасно повторить операцию. Порог 5% и latency 1000 мс в примере не являются рекомендацией для конкретной системы.

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

Минимальная проверка инструкции — проиграть один сценарий на тестовом окружении с контролируемым 503 и пройти ветку до конца. Такой прогон проверяет структуру runbook, но не доказывает поведение production, отсутствие флаков или безопасность реального rollback. Фактические результаты конкретной системы нужно приложить отдельно.

Проверяемые источники

"} +{ + "index": 4, + "slug": "editorial-2027-11-field-mistakes-revisions", + "title": "Incident runbook: как пройти от симптома к проверенному rollback", + "excerpt": "Практический маршрут инцидента: зафиксировать симптом и границу влияния, проверить одну гипотезу, выполнить обратимое действие и доказать recovery по техническому и бизнес-сигналу.", + "contentHtml": "

После выката payments-api начал отвечать 503 на POST /payments. Ошибки видны только в EU, а health-check продолжает быть зелёным. Первое решение — откатить последнюю версию, но одного статуса Deployment недостаточно: он может сообщить о завершённом rollout, пока платежи остаются в очереди.

\n

Цена неточного диагноза — второй инцидент поверх первого. Если одновременно перезапустить pod, изменить лимит и откатить код, команда теряет причинную связь. Если старая версия не совместима с уже изменённой схемой или событиями, rollback вернёт бинарник, но добавит ошибки чтения, дубли или потерянные операции. Вопрос runbook такой: как перейти от наблюдаемого симптома к rollback, а затем подтвердить восстановление системы?

\n

Сначала факт, потом причина

\n

Код 503 сообщает о недоступности сервиса, а не о том, какой компонент виноват. Код 500 означает, что сервер столкнулся с неожиданным условием и не смог выполнить запрос; он также не раскрывает первопричину. Поэтому первой записью инцидента должен быть не вывод «сломался backend», а воспроизводимый факт: время, метод, endpoint, регион, текущая версия, доля ответов и baseline.

\n

Сигналы нужно разделять по функции. Метрика — числовое измерение во времени, trace — путь отдельного запроса через систему, log — запись события. Они отвечают на разные вопросы и дополняют друг друга. Например, error rate показывает масштаб отказа, trace — где выросло ожидание, а log — какое условие привело к отказу. Не стоит заменять три источника одним общим графиком.

\n
Симптом: 503 на POST /payments\nScope: EU, revision 18, 12:10–12:18 UTC\nBaseline: предыдущее сопоставимое окно\nГипотеза: revision 18 не проходит запрос к payment-provider\nПроверка: сравнить trace/span provider.call и ошибки revision 18/17\nОжидаемый признак: проблема сосредоточена в revision 18
\n

Числа в этом фрагменте — учебные значения, а не отчёт о реальном сервисе. В настоящем инциденте baseline, окно и источник данных берутся из вашей системы наблюдаемости. Если baseline неизвестен, так и запишите: неизвестное нельзя превращать в порог только ради красивого отчёта.

\n

Механизм: пять границ одного действия

\n

Runbook ограничивает неопределённость не количеством команд, а границами. Перед rollback нужно ответить на пять вопросов.

\n
  1. Что наблюдаем? Один симптом с единицей измерения и временным окном.
  2. Где действует проблема? Endpoint, регион, версия, доля трафика и зависимость.
  3. Какую гипотезу проверяем? Одно изменение должно иметь один ожидаемый сигнал.
  4. Что именно возвращаем? Flag, traffic split, конфигурацию или версию; это не взаимозаменяемые операции.
  5. Что считаем recovery? Не только rollout status, но и технический, пользовательский и, при необходимости, data-сигнал.
\n

Эта последовательность связывает состояние с владельцем. On-call фиксирует факт и координирует действие, владелец сервиса подтверждает совместимость версии, а владелец данных отвечает за незавершённые операции и recovery. В маленькой команде роли может выполнять один человек, но сами проверки не исчезают.

\n
Схема incident runbook: симптом и область влияния переходят в одну гипотезу, обратимое действие и проверку метрикой и бизнес-сигналом
Цикл должен замыкаться проверкой: после действия смотрим не на факт выполнения команды, а на восстановление нужного результата.
\n

Как выбрать обратимое действие

\n
Варианты первого действия при инциденте
ДействиеЧто оно возвращаетЦена и рискКогда выбирать
Выключить feature flagПоведение за границей flagНе поможет, если ошибка в общем коде; старый путь должен оставаться рабочимПроблема привязана к новому сценарию и flag уже предусмотрен
Уменьшить traffic splitДолю пользователей на новой версииОставляет часть риска и усложняет сравнение сигналовЕсть независимый старый и новый маршрут, а полное отключение не нужно
Откатить DeploymentPod template и версию контейнераНе отменяет миграцию базы, внешние записи и уже созданные событияСтарая версия подтверждённо совместима с текущим состоянием
Перезапустить процессТекущее runtime-состояние процессаМожет временно скрыть симптом и уничтожить полезный контекстЕсть подтверждение утечки или зависшего процесса, но это не замена rollback
\n

Самый дешёвый по blast radius вариант не всегда самый быстрый по времени восстановления. Flag хорош, если проблема действительно находится за ним. Rollback Deployment понятнее, но его граница уже: он не возвращает схему базы и не стирает побочные эффекты. Перезапуск выбирают только под подтверждённую гипотезу, а не как универсальную кнопку.

\n

Учебный пример: rollback Deployment

\n

Представим, что payments-api работает в namespace payments, текущая ревизия — 18, а ревизия 17 прошла проверку совместимости. Это пример для Kubernetes; имена, namespace, таймаут и номер ревизии нужно заменить на значения своего кластера.

\n
# 1. Сохраняем факт и смотрим, что именно было развернуто\nkubectl -n payments rollout history deployment/payments-api\nkubectl -n payments get deployment payments-api \\\n  -o jsonpath='{.spec.template.spec.containers[*].image}{"\\n"}'\n\n# 2. Выполняем один обратимый шаг\nkubectl -n payments rollout undo deployment/payments-api --to-revision=17\n\n# 3. Ждём завершения именно этого rollout\nkubectl -n payments rollout status deployment/payments-api --timeout=5m\n\n# 4. Проверяем фактическое состояние и пользовательский путь\nkubectl -n payments get deployment payments-api\ncurl -sS -o /dev/null -w '%{http_code}\\n' \\\n  https://payments.example.test/health/ready
\n

Команда rollout undo возвращает Deployment к выбранной ревизии, а rollout status показывает состояние rollout. Это техническая проверка контроллера. Она не доказывает, что авторизованный платёж завершился, очередь уменьшается или данные согласованы. Для этого нужен отдельный безопасный контрольный запрос и сверка бизнес-сигнала. Если у endpoint нет публичного readiness URL, используйте внутренний проверочный путь с нужной авторизацией, но не подменяйте его произвольным 200 OK.

\n

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

\n

Гейт перед rollback

\n

Даже маленькая проверка в коде помогает не превратить runbook в список команд. Функция ниже не выполняет откат и не объявляет recovery. Она только проверяет минимальные условия для учебного плана: есть наблюдаемый scope, известна целевая ревизия, записан путь возврата и подтверждена совместимость.

\n
function rollbackGate({\n  scope,\n  currentRevision,\n  targetRevision,\n  schemaCompatible,\n  eventsCompatible,\n  rollbackPathReady,\n}) {\n  const blockers = [];\n\n  if (!scope) blockers.push('scope-missing');\n  if (!Number.isInteger(currentRevision)) blockers.push('current-revision-missing');\n  if (!Number.isInteger(targetRevision)) blockers.push('target-revision-missing');\n  if (currentRevision === targetRevision) blockers.push('same-revision');\n  if (schemaCompatible !== true) blockers.push('schema-compatibility-unconfirmed');\n  if (eventsCompatible !== true) blockers.push('event-compatibility-unconfirmed');\n  if (rollbackPathReady !== true) blockers.push('rollback-path-unprepared');\n\n  return { allowed: blockers.length === 0, blockers };\n}\n\nconsole.log(rollbackGate({\n  scope: 'EU / POST /payments / revision-18',\n  currentRevision: 18,\n  targetRevision: 17,\n  schemaCompatible: true,\n  eventsCompatible: true,\n  rollbackPathReady: true,\n}));\n// { allowed: true, blockers: [] }
\n

Гейт намеренно консервативен: undefined не считается подтверждением. В production вместо булевых значений должны быть ссылки на migration contract, описание события или результат отдельной проверки. Сам код не знает, разрешено ли действие политикой доступа, есть ли активная атака и выдержит ли система выбранное окно наблюдения.

\n

Матрица симптомов и следующих проверок

\n
Как не перепутать улучшение графика с recovery
Симптом после действияЧто это может означатьСледующая проверкаРешение
5xx снизился, но операции не завершаютсяДоступность вернулась, а состояние платежа осталось незавершённымКонтрольная операция, очередь, дубли и записи в журналеИнцидент не закрывать; запускать recovery по процедуре владельца данных
p95 снизился, очередь продолжает растиHTTP-слой быстрее, consumer не успевает обрабатывать входLag, rate обработки и ошибки consumerПроверять consumer отдельно; не делать вывод об общем восстановлении
Ошибки остались только в EUЛокальная зависимость, конфигурация или маршрут регионаСравнить trace, конфигурацию и версию по регионамОграничить scope действия, не откатывать глобально без доказательства
После rollback метрики колеблютсяОкно наблюдения короче, чем цикл нагрузки или очередь ещё дренируетсяСопоставить окно с baseline и записать критерий остановкиНе считать quiet period доказательством; продолжить наблюдение по плану
Команда undo завершилась ошибкойНет нужной ревизии, конфликтует rollout или недоступен control planeИстория Deployment, фактическая версия, audit log и праваОстановить новые изменения и перейти к заранее описанному ручному пути
\n

В последней строке используется закрытая ветка: если control plane не отвечает, повтор команды не становится диагностикой. Сначала фиксируем ошибку и фактическое состояние. Действия в обход штатного пути разрешены только политикой вашей платформы и с отдельной записью владельца.

\n

Runbook от симптома до закрытия

\n
  1. Зафиксировать. Записать timestamp, endpoint, регион, версию, метрику, baseline, trace ID или ссылку на конкретный запрос. Не менять систему на этом шаге.
  2. Ограничить scope. Сравнить затронутые регионы, методы, версии, долю трафика и зависимости. Отделить пользовательскую ошибку от отказа сервера.
  3. Сформулировать гипотезу. Назвать один предполагаемый механизм и один сигнал, который его подтвердит или опровергнет.
  4. Выбрать рычаг. Сравнить flag, traffic split и rollback по blast radius, обратимости и совместимости. Зафиксировать владельца команды.
  5. Проверить гейт. Убедиться, что target version, schema, events, права и путь возврата известны. Если неизвестно — остановить опасную ветку.
  6. Выполнить одно действие. Сохранить команду, время начала, ожидаемый эффект и фактический результат. Не добавлять параллельный restart без новой гипотезы.
  7. Проверить rollout. Посмотреть фактическую версию и состояние контроллера. Успешное выполнение команды — промежуточный результат.
  8. Проверить recovery. Сверить error rate, latency, saturation, очередь и контрольную бизнес-операцию в заранее выбранном окне. Для денежных или иных важных операций добавить проверку согласованности данных.
  9. Закрыть или эскалировать. Закрывать инцидент только при выполнении всех критериев. Иначе сохранить отрицательный результат, остановить новые изменения и передать владельцу следующую ветку.
\n

Критерий «график стал зелёным» слишком слаб. Минимальный критерий recovery должен быть записан в терминах вашего сервиса: какой error rate допустим, в каком окне; какой latency считается приемлемой; должна ли очередь перестать расти или полностью опустеть; какая безопасная операция подтверждает пользовательский результат. Порог и длительность нельзя честно вывести из этого текста без данных о нагрузке и SLO.

\n

Ограничения

\n

Этот runbook описывает управляемый релизный инцидент, а не все виды аварий. При подозрении на компрометацию, утечку данных, повреждение базы или нарушение регуляторных требований действуют security, legal и data-recovery процедуры. NIST SP 800-61r3 задаёт общий язык и модель Detect, Respond, Recover для киберинцидентов, но не выдаёт пороги, команды Kubernetes или разрешение на конкретный rollback.

\n

Kubernetes-пример зависит от наличия истории ревизий и прав доступа; политика хранения истории может сделать старую ревизию недоступной. Команда откатывает шаблон Deployment, но не является миграцией базы и не отменяет внешние побочные эффекты. В системах без versioned rollout нужен другой адаптер: например, атомарный переключатель трафика или заранее подготовленный конфигурационный rollback.

\n

Наконец, этот текст не доказывает, что ваш сервис восстановится. Перед production добавьте тестовый прогон с контролируемым отказом, проверьте negative path и назначьте владельца каждого сигнала. Если прогон показывает, что команду можно выполнить, но recovery-сигнал получить нельзя, runbook ещё не готов.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/005.json b/editorial/agent-rewrites/005.json index 8ca88b0..ec08b33 100644 --- a/editorial/agent-rewrites/005.json +++ b/editorial/agent-rewrites/005.json @@ -1,7 +1 @@ -{ - "index": 5, - "slug": "editorial-2027-11-mechanism-mistakes-revisions", - "title": "Retry без шторма: как повторять HTTP-запросы безопасно", - "excerpt": "Повтор запроса помогает пережить временный сбой только после проверки идемпотентности, статуса, Retry-After и общего deadline. Разбираем backoff, jitter и отрицательный путь для записей.", - "contentHtml": "

Симптом обычно выглядит просто: upstream отвечает 503 или 429, клиент ждёт timeout, а затем несколько раз отправляет тот же запрос. Если таких клиентов много, восстановление превращается во вторую волну нагрузки. Растут latency и очередь, здоровые запросы получают меньше ресурсов. Для POST цена выше: сервер мог принять запись до разрыва соединения, а повтор может создать второй заказ, платёж или задачу.

\n

Тезис статьи короткий: retry — это решение о семантике, а не число попыток в конфиге. Сначала нужно понять, можно ли безопасно повторить операцию и как узнать результат первой попытки. Только после этого выбирают статус, задержку, jitter и предел. Backoff не делает небезопасную запись безопасной. Idempotency key не отменяет deadline и не заменяет проверку состояния.

\n

Механизм ошибки

\n

У клиента есть два независимых вопроса. Первый: разрешён ли повтор. Второй: когда его отправить. Код ответа и HTTP-метод помогают ответить на первый вопрос, но не описывают всю бизнес-семантику. GET обычно читает ресурс. PUT и DELETE относятся к идемпотентным методам по смыслу HTTP: повтор не должен менять запрошенный эффект. POST часто создаёт новую операцию, поэтому таймаут оставляет результат неизвестным.

\n

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

\n

Коды 429 и 503 тоже не дают универсального разрешения. 429 означает ограничение частоты; ответ может содержать Retry-After. 503 часто указывает на временную недоступность, но повтор без лимита способен продлить перегрузку. 400, 401 и 403 обычно требуют исправить входные данные или права. Повтор не изменит причину. Сетевой timeout не является HTTP-статусом и требует отдельно оценить, была ли операция отправлена и могла ли она завершиться.

\n
Решение о повторе по наблюдаемому симптому
СимптомПричинаПроверкаДействие
429 с Retry-AfterСработал лимит частотыПрочитать заголовок и область лимитаПовторить только идемпотентную операцию, не раньше разрешённого времени
503 без Retry-AfterUpstream временно недоступен или перегруженСверить статус, deadline и счётчик попытокПрименить ограниченный backoff с jitter
Timeout GETОтвет потерян или сервер ещё работаетПовторно прочитать ресурс и проверить остаток deadlineПовторить чтение при наличии бюджета
Timeout POSTЗапись могла завершитьсяПроверить idempotency key или статус операцииНе отправлять второй POST без защиты
400, 401 или 403Неверные данные или праваПосмотреть тело ответа и авторизациюНе повторять автоматически
\n

Backoff и jitter

\n

Экспоненциальный backoff снижает частоту повторов по мере роста номера попытки. Базовая формула: min(cap, base * 2^attempt). Параметр cap не даёт задержке расти бесконечно. Но один backoff не решает проблему синхронизации. Если тысячи клиентов получили сбой в одном интервале, одинаковая формула разбудит их почти одновременно.

\n

Jitter добавляет случайное смещение. В простом варианте клиент выбирает задержку в диапазоне от нуля до рассчитанного значения. В другом варианте он добавляет небольшой случайный интервал к deterministic backoff. Выбор варианта зависит от клиента и нагрузки. Важно не смешать случайность с бесконтрольным ожиданием: итоговая задержка всё равно должна укладываться в общий deadline и максимальное число попыток.

\n

Deadline принадлежит всей операции. Нельзя выдавать каждой попытке новый полный timeout. Если у операции осталось 120 миллисекунд, а рассчитанный backoff равен 500 миллисекундам, нужно завершить операцию или перейти к проверке состояния. Иначе локальный retry будет скрывать задержку от вызывающего кода и увеличивать очередь. В журналах сохраняйте номер попытки, статус, задержку, остаток deadline и причину остановки. Не записывайте секреты и полное тело запроса.

\n
\"Матрица
Сначала проверяется семантика операции. Только после этого выбирается задержка. Нижняя граница схемы напоминает: deadline и идемпотентность важнее числа попыток.
\n

Учебный пример политики

\n

Ниже — учебная функция для расчёта задержки. Она не выполняет HTTP-запрос, не генерирует случайность и не знает, разрешён ли retry для конкретного метода. Jitter передаётся числом, чтобы пример имел воспроизводимый результат. В реальном клиенте случайное значение нужно получать через управляемый генератор и сравнивать итог с остатком deadline.

\n
function retryDelay(attempt, baseMs, capMs, jitterMs) {\n  if (!Number.isInteger(attempt) || attempt < 0) {\n    return { ok: false, reason: 'invalid-attempt' };\n  }\n  if (![baseMs, capMs, jitterMs].every(Number.isFinite)) {\n    return { ok: false, reason: 'invalid-delay' };\n  }\n  if (baseMs < 0 || capMs < baseMs || jitterMs < 0) {\n    return { ok: false, reason: 'invalid-delay-range' };\n  }\n\n  const exponentialMs = Math.min(capMs, baseMs * (2 ** attempt));\n  return {\n    ok: true,\n    exponentialMs,\n    delayMs: exponentialMs + jitterMs,\n  };\n}\n\nconst result = retryDelay(3, 100, 1000, 37);\n// { ok: true, exponentialMs: 800, delayMs: 837 }
\n

Числа в примере учебные. На попытке с индексом 3 экспоненциальная часть равна 800 миллисекундам, потому что 100 * 2^3 не превышает cap 1000. Этот расчёт не доказывает, что задержка подходит вашему upstream. Для подбора параметров нужны его лимиты, общий deadline, размер пула и допустимая нагрузка. Если jitter добавить после проверки deadline, клиент всё равно может превысить бюджет, поэтому проверку выполняют для итоговой задержки.

\n

Idempotency key и отрицательный путь

\n

Request ID связывает попытки в логах. Он не заставляет сервер считать их одной операцией. Idempotency key должен входить в контракт endpoint. Сервер хранит ключ вместе с идентификатором операции и результатом в течение согласованного времени. При повторе с тем же ключом и тем же содержимым он возвращает тот же результат или текущее состояние. При другом содержимом сервер должен отклонить запрос, а не молча перезаписать первую операцию.

\n

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

\n

Отрицательный путь должен быть явным. Если POST завершился timeout, нет ключа и нет API статуса, клиент не повторяет его автоматически. Он возвращает состояние «результат неизвестен», сохраняет correlation id и предлагает безопасную проверку. Если upstream отвечает 400, клиент исправляет запрос или показывает ошибку. Если deadline закончился во время backoff, клиент останавливается. Повтор ради заполнения метрики успешных ответов не является корректным действием.

\n

Порядок настройки retry

\n
  1. Опишите endpoint, HTTP-метод, побочные эффекты и источник истины. Для записи укажите, как узнать результат после разрыва соединения.
  2. Разделите статусы и сетевые сбои. Для каждого сигнала задайте право на повтор, причину остановки и ожидаемый ответ сервера.
  3. Проверьте idempotency key, статусную операцию или иной механизм дедупликации. Если механизм отсутствует, запретите автоматический retry для неизвестного результата.
  4. Передайте один абсолютный deadline через весь вызов. Перед каждой попыткой сравнивайте остаток времени с рассчитанной задержкой и timeout самой попытки.
  5. Задайте base, cap и максимум попыток. Проверьте нулевую попытку, достижение cap, отрицательные и нечисловые параметры.
  6. Добавьте jitter после расчёта backoff, но до проверки deadline. В тесте подмените генератор случайных чисел или передайте фиксированное значение.
  7. Обработайте Retry-After. Не отправляйте запрос раньше указанного сервером времени, если это не запрещает deadline. Некорректный заголовок обработайте как недоверенное значение.
  8. Проверьте искусственные 429, 503, timeout до ответа и timeout после отправки тела. Сверяйте число запросов, дубли записей и время до остановки.
\n

Ограничения

\n

HTTP-метод не описывает побочные эффекты конкретного сервиса. GET может запускать плохо спроектированную команду, а POST может быть идемпотентным по отдельному контракту. Проверяйте реализацию endpoint, а не только название метода.

\n

Retry не заменяет circuit breaker, rate limit, очередь, bulkhead и backpressure. Эти механизмы решают разные задачи. Circuit breaker ограничивает вызовы при устойчивом сбое. Rate limit распределяет бюджет запросов. Очередь меняет момент выполнения. Ни один из них не разрешает повторить неизвестную запись без проверки семантики.

\n

Таблица и код выше не являются готовой библиотекой. Они не учитывают конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. Пример не содержит production-замеров и не заявляет универсальные значения base, cap или jitter. Эти параметры нужно подтвердить тестом на вашей границе нагрузки.

\n

Проверяемый критерий готовности

\n

Политика готова, если для каждого endpoint можно ответить на четыре вопроса: какую операцию повторяем, почему она безопасна, сколько времени и попыток разрешено, и что делаем при неизвестном результате. Тест должен показать, что 429 учитывает Retry-After, 503 не создаёт синхронную вторую волну, timeout POST не создаёт дубль без ключа, а исчерпание deadline останавливает цикл. Если хотя бы один ответ звучит как «повторяем всегда», политика не готова.

\n

Проверяемые источники

" -} +{"index":5,"slug":"editorial-2027-11-mechanism-mistakes-revisions","title":"Retry без шторма: как повторять HTTP-запросы безопасно","excerpt":"Повтор запроса переживает временный сбой только после проверки семантики операции, общего deadline и способа узнать результат записи. Разбираем backoff, jitter и безопасный отрицательный путь.","contentHtml":"

Симптом знакомый: upstream отвечает 503 или 429, клиент ждёт timeout и отправляет тот же запрос снова. Если так делают тысячи клиентов, восстановление превращается во вторую волну нагрузки. Растут очередь и latency, а здоровые запросы получают меньше ресурсов. Для POST цена ошибки выше: сервер мог сохранить заказ или платёж до разрыва соединения, а повтор создаст второй объект.

\n

Главный вопрос — не «сколько раз повторить», а «можно ли повторить именно эту операцию и как узнать результат первой попытки». Retry безопасен только при двух условиях: повторяемый эффект известен, а задержка подчиняется общему deadline. Backoff снижает частоту запросов, но не исправляет неверную семантику. Idempotency key уменьшает риск дубля, но не отменяет таймаут, лимит попыток или проверку состояния.

\n

Что именно ломается

\n

У клиента есть два независимых решения. Сначала он определяет право на повтор, затем — момент следующей попытки. HTTP-метод даёт полезную подсказку, но не заменяет контракт endpoint. По RFC 9110, GET, PUT и DELETE относятся к idempotent methods: несколько одинаковых запросов должны иметь тот же намеренный эффект, что и один. Это не запрещает серверу отдельно логировать каждый запрос. POST по умолчанию не даёт такой гарантии.

\n

При timeout результат становится неизвестным. Тело могло дойти до сервера, обработка могла завершиться, а ответ потерялся на обратном пути. Поэтому повтор POST — это не «продолжение» первой попытки, а новая команда. Без idempotency key, статусного endpoint или другого доказуемого механизма дедупликации клиент не знает, создаст ли дубль. В этом случае правильный результат — «состояние неизвестно», а не ещё один POST.

\n

Статус тоже нужно читать как сигнал, а не как разрешение. 429 означает ограничение частоты и может сопровождаться Retry-After. 503 обозначает временную недоступность или перегрузку; сервер также может подсказать время ожидания. 400, 401 и 403 обычно требуют исправить запрос или права, поэтому повтор без изменения входа не помогает. Сетевой timeout вообще не является HTTP-статусом: дополнительно выясните, успел ли транспорт отправить запрос.

\n
Минимальная матрица решения о повторе
СигналЧто известноПроверкаРешение
429 с Retry-AfterСработал rate limitПрочитать заголовок и область лимитаЖдать не меньше указанного времени; повторять только воспроизводимую операцию
503Сервис временно недоступен или перегруженПроверить deadline и счётчик попытокОграниченный backoff с jitter; остановиться при исчерпании бюджета
Timeout GETОтвет потерян, ресурс мог изменитьсяСделать чтение состояния и проверить остаток времениПовторить чтение, если остаётся deadline
Timeout POSTЗапись могла завершитьсяПроверить ключ идемпотентности или статус операцииНе отправлять второй POST без защиты
400, 401, 403Вход или права не подходятПосмотреть тело ответа и контекст авторизацииНе повторять автоматически
\n

Как не устроить вторую волну

\n

Экспоненциальный backoff увеличивает паузу по номеру попытки: min(cap, base × 2^attempt). cap ограничивает рост задержки, но одинаковая формула всё равно синхронизирует клиентов: после общего сбоя они проснутся почти одновременно. Jitter — случайное смещение — распределяет отправку по интервалу. Вариант full jitter выбирает случайную задержку от нуля до рассчитанного backoff; другой вариант добавляет небольшое смещение к базовой паузе. Выбор зависит от нагрузки и клиента.

\n

Серверный Retry-After задаёт минимальное ожидание. Если он есть, клиент не должен отправлять запрос раньше этого момента, но обязан сравнить его с собственным deadline. Некорректное или чрезмерное значение не должно заставить клиент ждать бесконечно. Deadline принадлежит всей операции: каждой попытке передаётся остаток времени, а не новый полный timeout.

\n

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

\n
\"Поток
Сначала проверяется семантика операции, затем рассчитывается задержка. Deadline и защита записи имеют приоритет над числом попыток.
\n

Самодостаточный пример политики

\n

Функция ниже не выполняет HTTP-запрос. Она принимает уже наблюдаемый сигнал и возвращает решение: повторять ли операцию и сколько ждать. Случайность передаётся через randomUnit, поэтому тест получает воспроизводимый вход. В production это значение выдаёт генератор случайных чисел, а итоговая задержка всё равно проходит проверку deadline.

\n
function planRetry({\n  method,\n  status = null,\n  transportError = false,\n  attempt,\n  nowMs,\n  deadlineMs,\n  baseMs = 100,\n  capMs = 2000,\n  maxAttempts = 3,\n  randomUnit = 0.5,\n  retryAfterMs = null,\n  idempotencyKey = false,\n  statusEndpoint = false,\n}) {\n  const idempotentMethod =\n    ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'].includes(method);\n  const repeatable = idempotentMethod || idempotencyKey || statusEndpoint;\n  const transient = transportError || status === 429 || status === 503;\n\n  if (!repeatable) return { retry: false, reason: 'unknown-result' };\n  if (!transient) return { retry: false, reason: 'not-transient' };\n  if (!Number.isInteger(attempt) || attempt < 0 ||\n      !Number.isInteger(maxAttempts) || maxAttempts < 1) {\n    return { retry: false, reason: 'invalid-attempt' };\n  }\n  if (attempt >= maxAttempts) {\n    return { retry: false, reason: 'max-attempts' };\n  }\n  if (![nowMs, deadlineMs, baseMs, capMs, randomUnit].every(Number.isFinite) ||\n      baseMs < 0 || capMs < baseMs) {\n    return { retry: false, reason: 'invalid-delay' };\n  }\n  if (retryAfterMs != null && (!Number.isFinite(retryAfterMs) || retryAfterMs < 0)) {\n    return { retry: false, reason: 'invalid-retry-after' };\n  }\n  if (nowMs >= deadlineMs) {\n    return { retry: false, reason: 'deadline-exhausted' };\n  }\n\n  const backoffMs = Math.min(capMs, baseMs * (2 ** attempt));\n  const jitterMs = Math.floor(backoffMs * Math.min(1, Math.max(0, randomUnit)));\n  const delayMs = retryAfterMs == null\n    ? jitterMs\n    : Math.max(jitterMs, retryAfterMs);\n\n  if (nowMs + delayMs >= deadlineMs) {\n    return { retry: false, reason: 'deadline-exhausted' };\n  }\n  return { retry: true, delayMs, backoffMs };\n}\n\nconst decision = planRetry({\n  method: 'POST', status: 503, attempt: 2,\n  nowMs: 1000, deadlineMs: 3000, randomUnit: 0.5,\n  idempotencyKey: true,\n});\n// { retry: true, delayMs: 200, backoffMs: 400 }
\n

В примере POST повторяется не из-за одного статуса 503, а потому что передан ключ идемпотентности. При попытке с индексом 2 backoff равен 400 мс, а половина случайного диапазона даёт 200 мс. Эти числа учебные: их нельзя переносить в production без знания лимитов upstream, размера пула, допустимой задержки и общего бюджета операции.

\n

Контракт ключа идемпотентности

\n

Request ID связывает попытки в логах, но сам по себе не заставляет сервер считать их одной операцией. Ключ идемпотентности должен быть частью контракта endpoint. В одном из распространённых вариантов сервер сохраняет ключ, параметры и результат первой обработки, а повтор с тем же ключом возвращает тот же результат. Тот же ключ с другим содержимым нужно отклонять. Это проектное правило сервиса, а не универсальное свойство HTTP.

\n

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

\n

Runbook для внедрения

\n
  1. Составьте список endpoint: метод, побочный эффект, владелец состояния и способ узнать результат после разрыва.
  2. Для каждого сигнала разделите HTTP-ответ, transport error и timeout после отправки тела. Запишите право на повтор и причину остановки.
  3. Для записи подтвердите ключ идемпотентности, статусный endpoint или иной механизм дедупликации. При отсутствии защиты запретите автоматический retry.
  4. Передайте один абсолютный deadline через весь вызов. Перед ожиданием сравнивайте итоговую паузу и timeout следующей попытки с остатком бюджета.
  5. Задайте base, cap и максимум попыток. Отдельно решите, что делать с Retry-After, отсутствующим и некорректным заголовком.
  6. Добавьте jitter до проверки deadline. В тестах подмените генератор и проверьте нулевую попытку, достижение cap и отрицательные параметры.
  7. Создайте искусственные 429 и 503, timeout до ответа и timeout после отправки тела. Сверяйте число запросов, дубли записей и причину остановки.
  8. Логируйте номер попытки, сигнал, выбранную паузу, остаток deadline и correlation id. Не записывайте токены, ключи и полное тело запроса.
\n

Ограничения и критерий готовности

\n

Метод не описывает побочные эффекты конкретной реализации. GET может запускать плохо спроектированную команду, а POST может быть повторяемым по отдельному контракту. Проверяйте endpoint и его хранилище, а не только глагол.

\n

Retry не заменяет rate limit, circuit breaker, очередь, bulkhead или backpressure. Они ограничивают разные части системы и не дают разрешения повторить неизвестную запись. Код выше не учитывает конкретный transport, прокси, DNS, HTTP/2, балансировщик и политику upstream. В нём нет production-замеров и универсальных значений задержки.

\n

Политика готова, если для каждого endpoint команда может ответить на четыре вопроса: какой эффект повторяется, почему он безопасен, сколько времени и попыток разрешено, и что делать при неизвестном результате. Тест должен показать, что 429 ждёт Retry-After, 503 не синхронизирует клиентов, timeout POST не создаёт дубль без защиты, а исчерпание deadline останавливает цикл.

\n

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/006.json b/editorial/agent-rewrites/006.json index 105bff5..965d312 100644 --- a/editorial/agent-rewrites/006.json +++ b/editorial/agent-rewrites/006.json @@ -1 +1 @@ -{"index":6,"slug":"editorial-2027-11-practice-mistakes-revisions","title":"Миграция схемы БД без простоя: совместимые фазы expand, switch, contract","excerpt":"Как изменить схему при работающем старом и новом коде: проверить совместимость чтения и записи, пережить backfill и удалить старую форму только после явного сигнала.","contentHtml":"

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

Цена ошибки — очередь запросов, простой части сервиса и откат приложения, который уже не возвращает совместимость со схемой. Откат кода не отменяет записи, сделанные новым writer, а обратное изменение типа или удаление данных может оказаться необратимым. Поэтому миграцию проектируют как последовательность совместимых состояний, а не как один SQL-файл.

Тезис: сначала совместимость, потом переключение

Безопасный порядок такой: добавить новую форму данных, научить код работать со старой и новой формами, переключить чтение, проверить потребителей и только затем удалить старую форму. Старый и новый binary некоторое время живут одновременно, экземпляры обновляются не синхронно, а миграция может остановиться между фазами.

Рассмотрим замену вычисляемого имени из first_name и last_name на колонку display_name. Сначала новая колонка должна быть совместима со старым кодом: она nullable или имеет безопасное значение по правилам домена. Пока старый reader ещё работает, новый writer не может отказаться от старых полей без fallback.

Механизм: матрица reader и writer

Перед DDL выпишите четыре возможности: умеет ли старый reader читать новую форму, умеет ли новый reader читать её, пишет ли старый writer старую форму и пишет ли новый writer обе формы. Из этой матрицы видно, на какой фазе находится система и где возникнет несовместимость.

Совместимые состояния миграции
ФазаЧтениеЗаписьЧто разрешеноКонтроль
Expandстарая формастарая формадобавить nullable-колонку или совместимый индексстарый binary продолжает работать
Dual writeстарая форма, новая с fallbackобе формызаполнять новую форму пачками или при записисверять значения и ошибки записи
Switchновая форма с fallbackобе формыперевести reader после проверки данныхнаблюдать долю чтения fallback
Contractновая формановая формаудалить старую форму отдельным изменениеместь сигнал, что старые потребители ушли
Rollbackстарая или fallbackсовместимая записьвернуть binary без потери данныхпуть отката проверен до switch

Новый writer без совместимого reader — небезопасное состояние. Двойная запись решает только доставку данных в две формы; она не доказывает, что значения одинаковы, что backfill не перезапишет более свежую запись и что все потребители готовы к switch.

DDL — операция с ресурсом

Изменение таблицы зависит от блокировок, объёма работы и конкретной версии PostgreSQL. В review смотрите на lock mode, время ожидания, границы транзакции, индексы, триггеры, репликацию и план восстановления. Добавление колонки, создание индекса, backfill и изменение типа имеют разную стоимость. Объединять их в одну «маленькую миграцию» нельзя без проверки.

Backfill — отдельная нагрузка, а не деталь миграции схемы. Большой UPDATE конкурирует с пользовательскими запросами и может увеличить WAL. Идемпотентные ограниченные пачки позволяют остановить работу и продолжить её позже. Размер пачки, пауза и условие обновления зависят от вашей нагрузки; пример ниже не задаёт универсальные значения.

\"Последовательность
Старая и новая формы сосуществуют до тех пор, пока проверяемый сигнал не разрешит удалить старую.

Минимальный рабочий пример

Небольшая функция формализует главный запрет: нельзя включать новую запись, если нет reader, который понимает новую форму. Это учебная проверка совместимости; она не подключается к базе, не запускает DDL и не заменяет проверку конкретного кластера.

function classifyMigrationStep({ oldReads, newReads, oldWrites, newWrites }) {\n  if (newWrites && !oldReads && !newReads) {\n    return { phase: 'unsafe', reason: 'new-writer-has-no-compatible-reader' };\n  }\n  if (newWrites && !oldWrites) {\n    return { phase: 'expand', reason: 'new-write-path-can-be-added-with-old-readers' };\n  }\n  if (newReads && oldReads && newWrites) {\n    return { phase: 'switch', reason: 'both-readers-and-writers-understand-format' };\n  }\n  if (oldReads && !newReads && !newWrites) {\n    return { phase: 'contract', reason: 'remove-format-only-after-consumers-move' };\n  }\n  return { phase: 'inspect', reason: 'compatibility-matrix-is-incomplete' };\n}\n\nconsole.log(classifyMigrationStep({\n  oldReads: false,\n  newReads: false,\n  oldWrites: false,\n  newWrites: true,\n}).phase);\n// unsafe

В настоящей миграции вместо boolean-признаков нужны конкретные версии приложения, формы записи и список потребителей. Проверка должна отвечать на вопрос «кто прочитает запись после этого шага?», а не только на вопрос «принял ли SQL сервер?».

Порядок выполнения

  1. Опишите старую и новую формы данных. Укажите каждый reader и writer, версию binary и допустимый fallback.
  2. Проверьте DDL на блокировки, размер таблицы, индексы, транзакцию, репликацию и план восстановления. Для production-объёма используйте среду с похожими данными, если это возможно в вашей процедуре.
  3. Добавьте новую форму без требования, которое сломает старый binary. Сначала проверьте, что старый код продолжает читать и писать прежнюю форму.
  4. Включите двойную запись или backfill идемпотентными пачками. Сверяйте количество обработанных строк, контрольные значения и случаи, когда более свежая запись уже существует.
  5. Переведите чтение на новую форму с fallback. Наблюдайте ошибки, latency, lock wait и долю чтения старой формы. Fallback должен быть виден, иначе нельзя понять, ушли ли старые потребители.
  6. Удалите fallback и старую форму отдельным изменением после окна наблюдения. Сохраните понятный сигнал, что старый reader больше не обращается к колонке и rollback-путь больше не требуется.

Почему rollback не равен обратной миграции

Откат приложения возвращает код, но не обязательно возвращает схему. Если новый writer заполняет только display_name, старый reader без fallback может увидеть пустое значение. Если преобразование типа потеряло информацию, обратный DDL не восстановит её. Поэтому старый reader должен оставаться совместимым с данными, которые создал новый writer, а путь отката нужно определить до switch.

Проверяйте промежуточные состояния: сразу после expand, во время частичной двойной записи, после остановки backfill и после переключения только чтения. В каждом состоянии остановка приложения или миграции должна иметь понятное продолжение. Финальный smoke test не проверяет совместимость всех этих переходов.

Ограничения и критерий готовности

Эта схема не выбирает lock mode, размер пачки, стратегию индексации или настройки WAL для вашего кластера. На результат влияют версия PostgreSQL, расширения, ORM, триггеры, партиционирование, репликация, размер таблицы и политика блокировок. Документация описывает свойства операций, но не разрешает выполнять их без проверки вашей нагрузки.

Миграция готова к contract, когда новая форма заполнена и сверена, новый reader работает без скрытой зависимости от старой формы, старый binary больше не является потребителем, а rollback-путь проверен на промежуточном состоянии. Если хотя бы один пункт нельзя доказать наблюдением или проверкой, оставьте старую форму и продолжите расследование.

Проверяемые источники

"} +{"index":6,"slug":"editorial-2027-11-practice-mistakes-revisions","title":"Миграция схемы БД без простоя: совместимые фазы expand, switch, contract","excerpt":"Как изменить схему при работающем старом и новом коде: сначала добавить совместимую форму, затем заполнить и проверить данные, переключить чтение и только после этого удалить старую колонку.","contentHtml":"

Миграция падает не только на самом ALTER TABLE. Частый симптом выглядит так: новый релиз уже пишет в display_name, а экземпляр старого кода ещё читает first_name и last_name. Другой вариант — DDL ждёт блокировку, пока пользовательские запросы продолжают работать. В обоих случаях одна команда пытается поменять контракт сразу для базы, writer и reader.

Цена ошибки — ошибки чтения, потерянное значение имени или очередь запросов. Откат бинарника не возвращает данные, которые новый writer уже записал только в новую форму, а обратный DDL не восстановит информацию после необратимого преобразования. Главный вопрос миграции поэтому такой: как сделать каждый промежуточный шаг совместимым с уже работающими потребителями?

Совместимость — это свойство перехода

Под reader будем понимать любой код, который читает строку, а под writer — код, который её создаёт или изменяет. Переход совместим, если после каждого шага любой ещё работающий reader может прочитать запись, созданную любым writer. Это правило относится и к фоновым задачам, отчётам, админке, скриптам и другим сервисам, а не только к основному HTTP-приложению.

Схема old -> new не выкатывается одной миграцией. Сначала расширяем контракт, потом некоторое время обслуживаем две формы, затем переключаем чтение и лишь в конце сужаем контракт. Именно такой смысл у паттерна expand and contract в документации GitLab: expand сохраняет обратную совместимость, migrate переводит потребителей, contract удаляет совместимость.

Временная линия миграции: expand добавляет новую форму, dual write заполняет обе, switch переводит чтение, contract удаляет старую
Удаление старой формы — последняя точка, а не часть первого выката.

Пример: составное имя становится одной колонкой

Пусть в таблице users уже есть nullable-колонки first_name и last_name. Новому экрану нужен display_name. Правило форматирования в примере учебное: соединить непустые части одним пробелом. В реальном домене нужно отдельно решить порядок имени, локаль, пробелы, пустые строки и допустимое отсутствие значения.

Что разрешено на каждой фазе
ФазаReaderWriterДействиеВыход из фазы
ExpandСтарая формаСтарая формаДобавить nullable display_name без требования для старого кодаСтарый binary читает и пишет как прежде
Dual writeНовая форма с fallbackОбе формы в одной операцииОбновлять колонку при каждой записи и заполнить старые строкиПроверка расхождений и готовности readers
SwitchНовая форма, fallback виденОбе формыПеревести основной путь чтения и наблюдать ошибкиНи один потребитель не использует fallback
ContractНовая формаНовая формаУбрать fallback, затем удалить старые колонки отдельными шагамиЕсть подтверждённый план отката или принято решение жить без него

Fallback в этой таблице — не молчаливый костыль. Он должен быть измеримым: например, код увеличивает счётчик чтений старой формы и пишет идентификатор потребителя. Если fallback не виден, команда не может доказать, что contract безопасен.

Expand: сначала меняем схему

Для PostgreSQL 16 минимальный первый шаг может выглядеть так:

BEGIN;\nSET LOCAL lock_timeout = '1s';\nALTER TABLE users ADD COLUMN display_name text;\nCOMMIT;

1s здесь — проектный пример, а не универсальное значение. При тайм-ауте транзакция должна завершиться с ошибкой, а миграционный runner — оставить понятный результат для повторного запуска. Документация PostgreSQL указывает, что требуемый lock level зависит от формы ALTER TABLE, а без специальной оговорки берётся ACCESS EXCLUSIVE. Поэтому nullable-колонка без backfill всё равно может ждать уже занятую блокировку.

После expand старый binary не должен требовать новую колонку. На этом шаге не добавляйте без проверки NOT NULL, тяжёлый default, изменение типа и удаление старых полей в ту же операцию. У этих действий другая стоимость и другой риск. Сначала отдельно подтвердите, что схема появилась, старые запросы всё ещё проходят, а повторный запуск миграции обрабатывается вашей системой миграций.

Dual write и backfill: две формы, одно правило

Новый writer должен в одной логической операции сохранить обе формы. Простейшая чистая функция показывает контракт, но не притворяется ORM или транзакцией:

function formatDisplayName(firstName, lastName) {\n  return [firstName, lastName]\n    .filter((part) => part !== null && part !== undefined && part !== '')\n    .join(' ');\n}\n\nfunction readDisplayName(row) {\n  if (row.display_name !== null && row.display_name !== undefined) {\n    return row.display_name;\n  }\n  return formatDisplayName(row.first_name, row.last_name);\n}\n\nfunction writeUser(input) {\n  return {\n    ...input,\n    display_name: formatDisplayName(input.first_name, input.last_name),\n  };\n}\n\nconsole.assert(\n  readDisplayName({ display_name: null, first_name: 'Ada', last_name: 'Lovelace' }) ===\n    'Ada Lovelace',\n);\nconsole.assert(\n  writeUser({ first_name: 'Ada', last_name: 'Lovelace' }).display_name ===\n    'Ada Lovelace',\n);

В приложении результат writeUser нужно записать атомарно с изменением исходных полей. Если ORM выполняет два независимых запроса, ошибка между ними создаёт ещё один промежуточный формат. Поэтому проверяйте не только функцию, но и границу транзакции, обработку повторной попытки и поведение при конфликте.

Старые строки заполняются отдельным backfill. Идемпотентная пачка для PostgreSQL 16 может быть такой:

UPDATE users\nSET display_name = trim(concat_ws(' ', first_name, last_name))\nWHERE id > $1\n  AND id <= $2\n  AND display_name IS NULL\n  AND (first_name IS NOT NULL OR last_name IS NOT NULL);

Параметры $1 и $2, размер пачки и пауза между пачками зависят от runner и нагрузки. Условие display_name IS NULL защищает уже заполненную строку от повторной записи, но не решает доменное различие между «пусто» и «ещё не обработано». Если исходные поля меняются во время backfill, задайте конфликтное правило: общий транзакционный writer, версия строки или повторная сверка.

Switch: переводим чтение после проверки данных

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

SELECT\n  count(*) FILTER (\n    WHERE display_name IS NULL\n      AND (first_name IS NOT NULL OR last_name IS NOT NULL)\n  ) AS missing_display_name,\n  count(*) FILTER (\n    WHERE display_name IS NOT NULL\n      AND display_name <> trim(concat_ws(' ', first_name, last_name))\n  ) AS different_display_name\nFROM users;

Нулевой результат этих двух счётчиков ещё не доказывает готовность всей системы. Нужно проверить потребителей: старый binary, новый binary, фоновые workers, экспорт, SQL-запросы и кэш. Для каждой записи в журнале изменения должно быть понятно, кто её пишет и какая форма будет прочитана после переключения.

Переводите reader отдельно от writer. Сначала новый reader использует display_name и считает fallback. Затем включайте новый путь постепенно, если такая возможность есть. Наблюдайте ошибки чтения, задержку, lock wait, отставание реплик и количество fallback. При росте ошибки верните reader на совместимый fallback, но оставьте dual write: откат чтения не должен остановить поддержание обеих форм.

Contract: удаляем только доказанно ненужное

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

Индексы и ограничения требуют отдельного плана. PostgreSQL описывает CREATE INDEX CONCURRENTLY как способ не блокировать обычные вставки, обновления и удаления, но такая операция делает больше работы, ждёт другие транзакции и не выполняется внутри транзакционного блока. Она не превращает любой DDL в безопасный для production и не отменяет проверку диска, CPU, репликации и времени ожидания.

Если нужно добавить уникальность или NOT NULL, разделите проверку существующих данных и изменение контракта. Например, constraint можно сначала добавить как NOT VALID, затем проверить и валидировать отдельной операцией, если конкретный тип ограничения и версия PostgreSQL это поддерживают. Не копируйте этот приём для каждого ограничения: синтаксис, блокировки и поведение нужно сверить с документацией вашей версии.

Пошаговый runbook

  1. Составьте список readers и writers, включая фоновые задачи и внешние SQL-доступы. Зафиксируйте старую форму, новую форму, владельца каждого потребителя и совместимый путь чтения.
  2. Опишите доменное преобразование. Для имени зафиксируйте правила пустых значений, пробелов, локали, длины и редких случаев. Учебный concat_ws не заменяет это решение.
  3. Проверьте expand на копии production-данных или на среде с сопоставимым объёмом. Запишите lock wait, план остановки, лимит ожидания и способ повторного запуска.
  4. Выпустите только расширение схемы. Проверка выхода: старый binary читает и пишет старые поля, новая колонка не обязательна, миграция повторяется безопасно.
  5. Включите dual write и fallback. Проверьте атомарность записи, повторную попытку и метрику fallback. Не запускайте backfill, пока writer не защищает новые изменения.
  6. Запускайте backfill ограниченными идемпотентными пачками. После каждой пачки сохраняйте диапазон, количество обновлений и ошибку; при остановке продолжайте с последнего подтверждённого диапазона.
  7. Сверьте значения и конфликтные строки. Ненулевые расхождения — причина остановить switch, а не повод подобрать фильтр, который скроет проблему.
  8. Переведите чтение на новую форму, оставив fallback. Наблюдайте ошибки, задержку, реплики и долю fallback; при деградации верните только чтение, сохранив dual write.
  9. После подтверждённого ухода старых потребителей уберите fallback и старые поля отдельным выпуском. Перед удалением проверьте, что план rollback описывает уже созданные новые записи, а не только возврат версии приложения.

Ограничения и критерий готовности

Expand/switch/contract не гарантирует нулевую блокировку и не заменяет резервное копирование, репликацию и проверку восстановления. На результат влияют major-версия PostgreSQL, размер и партиционирование таблицы, индексы, триггеры, ORM, внешние readers, длительные транзакции, политика lock timeout и способ доставки релиза. Для MySQL, другой СУБД или другой major-версии PostgreSQL нельзя механически переносить lock behavior из этого примера.

К contract переходите только когда новая форма заполнена и сверена, каждый известный writer поддерживает её, readers больше не обращаются к старым полям, fallback не нужен по наблюдаемому сигналу, а команда понимает необратимые последствия удаления. Если один пункт нельзя доказать запросом, метрикой, логом или тестом, остановитесь на dual write и продолжите сбор сведений.

На практике полезно начать с маленькой таблицы или копии данных: выполнить expand, остановить backfill, вернуть старый binary и проверить чтение записи, созданной новым writer. Такой сценарий показывает реальный путь отката лучше, чем зелёный финальный smoke test.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/007.json b/editorial/agent-rewrites/007.json index 1a15caa..8dbc494 100644 --- a/editorial/agent-rewrites/007.json +++ b/editorial/agent-rewrites/007.json @@ -1 +1 @@ -{"index":7,"slug":"editorial-2027-10-field-long-form-interview","title":"Trace Context в HTTP: как сохранить запрос на границе proxy","excerpt":"Если traceparent исчезает или не проходит проверку, gateway и сервис перестают видеть один запрос. Разбираем формат, безопасный fallback и проверку через реальный proxy.","contentHtml":"

Симптом появляется во время сбоя: gateway сообщает timeout, backend пишет 500, а записи нельзя связать одним запросом. Инженер видит несколько похожих событий и не понимает, относятся ли они к одной операции. Цена ошибки — лишний поиск по логам, неверная гипотеза о медленном сервисе и более долгий разбор пользовательской проблемы.

Часто ломается не сама трассировка, а граница между компонентами. Proxy удаляет неизвестный заголовок, middleware создаёт новый trace вместо продолжения, либо сервис принимает строку с неверным форматом. Исправление должно проверять весь путь: что отправили, что пропустил proxy, что разобрал сервис и что записал logger.

Тезис: traceparent — транспортный контракт, а не доказательство доступа

W3C Trace Context задаёт переносимый HTTP-заголовок traceparent. Он связывает участки обработки одного запроса, но не гарантирует наличие span, корректность логирования или сохранность заголовка на каждом proxy. Поэтому проверять нужно не только парсер. Нужен контракт между клиентом, gateway, middleware и сервисом.

Валидный внешний контекст можно продолжить как родительский. После этого локальная библиотека создаёт новый span для текущего сервиса. Невалидный контекст нельзя молча чинить: сервис отбрасывает его, создаёт новый локальный trace и фиксирует короткую причину отказа. Trace-id не заменяет авторизацию и не должен содержать пользовательские данные.

Механизм передачи

traceparent состоит из четырёх полей: версии, trace-id, parent-id и flags. Для базового формата важны точные длины, lowercase hexadecimal и ненулевые идентификаторы. Ошибка в одном поле делает вход непригодным для продолжения. Парсер должен вернуть результат проверки, а не угадывать намерение отправителя.

Proxy — часть этого контракта. Он может удалить неизвестный заголовок, нормализовать имя, ограничить размер или создать собственный контекст. Локальный тест функции парсинга не доказывает, что значение дошло до приложения. Проверка должна проходить через ту же пару gateway и service, с которой работает запрос.

Даже сохранённый trace-id не объясняет задержку сам по себе. Для поиска места ожидания нужны границы span, временные отметки и статус ошибки. Если компоненты пишут только общий идентификатор, связь событий появится, но длительность отдельных участков останется неизвестной.

\"Клиент
Проверяйте контекст на границе сервиса после proxy. Заголовок связывает события, но не является авторизацией и не заменяет хранение telemetry.

Минимальный рабочий пример

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

import { parseTraceparent } from './upgrade-2027-10.mjs'; const valid = parseTraceparent('00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'); const invalid = parseTraceparent('00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01'); console.log(valid.ok, valid.traceId.slice(0, 8)); console.log(invalid.ok, invalid.reason); // true 4bf92f35 // false trace-id-invalid

В примере uppercase trace-id отклоняется. Это controlled fallback, а не 500: сервис не принимает повреждённую строку как родительский контекст и не использует её как право доступа. Конкретную политику для version и flags нужно закрепить интеграционным тестом выбранной библиотеки.

Диагностика по симптому

СимптомПричинаПроверкаДействие
В gateway и service разные trace-idProxy удалил заголовок или middleware начал новый traceСравнить raw-заголовок до и после proxyРазрешить передачу traceparent и проверить продолжение валидного контекста
Контекст есть только в локальном запускеРеальный proxy применяет другой allow-list или лимит размераПовторить запрос через gateway с теми же правиламиДобавить integration test для gateway + service
Валидный на вид заголовок отклонёнUppercase, неверная длина, нулевой id или неподдерживаемая версияПрогнать parser fixture на каждое полеОтбросить вход и записать безопасную причину
По trace-id не находится событиеLogger пишет другой ключ или sampling удалил eventПроверить структурное поле и политику samplingУнифицировать JSON key и сохранить ошибку парсинга как счётчик
После HTTP-запроса теряется связь с очередьюHTTP-контекст не перенесён в механизм брокераПроверить envelope или headers сообщенияИспользовать механизм контекста очереди и отдельный link между участками

Порядок проверки

  1. Выберите один запрос и выпишите его границы: клиент, gateway, service и downstream. Для каждой границы определите, где должен появиться trace-id.
  2. Снимите заголовок до proxy и после proxy. Если значение исчезло на этом участке, не начинайте поиск с backend-библиотеки.
  3. Добавьте тестовые случаи для нулевых идентификаторов, uppercase, неверной длины и запрещённой версии. Для каждого случая зафиксируйте ожидаемый controlled fallback.
  4. Проверьте middleware на валидном входе: trace-id должен продолжиться, а текущий сервис должен получить новый локальный parent-id для своего span.
  5. Сведите записи к одному структурному ключу. Причину отказа парсера считайте отдельно и не сохраняйте полный внешний заголовок без необходимости.
  6. Прогоните end-to-end проверку через реальный proxy. Затем отдельно проверьте sampling и задержку доставки telemetry, чтобы отсутствие записи не принять за потерю контекста.

Ограничения и критерий готовности

Этот подход проверяет HTTP-заголовок и передачу контекста между компонентами. Он не проверяет подпись, доверие к отправителю, backend sampling, полноту collector и форматы контекста Kafka или другой очереди. Он также не делает trace-id бизнес-идентификатором: повтор операции и асинхронная обработка требуют отдельного безопасного operation-id.

Готовность можно считать доказанной, если интеграционный тест через реальный proxy сохраняет один trace-id на границе gateway и service, валидный контекст получает локальный span, а повреждённый вход приводит к новому локальному trace без 500. Логи должны содержать единый trace-id key, а проверка не должна превращать внешний идентификатор в секрет или разрешение на действие.

Проверяемые источники

"} +{"index":7,"slug":"editorial-2027-10-field-long-form-interview","title":"Trace Context в HTTP: как сохранить запрос на границе proxy","excerpt":"Если traceparent исчезает или не проходит проверку, gateway и сервис перестают видеть один запрос. Разбираем контракт proxy, безопасный fallback и проверку через реальный HTTP-маршрут.","contentHtml":"

Симптом появляется во время сбоя: gateway сообщает timeout, backend пишет 500, а записи нельзя связать одним запросом. Инженер видит несколько похожих событий и не понимает, относятся ли они к одной операции. Цена ошибки — лишний поиск по логам, неверная гипотеза о медленном сервисе и более долгий разбор пользовательской проблемы.

Чаще ломается не сама трассировка, а граница между компонентами. Proxy не пропускает заголовок, middleware начинает новый trace вместо продолжения, либо сервис принимает строку с неверным форматом. Главный вопрос статьи — как доказать, что один HTTP-запрос сохранил контекст на этой границе, не превратив trace-id в право доступа или бизнес-идентификатор.

Короткий ответ: задайте контракт на границе

W3C Trace Context задаёт переносимые HTTP-заголовки traceparent и tracestate. Первый несёт общий trace-id, идентификатор родительской операции и flags; второй хранит необязательное состояние конкретных систем трассировки. Контекст связывает участки обработки, но не гарантирует наличие span, корректность логирования или доставку telemetry.

На границе нужно заранее выбрать один из трёх режимов. Обычный proxy пересылает валидный контекст. Инструментированный proxy участвует в трассе, создаёт свой span и меняет parent-id для следующего участка. Защитный gateway может начать новую трассу на доверенной границе. Последний вариант разрывает внешнюю корреляцию по решению безопасности, поэтому его нельзя выдавать за «потерю заголовка».

Выбор режима: что именно делает proxy

РежимЧто уходит дальшеЦенаКогда применять
ForwardВалидные traceparent и tracestate; proxy не создаёт spanДешевле, но задержка внутри proxy не видна отдельным участкомТонкий reverse proxy без собственной инструментализации
ParticipateТот же trace-id, новый parent-id и span proxyНужны SDK, sampling и единые правила имёнКогда задержка маршрутизации входит в SLO сервиса
RestartНовый локальный trace; внешний контекст не становится родителемКорреляция через границу теряетсяЯвная trust boundary или защита от злоупотребления входным контекстом

Для обычной внутренней HTTP-границы начинайте с forward или participate. Владелец proxy должен записать режим в конфигурации и интеграционном тесте. Иначе команда будет спорить по логам, где разные trace-id могут быть как дефектом, так и намеренным restart.

Механизм: формат, извлечение и новый span

В формате версии 00 значение traceparent содержит четыре поля: version-trace-id-parent-id-trace-flags. Это две hex-цифры версии, 32 lowercase hex-цифры trace-id, 16 lowercase hex-цифр parent-id и две lowercase hex-цифры flags. Trace-id и parent-id не могут состоять из одних нулей. Имя HTTP-заголовка регистронезависимо, но отправлять его следует в lowercase; uppercase внутри значений — уже другая проверка.

Версия 00 — формат, который проверяет пример ниже. Спецификация описывает правила для будущих версий: pass-through-компонент не должен без причины разбирать неизвестное расширение, а инструмент, который участвует в трассе, обязан иметь политику обработки более высокой версии. Поэтому «не поддерживаем version» — это проектное решение парсера, а не универсальное требование W3C.

Извлечение выполняет propagator, а не случайный middleware с split('-'). Невалидный carrier не должен приводить к исключению и не должен записываться в контекст как новый родитель. Если валидного входящего контекста нет, SDK создаёт локальный trace по своей политике. Для валидного удалённого контекста текущий сервис создаёт свой span и передаёт downstream новый parent-id, сохраняя trace-id.

Proxy проверяется отдельно от библиотеки. Сначала выясните, пропускает ли его allow-list заголовок, затем проверьте лимит размера и поведение при нескольких значениях. Нормализация регистра имени — нормальна для HTTP, а удаление поля, непреднамеренный restart или разные правила для HTTP/1.1 и HTTP/2 — уже часть вашего runtime-контракта. Локальный unit test парсера этого не доказывает.

Trace-id не объясняет задержку сам по себе. Для поиска места ожидания нужны границы span, временные отметки и статус ошибки. OpenTelemetry отдельно определяет HTTP span и его атрибуты; если компоненты пишут только общий идентификатор, связь событий появится, но длительность отдельных участков останется неизвестной.

\"Клиент
Сначала проверьте режим proxy, затем формат и создание локального span. Заголовок связывает события, но не является авторизацией.

Самодостаточный пример проверки version 00

Этот фрагмент можно сохранить как отдельный файл и запустить в Node.js без пакетов. Он проверяет только синтаксический контракт версии 00: не создаёт span, не обращается к collector и не утверждает, что заголовок дошёл через proxy. Причина отказа короткая, поэтому её можно считать в метрике без сохранения полного внешнего значения.

function parseTraceparent(value) { if (typeof value !== 'string') return { ok: false, reason: 'missing-or-not-string' }; const parts = value.split('-'); if (parts.length !== 4) return { ok: false, reason: 'field-count' }; const [version, traceId, parentId, flags] = parts; if (!/^[0-9a-f]{2}$/.test(version) || version === 'ff') return { ok: false, reason: 'version-invalid' }; if (version !== '00') return { ok: false, reason: 'version-unsupported-by-example' }; if (!/^[0-9a-f]{32}$/.test(traceId) || /^0+$/.test(traceId)) return { ok: false, reason: 'trace-id-invalid' }; if (!/^[0-9a-f]{16}$/.test(parentId) || /^0+$/.test(parentId)) return { ok: false, reason: 'parent-id-invalid' }; if (!/^[0-9a-f]{2}$/.test(flags)) return { ok: false, reason: 'trace-flags-invalid' }; return { ok: true, version, traceId, parentId, flags }; } const fixtures = [['valid', '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'], ['uppercase-value', '00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01'], ['zero-parent', '00-4bf92f3577b34da6a3ce929d0e0e4736-0000000000000000-01']]; for (const [name, value] of fixtures) { const result = parseTraceparent(value); console.log(name, result.ok ? 'accepted' : result.reason); } // valid accepted // uppercase-value trace-id-invalid // zero-parent parent-id-invalid

Uppercase trace-id здесь отклоняется, хотя имя заголовка TraceParent само по себе допустимо. Это controlled fallback, а не HTTP 500: сервис не использует повреждённую строку как родительский контекст. В production коде такую проверку лучше отдать официальному propagator выбранного SDK, а fixture оставить как контракт интеграции и регрессионный тест.

Диагностика по симптому

СимптомВероятная границаПроверкаДействие
В gateway и service разные trace-idProxy удалил заголовок, либо выбран restartСравнить raw-заголовок до и после proxy и прочитать режимИсправить allow-list или документировать trust boundary
Контекст есть только локальноRuntime применяет другой маршрут, лимит или фильтрПовторить запрос через тот же gateway с теми же правиламиДобавить integration test gateway + service
Валидный на вид заголовок отклонёнUppercase в значении, длина, нулевой id или versionПрогнать fixture на каждое поле и записать reasonОтбросить вход без 500; не логировать полное значение
По trace-id не находится событиеДругой ключ, sampling или задержка доставкиПроверить структурное поле, span и exporterРазвести correlation gap и отсутствие telemetry
После HTTP-запроса теряется связь с очередьюHTTP carrier не перенесён в message carrierПроверить envelope или headers сообщенияНастроить propagator брокера и отдельный link при необходимости

Пошаговый runbook

  1. Зафиксируйте границы. Выберите один запрос и выпишите client, gateway, service и downstream. Для каждого перехода укажите ожидаемый режим: forward, participate или restart. Результат — короткая схема, с которой можно сравнить логи.
  2. Снимите вход и выход. В тестовой среде запишите наличие и значение traceparent до proxy и на входе сервиса. Не передавайте в логах пользовательские данные и не делайте trace-id секретом.
  3. Проверьте транспорт. Убедитесь, что маршрут proxy разрешает заголовок, не переписывает его неожиданно и одинаково работает для реального протокола. Если заголовок исчез до сервиса, backend-библиотека пока не подозревается.
  4. Проверьте извлечение. Прогоните валидный fixture, uppercase в значении, нулевые id, неверную длину, ff и выбранную политику для более высокой версии. Для каждого случая зафиксируйте expected decision.
  5. Проверьте span. На валидном входе trace-id должен продолжиться, а текущая операция получить новый локальный span-id. На невалидном входе должен появиться новый локальный trace или controlled fallback SDK, а не исключение из HTTP handler.
  6. Сверьте наблюдаемость. Сведите логи к одному структурному ключу, сравните trace-id и parent-id, а отсутствие записи проверьте с учётом sampling и задержки exporter. Одного совпадения trace-id недостаточно для вывода о latency.
  7. Прогоните маршрут. Выполните end-to-end запрос через реальный proxy и повторите его после изменения конфигурации. Сохраните raw headers, решение извлечения и ссылки на spans; только после этого меняйте rollout или лимиты.

Ограничения и критерий готовности

Этот подход проверяет HTTP carrier и передачу контекста между компонентами. Он не проверяет подпись, доверие к отправителю, полноту collector, backend sampling или форматы контекста Kafka и другой очереди. Для асинхронного сообщения нужен отдельный message carrier; иногда правильнее использовать span link, а не притворяться прямым parent-child. Повтор операции и бизнес-связь требуют отдельного безопасного operation-id.

Готовность доказана, когда интеграционный тест через реальный proxy подтверждает выбранный режим, валидный context получает ожидаемый локальный span, а повреждённый вход приводит к controlled fallback без 500. Логи используют единый trace-id key, span boundaries позволяют искать задержку, а документация явно говорит, где сделан restart. Если эти условия не проверены, проблема «trace-id пропал» остаётся гипотезой.

Проверяемые источники

"} diff --git a/editorial/agent-rewrites/008.json b/editorial/agent-rewrites/008.json index 908406d..2c94b81 100644 --- a/editorial/agent-rewrites/008.json +++ b/editorial/agent-rewrites/008.json @@ -1 +1,7 @@ -{"index":8,"slug":"editorial-2027-10-mechanism-long-form-interview","title":"TLS-сертификат: почему «curl работает» не закрывает проверку","excerpt":"Разбираем цепочку доверия, срок действия и SAN: какие проверки проходят до HTTP и почему один успешный клиент ничего не доказывает для другого.","contentHtml":"

Проблема обычно выглядит противоречиво: браузер открывает адрес, а сервисный клиент получает certificate error; либо один контейнер подключается, а второй — нет. Цена ошибки — отключить проверку TLS «временно», потерять имя хоста в диагностике и превратить сетевую проблему в уязвимость.

Причина в том, что «сертификат валиден» — это не одна проверка. Клиент строит цепочку до доверенного корня, проверяет период действия, имя назначения и ограничения сертификата. Разные хранилища корней, SNI, proxy и часы системы меняют результат. Нужно разделить слой протокола, X.509-структуру и локальную политику доверия.

Что проверяет клиент до HTTP

TLS handshake создаёт защищённый канал, но доверие к peer не появляется из шифрования автоматически. Сертификат содержит открытый ключ, имя и подпись издателя; клиент проверяет цепочку и применимость к назначенному хосту. Если запрос идёт на api.example.test, сертификат только для admin.example.test не должен считаться подходящим из-за того, что ключ технически рабочий.

Период действия проверяется по часам клиента. Ошибка в системном времени даёт симптом «сертификат ещё не действителен» или «истёк», хотя сервер ничего не менял. Переход на другой контейнер может поменять корневое хранилище и набор промежуточных сертификатов. Поэтому при сравнении сред нужно собирать не только URL, но и hostname, SNI, trust store, время и цепочку.

Минимальная проверка сертификата
ПроверкаВопросОтказЧто собрать
Срокnow между notBefore и notAfter?not-yet-valid / expiredUTC-время клиента и поля сертификата
Имяhost есть в SAN?hostname mismatchSNI, hostname и SAN
Цепочкаесть путь до доверенного корня?unknown issuerleaf, intermediate, trust store
Подписьалгоритм и ключ разрешены?signature/algorithm errorTLS policy и negotiated version
Отзывполитика проверяет статус?revoked/unknownOCSP/CRL policy и доступность

Почему отключение verify ухудшает диагностику

Флаг вроде insecure меняет вопрос с «можно ли доверять peer» на «зашифрован ли канал до кого-то». Запрос начинает проходить, но факт успеха перестаёт говорить о подлинности сервера. Если потом этот флаг попадёт в общий клиент или пример конфигурации, временная отладка станет постоянной дырой.

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

\"Матрица
Диаграмма разделяет данные сертификата и локальную политику. Успешный запрос одного клиента не является доказательством для другого trust store.

Runnable-пример: срок и SAN как отдельные причины

Функция ниже не строит X.509-цепочку и не заменяет TLS-библиотеку. Она принимает ISO-даты, hostname и список SAN, затем показывает две базовые проверки, которые полезно видеть в тестах и диагностическом выводе. Входы специально простые; ожидаемый результат различает успех, истёкший сертификат и несовпадение имени.

import { validateCertificateWindow } from './upgrade-2027-10.mjs';\n\nconst common = {\n  host: 'api.example.test',\n  sans: ['api.example.test', 'api.internal.test'],\n  notBefore: '2026-01-01T00:00:00Z',\n  notAfter: '2027-01-01T00:00:00Z',\n};\n\nconsole.log(validateCertificateWindow({ ...common, now: '2026-06-01T00:00:00Z' }));\nconsole.log(validateCertificateWindow({ ...common, host: 'cdn.example.test', now: '2026-06-01T00:00:00Z' }).reason);\n// { ok: true, reason: 'certificate-window-and-san-match' }\n// host-not-listed-in-san

Порядок проверки в среде

  1. Зафиксируйте точный hostname и порт, который видит TLS-клиент. IP-адрес в логе не заменяет имя для проверки SAN.
  2. Проверьте часы контейнера и узла в UTC. Ошибку времени нельзя лечить повторной загрузкой сертификата.
  3. Снимите leaf и intermediate без отключения verify. Сравните цепочку с trust store конкретного процесса.
  4. Проверьте SAN, SNI и redirect. Сертификат для исходного адреса не обязан подходить для нового host после перенаправления.
  5. Разделите ошибку доверия, имени, срока и алгоритма. Для каждой причины оставьте отдельный тестовый fixture.
  6. Исправляйте trust store или цепочку на сервере; флаг обхода проверки не используйте как решение.

Цепочка не равна доверию

Наличие intermediate в файле сервера не означает, что клиент доверяет корню. И наоборот, локальный trust store может содержать корень, но сервер не отправить промежуточный сертификат. Успешная проверка строит путь по подписи и ограничениям, а не по совпадению строк в PEM-файле. Это объясняет, почему «в браузере работает» может быть правдой одновременно с ошибкой минимального контейнера.

Сертификат также не сообщает всю эксплуатационную политику. Клиент может проверять отзыв, запрещать старый алгоритм или требовать минимальную версию TLS. Если диагностический отчёт пишет только «certificate valid», он скрывает полезную часть причины. Сохраняйте код ошибки библиотеки и параметры соединения, а секретный ключ и полное содержимое лишний раз не логируйте.

Ограничения и следующий шаг

Учебная функция не проверяет подпись, CRL, OCSP, wildcard-правила, DNS и реальное TLS-согласование. Она не является security scanner. Её роль — сделать две часто потерянные проверки явными и тестируемыми без сетевой зависимости.

Следующий шаг — воспроизвести ошибку в том же контейнере, где работает сервис, собрать hostname/SNI, цепочку, время и код ошибки, а затем исправить конкретный слой. После исправления оставьте regression test на истёкший срок и неверный SAN, чтобы повторное «временное» отключение доверия стало заметным.

Проверяемые источники

"} +{ + "index": 8, + "slug": "editorial-2027-10-mechanism-long-form-interview", + "title": "TLS-сертификат: почему «curl работает» не закрывает проверку", + "excerpt": "Один успешный запрос доказывает только один набор условий: маршрут, SNI, часы, trust store и политику конкретного клиента. Разбираем, как отделить эти проверки и исправить причину без отключения TLS.", + "contentHtml": "

Симптом знакомый: curl -I https://api.example.test/health с ноутбука возвращает ответ, а приложение в контейнере получает ошибку сертификата. Иногда браузер открывает тот же адрес, но PHP cURL сообщает unable to get local issuer certificate. Цена неверного вывода — не только потерянное время. Если сделать запрос успешным через отключение проверки, сервис может отправить токен или платёжные данные узлу, чью личность он не подтвердил.

\n

Главный вопрос здесь не «работает ли curl», а «какой именно клиент, с каким trust store и каким именем прошёл какие проверки». Успешный запрос доказывает конкретный маршрут, часы, TLS-библиотеку, набор доверенных центров сертификации и политику одного процесса. Это не сертификат исправности браузера, другого контейнера или production-конфигурации. Ниже разберём границы доказательства и оставим runbook, который можно повторить в том же окружении, где живёт ошибка.

\n

Успешен не «curl», а конкретный verifier

\n

TLS не начинается с HTTP-статуса. Сначала клиент устанавливает соединение, договаривается о параметрах и получает от сервера сертификат или цепочку сертификатов. Затем он решает, можно ли доверять цепочке и подходит ли заявленная идентичность имени из URL. Только после успешного обычного handshake появляется защищённый канал для HTTP.

\n

Поэтому фраза «curl работает» слишком короткая. Она может означать: CLI использовал встроенный путь к CA bundle, попал на другой IP, получил сертификат для нужного виртуального хоста и проверил его по часам этой машины. PHP-процесс в контейнере может использовать другой libcurl, другой TLS backend, другой файл доверия, другой proxy и другое системное время. Оба результата будут честными, но отвечать на разные вопросы.

\n
\"Матрица
Один клиентский запрос проходит несколько условий. Исправлять нужно тот слой, который дал отказ, а не отключать всю проверку.
\n
URL и hostname\n  -> DNS и TCP-маршрут\n  -> ClientHello с SNI (если клиент его отправляет)\n  -> сертификат и цепочка от сервера\n  -> путь до доверенного CA + срок + политика\n  -> совпадение имени с сертификатом\n  -> обычный HTTP-запрос
\n

В TLS 1.3 серверное сообщение Certificate передаёт цепочку, когда аутентификация опирается на сертификат. Сам протокол описывает handshake и защищённый канал, но подробные правила проверки X.509 и сопоставления имени дополняются профилями и политикой клиента. Это важная граница: TLS сообщает, как обменяться сертификатом и ключами, но не выбирает за приложение доверенный корень.

\n

Пять проверок, которые нельзя смешивать

\n
Что доказывает диагностика и чего она не доказывает
СлойКто владеетУспех означаетЕщё нужно проверить
МаршрутDNS, сеть, proxyКлиент дошёл до некоторого TLS endpointЧто это нужный IP и proxy-путь
SNI и имяКлиент и TLS-серверВыбранный сертификат покрывает hostname из URLSAN, redirect и имя, которое видит приложение
ЦепочкаСервер и trust store клиентаИз leaf можно построить путь к доверенному CAВсе intermediate и состав доверия этого процесса
ВремяЧасы клиентаnow попадает в окно notBefore–notAfterUTC-время контейнера и узла
ПолитикаTLS backend и приложениеАлгоритмы, версии и режим проверки разрешеныНастройки конкретной сборки и требования endpoint
\n

Сертификат не является одним флагом «валиден». RFC 5280 описывает путь сертификации: сертификаты в цепочке должны связываться по issuer/subject, быть действительными в рассматриваемый момент и вести к trust anchor. Выбор trust anchor — локальная политика. Поэтому одинаковый PEM-файл на двух машинах не гарантирует одинаковый результат, если процессы читают разные файлы или один из них использует системное хранилище.

\n

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

\n

Имя сертификата и SNI — не одно и то же

\n

SNI (Server Name Indication) — расширение ClientHello, в котором клиент сообщает имя сервера. Виртуальный хост использует его, чтобы выбрать сертификат среди нескольких конфигураций на одном IP. После этого клиент всё равно должен сопоставить ожидаемое имя с идентификатором в сертификате. SNI помогает получить правильный сертификат, но не превращает неправильное имя в правильное.

\n

Для HTTPS ожидаемое имя обычно берётся из hostname URL. Заголовок Host отправляется на HTTP-уровне позже. Если клиент подключился к IP и получил сертификат default-vhost, добавление другого Host не отменяет уже случившийся отказ TLS. Когда нужно проверить конкретный IP, сохраняйте hostname и меняйте только маршрут. Для этого подходит контролируемый тест вроде curl --resolve api.example.test:443:IP https://api.example.test/health, где IP заменяется на адрес из вашей инфраструктуры.

\n

RFC 9525 описывает service identity через reference identifier и presented identifier. Для DNS-имени это означает сравнение имени клиента с DNS-ID в subjectAltName; wildcard тоже подчиняется правилам сопоставления, а не произвольному поиску подстроки. Поэтому проверка «в сертификате где-то встречается нужное слово» недостаточна. Смотрите SAN и фактический hostname, а не только Common Name в старом просмотрщике.

\n

Почему разные клиенты дают разные ответы

\n

CLI cURL и PHP cURL могут выглядеть одинаково в логе, но иметь разную конфигурацию. Официальная документация cURL отмечает, что CA store зависит от сборки и TLS backend: в одних окружениях используется файл, в других — нативное хранилище ОС. PHP задаёт curl.cainfo как значение по умолчанию для CURLOPT_CAINFO, и для него нужен абсолютный путь. Это уже две точки расхождения до того, как приложение добавило собственные настройки.

\n

Проверка peer и проверка имени — отдельные операции. CURLOPT_SSL_VERIFYPEER => true проверяет, что сертификат можно связать с доверенным CA. CURLOPT_SSL_VERIFYHOST => 2 проверяет заявленное имя. Выключение первой операции не исправляет вторую; выключение обеих только убирает доказательство личности. Шифрование канала при этом может остаться, но оно не отвечает на вопрос, с тем ли endpoint вы говорите.

\n

Опция --cacert или CURLOPT_CAINFO — способ явно указать доверенный CA bundle для конкретного клиента. Это не команда «взять сертификат у сервера и доверять ему». Для публичного endpoint нужен поставляемый и проверенный набор доверенных CA; для частного CA — согласованный владельцем инфраструктуры сертификат корневого центра и контролируемая доставка. Если сервер не прислал intermediate, добавление leaf в общий bundle маскирует проблему вместо исправления серверной цепочки.

\n

Самодостаточный PHP-пример

\n

Ниже минимальный CLI-скрипт без фреймворка. Он принимает URL и, при необходимости, существующий абсолютный путь к CA bundle. В коде проверка peer и имени включена явно, а номер ошибки и текст сохраняются до закрытия дескриптора. Значения таймаутов здесь учебные: они не являются рекомендацией для каждого API.

\n
<?php\nfunction request($url, $caFile = null)\n{\n    if ($caFile !== null and !is_readable($caFile)) {\n        throw new RuntimeException('CA file is not readable: ' . $caFile);\n    }\n\n    $handle = curl_init($url);\n    if ($handle === false) {\n        throw new RuntimeException('curl_init failed');\n    }\n\n    $options = array(\n        CURLOPT_RETURNTRANSFER => true,\n        CURLOPT_SSL_VERIFYPEER => true,\n        CURLOPT_SSL_VERIFYHOST => 2,\n        CURLOPT_CONNECTTIMEOUT => 5,\n        CURLOPT_TIMEOUT => 15,\n    );\n\n    if ($caFile !== null) {\n        $options[CURLOPT_CAINFO] = $caFile;\n    }\n\n    curl_setopt_array($handle, $options);\n    $body = curl_exec($handle);\n    $errorNo = curl_errno($handle);\n    $error = curl_error($handle);\n    $info = curl_getinfo($handle);\n    curl_close($handle);\n\n    if ($body === false) {\n        throw new RuntimeException('cURL ' . $errorNo . ': ' . $error);\n    }\n\n    return array(\n        'httpStatus' => $info['http_code'],\n        'sslVerifyResult' => $info['ssl_verifyresult'],\n        'bodyBytes' => strlen($body),\n    );\n}\n\n$url = $argv[1] ?? 'https://example.com/';\n$caFile = $argv[2] ?? null;\nvar_export(request($url, $caFile));
\n

Запуск без второго аргумента использует CA store, который видит libcurl процесса:

\n
php tls-check.php https://api.example.test/health\nphp tls-check.php https://api.example.test/health /absolute/path/to/ca-bundle.pem
\n

Если доверие не строится, скрипт завершится исключением до HTTP-статуса. Если handshake прошёл, результат содержит HTTP-код и значение ssl_verifyresult, но это не заменяет проверку ответа API. Успешный 200 может означать только то, что TLS и HTTP для данного запроса завершились; он не доказывает правильность авторизации, данных или бизнес-операции.

\n

Пошаговый runbook без обхода проверки

\n
  1. Зафиксируйте наблюдение. Сохраните полный URL без секретных query-параметров, hostname, порт, текст ошибки, время UTC и место запуска. Запишите, это CLI, PHP-FPM, worker или другой SAPI, а также имя контейнера или образа.
  2. Сравните инструменты. Выполните curl --version, php --ini и php -i | grep -E 'curl.cainfo|openssl.cafile' в том же окружении. Версия CLI не является версией libcurl внутри PHP.
  3. Посмотрите handshake. Запустите curl -vS --connect-timeout 5 --max-time 15 https://api.example.test/health -o /dev/null. Не публикуйте в тикете cookies, authorization-заголовки и чувствительные URL. Вывод показывает путь диагностики, но не заменяет тест приложения.
  4. Проверьте серверную цепочку. Для наблюдения используйте openssl s_client -connect api.example.test:443 -servername api.example.test -showcerts < /dev/null. Ключ -servername важен для виртуального хоста. Этот инструмент показывает выданные сертификаты; итоговое доверие всё равно оценивайте с тем CA bundle, который использует приложение.
  5. Разделите имя и маршрут. Сверьте hostname URL с SAN leaf-сертификата. Если подозреваете неправильный IP, повторите запрос через --resolve, сохранив исходное имя. Не заменяйте hostname на IP как «проверку»: это меняет проверяемую идентичность.
  6. Проверьте время и права. Снимите date -u внутри процесса или контейнера и убедитесь, что пользователь приложения может читать заявленный CA bundle. Ошибка доступа к файлу и отсутствие нужного корня выглядят по-разному на уровне причины, но обе проявляются до HTTP.
  7. Сделайте одно изменение. Если проблема в trust store, передайте подтверждённый CA bundle через CURLOPT_CAINFO или настройку curl.cainfo. Если проблема в intermediate, исправьте цепочку на TLS-сервере. Если проблема в SAN, перевыпустите сертификат с правильным именем. Не меняйте все слои одновременно.
  8. Повторите тем же SAPI. После изменения php.ini перезапустите PHP-FPM или другой долгоживущий процесс. Выполните безопасный health-запрос из того же контейнера и сохраните путь к bundle, версии и команду отката.
  9. Оставьте регрессию. Добавьте отдельные фикстуры для истёкшего сертификата, неизвестного issuer и неверного имени, если ваш тестовый стенд позволяет это сделать. Тест должен подтверждать отказ при включённой проверке, а не только успешный HTTP-ответ.
\n

Что считать исправлением, а что — маскировкой

\n

Исправление имеет владельца. Неполная цепочка — задача владельца TLS endpoint; отсутствующий корпоративный root — задача поставки trust store; неверный SAN или SNI — задача конфигурации имени и виртуального хоста; неверные часы — задача среды; запрещённый алгоритм или версия — задача совместимости и политики. Смена CA bundle не лечит все четыре случая.

\n

curl -k и CURLOPT_SSL_VERIFYPEER => false полезны только как короткий локальный эксперимент, когда нужно доказать, что дальше есть HTTP-ответ. Они не должны попадать в production-конфигурацию, общий helper или постоянный пример. Такой прогон отвечает на вопрос «можно ли продолжить без проверки», но не на вопрос «кому мы отправляем данные».

\n

Не скачивайте новый bundle по URL при каждом старте и не добавляйте в него любой сертификат, который встретился в handshake. CA bundle — часть поставки и политики доверия, а не кеш наблюдаемого ответа. Не считайте проверку сертификата доказательством прав доступа: после TLS остаются HTTP-аутентификация, авторизация, корректность endpoint и безопасность данных.

\n

Ограничения и критерий готовности

\n

Этот разбор не обещает диагностировать отзыв сертификата одинаково во всех TLS backend: поддержка CRL, OCSP и режима «best effort» зависит от клиента и политики. Он также не проверяет DNSSEC, корректность proxy, mutual TLS, pinning или авторизацию API. openssl s_client без явного набора параметров не является полным эквивалентом PHP cURL. Учебный скрипт не валидирует X.509 сам и не должен становиться security scanner.

\n

Считайте работу готовой, когда один и тот же PHP-SAPI в целевом окружении читает ожидаемый trust store, hostname совпадает с SAN, цепочка строится до согласованного CA, часы корректны, проверка peer и имени остаётся включённой, а безопасный запрос проходит без ошибки TLS. После этого отдельно проверяется HTTP-контракт. Вот почему «curl работает» — полезный сигнал, но не закрытие проверки: закрыть её можно только воспроизводимым результатом всех условий конкретного клиента.

\n

Проверяемые источники

\n" +} diff --git a/editorial/agent-rewrites/009.json b/editorial/agent-rewrites/009.json index 1c1d986..b8fe28d 100644 --- a/editorial/agent-rewrites/009.json +++ b/editorial/agent-rewrites/009.json @@ -1 +1,7 @@ -{"index":9,"slug":"editorial-2027-10-practice-long-form-interview","title":"HTTP-таймауты: как разложить общий deadline на измеримые фазы","excerpt":"Запрос может завершиться таймаутом до ответа сервера или повторить запись после неясного результата. Разбираем deadline, фазы HTTP и проверку retry без догадок.","contentHtml":"

Запрос к API иногда отвечает за 900 мс, а клиент прекращает ждать через 800 мс. Пользователь видит ошибку, хотя сервер мог завершить операцию. Хуже случай с записью: клиент повторяет POST, не зная, успел ли первый запрос попасть в обработчик. Цена ошибки — потерянный результат, двойное изменение состояния и лишняя нагрузка на upstream.

Симптом не говорит, где потрачено время. Один общий timeout смешивает DNS, TCP, TLS, отправку тела, ожидание первого байта и чтение ответа. Повторная попытка получает новый полный бюджет и скрывает исходную причину. Тезис статьи прост: задайте один абсолютный deadline для операции, разложите его на наблюдаемые фазы и разрешайте retry только после проверки семантики метода.

Deadline — граница операции

Общий deadline отвечает на вопрос: до какого момента результат имеет смысл для вызывающего кода. Фазовый timeout отвечает на другой вопрос: сколько можно ждать конкретный переход. Если дать DNS, TCP, TLS и чтению по 800 мс каждому, запрос может жить несколько секунд. Если ограничить connect 50 мс, холодное разрешение имени превратит нормальный запрос в ложный отказ.

Храните абсолютный момент окончания или остаток бюджета. Перед каждой фазой вычисляйте remaining = deadline - now. Если остатка нет, завершайте операцию до следующего сетевого вызова. Новый независимый таймер после истечения deadline нарушает контракт вызывающего кода.

Механизм: что измерять

DNS показывает время разрешения имени. TCP connect — установление соединения. TLS handshake — защищённый обмен и проверку сертификата. Request write — передачу заголовков и тела. TTFB показывает ожидание первого байта, а read — получение остального ответа. Эти интервалы отвечают на разные вопросы, поэтому один счётчик total duration не заменяет их.

Фазы HTTP-запроса и сигналы
ФазаЧто измеряемТипичный симптомДействие
DNSРазрешение имениМедленный первый запросПроверить resolver, кэш и лимит DNS
TCP connectОткрытие соединенияОтказ до TLSПроверить маршрут, pool и connect timeout
TLS handshakeЗащищённый обменСоединение есть, ответа нетПроверить цепочку, crypto и TLS budget
Request writeОтправка телаЗависает uploadПроверить размер, backpressure и write timeout
TTFB/readОтвет и телоКлиент ждёт серверСопоставить server time, read timeout и payload

Симптом → причина → проверка → действие

СимптомПричинаПроверкаДействие
Timeout без статуса HTTPИстёк deadline до первого байта или клиент закрыл сокетСравнить timestamps DNS, connect, TLS, TTFB и server logРазделить фазовые интервалы и не называть отказ HTTP-статусом
Первый запрос медленный, следующие быстрыеCold DNS, TCP или TLS скрыты poolСравнить cold и warm соединенияИзмерять установление соединения отдельно от чтения
POST повторяет изменениеRetry запущен без доказанной идемпотентностиПроверить метод, operation-id и результат первого вызоваДобавить ключ дедупликации или сначала читать статус операции
Каждый retry ждёт полный timeoutПопытки не делят общий deadlineПосчитать абсолютный deadline всего запросаПередавать остаток бюджета, а не запускать новый полный таймер
В логах только total durationКлиент не экспортирует фазыПроверить API instrumentation и события poolФиксировать известную границу и не выдавать реконструкцию за факт

Повторная попытка не лечит неизвестный результат

Timeout не доказывает, что сервер ничего не сделал. Для GET повтор обычно допустим, если ресурс читает актуальное состояние и клиент принимает возможное изменение данных между попытками. Для POST, PUT и DELETE решение зависит от семантики операции. Нужны идемпотентность, ключ дедупликации или endpoint для проверки статуса. Одного сетевого флага retry недостаточно.

Три попытки по 800 мс дают до 2,4 секунды ожидания без учёта задержек и одновременно утроенную нагрузку на upstream. Общий deadline должен охватывать все попытки. Если остатка мало, новая попытка не должна начинать работу. Для записи безопаснее вернуть состояние «результат неизвестен» и проверить operation-id, чем отправлять запрос повторно наугад.

\"Карта
Общий бюджет проходит через фазы запроса. Диагноз должен ссылаться на сигнал конкретной фазы, а не только на слово timeout.

Учебный пример без сети

Функция ниже получает общий бюджет и длительности уже измеренных фаз. Она возвращает сумму, остаток и причину. Это учебный пример: он не открывает socket, не моделирует отмену запроса и не заменяет таймеры HTTP-клиента. Его граница — проверка арифметического инварианта отдельно от сети.

import { allocateTimeoutBudget } from './upgrade-2027-10.mjs';\n\nconst within = allocateTimeoutBudget({\n  totalMs: 800,\n  dnsMs: 42,\n  tlsMs: 88,\n  requestMs: 510,\n});\nconst late = allocateTimeoutBudget({\n  totalMs: 800,\n  dnsMs: 120,\n  tlsMs: 210,\n  requestMs: 560,\n});\n\nconsole.log(within.ok, within.remainingMs);\nconsole.log(late.ok, late.reason, late.remainingMs);\n// true 160\n// false deadline-exceeded 0

В первом вызове потрачено 640 мс, поэтому остаётся 160 мс. Во втором сумма равна 890 мс: функция возвращает deadline-exceeded и нулевой остаток. Реальный адаптер должен дополнительно отменять вложенные DNS, socket и чтение, иначе завершение функции не остановит работу сокета.

Порядок проверки

  1. Запишите абсолютный deadline и момент, когда клиент прекратил ожидание. Не начинайте диагностику с увеличения числа.
  2. Добавьте DNS, TCP, TLS, write, TTFB и read в одну структурированную запись. Одинаковые имена фаз важнее названий полей конкретной библиотеки.
  3. Проверьте, передаётся ли остаток бюджета на следующую фазу и между retry. Новый таймер не должен начинаться после истечения deadline.
  4. Сопоставьте фазу отказа с HTTP-методом. Для записи проверьте идемпотентность, operation-id и способ узнать результат первого вызова.
  5. Разделите connect, read и общий deadline в конфигурации. У каждого параметра должен быть владелец и тест на граничное значение.
  6. Сравните cold и warm соединение. Pool может скрыть TLS в одном случае и показать его в другом.
  7. Повторите проверку через реальный proxy и downstream. Локальная функция не доказывает поведение всей цепочки.

Что измерение не доказывает

Большой TTFB не доказывает, что сервер медленный: время могло уйти на proxy, очередь или повторный TLS. Малый TTFB не гарантирует быструю загрузку всего тела. Клиентский timeout не равен HTTP-статусу: клиент может закрыть соединение, а сервер продолжить обработчик. Метрики фаз нужно связывать с серверным временем и размером ответа, но не подменять ими друг друга.

Если библиотека отдаёт только total duration, реконструированные интервалы нельзя называть фактами. Выберите instrumented adapter или события connection pool. До этого фиксируйте только известную границу: «клиент прекратил ждать через N мс». Неполное, но честное измерение лучше точного на вид вымысла.

Ограничения и критерий готовности

Подход не учитывает jitter часов, системные очереди, HTTP/2 multiplexing и детали конкретного proxy. Учебные числа не являются нормативными значениями для вашей сети. RFC описывает протокол, но не выбирает таймауты, retry policy или параметры клиента. Для боевой системы нужен адаптер с отменой всех вложенных операций и отдельная проверка безопасности повторов.

Материал готов к применению, если интеграционный тест для выбранного endpoint показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, а просроченная операция останавливает вложенное чтение. Для записи дополнительно нужен проверяемый operation-id: после timeout система может узнать, был ли первый вызов принят. Если хотя бы одна граница не наблюдается, результат следует считать недоказанным, а не успешным.

Проверяемые источники

"} +{ + "index": 9, + "slug": "editorial-2027-10-practice-long-form-interview", + "title": "HTTP-таймауты: как разложить общий deadline на измеримые фазы", + "excerpt": "Запрос может завершиться таймаутом до ответа сервера или повторить запись после неясного результата. Разбираем общий deadline, фазы HTTP и безопасную проверку retry.", + "contentHtml": "

Клиент отправляет запрос с бюджетом 800 мс. Сервер отвечает примерно через 900 мс, поэтому пользователь получает ошибку, хотя обработчик мог успеть изменить данные. Если после этого автоматически повторить POST, операция может выполниться дважды. Цена ошибки — потерянный результат, двойное списание или создание лишней записи, а также дополнительная нагрузка на upstream.

Главный вопрос не в том, какое число поставить в поле timeout. Нужно понять, как один абсолютный deadline проходит через DNS, установление соединения, TLS, отправку запроса, ожидание первого байта, чтение тела и retry. Ни одна новая фаза или повторная попытка не получает отдельные 800 мс: они используют только остаток общего бюджета.

Сначала зафиксируем контракт

Deadline — это момент, после которого результат операции больше не подходит вызывающему коду. Таймаут фазы — верхняя граница отдельного перехода. Эти понятия связаны, но не взаимозаменяемы. Если дать DNS, connect, TLS и чтению по 800 мс каждому, цепочка может длиться несколько секунд. Если поставить каждой фазе слишком маленький лимит, рабочий запрос будет отклонён до исчерпания общего бюджета.

Храните не набор независимых секундомеров, а абсолютный момент окончания, рассчитанный на монотонных часах среды. Перед каждой фазой вычисляйте remaining = deadline - now. При нулевом или отрицательном остатке не начинайте следующий сетевой вызов. Тот же остаток передаётся в retry и в ожидание ответа от endpoint статуса. Настенные часы могут корректироваться синхронизацией, поэтому они не должны быть единственным источником для измерения длительности.

START -> deadline = monotonicNow() + totalBudget -> remaining <= 0? -> STOP: deadline-exceeded -> DNS -> TCP connect + TLS -> request write -> TTFB -> read -> response? -> check status/semantics -> retry only with the same deadline -> otherwise timeout or unknown-result

Это схема владения состоянием: вызывающий код владеет общим deadline, HTTP-адаптер — фазовыми сигналами и отменой, а API записи — способом узнать судьбу операции. Если один слой заводит новый полный таймер, он нарушает контракт верхнего слоя.

Какие фазы действительно видны

Полный путь HTTPS-запроса можно описать как разрешение имени, TCP-соединение, TLS handshake, запись сообщения и получение ответа. RFC 9110 описывает для схемы https последовательность от разрешения адреса и TCP до TLS и HTTP-запроса; RFC 8446 описывает handshake, после которого стороны получают ключевой материал для прикладных данных. Это протокольная последовательность, а не обещание конкретной библиотеки показать каждую фазу.

Пул соединений меняет картину. На тёплом соединении DNS, TCP и TLS могли произойти раньше, поэтому текущая операция начинается с записи запроса. В HTTP/2 несколько запросов используют одно соединение, и TLS нельзя честно приписать одному из них. Название измерения должно говорить, что именно наблюдалось: connection_setup_ms, request_write_ms, ttfb_ms или response_read_ms.

Фазы, наблюдаемый сигнал и граница вывода
ФазаСигналСимптомЧто можно заключитьВладелец проверки
DNSВремя от lookup до адресаМедленный первый запросЗадержка возникла при разрешении, если lookup измерен отдельноКлиент или resolver
TCP connectВремя до установления TCPНет ответа до TLSПроблема на маршруте, в пуле или лимите connect; причина требует сетевой проверкиHTTP-адаптер и сеть
TLS handshakeВремя до готовности защищённого каналаСоединение открывается, HTTP не начинаетсяЗатронута установка TLS или проверка сертификата, если событие записаноКлиент и владелец TLS
Request writeВремя передачи заголовков и телаЗависает uploadНужно проверить размер тела, backpressure и write timeoutКлиент и proxy
TTFBОт конца записи до первого байтаКлиент ждёт серверВ бюджет попали очередь, proxy и обработка; это не доказательство медленного originProxy и сервис
ReadВремя от первого байта до полного телаСтатус уже получен, тело не пришлоНужно проверить размер ответа, streaming и скорость downstreamКлиент и сервис

Если инструмент сообщает только total_duration, нельзя восстановить DNS, TLS или TTFB делением общего числа. Такая реконструкция выглядит точной, но не является измерением. До instrumented-адаптера логируйте только известные границы: время старта, время отмены, наличие HTTP-статуса и размер полученного тела.

Как распределить бюджет

Распределение фаз — проектное решение, а не таблица из RFC. Сначала измерьте cold и warm соединения, затем выберите резерв для вариативности. Жёсткий лимит connect защищает upstream от зависших попыток, но не должен превращать редкий cold start в массовый отказ. Для долгого ответа важнее отделить ожидание первого байта от чтения тела, иначе команда будет увеличивать read timeout, пытаясь лечить медленную обработку.

Ниже учебный бюджет в 800 мс. Он показывает арифметику, а не рекомендуемые значения. Сумма потолков равна общему deadline; фактические фазы могут закончиться раньше и оставить место для безопасного действия. У каждой строки должен быть владелец, измерение и тест на границе.

Иллюстративный бюджет одной попытки на 800 мс
УчастокПлановый пределЗачем выделенЕсли превышен
DNS80 мсНе держать запрос из-за resolverОтменить lookup и проверить кэш/резолвер
TCP + TLS180 мсОткрыть защищённое соединениеПроверить сеть, pool и холодное соединение
Write60 мсПередать небольшой запросПроверить тело и backpressure
TTFB300 мсДождаться решения upstreamСопоставить trace, очередь и server time
Read180 мсПолучить всё телоПроверить streaming и размер payload

Таблица полезна только как видимый контракт. На практике некоторые лимиты не складываются последовательно: тёплый pool пропускает DNS, TCP и TLS, а HTTP/2 делит соединение между запросами. Поэтому конфигурация должна хранить и общий deadline, и фазовые наблюдения, но не выдавать плановый предел за фактическое время.

Retry начинается с семантики

Таймаут не сообщает, был ли запрос принят сервером. Клиент мог закрыть сокет после отправки тела, а сервер продолжить обработчик. Для чтения это обычно означает повторную проверку состояния. Для записи сначала определите, можно ли повторить действие без второго эффекта.

RFC 9110 называет идемпотентными безопасные методы, а также PUT и DELETE: повтор одинакового запроса должен иметь тот же задуманный эффект, что и один запрос. Та же спецификация отдельно предупреждает, что клиенту не следует автоматически повторять неидемпотентный метод, если нет способа доказать идемпотентность или узнать, что исходный запрос не применился. Сам метод не делает прикладную операцию безопасной: сервер может отправлять лог или создавать побочный аудит при каждом обращении.

POST с ключом дедупликации может получить прикладную идемпотентность, но это контракт API, а не свойство HTTP. Ключ должен быть стабильным для одной операции, храниться на сервере достаточно долго и связываться с результатом. Если такого контракта нет, честный ответ после таймаута — unknown-result. Клиент показывает возможность проверить статус, а не угадывает, что запись не произошла.

Решение о повторе после неясного результата
СлучайЧто известноСледующий шагРиск
GET чтенияНужно получить состояние ресурсаПовторить только в оставшемся бюджете или запросить актуальное состояниеДанные могли измениться между попытками
PUT/DELETEМетод идемпотентен по HTTP-семантике, но побочные эффекты сервера отдельныПовторить с тем же deadline после проверки API-контрактаКонкретный endpoint может иметь дополнительный эффект
POST с operation-idСервер умеет связать повторы с одной операциейПовторить или прочитать статус по тому же идентификаторуИстёк срок хранения ключа или статус недоступен
POST без дедупликацииНеизвестно, применилось ли изменениеНе повторять вслепую; вернуть unknown-result и дать проверкуДублирование записи или списания
Ответ 503/504Сигнал от сервера или gateway, но не доказательство отсутствия записиПроверить retry policy и состояние операцииПовтор может усилить перегрузку

Коды 408 и 504 тоже нельзя превращать в универсальную команду «повторить». 408 означает, что сервер не получил полный запрос за время ожидания; 504 означает, что gateway или proxy не дождался нужного upstream. Оба кода описывают наблюдаемую точку в цепочке, но не устанавливают прикладную судьбу POST. Для этой границы нужны логи сервера, correlation-id и endpoint статуса.

Самодостаточная проверка арифметики

Перед подключением к реальному клиенту удобно проверить сам инвариант: сумма уже потраченных фаз не должна превысить общий бюджет, а следующий вызов стартует только при положительном остатке. Фрагмент ниже запускается обычным node, не импортирует локальные модули и не притворяется HTTP-адаптером.

function evaluateAttempt({ totalMs, phases }) {
  if (!Number.isFinite(totalMs) || totalMs <= 0) throw new RangeError('totalMs must be positive');
  const elapsedMs = phases.reduce((sum, phase) => {
    if (!Number.isFinite(phase.ms) || phase.ms < 0) throw new RangeError('phase duration must be non-negative');
    return sum + phase.ms;
  }, 0);
  const remainingMs = Math.max(0, totalMs - elapsedMs);
  return { ok: elapsedMs <= totalMs, elapsedMs, remainingMs, canStartNextPhase: remainingMs > 0, reason: elapsedMs > totalMs ? 'deadline-exceeded' : null };
}

const within = evaluateAttempt({ totalMs: 800, phases: [{ ms: 42 }, { ms: 88 }, { ms: 310 }, { ms: 200 }] });
const late = evaluateAttempt({ totalMs: 800, phases: [{ ms: 120 }, { ms: 210 }, { ms: 380 }, { ms: 180 }] });
console.log(within);
console.log(late);
// { ok: true, elapsedMs: 640, remainingMs: 160, canStartNextPhase: true, reason: null }
// { ok: false, elapsedMs: 890, remainingMs: 0, canStartNextPhase: false, reason: 'deadline-exceeded' }

Функция считает длительности, которые ей уже передали; она не измеряет сеть. В production-адаптере каждый вызов должен получать remainingMs, а при отмене должен завершаться и его дочерний lookup, socket или stream. Иначе арифметический тест будет зелёным, а сокет продолжит жить после ответа пользователю.

Пошаговый runbook

  1. Опишите операцию. Запишите метод, endpoint, размер запроса, ожидаемый результат и последствия повторной записи. Для изменения состояния сразу заведите operation-id или зафиксируйте, почему его нет.
  2. Зафиксируйте границу. Выберите общий бюджет и монотонные часы. Запишите момент старта, deadline, момент отмены и результат: HTTP-статус, транспортная ошибка или unknown-result.
  3. Разметьте фазы. Соберите DNS, connect, TLS, write, TTFB и read, если клиент их действительно предоставляет. Не называйте реконструкцию измерением.
  4. Сравните cold и warm. Выполните тест без готового соединения и повторите его через pool. Отдельно проверьте HTTP/2, если он включён: setup соединения не принадлежит одному запросу.
  5. Проверьте остаток. Искусственно замедлите одну фазу и убедитесь, что следующая получает остаток, а не новый полный timeout. На нулевом остатке сетевой вызов не должен начинаться.
  6. Проверьте retry. Для GET/PUT/DELETE сопоставьте метод и контракт endpoint. Для POST создайте тест «ответ потерян после записи» и проверьте, что operation-id возвращает тот же результат, а повтор не создаёт второе изменение.
  7. Сопоставьте стороны. Сверьте client timestamps с proxy access log и server trace по correlation-id. Если клиент прервался на 800 мс, это не доказывает, что сервер остановился на 800 мс.
  8. Оформите владельца. В конфигурации рядом с каждым фазовым лимитом укажите owner, причину значения, тест границы и действие при деградации. Ревью должен менять контракт и проверку вместе, а не только число.

Ограничения и критерий готовности

Единый deadline не делает любую цепочку быстрой. На результат влияют очереди, планировщик, proxy, размер тела, повторные TLS-соединения, multiplexing и отмена в конкретной библиотеке. Фазовые лимиты не заменяют rate limit, circuit breaker или контроль размера запроса. Их задача уже: не дать вложенной операции пережить смысловой бюджет вызывающего кода.

Отдельно проверьте границу между клиентом и сервером. HTTP-таймаут клиента может оставить серверный обработчик работающим. Если сервер умеет принимать deadline в заголовке или контексте, согласуйте его с клиентским значением и оставьте запас на ответ. Не копируйте это поведение без документации вашего proxy и сервиса.

Решение готово к эксплуатации, когда тест показывает фазы на cold и warm соединении, общий deadline ограничивает все попытки, отмена останавливает вложенное чтение, а логи позволяют связать клиентский отказ с proxy и server trace. Для записи нужен ещё один проверяемый факт: по operation-id можно узнать, был ли первый вызов принят. Если этой проверки нет, успешный HTTP-статус и отсутствие ошибки сети не должны превращаться в универсальный вывод о retry.

Проверяемые источники

\"Схема:
Диагноз должен опираться на сигнал конкретной фазы и семантику операции, а не только на слово «timeout».
" +} diff --git a/editorial/agent-rewrites/010.json b/editorial/agent-rewrites/010.json index bee99ea..a0c2af7 100644 --- a/editorial/agent-rewrites/010.json +++ b/editorial/agent-rewrites/010.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-09-field-mentor-series", "title": "API-diff в code review: как найти несовместимость и сохранить откат", "excerpt": "Практический разбор API-изменений: классифицируем риск, ищем потребителей, проверяем частичный rollout и удаляем старый контракт только после измеримого сигнала.", - "contentHtml": "

Ошибка в API-изменении часто выглядит безобидно: сервер собирается, локальный тест получает 200, diff занимает несколько строк. Затем старый клиент отправляет прежний запрос, получает новый обязательный ответ или неизвестное значение enum и ломается на успешном пути. Цена ошибки растёт быстро: приходится восстанавливать потребителей, задерживать rollout и решать, как вернуть сервер, если он уже записал данные в новом формате.

\n

Тезис простой: review API-diff должен проверять не только код сервера. Он должен связать форму контракта, потребителей, данные и порядок выката. Сначала классифицируйте изменение. Затем найдите тех, кто читает и пишет старую форму. После этого выберите расширение, версию или expand/contract. Такой порядок превращает спор о «безопасном» diff в набор проверяемых условий.

\n

Механизм: контракт живёт по обе стороны границы

\n

API состоит как минимум из запроса, ответа и иногда события. У каждого есть форма, значения и смысл. Добавление необязательного поля в ответ обычно расширяет контракт: старый клиент может его проигнорировать. Удаление поля сужает контракт. Новое обязательное поле в запросе ломает старого отправителя. Сужение enum ломает ветвление, которое раньше обрабатывало удалённое значение.

\n

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

\n
Симптом → причина → проверка → действие
СимптомПричинаПроверкаДействие
Старый клиент не создаёт ресурсВ запрос добавили required-полеНайти builders, SDK и fixtures старой версииОставить поле optional, задать совместимое значение или выпустить версию
Клиент падает на успешном ответеУдалили свойство или изменили типПоиск чтения свойства и contract-тест старого клиентаСначала deprecated-окно и новое поле, затем удаление
Новая ветка обработки не срабатываетСузили enum или добавили неизвестное значениеПрогнать значения через старые switch и парсерыСохранить старые значения либо сменить версию
Откат приложения не восстанавливает работуНовый сервер записал только новый форматПроверить чтение старой версией после частичного rolloutСначала expand, затем switch, потом contract
Тесты зелёные, внешний потребитель сломанПотребитель не попал в репозиторийПроверить registry, документацию, логи и владельцев интеграцийОстановить удаление и оставить совместимый путь
\n

Пример: классификатор как первая страховка

\n

Ниже учебная функция получает уже выделенные признаки diff. Она не читает OpenAPI, не ищет клиентов и не разрешает pull request автоматически. Её граница полезна именно поэтому: генератор или reviewer передаёт факты, а функция одинаково маркирует очевидный breaking-риск. Реальная проверка должна дополнить её consumer-тестами и проверкой данных.

\n
function classifyApiChange(change) {\n  const removed = Array.isArray(change.removedProperties)\n    ? change.removedProperties\n    : [];\n  const required = Array.isArray(change.addedRequiredProperties)\n    ? change.addedRequiredProperties\n    : [];\n  const narrowedEnum = Boolean(change.narrowedEnum);\n  const breaking = removed.length > 0\n    || required.length > 0\n    || narrowedEnum;\n\n  return {\n    status: breaking ? 'breaking' : 'compatible',\n    action: breaking\n      ? 'version-or-expand-compatibility-window'\n      : 'run-consumer-contract-tests',\n  };\n}\n\nconst result = classifyApiChange({\n  removedProperties: ['displayName'],\n  addedRequiredProperties: [],\n  narrowedEnum: false,\n});\n\nconsole.log(result);\n// { status: 'breaking',\n//   action: 'version-or-expand-compatibility-window' }
\n

Функция намеренно не считает любое добавление опасным. Необязательное поле в ответе обычно можно выпустить сразу, если клиенты действительно игнорируют неизвестные свойства. Но это условие нужно проверить. Клиент с жёстким декодером, схемой с additionalProperties: false или сравнением полного JSON может сломаться даже на расширении. Статус compatible здесь означает «нет очевидного структурного breaking-признака», а не «изменение доказанно безопасно».

\n

Обратимость: сначала данные, потом бинарник

\n

Rollback приложения не возвращает базу, очередь и уже отправленные события. Если новый код записал только displayNameV2, старый код может не знать, как его читать. Если событие получило новое обязательное поле, повторная доставка старому consumer не станет безопасной от одного переключения образа. Поэтому опасные изменения проводите в несколько фаз.

\n

На фазе expand добавьте новую колонку, поле или форму так, чтобы старый код продолжал работать. На фазе совместимости научите новый код читать старую и новую формы и, при необходимости, писать обе. На фазе switch переведите потребителей и проверьте сигнал использования старого пути. Только после этого выполняйте contract: удаляйте поле, старый writer или колонку. Для каждой фазы нужна обратная операция и условие перехода.

\n
Маршрут API review: классификация diff, проверка потребителей и окно обратимой миграции перед удалением старой формы.
Схема связывает локальный diff с потребителями и данными. Если старый формат ещё нужен, путь к удалению должен остановиться.
\n

Порядок действий для одного изменения

\n
  1. Назовите endpoint, метод, статус, media type и версию клиента. Не смешивайте request, response и внутреннюю модель в одном описании.
  2. Снимите старую и новую формы. Выпишите required-поля, типы, nullable, enum, значения по умолчанию и изменение смысла.
  3. Запустите классификацию. Для каждого breaking-признака укажите конкретное поле и потребителя, которого он затрагивает.
  4. Найдите потребителей по сгенерированным типам, сериализаторам, SDK, fixture, документации, очередям и логам. Отдельно отметьте внешние интеграции, которых нет в монорепозитории.
  5. Составьте матрицу чтения и записи: старая версия против старой формы, старая против новой, новая против старой и новая против новой. Добавьте частичный rollout и повторную доставку события.
  6. Напишите отрицательный contract-тест для старого клиента и положительный тест для нового. Ошибка должна называть поле, значение и ожидаемую форму.
  7. Выберите способ перехода: совместимое расширение, новая версия или expand/contract. Для rollout запишите точку остановки и способ возврата.
  8. Удаляйте старую форму только после измеримого сигнала: старый consumer не обращается к полю, истёк согласованный срок хранения, а восстановление проверено.
\n

Где автоматическая проверка не помогает

\n

Классификатор не знает, что внешний клиент использует поле чаще внутреннего. Он не видит подписанный payload, кэш, задержанную очередь и SDK, который обновляется отдельно. Он также не проверяет авторизацию, конкурентное изменение ресурса и идемпотентность повторного запроса. Эти риски нужно описать в своих тестах и процедуре rollout.

\n

Схема не доказывает корректность операции. Ответ может быть валидным по JSON, но устареть между чтением и записью. Для такого случая нужны версия ресурса, условный запрос вроде If-Match, правило конфликта и отдельный статус. Не прячьте доменный инвариант в описании поля: форма данных и допустимость перехода состояния — разные проверки.

\n

Пример синтетический. Он показывает границу классификатора и порядок миграции, но не заявляет замеры, production-результаты или совместимость с конкретным сервисом. Перед выпуском замените вымышленные поля реальным diff и приложите доказательства по вашим потребителям.

\n

Критерий готовности

\n

Изменение готово к выпуску, если reviewer может открыть одну страницу и увидеть старую и новую формы, список потребителей, результат классификации, матрицу частичного rollout, contract-тесты и процедуру возврата. Для breaking-изменения дополнительно указан владелец старого пути, сигнал его использования и условие удаления. Если хотя бы один потребитель неизвестен, не переходите к contract-фазе: оставьте совместимое расширение или выпустите отдельную версию.

\n

Проверяемые источники

" + "contentHtml": "

В review приходит небольшой API-diff: в ответ добавили поле, в запросе появился новый параметр, а локальный тест всё ещё получает 200. Через несколько минут старый клиент отправляет прежнюю форму и получает 400, либо успешно читает ответ, но падает на новом значении enum. Цена такой ошибки — не только красный график: приходится останавливать rollout, искать неизвестного потребителя и решать, совместим ли уже записанный новый формат со старой версией приложения.

\n

Главный вопрос ревью звучит так: может ли старый потребитель выполнить ту же операцию, пока новая версия уже частично работает? Ответ нельзя получить из diff сервера в одиночку. Нужно связать форму запроса и ответа, смысл полей, список читателей и писателей, состояние данных и план возврата. Ниже — рабочий порядок: классификация, проверка потребителей, выбор перехода и явная точка остановки.

\n

Сначала отделяем форму от смысла

\n

API-контракт — это не только TypeScript-интерфейс или OpenAPI-файл. У него есть метод, путь, media type, статусы, форма запроса, форма ответа и допустимые значения. У поля есть ещё смысл: часовой пояс, единица измерения, правило пустого значения и связь с состоянием ресурса. Изменение может пройти структурный валидатор и всё равно изменить операцию для клиента.

\n

OpenAPI 3.1.1 описывает поверхность HTTP-интерфейса и позволяет инструментам строить документацию, клиентов и тесты. Это полезный источник формы, но не доказательство фактического поведения конкретного сервиса. Сверяйте описание с обработчиком, реальным ответом и потребителем. В статье примеры используют OAS 3.1.1 и JSON Schema Validation 2020-12; если проект работает на другой версии или диалекте, сначала проверьте поддерживаемые ключевые слова и генератор.

\n
Симптом API-diff: что проверять и где остановиться
ИзменениеРиск для старого потребителяПроверкаРешение и stop condition
В запрос добавили required-полеСтарый отправитель не проходит валидациюЗапустить старый SDK или fixture без поляСделать поле optional или выпустить новую версию; остановиться, если старый путь получает 4xx
Из ответа удалили поле или изменили его типЧтение становится ошибочным или теряет значениеНайти чтения поля и прогнать старый декодерСначала новое поле рядом со старым; удаление только после сигнала отсутствия чтений
В ответ добавили новое значение enumЗакрытый switch или декодер не знает значениеПередать новое значение старому consumerДобавить unknown-ветку или версию; остановиться на необработанной ветке
В запросе сузили принимаемый enumРанее допустимый отправитель получает отказСравнить старый набор входов с новым и проверить логи 4xxСохранить старое значение или сменить версию; удаление — после миграции отправителей
Новый writer сохраняет только новый форматRollback бинарника оставляет старый reader без данныхЗаписать новой версией, затем прочитать старойExpand/contract с двойным чтением или записью; остановиться до contract-фазы
Потребитель вне репозиторияЗелёный CI не видит интеграциюПроверить registry, владельца, документацию и журналы вызововНазначить owner или оставить совместимый путь; неизвестный consumer блокирует удаление
\n

Что именно считать несовместимостью

\n

Required отвечает на вопрос о наличии поля. В JSON Schema объект проходит это ограничение, только если каждое имя из массива required есть в экземпляре. Поэтому добавление обязательного поля в запрос меняет минимальную форму, которую должен уметь отправить старый клиент.

\n

Enum ограничивает множество значений. Расширение enum в ответе опасно для клиента с закрытым набором веток. Сужение enum в запросе опасно для отправителя, который ещё шлёт удалённое значение. Удаление значения из ответа не равно автоматически breaking-изменению: оно может быть безопасным для парсера, но изменить бизнес-переход. Этот смысл проверяется отдельно, а не угадывается по схеме.

\n

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

\n

Наконец, тип и форма не покрывают инвариант. Поле revision может оставаться строкой, но сервер может начать требовать его актуальность. Для конкурентной записи полезен условный запрос: RFC 9110 описывает If-Match как проверку текущего entity tag до выполнения метода и допускает ответ 412 Precondition Failed, если условие не выполнено. Это отдельный контракт состояния, а не следствие сравнения JSON.

\n

Учебный API-diff, который можно запустить

\n

Ниже самодостаточный Node.js-скрипт без пакетов. Он не парсит OpenAPI и не пытается автоматически одобрить pull request. Скрипт получает две уже нормализованные формы, находит добавленное required-поле, удалённое свойство и опасное изменение набора входных enum. Сохраните его как api-diff.mjs и запустите командой node api-diff.mjs.

\n
const before = {\n  requestRequired: ['name'],\n  requestEnums: { role: ['reader', 'editor'] },\n  responseProperties: { id: 'string', name: 'string', status: 'string' },\n};\n\nconst after = {\n  requestRequired: ['name', 'ownerId'],\n  requestEnums: { role: ['reader'] },\n  responseProperties: { id: 'string', name: 'string', status: 'string' },\n};\n\nfunction difference(left, right) {\n  return left.filter((value) => !right.includes(value));\n}\n\nfunction classifyApiDiff(oldContract, newContract) {\n  const oldRequired = oldContract.requestRequired || [];\n  const newRequired = newContract.requestRequired || [];\n  const addedRequired = difference(newRequired, oldRequired);\n  const removedResponse = difference(\n    Object.keys(oldContract.responseProperties || {}),\n    Object.keys(newContract.responseProperties || {}),\n  );\n  const narrowedRequestEnums = Object.entries(oldContract.requestEnums || {})\n    .filter(([name, oldValues]) => {\n      const newValues = newContract.requestEnums?.[name] || [];\n      return difference(oldValues, newValues).length > 0;\n    })\n    .map(([name]) => name);\n\n  const breaking = addedRequired.length > 0\n    || removedResponse.length > 0\n    || narrowedRequestEnums.length > 0;\n\n  return {\n    status: breaking ? 'breaking-risk' : 'no-obvious-breaking-change',\n    addedRequired,\n    removedResponse,\n    narrowedRequestEnums,\n    next: breaking ? 'stop-and-open-compatibility-plan' : 'run-consumer-tests',\n  };\n}\n\nconsole.log(JSON.stringify(classifyApiDiff(before, after), null, 2));
\n

Для приведённых данных результат содержит ownerId в addedRequired и role в narrowedRequestEnums. removedResponse пуст. Скрипт намеренно не помечает добавление необязательного response-поля как breaking: это оставляет место для проверки tolerant и strict consumers. Он также не проверяет изменение смысла, статусы, авторизацию, события, кеши и записи. Эти границы важнее красивого зелёного результата, поэтому автоматический классификатор — первая страховка, а не решение reviewer.

\n

Потребитель — это читатель, писатель и очередь

\n

После классификации составьте карту поверхности. Для каждого endpoint запишите клиентов браузера и мобильного приложения, SDK, batch-задачи, webhooks, очереди и внешние интеграции. Внутри репозитория ищите сгенерированные типы, сериализаторы, имена полей, fixtures и contract-тесты. Вне репозитория запросите owner и дату последнего вызова; отсутствие записи в монорепозитории не доказывает отсутствие потребителя.

\n

Разделяйте направление зависимости. Старый writer → новый reader обычно проверяется легче, чем новый writer → старый reader. Второй путь критичен для rollback: новая версия могла уже записать данные, которые старая не умеет прочитать. Для событий добавьте задержанную доставку и повторное проигрывание. Для кеша проверьте старую сериализацию после истечения TTL, а не только свежий запрос.

\n
old client  --request v1--> new server  --response v2--> old client\n     |                              |\n     +-- old data <-- new writer ---+\n\nПеред switch проверяем четыре перехода:\n1. old client -> old server\n2. old client -> new server\n3. new client -> old server, если rollback реален\n4. new writer -> old reader, если данные переживают rollback
\n

Если команда не может воспроизвести третий или четвёртый переход, это не повод молча исключить его из тестов. Это сигнал, что rollback не определён. Тогда сначала ограничьте rollout, сохраните старый writer или подготовьте чтение обеих форм. Название «backward compatible» без конкретного направления мало помогает reviewer.

\n

Три способа выпустить изменение

\n

Совместимое расширение — самый дешёвый путь, когда добавляется optional-поле, сохраняются прежние значения и все декодеры это допускают. Цена — временно поддерживать две формы и не путать отсутствие значения с пустым значением. Этот вариант хорош для одного независимого поля, но не спасает изменение смысла.

\n

Новая версия endpoint или media type изолирует breaking-контракт. Клиенты мигрируют по отдельному графику, а старый путь живёт до объявленного срока. Цена — две документации, два набора тестов, маршрутизация и наблюдение за обоими путями. Версия оправдана, когда старую и новую семантику нельзя честно совместить.

\n

Expand/contract подходит для данных, которые переживают релиз. На expand добавьте новую колонку или поле, не ломая старый reader. На compatibility научите новый код читать старую и новую форму и, если нужно, писать обе. На switch переведите потребителей и включите новый writer. На contract удалите старую форму только после сигнала использования и проверенного восстановления. Это дороже в коде и миграциях, зато rollback остаётся возможным после записи.

\n
Цикл проверки совместимости: сравнение схемы, required-поверхности, manifest и потребителя, после чего изменение либо возвращается на доработку, либо проходит ограниченный gate.
Схема напоминает о границе автоматизации: сравнение формы должно закончиться проверкой реального потребителя и явным решением продолжать или остановиться.
\n

Порядок можно записать коротким flow: diff → направление чтения/записи → список consumers → contract-тест → частичный rollout → сигнал → switch → contract. На каждой стрелке должен быть владелец. Если сигнал не определён, переход заканчивается на предыдущем узле.

\n

Runbook одного API-изменения

\n
  1. Зафиксировать baseline. Сохраните старую схему, пример запроса и ответа, статусы, media type и версии клиентов. Не смешивайте внешнюю форму с внутренней моделью.
  2. Разложить diff. Выпишите added/removed properties, required, nullable, типы, enum, default и изменение смысла. Для каждого пункта укажите request, response или event.
  3. Классифицировать риск. Запустите проверку вроде примера выше, но не называйте результат доказательством. Для каждого флага приложите конкретное поле и направление потока.
  4. Найти consumers. Проверьте код, SDK, генератор, fixtures, registry, владельцев внешних интеграций, очереди, webhooks и логи. Не заменяйте неизвестного owner предположением.
  5. Собрать четыре перехода. Прогоните старый и новый client против старого и нового server там, где такой маршрут возможен. Отдельно проверьте новый writer → старый reader и повторную доставку события.
  6. Написать тесты отказа. Старый клиент должен показать, что именно ломается: поле, значение, статус или декодирование. Новый клиент должен пройти положительный путь. Для семантических изменений добавьте бизнес-инвариант, а не только schema validation.
  7. Выбрать переход. Для простого расширения оставьте optional-путь; для несовместимой семантики версионируйте; для сохраняемых данных примените expand/contract. В карточке изменения запишите владельца rollback и момент остановки.
  8. Провести частичный rollout. Сначала включите небольшой контролируемый срез, сравните ошибки старых и новых consumers, проверьте чтение записей обеими версиями и только потом расширяйте охват. Число среза — параметр вашей платформы, здесь оно не задано.
  9. Закрыть старый путь. Нужны измеримый сигнал отсутствия старого consumer, согласованный срок хранения, подтверждённое восстановление и owner удаления. Если любой пункт неизвестен, contract-фаза не начинается.
\n

Ограничения и ложные зелёные проверки

\n

Schema diff не видит авторизацию и права, конкурентную запись, идемпотентность, подписанные payload, кэш, задержанную очередь и клиент, обновляющийся вне вашего графика доставки. JSON Schema проверяет форму экземпляра, но не знает, имеет ли пользователь право изменить ресурс и не устарела ли его версия. Эти условия должны появиться в тесте boundary или в runbook, если они входят в ваш контракт.

\n

HTTP-статус тоже нельзя выводить из имени поля. Ответ 200 может содержать бизнес-отказ, а 412 требует реальной проверки precondition на сервере. Если используете If-Match, проверьте сильное сравнение entity tag, порядок проверки до изменения состояния и поведение повторной доставки. Если такой контракт не поддержан сервером, не добавляйте заголовок в пример только ради видимости надёжности.

\n

Учебные имена ownerId, role и status не описывают конкретный production-сервис. В тексте нет измерений rollout и заявленного результата: его нужно получить у своей системы. Версии OAS и JSON Schema здесь указаны для воспроизводимости примера; генератор, валидатор и политика неизвестных полей всё равно требуют проверки в проекте.

\n

Когда review можно закрыть

\n

Я закрываю API-diff, когда в одном месте видны baseline и candidate, классификация с конкретными полями, список readers/writers, четыре перехода, contract-тесты, выбранный способ миграции, сигнал частичного rollout и операция возврата. Для изменения данных добавляю результат чтения старой версией после записи новой. Для внешнего consumer указываю owner или оставляю старый путь.

\n

Начните со следующего небольшого изменения и заполните только одну карточку: endpoint, направление, поле, consumer, проверка и stop condition. Если после этого нельзя ответить, что произойдёт с данными при rollback, остановите удаление и сначала сделайте чтение старой и новой формы совместимым. Такой review занимает место в процессе, но возвращает команде управляемый выбор вместо срочного восстановления неизвестного клиента.

\n

Проверяемые источники

" }