Протоколы, которые пережили всё

Протоколы, которые пережили всё

4 октября 2021 года Facebook, Instagram и WhatsApp исчезли из интернета на шесть часов. Три миллиарда пользователей не могли залогиниться, написать сообщение, посмотреть ленту. Причина оказалась почти комичной: ошибка в конфигурации BGP — протокола маршрутизации, которому больше тридцати лет. Один неудачный коммит, и серверы Facebook перестали «видеться» с остальным миром.

Но вот что любопытно: сам BGP при этом работал безупречно. Он честно выполнил свою работу — перестал анонсировать маршруты, которые ему сказали не анонсировать. Проблема была не в протоколе, а в том, как его использовали.

Эта история хорошо иллюстрирует странную асимметрию современного интернета:

  • внизу, под всеми сервисами, работают удивительно «старые» протоколы — TCP/IP, DNS, TLS, HTTP. Они родились десятилетия назад, и при этом продолжают тащить на себе весь интернет-трафик;
  • наверху, в каждом отдельном приложении, мы строим сложные микросервисные конструкции, которые иногда рассыпаются от одного неаккуратного изменения.

Создаётся ощущение, что фундамент сделан неожиданно мудро, а вот надстройки — как получится.

Немного о том, как вообще думают протоколами

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

Протокол — это не «формат JSON» и не «набор REST-эндпоинтов». Это договорённость о том, как две стороны разговаривают:

  • кто начинает разговор;
  • в каком порядке идут шаги;
  • что считается корректным ответом;
  • что делать, если что-то сломалось по дороге.

И хороший протокол почти всегда устроен по-скромному:

  • он решает одну понятную задачу;
  • он старается предусмотреть не только «счастливый путь», но и ошибки, половинчатые состояния, сбои;
  • он пишет это всё в документ, который можно читать и спорить — в RFC, а не только в исходниках.

Если взглянуть на основные протоколы интернета, окажется, что именно эта скучная дисциплина и даёт им такую живучесть. Посмотрим на конкретные примеры.

TCP/IP: труба, которая умеет терпеть хаос

В 70-х годах сетей было мало, они были нестабильными, маршруты менялись, пакеты терялись. В таких условиях «идеальной связи» не существовало в принципе — ARPANET соединял несколько десятков узлов, и каждый день что-нибудь отваливалось.

TCP (Transmission Control Protocol) родился как попытка построить поверх этого хаоса иллюзию надёжного потока байтов. Он:

  • устанавливает соединение (трёхстороннее рукопожатие);
  • нумерует сегменты данных;
  • ждёт подтверждений;
  • повторно отправляет потерянное;
  • следит за скоростью, чтобы не утопить сеть.

А IP (Internet Protocol) под ним вообще ничего не обещает — он просто старается донести пакет «как получится».

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

Закон Постела: «будь строг в отправке и терпим в приёме»

Джон Постел, один из авторов TCP и редактор первых RFC, сформулировал принцип, который потом окрестили «robustness principle»:

Будь консервативен в том, что сам отправляешь, и либерален к тому, что принимаешь от других.

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

  • свой трафик старались делать максимально аккуратным;
  • входящий — парсить и интерпретировать, насколько возможно, не падая при первом странном байте.

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

Теперь посмотрим на другой столп интернета — систему, без которой не откроется ни один сайт.

DNS: телефонная книга для планеты

DNS родился из банальной задачи: людям удобно работать с именами (example.com), а машинам нужны IP-адреса. В начале 80-х эту проблему решали файлом HOSTS.TXT, который вручную рассылали по сети. Когда узлов стало слишком много, понадобилась распределённая, расширяемая телефонная книга.

У DNS есть несколько ключевых идей, которые до сих пор с нами:

  • иерархия имён: корень . → домены верхнего уровня (.com, .org, .ru) → поддомены;
  • делегирование ответственности: каждая зона отвечает за свой кусок;
  • кеширование: ответы снабжаются TTL, чтобы не бегать каждый раз к корню;
  • базовый протокол — простые запросы и ответы поверх UDP, с возможностью перейти на TCP, если сообщение большое.

На бумаге всё выглядит настолько скромно, что кажется: «ну да, обычный справочник». Но поверх этого справочника живёт всё — от веб-сайтов и почты до VoIP и современных облаков.

