Смотрим «под капот» бэкенда изображений в ОК

ea8f6270ca9d2576260789dd688c0694.jpg

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

Меня зовут Руслан Измайлов. Я ведущий Java‑разработчик в ОК. В этой статье я хочу показать на конкретном юзкейсе весь путь изображения в соцсети ОК: от его загрузки на портал до скачивания с узлов CDN.

Картинки в ОК: краткая статистика

Месячная аудитория ОК — около 36 млн уникальных пользователей из РФ. И аудитория у нас активная, в том числе и в контексте взаимодействия с картинками. В часы пиковой нагрузки:

  • на Портал грузится около 400 картинок в секунду (около 2 Гб/с);

  • ежесекундно скачивается с серверов ОК около 750 тысяч картинок (200 Гб/с);

  • основное хранилище содержит около 80 млрд картинок и занимает 10 ПБ.

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

  • скачивания фото (выделены синим);

  • размещения фото (выделен фиолетовым);

  • хранения бинарных данных (сервис Blob Storage, выделен желтым).

d6c3d45e61e320530c4afee253d30848.jpeg

Примечание: Подробнее о хранилище бинарных данных (его полное название — one‑blob‑storage) можно узнать, посмотрев лекцию Александра Христофорова.

Разбор пути изображения в соцсети ОК на примере конкретного юзкейса

Чтобы лучше понимать алгоритм работы с картинками на бэкенде ОК, предлагаю рассмотреть наглядный пример.

Допустим, у нас есть некий пользователь, который хочет загрузить фотографию к себе в профиль и поставить ее на аватарку так, чтобы лицо было крупным планом.

2dff48b437201c01b442af351ad0988b.jpeg

Посмотрим, как в реализации данного сценария участвуют сервисы, которые мы увидели на схеме выше. Первым на очереди будет сервис Uploader, который отвечает за загрузку картинки на Портал.

2dc39ef55992e8312061d77dd0fb24a9.jpeg

Загрузка фотографии осуществляется в три этапа:

  • Клиент обращается к бэкенду и просит выделить новый ID для фото. В ответ бэкенд возвращает ID и URL загрузки.

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

  • Клиент вызывает метод commit на бэкенде. Бэкенд использует токен, который был передан Uploader на предыдущем этапе, и сохраняет информацию из него в хранилище метаинформации.

0749f22f94bd1171d0f3c7f0d4b508a4.jpeg

После этого фотография считается загруженной на Портал ОК и слинкованной с основной сущностью, в нашем случае — с профилем конкретного пользователя.

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

В качестве хранилища метаинформации мы задействуем кастомную версию Cassandra, которую сделали в ОК для себя. Подробнее об этом можно узнать из доклада Олега Анастасьева. Также наша Cassandra умеет генерировать уникальные ID, что используется на первом этапе загрузки.

Теперь немного подробнее об Uploader.

Uploader

Uploader — Java‑сервис, который получает фотографию и трансформирует ее несколькими способами для получения набора картинок унифицированного размера. Единый размер картинок важен, чтобы обеспечить возможность единообразной работы со всеми фотографиями.

Для преобразования используется два метода:

Результаты обработки складываются в Blob Storage

Результаты обработки складываются в Blob Storage

Для Uploader у нас выделено по 27 контейнеров в трех дата‑центрах. Каждый контейнер имеет 16 ядер и 18 ГБ оперативной памяти.

Про размеры картинок

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

00495f3a0da60bb19e7397d5920cca90.jpeg

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

99f07a321d343fbf077ab12f3ebf8cf5.jpeg

Провести подобную подготовку разных размеров картинки можно несколькими способами.

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

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

  • большое количество запросов за оригиналами будет перегружать и забивать сеть;

  • при работе с фото большого объема устройство пользователя может начать тормозить.

Подход изначально неоптимальный, поэтому мы его сразу отмели и пошли другим путем.

Так, на моменте загрузки фотографии мы стали подготавливать сразу все размеры и складывать их в Blob Storage. И на моменте скачивания, мы отдавали пользователю уже ту фотографию, которая максимально близка к тому, что ему нужно.

8986c8bc7ac54cfbfc638037919edc8b.jpeg

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

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

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

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

Чтобы исключить описанные проблемы, мы перешли к новой схеме.

Так, в новой реализации во время загрузки фотографии мы создаем всего два размера (крупный и мелкий), которые размещаем в нашем Blob storage. При этом все запросы по поводу картинки от клиентов теперь идут через dispatcher:

  • если нужный размер уже есть в Blob storage — dispatcher просто сразу отдает его клиенту;

  • если нужного размера нет — dispatcher передает управление сервису transformer, который скачивает нужный исходник из Blob storage, выполняет преобразования и складывает результат в Blob cache (кеш бинарных данных).

73e98effb616819cac3e88da0111674d.jpeg

