Привет, меня зовут Иван Волков, я CTO продукта CDN MediaBasket в Wildberries. Это большое распределенное файловое хранилище, используемое различными внутренними продуктами Wildberries. Одним из продуктов, с которым взаимодействуют внешние клиенты, является каталог товаров, с которым взаимодействуют все пользователи маркетплейса. Это ставит перед хранилищем высокую планку по оптимизации и готовности к экстремальным нагрузкам. В этой статье я расскажу, какие решения мы использовали в архитектуре продукта и как при миллионном RPS мы доставляем картинки пользователям за считанные миллисекунды.
Масштаб и задачи Wildberries
Wildberries — огромный маркетплейс: порядка 20 млн заказов в сутки, свыше 79 млн уникальных пользователей в месяц и более миллиарда запросов в день. Это ли не настоящий HighLoad? Маркетплейс - это продажи, успешный маркетплейс - это много продаж. Много продаж находится в прямой корреляции с возможностью посмотреть как выглядит товар - его фото. Наша задача не просто выдать фото товара для показа его покупателям, а сделать это наилучшим образом. А что такое лучший контент? Лучший контент — это не просто красивый визуал, это контент, который загружается максимально быстро.

Скорость получения изображения — ключевой компонент клиентского опыта. По нашим измерениям, время от запроса до получения картинки каталога товара обычно составляет 8–14 мс. Чтобы обеспечивать минимальные задержки, архитектура доставки должна быть предельно простой.

Фундаментальное устройство архитектуры
Что происходит внутри? Клиентское приложение или веб-сайт запрашивает картинку товара на домен CDN MediaBasket, и запрос сразу попадает на конкретную шарду хранилища. Мы сократили цепочку до минимальных звеньев: между клиентом и нашими серверами нет ни прокси, ни дополнительных кеширующих слоёв. Запрос идёт на IP-адрес одной из реплик нужной шарды — и сразу возвращает картинку.
Каждый шард, на который приходит запрос, представлен множеством реплик. Одна реплика — один сервер, полная копия других реплик шарда. Сервер-реплику мы называем «Корзинкой», от слова «basket». Когда к нам поступает новый файл от поставщика, он сразу записывается на одну из реплик целевого шарда.
А теперь подробнее.
Каждый товар в каталоге Wildberries имеет уникальный номенклатурный номер (мы называем его NM). На основе этого номера вычисляется то, что мы называем VOL (NM/100000). VOL определяет дальнейший путь — в какую именно шарду и поддиректорию попадёт файл. Дополнительно используем разделение на part (NM/1000), чтобы не перегружать файловую систему огромным количеством директорий на одном уровне.

1, 2, 3.webp — это первая, вторая, третья картинка карточки товара для отображения в каталоге. Каталогу не требуется хранить никакой дополнительной мета-информации для определения главной картинки товара при перемотке каталога и всех остальных картинок при свайпе. Все запрашиваемые URL необходимых картинок вычислимы на стороне FrontEnd и уже существуют в хранилище. NM товара служит своего рода контейнером, вычислив шарду можно сформировать полный путь к нужной картинке, подставив необходимый размер и порядковый номер.
Как мы справляемся с ситуацией, когда селлер меняет порядок картинок товара местами, например 6-ую ставит 1-ой? Ведь при этом нужно переписать все картинки одного товара в хранилище, что приведет к излишней нагрузке на диски. А картинок может быть много, например 30. Как сделать это красиво? Это одна из множества “фишек” нашего проекта, описание которых выходит за рамки сегодняшней темы. Предлагаю тебе подумать, включить архитектурное мышление и предложить свое решение в комментариях.
Хорошо, продолжаем. Поговорим об определении шард. Шарды мы готовим заранее. Скажем, у нас есть активная шарда, в которую сейчас заливаются все новые товары (28-я для номеров VOL 5190–5501 на картинке ниже). Это значит что NM в интервале с 519000001-550199999 однозначно относятся к 28 шарду. Учитывая что NM постоянно возрастающие и не переиспользуются, мы можем прогнозировать наполнение следующей шарды. Как только подходящие номера поступают, данные начинают загружаться в заранее подготовленную шарду. Старые (закрытые) шарды (1-26) работают только на выдачу и перезапись. Посмотри, ниже на схеме я все показал максимально просто.