С годами DNS обзавёлся расширениями:

  • DNSSEC — криптографическая подпись записей;
  • EDNS — возможность тащить дополнительные данные;
  • механизмы геораспределения, Anycast, сложные схемы отказоустойчивости.

Но основа осталась прежней: запрос → набор записей → время жизни. И именно эта ясность делает DNS удивительно живучим: пока выдержана базовая модель, вокруг можно наращивать сколько угодно CDN-слоёв и хитрых балансировщиков.

От «телефонной книги» перейдём к протоколу, который эволюционировал буквально на наших глазах.

HTTP/1.1 → HTTP/2 → HTTP/3: усложнение внизу при сохранении простоты наверху

Если нужен пример эволюции протокола «по-взрослому», лучше всего смотреть на HTTP.

HTTP/1.1: всё просто, но дорого

Классический HTTP/1.1 очень прямолинеен:

  • текстовый протокол поверх TCP;
  • один запрос — один ответ;
  • всё идёт последовательно, в одном потоке.

Для первых лет веба этого было достаточно. Но с ростом сложности страниц (картинки, скрипты, стили, шрифты) стало ясно, что открывать новое TCP-соединение для каждого ресурса — роскошь: дорого устанавливать, долго разогревать.

HTTP/2: один сложный поток вместо многих простых

HTTP/2 предложил другой подход:

  • одно TCP-соединение между клиентом и сервером;
  • внутри него — много логических потоков (стримов), каждый со своим ID;
  • фреймы запросов и ответов перемешиваются и мультиплексируются.

Семантика для приложения почти не меняется — всё те же методы и заголовки. Но под капотом теперь бинарный фрейминг, приоритезация, сжатие заголовков.

Выгода очевидна: можно параллельно тянуть кучу ресурсов по одному соединению. Но есть проблема — TCP не умеет в частичный проигрыш. Потерялся один пакет — блокируется весь поток (head-of-line blocking). Для HTTP/2 это означает, что ошибка в одном месте задерживает все стримы, даже те, которые к потерянному пакету отношения не имеют.

HTTP/3 и QUIC: переносит сложность в новый транспорт

Решение: взять UDP, поверх него построить новый транспорт QUIC и перенести часть функций TCP в пользовательское пространство.

QUIC:

  • работает поверх UDP;
  • устанавливает шифрованное соединение (TLS 1.3 встроен внутрь);
  • ведёт несколько независимых стримов внутри одного соединения;
  • при потере пакета блокирует только тот стрим, к которому этот пакет относился.

HTTP/3 — это по сути привычный HTTP поверх QUIC. Для браузера и сервера это означает:

  • меньше задержек при установке соединения;
  • отсутствие глобального head-of-line blocking;
  • лучшее поведение в мобильных и нестабильных сетях.

Сейчас HTTP/3 уже стандартизован, и, по статистике Cloudflare, примерно четверть–треть трафика у провайдеров уровня Cloudflare ходит именно по HTTP/3/QUIC.

Важно, что здесь сделали:

  • верхний уровень протокола почти не менялся (методы, заголовки, модели ответов);
  • все сложные реформы происходили ниже, в транспорте.

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

Раз уж заговорили о QUIC и встроенном в него TLS 1.3, стоит рассмотреть этот протокол отдельно.

TLS 1.3: когда безопасность проектируют «под микроскопом»

Ещё один пример современного протокола, сделанного с умом, — TLS 1.3.

У TLS 1.2 за годы накопилась масса проблем: устаревшие шифры, сложные режимы, возможность «договариваться» на слабый алгоритм, атаки типа BEAST, POODLE, CRIME и так далее. Каждый год находили новый способ обойти защиту, если сервер поддерживал какой-нибудь legacy-режим.

Цель 1.3-й версии была амбициозной:

  • выкинуть всё опасное;
  • упростить протокол;
  • при этом ускорить установку соединения.

Что сделали:

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

0-RTT даёт заметный выигрыш, особенно на мобильных сетях, но создаёт риск атак с повтором (replay). Поэтому вокруг TLS 1.3 развернулась целая волна исследований: одни работы формально доказывали безопасность базового протокола, другие — анализировали ослабления в 0-RTT, третьи предлагали надстройки вроде TLS Guard для борьбы с replay в распределённых системах.

То есть это не «как-нибудь придумаем формат и заложим в код». Это:

  • открытый дизайн-процесс в рабочей группе IETF;
  • формальное моделирование и доказательства безопасности;
  • практические тесты на производительность и устойчивость.

