← Журнал
Jiva Studio

Строим в расчёте на рубильник

Чтобы приложение работало в России — даже если страну отрежут от внешнего мира — мы построили внутри неё полную самодостаточную копию инфраструктуры. По две копии всего, так задумано.

Слушай СадхуEngineeringInfrastructureRussia

Россия — особый случай. В регионах медленный интернет, связь с заграницей нестабильна, а над всем этим нависает «Чебурнет» — национальная сеть, которую однажды могут одним рубильником отрезать от внешнего мира. Наше приложение раздаёт сотни гигабайт лекций и медиа, и для него это не пустая тревога. Пользователь нажимает «Скачать» — запрос уходит из страны, упирается в придушенный или разбитый трансграничный канал, а на том конце его ждёт таймаут. Бабушке из деревни на 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 выбирается сам.

Разрыв заметный. Замерено с нашего билд-сервера в Европе, напрямую по эндпоинтам хранилищ (по три замера на каждый, время до первого байта):

ЭндпоинтРегионConnectTLS-рукопожатие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, и у студента в Москве на гигабите. И у слушателя в Торонто, и у слушателя в Новосибирске. Если для этого нужно держать по две копии всего — два контура, а внутри глобального ещё два региона, — значит, держим по две копии всего.


Часть проекта

Слушай Садху

Открыть проект