После определения шарды и каталожной папки запрос напрямую попадает на одну из реплик этой шарды. Для внешних клиентов реплики скрыты, есть только доменное имя шарды (например basket-28.wbbasket.ru). Далее мы используем BGP и Round Robin для размазывания нагрузки на чтение по репликам. На каждой реплике поднят BIRD, демон динамической маршрутизации, — он анонсирует адрес в нашей сети. По этой схеме, чем больше реплик на один шард, тем более производительнее шард не только на чтение, но и на запись (больше RPS).
Если одна из реплик перестаёт отвечать (например, из-за отказа диска), мы быстро гасим её BGP-анонс — и трафик мгновенно начинает идти на другие реплики. Клиент при этом не видит задержек: в статистике фиксируются единичные 500-е ответы (на уровне десятков в секунду при общем числе 700 тыс. RPS). Если до клиента доходят 500-е, он отправляет запрос повторно, незаметно для пользователя.
В каждой реплике данные хранятся по блочно-префиксной схеме: мы циклически распределили vol по 12 физическим дискам. Первый vol идёт на диск № 1, второй — на диск № 2, и так до диска № 12, а затем опять на диск № 1. Это позволяет равномерно распределять запись и чтение по дискам. Ранее мы пробовали записывать vol на диски подряд — в итоге все новые файлы приходили на первый диск, он перегружался, быстро деградировал и выходил из строя. Ты же крутой инженер? Скажи что в нашей схеме с циклическим распределением по VOL не так? Какие видишь недостатки, как можно еще быстрее, напиши в коментах. Мы это уже решили, а тебе оставлю в виде задачки на подумать.

Зная (статистически) сколько занимает места 1000 NM, мы можем предположить сколько может занимать места на диске 1 VOL. Таким образом зная конфигурацию дисков на сервер-реплику (количество и объем), мы определяем плановое количество VOL на сервер-реплику, таким образом чтобы ее емкость не превышала 50%. Это нужно для того чтобы у нас оставался люфт-избыток, если в будущем файлы будут перезаписаны на более тяжелые.
Поток данных: от загрузки до репликации
Как данные попадают к нам в хранилище? У продавцов есть MediaService, через который они загружают изображения и видео товаров в нашу систему. Сервис передаёт это в Render (это другой продукт поддерживаемый другой командой WB). Задача Render, прием первичных (сырых) файлов, проверка, валидация, нарезание на нужные форматы и разрешения. Итоговый набор нарезок передаётся к нам в хранилище, в систему MediaBasket оперируя ip адресами реплик. А внешние клиенты запрашивая контент, заходят на наши серверы по доменным именам шард (выше уже говорил как принимаем запросы на получение контента).

Render знает о живых репликах текущей шарды (мы даем такую информацию в реальном времени). Он посылает запрос на одну из них по схеме Round Robin. Когда реплика получает файл, она публикует в Kafka сообщение «Файл X получил» и возвращает Render ответ с успешным статус-кодом. Для Render операция записи завершена.
Далее начинается фоновая репликация. Все реплики шарды (в том числе та, которая уже получила файл) подписаны на Kafka-топик. Они видят, что файл X есть на реплике Y, и каждая реплика, у которой ещё нет этого файла, качает его непосредственно у реплики Y. Реплика Y, получив своё сообщение, просто игнорирует его, поскольку файл у неё есть. Таким образом мы добиваемся согласованности: в итоге каждая реплика (распределены по различным ЦОД) содержит полный набор данных шарды.