На примере TLS 1.3 видно, что современные протоколы могут быть очень сложными внутри, но к наружу всё равно предъявляется старое доброе требование: понятные гарантии и чётко описанное поведение.

А теперь поднимемся на несколько уровней выше — туда, где живут приложения, которыми мы пользуемся каждый день.

А что в это время происходит «на верхних этажах»

Пока нижние уровни стека тщательно шлифуют TCP, DNS, HTTP/3 и TLS 1.3, на уровне прикладных систем разворачивается совершенно другая история.

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

  • десятки микросервисов;
  • несколько баз и кэшей;
  • слой очередей и брокеров;
  • API-шлюзы, балансировщики, прокси.

Каждый пользовательский запрос может запустить цепочку из десятков сетевых вызовов. Все они формально работают поверх устойчивых протоколов (TCP, HTTP, TLS), но верхнеуровневая логическая конструкция получается весьма хрупкой.

Чтобы понять, почему так происходит, стоит сначала разобраться, зачем вообще понадобились микросервисы.

Зачем вообще ушли в микросервисы

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

  • сложно масштабировать монолит, когда команда и количество фич растут;
  • разные куски системы хочется разворачивать и обновлять независимо;
  • нужны разные языки и стеки для разных задач (например, что-то на Java, что-то на Go, что-то на Python).

Netflix, Amazon, много крупных компаний прошли через этот путь. История Netflix особенно показательна: в 2008 году они испытали серьёзный сбой базы данных, который положил сервис на три дня. После этого началась многолетняя миграция с монолита на сотни микросервисов в AWS. Результат — компания, которая сегодня обслуживает более 200 миллионов подписчиков и известна своими практиками устойчивости.

Проблема не в самом подходе, а в том, что на уровне протоколов и дисциплины общения между сервисами он часто остаётся на уровне «а давайте обмениваться JSON-ами как получится».

Посмотрим, к чему это приводит.

Как именно микросервисы умудряются падать каскадом

Попробуем нарисовать типичный сценарий сбоя — в духе тех, что описывают SRE-команды и исследования по микросервисным отказам.

История первая: таймауты и «шторм ретраев»

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

В один прекрасный момент база временно тормозит: запросы вместо 50 мс стали отвечать за 2 секунды. При этом:

  • сервис скидок не имеет нормальных таймаутов — он честно ждёт;
  • сервис платежей на каждый таймаут запускает три повторные попытки;
  • фронтенд, не получив ответ вовремя, тоже начинает ретраи.

И вот уже единичная проблема в базе превращается в:

  • лавину запросов (каждый таймаут порождает несколько новых попыток);
  • рост очередей во всех промежуточных сервисах;
  • истощение ресурсов (пулы соединений, потоков, CPU).

Этот классический эффект называется retry storm. Вместо того чтобы дать системе немного «переждать», клиенты пытаются «достучаться ещё раз», усиливая удар.

История вторая: единая точка отказа, о которой никто не догадался

Представим, что у десятка микросервисов есть общий кэш (Redis, Memcached). До поры до времени всё хорошо: база разгружена, ответы быстрые.

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

Никто формально не думал о кэше как о «центральной части протокола». Он был где-то «по пути». А фактически оказался той самой единой точкой отказа.

История третья: «немного» изменили контракт

Команда одного сервиса решает «почистить API» — переименовать поле, изменить семантику ответа или ошибочного кода. Документация слегка отстаёт от реальности; какие-то клиенты переходят на новую версию, какие-то — нет. Через некоторое время:

  • часть запросов неожиданно начинает падать валидацией;
  • часть — обрабатывается с неверной логикой (например, скидки вдруг считаются иначе);
  • попытки «быстро hotfix-нуть» ситуацию часто только усиливают бардак.

Исследования по отказам в микросервисных системах показывают, что изменения контрактов и конфигурации — одна из самых частых причин аварий, наряду с таймаутами и ресурсным истощением.

Что здесь по-настоящему отличается от мира TCP/DNS/HTTP

В обоих мирах есть сеть, ошибки, задержки, несовместимые версии. Но:

  • TCP и DNS с самого начала проектировались как протоколы с явной моделью ошибок и совместимости;
  • микросервисные «внутренние API» часто рождаются как «пятистраничный Swagger», который не воспринимают как живой контракт.