Это решает проблемы, перечисленные выше:

  • «экзотические» размеры создаются только для тех картинок, которым они нужны;

  • новые размеры автоматически становятся доступными для старых изображений;

  • ненужные размеры сами естественным образом вымываются из кеша.

Кроме того, особенность реализации дает нам дополнительные бонусы:

  • мы сделали простенький DSL для описания трансформации, и теперь создать новый размер стало очень просто: достаточно добавить одну строчку в конфигурацию. В строке должно быть название и спецификация трансформации. Вот как это может выглядеть: sqr_ctr_128=tx_outline_crop_cntr{w=128, h=128, sharp=1}

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

6310ce5492e7d4e5a09ec5ce642ef5c5.jpeg

Так, для формирования аватарки достаточно выбрать нужную зону и нажать «Готово». Вместе с тем, после нажатия на кнопку сначала происходит не обработка фото, а только запись спецификации кропа в хранилище метаинформации.

d77a661ba266e8927a7195328c96d49d.jpeg

И применяется этот кроп только в момент скачивания.

Скачивание картинки

Очевидно, что для скачивания фотографии нужна ссылка.

https://vki9.okcdn.ru/i?r=BUFgLXxnWm2xnWtZtf4yu8JRXP2u4exBvAW8DS4jY58TTSkAl4AGw2ZG_xF14Hblg6kbSWkciLWyDLUYVS57pN33

В ней нас будут интересовать две вещи:

  • хост (о нем мы поговорим ближе к концу статьи);

  • запрос, в котором закодирована сущность, которую можно упрощенно представить как интерфейс ImageId.

public sealed interface ImageId permits ImageTx, ImageSrc {}

public record ImageSrc(
        String type,
        String id)
        implements ImageId {}

public record ImageTx(
        String function,
        ImageId[] arguments,
        Map options)
        implements ImageId {}

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

  • Первая — ImageSrc. Используется, когда мы хотим просто скачать исходник из базы. Мы просто задаем название источника и ID в этом источнике.

  • Вторая реализация — ImageTx. Применяется, когда нужна трансформация. Для использования ImageTx мы в нее передаем название функции, которая лежит в конфигурации, далее рекурсивно передаем ImageId аргументов этой функции, и, если необходимо, передаем список опций.

В нашем случае ImageId будет таким.

ImageTx{
    function=‘resize_ava’
    arguments=[
        ImageSrc{type=photo, id=3@968603195616}
    ],
    options={crop={size=442, x=614, y=102}}
}

Мы применяем ImageTx, поскольку нам нужна трансформация. Здесь:

  • в качестве функции мы передаем resize под размер для показа аватарки;

  • в качестве аргумента мы передаем ImageSrc, то есть полноразмерный исходник фотографии, которую мы загрузили ранее;

  • в качестве опции мы передаем параметры кропа.

Дальше эта структура сериализуется и кладется в ссылку.

Именно после клика на эту ссылку, кроп применяется

Именно после клика на эту ссылку, кроп применяется

Путь картинки

После клика пользователь может попасть в несколько мест. Так, пользователи из регионов обычно попадают на CDN (на схеме это сервис Proxy).

a04d0b52b04bf35774ab0c8cb9d37130.jpeg

При этом, если пользователь находится в Москве, то он сразу попадает в наше московское облако, в сервис Dispatcher.

Алгоритм обработки запроса следующий:

  • Клиент отправляет запрос.

  • Запрос поступает на dispatcher.

  • Dispatcher расшифровывает запрос.

  • Dispatcher обращается в Blob cache, где проверяет наличие картинки нужного размера.

  • Если нужный размер есть, dispatcher возвращает картинку клиенту. Если нужного размера нет — dispatcher передает запрос на transformer, который генерирует нужный размер и возвращает его в dispatcher.

  • Dispatcher сохраняет новый размер в Blob cache, после чего отдает его клиенту.

bf2b1f72486f41be0b700c167408d93e.jpeg

В кешировании участвуют два слоя:

  • Локальный кеш на самом Dispatcher. Нужен для самых популярных картинок, чтобы не перегружать следующий слой. Поэтому запись в этот кеш происходит лишь с некоторой вероятностью.

  • Распределенный кеш, который на нашей схеме сервисов называется Blob cache.

Теперь чуть подробнее о Blob cache.

Устройство Blob cache

Blob cache является распределенным кешем бинарных данных.

98888aff9680b453e4829b88e4d8f1c1.jpeg

Основная структурная единица Blob cache — диски, которые могут быть свободно распределены по хостам.

9145106f30922f78a22568d87fbd2211.jpeg

Диски состоят из сегментов.

e0dda95ae0832dd385a8dcf380e7cba4.jpeg

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

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

a29dad4bff18fce9285d6561c438e0dd.jpeg

Что касается устройства сегмента, то:

  • Сначала идут пары ключ‑значение.

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

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

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

Lookup — хэш‑таблица, реализованная в off‑heap пространстве очень компактным образом. В этом нам помогает использование sun.misc.Unsafe для работы с памятью. Также в индексе обеспечивается потокобезопасность, для получения которой данные разделены на множество бакетов, у каждого из которых есть свой ReadWriteLock.