С учетом прямого доступа к шардам без промежуточных узлов и дублирования всего по различным ЦОД такая схема является устойчивой к сбоям и обладает высокой степенью доступности. Пользователи не замечают, когда что-то в системе вышло из строя и «лечится».
Немного о железе для HighLoad
Мы сознательно выбрали физические серверы вместо виртуальных машин, чтобы выжать максимум производительности: каждая миллисекунда задержки на счету. Высокая отзывчивость приложения, для бизнеса — деньги. Это требует дополнительной автоматизации и обслуживания, и мы осознанно идем на это ради скорости отдачи контента.
Все хранилище построено на сверхбыстрых SSD на протоколе NVMe. Это позволяет добиться молниеносной производительности. HDD диски также используются для хранения холодных бэкапов. Да, в нашем хранилище хранятся все версии когда либо загруженных файлов. Но это уже другая история.
В качестве файловой системы на NVMe мы выбрали Ext4, хотя рассматривали и XFS. XFS хорош тем, что умеет динамически расширять количество i-node, то есть их не надо добавлять в ходе эксплуатации. Добавление i-node “налету” не самая тривиальная задача, с учетом вывода реплики из эксплуатации с последующей процедурой синхронизации. Поэтому в нашей схеме есть планирование VOL на диск с прогнозированием количества файлов. Иногда мы ошибаемся с прогнозом и приходится делать вышеописанные процедуры. Но все же это лучше, чем работа XFS на NVMe по части операции TRIM. XFS отрабатывает TRIM без кеширования информации по блокам, что снижает производительность и чуть больше изнашивает диски чем кэшированный TRIM на Ext4. Если TRIM отключить совсем то с нашим профилем нагрузки и размерами файлов, диски деградируют еще быстрее. Использование же DISCARD для “трима” на лету съедает производительность и на высоких нагрузках приводит к разрушению журнала и выходу диска из строя. Btrfs, ZFS и другие ФС не рассматриваем по причине их неготовности к проду. Риски тут совершенно не нужны.
MediaBasket задействует более тысячи серверов, их суммарная сырая ёмкость — примерно 10,5 Пб. При этом мы не используем все диски на 100%, есть резерв 50% (с учетом строгого предварительного планирования шардов и запасом на возможное изменение уже существующих данных на более тяжелые по объему). В SSD-контейнерах ежемесячный прирост данных составляет около 127 Тб, в год набирается полтора петабайта новых данных.
Аналитика и оптимизация на разных уровнях
Для оценки состояния системы настроено свыше 100 дашбордов и 1300 графиков в Grafana. Для мониторинга также используем Zabbix. В связи с большим количеством поступающих метрик от наших сервисов, был развернут отдельный кластер VictoriaMetrics. Для алертинга изначально применялась Grafana, но мы перешли на связку VMalert + AlertManager. Grafana осталась только для визуализации данных.


Мы постоянно ищем способы уменьшить трафик, нагрузку на сеть и диски. Так, переход с GZIP-сжатия на BR при отдаче контента дал сокращение трафика порядка 20%. При этом BR сжимает быстрее и позволяет перегонять меньше трафика. Учитывая эффект масштаба этот подход стал давать огромную экономию на обслуживании сетевой инфраструктуры.

А когда мы заменили традиционный JPEG на WEBP, выиграли ещё около 30% трафика и в качестве вторичного эффекта снизилась нагрузка на диски на 4%. При наших масштабах каждый процент экономии — это миллионы рублей.

Чтобы отдавать контент ближе к пользователю, Wildberries силами нашей команды строит собственную CDN. От Владивостока до Калининграда уже развёрнуты наши HyperLook-точки с двухуровневым кешем (RAM + SSD). Они работают как зеркала: первая загрузка содержимого идёт из основного хранилища, а последующие запросы обслуживаются из памяти или SSD этой точки.

На текущем этапе в CDN мы кешируем всю публичную статику — ресурсы сайтов, приложений и картинки, которые нужны всем пользователям Wildberries. Это значительно ускоряет загрузку страниц в регионах, особенно если они находятся далеко от наших основных дата-центров. Наш CDN не заканчивается на территории РФ, точки присутствия кэшированного контента распространяются по всему миру. Мы построили CDN с нуля, это была сложная и интересная задача с которой мы блестяще справились. Уверен, у нас еще будет возможность подробнее поговорить о том, как мы продумали архитектуру, а затем реализовали этот проект.
Секрет успеха — команда
Одно из наших главных достижений — стопроцентный аптайм хранилища за последний год. И можно смело ставить 5 девяток после запятой за предыдущие годы. Система реально ни разу не упала. Даже кратковременные проблемы внешнего характера вроде перебоев в сети не повлияли на доступность (независимость и распределенность по ДЦ). Если факт недоступности хранилища и был, то это связано с периодами, когда частично не работал Интернет в РФ, но само хранилище при этом не падало. Этого результата мы добились не только адекватной архитектурой, мощным железом и постоянной аналитикой, но и грамотной организацией процессов. А главное — талантливыми людьми!
За стабильностью работы хранилища наблюдает отдельная команда мониторинга. Их задача — следить за графиками и реагировать ещё на стадии предвестников проблем, не дожидаясь момента, когда проблема затронет клиентский трафик. Почти всегда мониторинг либо самостоятельно корректирует ситуацию на лету, либо вовремя информирует об этом разработчиков и SRE.
Рука об руку с мониторингом работают SRE/DevOps-инженеры. Они постоянно помогают с автоматизацией, дают обратную связь, занимаются сетями, настройкой серверов, конфигами программного и аппаратного обеспечения. Всё, как вы поняли, на Bare Metal (Ansible нам в помощь).
Два QA/AQA инженера занимаются интеграционным и нагрузочным тестированием. На них лежит огромная ответственность по выявлению потенциальных и реальных ошибок на ранних стадиях. Выкатка новых релизов в прод предварительно проходит две стадии, тестирование на DEV и STAGE окружениях. Не всегда у нас были QA, долгое время мы работали в режиме, когда тестированием занимались сами разработчики. Теперь наличие QA дает возможность разгрузить разработку и позволяет сохранять Dev-ядро команды компактным.
Наша команда CDN Mediabasket существует в специфическом режиме: RnD, архитектура новых решений и бесконечный тюнинг работающих сложных проектов. Все это требует четко оформленной и структурной документации. Я сначала инициировал новую роль - технический писатель, но потом было принято решение что нам нужен SA (системный аналитик). А учитывая работы по развертыванию CDN наша команда обогатилась еще и BI (бизнес аналитиком). Итого получилась идеальная и эффективная команда супер-специалистов. Ниже я приведу наглядную итоговую структуру команды CDN Mediabasket.