Там, где авторы протокола на уровне RFC задают себе скучные вопросы «что будет, если половина пакетов потеряется», «как мы будем расширять формат через 10 лет», разработчики внутренних сервисов чаще думают: «главное успеть к релизу, остальное как-нибудь переживём».

Отсюда и разница в устойчивости. Но эту разницу можно сократить.

Протокольное мышление для микросервисов

Идея здесь не в том, что «надо отказаться от микросервисов и вернуться к виртовскому паскалю». Идея в том, что оставить те же правила игры, но ориентироваться на другие масштаб и контекст.

Некоторые вещи, которые хорошо бы перенести из мира TCP/DNS/HTTP в любой микросервисный проект:

1. Считать протоколы «гражданами первого сорта»

Если между сервисами есть HTTP/JSON, это не «временная обвязка», а протокол, который:

  • имеет версии (v1, v2, v3);
  • имеет чёткое описание полей и ошибок;
  • имеет правила расширения («добавляем только необязательные поля», «никогда не меняем семантику уже существующих»).

Это скучно, но именно так делают в мире публичных API, и именно поэтому старые клиенты не падают при каждом обновлении.

2. Заранее думать о сбоях и бюджетах

TCP и TLS принимали как данность: сеть плохая, пакеты теряются, соединения обрываются. Внутренний микросервисный мир иногда живёт в иллюзии «у нас же внутри дата-центра, там всё хорошо».

И оттуда вырастают:

  • отсутствие нормальных таймаутов;
  • агрессивные повторы без пауз;
  • отсутствие «автоматических выключателей» (circuit breaker), которые могли бы вовремя прекратить запросы к умирающему сервису вместо того, чтобы добивать его.

Google SRE-книга предлагает смотреть на систему через «золотые сигналы»: латентность, ошибки, трафик, насыщение. Если об этих вещах думать с первого дня, половина каскадных отказов даже не успевает случиться.

Netflix пошёл ещё дальше: они создали Chaos Monkey — инструмент, который случайным образом «убивает» сервисы в продакшене, чтобы команды были вынуждены проектировать устойчивые системы. Звучит безумно, но это работает: если ваш сервис регулярно «падает» по расписанию, вы очень быстро научитесь делать его восстанавливаемым.

3. Концентрировать сложность в одном слое

HTTP/3 показывает хороший приём: сложность спрятали в транспорт (QUIC), а сверху оставили простую и привычную модель.

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

  • в sidecar-прокси — вспомогательные процессы, которые запускаются рядом с каждым сервисом и берут на себя всю сетевую «механику»;
  • в единообразные библиотеки клиента;
  • в «инфраструктурный» слой, который проектируется так же серьёзно, как сетевой протокол.

Тогда бизнес-логика сервисов остаётся относительно простой, а системная сложность концентрируется в одном месте, где ею занимаются люди с соответствующей компетенцией.

Возвращаясь к истокам

Если теперь снова взглянуть вниз, на TCP/IP, DNS, HTTP/3, TLS 1.3, картина вырисовывается достаточно цельная.

Эти протоколы:

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

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

Микросервисы, наоборот, часто живут в мире «мы быстро всё собрали, а дальше будем чинить по ходу дела». Какие-то компании за несколько лет вырабатывают свою внутреннюю «протокольную» дисциплину — Netflix со своими практиками fault injection, Google с SRE, крупные банки с жёсткими SLA — но в среднем индустрия пока ещё где-то на полпути.

И, возможно, главный урок как раз в этом:

  • протоколы низкого уровня давно научились проектировать так, чтобы они переживали поколения технологий;
  • прикладные системы только учатся относиться к своим внутренним API так же серьёзно, как авторы TCP относились к своим RFC.

Пока этот разрыв есть, будут возникать ситуации, когда под нами работает удивительно надёжный TCP/IP, над ним — аккуратный HTTP/3 и TLS 1.3, а ещё выше — хрупкий микросервис, который падает от одной неудачно настроенной повторной попытки.

И с этим ничего волшебного не сделать. Но можно, по крайней мере, каждый раз, когда хочется быстро «накинуть ещё один REST-эндпоинт», на минуту представить, что он попадёт в RFC и останется с системой на двадцать лет. Тогда, может быть, и дисциплины станет чуть больше.

Источники

P.S. Facebook, Instagram, WhatsApp принадлежат экстремистской корпорации Meta, запрещённой в РФ.

Предыдущий пост Следующий пост
Наверх