Кроме того, данные у нас могут быть шардированы и реплицированы. И мы можем регулировать уровень консистентности, то есть количество реплик, на которые мы записываем данные (при желании, на часть реплик можно писать асинхронно, то есть не дожидаясь ответа), и количество реплик, с которых мы их читаем.

e08a9417c36b1e991020e3f4de8af227.jpeg

Под кеш бинарных данных у нас выделено по 96 контейнеров в трех дата‑центрах. «Под капотом» каждого контейнера — 64 ГБ оперативной памяти, 5 ядер процессора и 2,4 ТБ SSD. Благодаря такой конфигурации, мы достигаем hit rate на уровне 95%.

Transformer

Теперь поговорим про Transformer.

2a7ad56a87e00f485866206396fd9823.jpeg

Transformer — компонент, который осуществляет трансформации картинок. В нашем случае он вырезает нужный фрагмент фотографии для аватарки.

f450fb2f6c1debe49870572ae0b893b3.jpeg

«Под капотом» у Transformer Java, в контейнере с которой запущено еще несколько процессов под названием ippmagick.

ippmagick — это уже C++. Сервис является оберткой над библиотекой IPP (Integrated Performance Primitives) от Intel, которая позволяет эффективно осуществлять преобразования картинок.

Java общается с ippmagick с помощью записи команд stdin и чтением ответа из stdout.

Interface ImageSource

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

Константы вида ext, ovdp и прочие, которыми подписаны стрелки, исходящие из интерфейса, указывают, какой именно источник мы хотим использовать. Именно эти константы мы записываем в ImageSrc.type при формировании ссылки на скачивание картинки.

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

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

028dd6ce29d339eafb67529c4ca2e69a.jpeg

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

ImageSrc{
    type=ext,
    id=https://i.imgur.com/C8RlrzS.jpeg
}

CDN

Вернемся к нашей ссылке на скачивание фото, теперь поговорим про хост. Он указывает, откуда скачивать фотографию.

https://i.okcdn.ru/i?r=BUFgLXxnWm2xnWtZtf4yu8JRXP2u4exBvAW8DS4jY58TTSkAl4AGw2ZG_xF14Hblg6kbSWkciLWyDLUYVS57pN33

И самый простой случай — когда все пользователи приходят в одно и то же место. Например, в основной дата‑центр в Москве.

2ad751d1c7611948eda7d0a2c9d8c8e7.jpeg

Вариант технически простой, но у него есть недостатки.

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

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

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

Для борьбы с этими недостатками принято использовать CDN (content delivery network или сеть доставки контента).

7848337b4eb60bf2defad543fa6a4ddc.jpeg

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

При этом в основной ЦОД ходят только сами прокси. Это решает упомянутые раньше трудности:

  • запрос не идет через всю страну, поэтому пинг не растет;

  • нагрузка на основной ЦОД снижается за счет кеширования на стороне прокси;

На узлах CDN развернуты сервисы Proxy, являющиеся гибридами Dispatcher и Blob Cache.

4590707fe2d01338fd731866f0fe69c0.jpeg

В нашей реализации каждая площадка CDN имеет свой хост. Например, если мы хотим отправить пользователя в Санкт‑Петербург, то мы выбираем хост vkI9.okcdn.ru. Если мы захотим отправить его на другую площадку — достаточно поменять номер в поддомене. Также мы можем сразу отправить пользователя на «родину» данных в Москву, использовав хост i.okcdn.ru.

Осталось разобраться, как именно мы выбираем, на какую площадку отправить пользователя. Мы стараемся подобрать площадку, до которой путь по сети будет кратчайшим. В этом нам помогает Border Gateway Protocol (BGP).

Вообще, BGP «склеивает» между собой различные сети таким образом, чтобы получился единый Интернет. В рамках этого протокола Интернет делится на так называемые «автономные системы» (AS). Например, автономной системой может быть сеть оператора связи.

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

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

И как же мы эту информацию используем?

8f1ad65a7cdf19ae931fb869b2b4567c.jpeg

У нас есть отдельный компонент, который называется route‑aggregator.

Это тоже Java‑сервис, и у него в контейнере, помимо Java, запущен процесс BIRD (расшифровывается как BIRD Internet Routing Daemon) — open‑source утилита, которая умеет разговаривать по BGP.

BIRD опрашивает все площадки CDN, чтобы узнать, как далеко они находятся от известных подсетей. Дальше вся эта информация агрегируется и используется, чтобы определить, какая площадка наиболее близка к тому или иному IP‑адресу. Так мы понимаем, на какую площадку отправлять пользователя с данным IP‑адресом. А это значит, что мы получаем хост и теперь можем сформировать ссылку на скачивание.

Таким образом мы закрываем все основные сценарии взаимодействия с картинками в ОК.

Заключение

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

© Habrahabr.ru