Наш основной язык — Go. На нём написаны ключевые сервисы: HTTP-корзинка (заливка контента) и HTTP-кеш. На нём же делается вышеупомянутый HyperLook. Smart Proxy (выдача картинок) написан на Rust и поддерживается соседней командой из нашего инфраструктурного подразделения. Сами мы экспериментируем с C для экстремально производительных задач: по предварительным результатам мы получим прирост производительности в 10–30%.
Наш подход к разработке во многом уникален.
Во-первых, каждый разработчик — архитектор. Мы стремимся к максимально простому коду по принципу бритвы Оккама, потому что производительность важнее красоты реализации. Если сервис будет работать быстрее в виде монолита — пусть будет монолит. Так что у нас есть и полностью микросервисные компоненты, и монолиты. Главное — результат, а не догма.
Во-вторых, мы называем себя заядлыми велосипедистами. Потому что считаем, что не надо бояться изобретения велосипедов. Многие наши микропродукты и утилиты написаны нами самими вместо бесконечного подтягивания готовых библиотек. Это даёт полное понимание системы и гибкость, хотя иногда требует больших усилий.
В-третьих, горящие задачи настоящего — не повод игнорировать R&D для будущего. HyperLook случился благодаря R&D. Другой пример — мы разработали собственный HTTP/3-фреймворк, убрав всё лишнее из FastHTTP и добавив поддержку QUIC. Лёгкий и быстрый сервер без ненужных модулей! R&D — это заблаговременная оптимизация. Закладываем будущее сегодня, чтобы не тушить пожары завтра.
Светлое будущее хранилища
Наша R&D-команда проектирует следующее поколение хранилища — на жёстких дисках с расширенной ёмкостью без потери производительности (> млн RPS). Это новый экспериментальный проект. Идея в том, что HDD дают существенно больше места, но с ними трудно делать гибко-масштабируемое хранилище. Множество схем шардирования требуют механизм полного или частичного перешардирования. Мы придумали архитектуру, в которой всё хранилище выглядит как один большой виртуальный диск, где весь объём данных (десятки и сотни петабайт) объединён в один пул не требующий перешардирования при изменении конфигурации кластера. Основное кредо наших разработок - простота. Вот и в архитектуре нового хранилища заложен этот принцип. Никаких метаданных о ключах и шардах, все как мы привыкли — вычислимо.
Утилизация свободного объема дисков приблизится к 100%, потому что мы не будем резервировать место про запас, как сейчас, а расширять хранилище можно будет без переконфигураций и пересортировки данных. Мы сможем начать с 20 Пб и через месяц масштабироваться до 30, потом до 50 Пб и выше — всё без остановок. Мы учимся расти вместе с бизнесом бесшовно, просто докупая диски и добавляя узлы, без сложных ручных миграций. Задача, построить гигантское высоконагруженное и при этом надежное хранилище - это вызов. Это интересный инженерный вызов с интересной архитектурной начинкой внутри. Что лежит под капотом этого проекта, возможно станет темой нашего следующего доклада.
Спасибо, что дочитали до конца. Надеюсь, наш опыт покажется вам полезным. Поделитесь лайфхаками, как решаете похожие задачи!