Строим в расчёте на рубильник
Чтобы приложение работало в России — даже если страну отрежут от внешнего мира — мы построили внутри неё полную самодостаточную копию инфраструктуры. По две копии всего, так задумано.
Россия — особый случай. В регионах медленный интернет, связь с заграницей нестабильна, а над всем этим нависает «Чебурнет» — национальная сеть, которую однажды могут одним рубильником отрезать от внешнего мира. Наше приложение раздаёт сотни гигабайт лекций и медиа, и для него это не пустая тревога. Пользователь нажимает «Скачать» — запрос уходит из страны, упирается в придушенный или разбитый трансграничный канал, а на том конце его ждёт таймаут. Бабушке из деревни на 3G незачем разбираться в маршрутизации BGP, чтобы послушать лекцию.
Поэтому мы сделали не запасной вариант. Мы сделали закрытый контур — полную самодостаточную копию инфраструктуры. Она целиком живёт внутри России и ничего не берёт снаружи. Каталог, аудио, транскрипты, обложки, метаданные, бэкенд чата, авторизация — всё продублировано. Идея простая и слегка параноидальная: подготовиться к худшему заранее, до того как оно случится. Закроются границы, погаснут трансграничные каналы, случится что угодно — внутри приложения этого даже не заметят. Оно уже работает на инфраструктуре, которой и не нужно пересекать границу. Мы не реагируем на блэкаут — мы к нему готовы заранее.
Копия на каждом контуре
Все данные приложения лежат сразу на двух контурах — российском и международном. Внешние каналы просядут или закроются совсем — российский контур работает как ни в чём не бывало. За пределами России вас обслуживает международный, без потери скорости.
В коде это просто два региона со своими хостами: global отдаётся с AWS,
russia — с Yandex Object Storage. Каждый HTTP-клиент в момент вызова читает
базовый URL активного региона:
const REGIONS = {
global: {
name: "Global",
urlTemplate: "https://…s3.amazonaws.com/{path}", // CDN-fronted, US + EU
chatBaseUrl: HOST,
},
russia: {
name: "Russia",
urlTemplate: "https://…storage.yandexcloud.net/{path}", // in-country, Yandex
chatBaseUrl: HOST_RU,
},
} as const;
let activeRegion: keyof typeof REGIONS = "global";
const urlFor = (path: string) =>
REGIONS[activeRegion].urlTemplate.replace("{path}", path);
Переключение — одно действие. В настройках выберите регион. Отметьте тот, где
находитесь, — и приложение перенаправит туда всё: каталог, аудио, транскрипты,
авторизацию, чат. Перезапуск не нужен. Ни VPN, ни прокси, ни ручной правки DNS.
Просто галочка, которая меняет одно значение activeRegion.
У глобальной стороны — свои два
«Global» — это не один бакет в одном городе. Один источник в Северной Америке
хорошо обслуживает слушателя в Торонто и плохо — в Лиссабоне: на каждый
range-запрос пакетам приходится пересекать Атлантику туда и обратно. Поэтому и
международный контур раздвоен: источник в США (us-east-1, Сев. Вирджиния) и
edge в Европе (eu-central-1, Франкфурт). CDN направляет каждый запрос к
тому, что ближе. Слушатель из Европы завершает TLS во Франкфурте, из Северной
Америки — в Вирджинии. Та же галочка, тот же регион global — ближайший edge
выбирается сам.
Разрыв заметный. Замерено с нашего билд-сервера в Европе, напрямую по эндпоинтам хранилищ (по три замера на каждый, время до первого байта):
| Эндпоинт | Регион | Connect | TLS-рукопожатие | TTFB |
|---|---|---|---|---|
s3.eu-central-1.amazonaws.com | Европа (FRA) | ~33 мс | ~75 мс | ~110 мс |
storage.yandexcloud.net | Россия | ~75 мс | ~155 мс | ~250 мс |
s3.us-east-1.amazonaws.com | США (IAD) | ~125 мс | ~255 мс | ~385 мс |
С европейской точки первый байт от источника в США приходит примерно в 3,5 раза позже, чем от европейского edge. Это одна точка и один момент, у вас абсолютные числа будут другими. Но урок — в самой закономерности: расстояние до источника отыгрывается на каждом запросе. Пакет лекций на 300 МБ разбит на сотни range-запросов, и эти миллисекунды складываются в реальные минуты. CDN, который для европейцев выбирает Франкфурт вместо Вирджинии, — не приятная мелочь. Это разница между скачиванием, которое доходит до конца, и тем, что пользователь бросает.
Чего таблица задержек из Европы показать не может — так это настоящей боли внутри России. Там узкое место редко в первом байте источника. Это потери пакетов, троттлинг и сбросы на трансграничном участке. Они превращают чистый запрос на 250 мс в зависшее соединение, которое так и не завершается. Канал, который активно душат, никаким CDN не обойти. Честное решение одно — вообще не пересекать границу. Именно это и делает российский контур.
Скачивание, повторы и переключатель
Карта регионов — только половина дела. На деле пользователь чувствует менеджер загрузок, и вся его работа — пережить ненадёжный канал. Три правила. Возобновлять, а не начинать заново: обрыв на 280 МБ подхватывается с 280 МБ, а не с нуля. Повторять с ограниченным откатом: мгновенный сбой сам залечивается и не заваливает сервер. Падать громко, но с выходом: когда повторы кончились, показать единственное, что реально помогает, — сменить регион.
async function download(path: string, into: PartialFile, retries = 4) {
for (let attempt = 0; ; attempt++) {
try {
const from = into.size; // bytes already on disk → resume from here
const res = await fetch(urlFor(path), {
headers: from ? { Range: `bytes=${from}-` } : {},
});
if (res.status !== 200 && res.status !== 206) throw new HttpError(res.status);
await into.append(res.body);
return;
} catch (err) {
if (attempt >= retries) {
// out of retries: this is the moment to suggest the other contour
throw new DownloadFailed(path, { hint: "switch-region", cause: err });
}
// 0.5s, 1s, 2s, 4s … capped at 8s, plus jitter to avoid a thundering herd
await sleep(Math.min(2 ** attempt * 500, 8000) + Math.random() * 250);
}
}
}
Возобновление держится на Range-запросах: и Yandex Object Storage, и S3 их
соблюдают и отвечают 206 Partial Content. Один и тот же цикл без изменений
работает на обоих контурах, ведь urlFor уже указывает на активный регион.
Ограниченный экспоненциальный откат (0,5 → 1 → 2 → 4 → потолок 8 с, с джиттером)
означает вот что: секундная заминка не стоит пользователю ничего, а по-настоящему
мёртвый канал сдаётся за секунды, а не крутится вечно. А когда канал сдаётся,
ошибка несёт подсказку switch-region, а не голое «загрузка не удалась». Тогда
интерфейс покажет ту самую кнопку, которая всё исправит.
Не 100 мс → 30 мс. Десять минут → тридцать секунд.
Заголовок Range выглядит мелочью. А в нём было всё дело.
Когда мы взялись за «скачивание слишком медленное», первой под подозрение попала задержка — метаданные и API-вызовы, что гоняют туда-обратно к далёкому источнику. Но трассировки показали странное: ответы приходили быстро. Список, метаданные трека, первые байты — всё шустро. Полз именно сам файл. Баг был не в том, как быстро приходит первый байт, а в том, что было дальше.
Нативный загрузчик слал обычный GET — без заголовка Range и без возобновления.
На Android, на iOS, на вебе одинаково. И как только мобильное соединение
спотыкалось на 50 МБ из 80-мегабайтного трека — а спотыкается оно постоянно, —
следующая попытка не подхватывала с 50 МБ. Она начинала заново, с нулевого
байта. На ненадёжном канале это значило скачать один и тот же трек два-три раза,
прежде чем он дойдёт. Одна лекция могла проторчать в этой спирали «начни сначала»
почти десять минут. Ответ всё это время был быстрым. Просто файл не доходил.
Убили это два изменения. Первое — Range и возобновление, тот самый цикл выше.
Обрыв на 50 МБ подхватывается с 50 МБ, а не начинается заново. «Перекачать всё
целиком 2–3 раза» превращается в «дотянуть недостающий хвост один раз». Второе —
edge рядом с пользователем вместо одного источника в Вирджинии. Каждый
range-запрос отыгрывается на точке присутствия в паре миллисекунд, а не через
океан. На реальном треке в 7,3 МБ один только edge сократил время с ~1,8 с и
~4 МБ/с (S3 напрямую из Европы) до ~0,38 с и ~19 МБ/с на прогретом edge. Это
примерно вчетверо больше пропускной способности и в ~10 раз лучше время до
первого байта. И это множится на каждый кусок стомегабайтного пакета.
Так что главное число здесь — не из тех, где сбривают пару миллисекунд. Мы не превращали 100 мс в 30 мс. Мы превращали скачивание, которое тянулось десять минут — или не заканчивалось вовсе, — в такое, что идёт секунд тридцать. Другой порядок величины, другой баг, другое исправление. Медленным был не ответ, приходящий назад, а файл, уходящий наружу.
Что это даёт на деле
Для слушателя внутри России выигрыш — не «чуть быстрее». Это разница между «работает» и «не работает». Раньше большой пакет скачивался через границу, застревал на трансграничном участке и ловил таймаут. Теперь данные лежат в Yandex Object Storage внутри страны, и запрос вообще не покидает Россию. Задержка первого байта падает с «переменной и с потерями» до локального обращения в десятки миллисекунд, а круговерть таймаутов и повторов, что раньше съедала скачивания, схлопывается почти в ноль. Ещё закрытый контур означает вот что: нет ни одного рубильника — в Москве или где угодно, — которым можно вывести приложение из строя для российских слушателей. Вот это, а не миллисекунды, и есть главное.
Честная цена
Не будем притворяться: нагрузка на поддержку удваивается. Каждое обновление каталога, каждую новую лекцию, каждый исправленный транскрипт нужно раскатать по обоим контурам. Хранилище и трафик тоже вдвое. Архитектурно это дороже и капризнее в работе, чем один CDN на весь мир.
Но доступность важнее. Лекции должны одинаково открываться и у бабушки в деревне на 3G, и у студента в Москве на гигабите. И у слушателя в Торонто, и у слушателя в Новосибирске. Если для этого нужно держать по две копии всего — два контура, а внутри глобального ещё два региона, — значит, держим по две копии всего.
Часть проекта
Слушай Садху