Хотя Discord — это приложение для голосового и текстового чата, каждый день через него проходит более ста миллионов изображений. Конечно, мы бы хотели, чтобы задача была простой: просто перенаправить картинки вашим друзьям по всем каналам. Но в реальности доставка этих изображений создаёт довольно большие технические проблемы. Прямая ссылка на картинки выдаст хосту с картинкой IP-адреса пользователей, а большие изображения расходуют много трафика. Чтобы избежать этих проблем, требуется промежуточный сервис, который будет получать изображения для пользователей и изменять их размер для экономии трафика.
Встречайте Image Proxy
Для выполнения этой работы мы создали сервис Python и креативно назвали его Image Proxy. Он загружает картинки с удалённых URL, а затем выполняет ресурсоёмкую задачу по ресайзингу с помощью пакета pillow-simd. Этот пакет работает удивительно быстро, используя где только возможно для ускорения ресайзинга инструкции x86 SSE. Image Proxy будет получать HTTP-запрос, содержащий URL, чтобы загрузить, изменить размер и, наконец, выдать окончательное изображение.
Поверх него мы установили кэширующий слой, который сохраняет в памяти картинки с изменённым размером и пытается по возможности выдавать её напрямую из памяти. Слой HAProxy перенаправляет запросы на основании хэша URL на кэширующий слой Nginx. Кэш выполняет объединение запросов, чтобы свести к минимуму количество требуемых трансформаций по изменению размеру изображений. Сочетание кэша и прокси позволило масштабировать наш сервис ресайзинга на миллионы пользователей.
По мере роста Discord сервис Image Proxy начал демонстрировать признаки перенагрузки. Самой большой проблемой стало то, что нагрузка распределялась неравномерно, из-за чего страдала пропускная способность. Запросы выполнялись в совершенно разное время, вплоть до нескольких секунд. Мы могли бы решить эту проблему в существующем Image Proxy, но в то время мы как раз экспериментировали с дополнительным использованием Go, а здесь как будто было отличное место для применения Go.
Так появился Media Proxy
Переписать сервис, который уже работает, стало нелёгким решением. К счастью, Image Proxy относительно прост и можно было легко сравнить результаты от него и новой альтернативы. Кроме более быстрого выполнения запросов, новый сервис имеет и некоторые новые функции, в том числе возможность извлекать первые кадры видеороликов .mp4 и .webm —? следовательно, назовём его Media Proxy.
Мы начали с измерения производительности существующих пакетов для изменения размеров изображений на Go и быстро пришли в уныние. Хотя Go в целом более быстрый язык, чем Python, никакие из найденных пакетов не превосходили надёжно по скорости Pillow-simd. Основной частью работы Image Proxy было транскодирование и изменений размера изображений, так что это определённо стало бы узким местом в производительности Media Proxy. Язык Go может быть немного быстрее при обработке HTTP, но если он не способен быстро ресайзить картинки, то дополнительный выигрыш в скорости нивелируется дополнительным временем на ресайзинг.
Мы решили удвоить ставку и собрать собственную библиотеку ресайзинга изображений на Go. Некоторые многообещающие результаты показал один пакет Go на основе OpenCV, но он не поддерживал все нужные нам функции. Мы создали свой ресайзер Go под названием Lilliput с собственным враппером Cgo поверх OpenCV. При создании мы внимательно следили, чтобы не генерировать лишний мусор в Go. Враппер Lilliput делает почти всё, что нам надо, хотя понадобилось слегка форкнуть OpenCV для наших нужд. В частности, мы хотели иметь возможность проверять заголовки, прежде чем принимать решение о декомпрессии изображений: так можно мгновенно отказываться от изменений размера слишком больших изображений.
Lilliput использует для сжатия и декомпрессии существующие и проверенные библиотеки C (например, libjpeg-turbo для JPEG, libpng для PNG) и векторизованный код OpenCV для быстрого ресайзинга. Мы добавили fasthttp для соответствия нашим требованиям к параллельным HTTP-клиенту и серверу. В итоге, такая комбинация позволила запустить сервис, который неизменно превосходил Image Proxy в синтетических бенчмарках. Кроме того, lilliput работал не хуже или лучше, чем pillow-simd в тех задачах, которые нам нужны.
Первый код не обошёлся без проблем. Изначально Media Proxy выдавал утечку 16 байт на каждый запрос. Это достаточно мало, чтобы сразу заметить, особенно при тестировании на небольшом объёме запросов. Для решения проблемы в Media Proxy были установлены большие статичные пиксельные буфера для целей ресайзинга. Он использует по два таких буфера на CPU, так что на 32-ядерной машине сразу занимает 32 гигабайта памяти. Во время тестирования перезапуск Media Proxy занимал несколько часов, поскольку он использует абсолютно всю память. Это достаточно длительное время, чтобы затруднить выяснение ситуации: то ли у нас действительно утечка памяти, то ли просто превышение лимита во время работы.
В конце концов мы решили, что всё-таки должна быть какая-то утечка памяти. Мы не были уверены, эта утечка в Go или C++, и изучение кода не дало ответа. К счастью, Xcode поставляется с отличным профилировщиком памяти —? инструмент Leaks в меню Instruments. Этот инструмент показал размер утечки и примерное место, где она происходит. Такой подсказки хватило, чтобы более тщательное изучение позволило определить источник и исправить утечку.
В Media Proxy мы столкнулись с ещё одним багом, из-за которого пришлось прерваться. Иногда он выдавал странно испорченные картинки, где одна половина оставалась нормальной, а вторая была «глючной». Мы подозревали, что мы где-то частично кодируем изображение или как-то неправильно вызываем OpenCV. Баг проявлялся нечасто и его было трудно диагностировать.
Чтобы продвинуться в расследовании, мы разработали высокопроизводительный симулятор запросов, который выдавал URL'ы со ссылкой на HTTP-сервер в симуляторе, так что этот симулятор работал одновременно как запрашивающий клиент и хост-сервер. Он случайным образом вставлял задержки в ответы, чтобы спровоцировать такую порчу изображений в Media Proxy. Надёжно воспроизведя проблему, удалось изолировать компоненты Media Proxy и найти состояние гонки в выходном буфере, содержащем картинку изменённого размера. В этот буфер записывалось одно изображение, а затем другое, прежде чем первое возвращалось в систему. Глючные картинки в реальности были двумя JPEG'ами, записанные один поверх другого.
Реальный глючный JPEG, сгенерированный Media Proxy
Другой способ искать баги в сложных системах — фаззинг, когда генерируются случайные входные данные и отправляются в систему. В таком случае система может проявить странное поведение или обвалиться. Поскольку наша система должна быть устойчива к любым входным данным, мы решили применить эту важную технику в процессе тестирования. AFL — исключительно хороший фаззер, так что мы выбрали его и натравили на Lilliput, что позволило выявить несколько сбоев из-за неинициализированных переменных.
После исправления этих багов наша уверенность выросла достаточно, чтобы выкатить Media Proxy в продакшн — и мы с радостью убедились, что наши усилия стоили того. Media Proxy требовал на 60% меньше серверных инстансов для обработки такого же количества запросов, что и Image Proxy, выполняя эти запросы в гораздо меньшие разбросы времени. Профилирование показало, что более 90% времени CPU в новом сервисе уходит на декомпрессию, изменение размера и сжатие. Эти библиотеки уже значительно оптимизированы, то есть дополнительного прироста добиться было бы нелегко. Вдобавок, сервис почти не генерировал мусора в процессе работы.
Сейчас Media Proxy выполняет изменение размера изображения с медианным временем 25 мс, а задержка ответа составляет по медиане 85 мс. Он изменяет размер более 150 миллионов картинок каждый день. Media Proxy работает на автомасштабируемой GCE-группе хостов типа n1-standard-16, с пиковым количеством 12 инстансов в обычный день.
Загрузка медиа в Media Proxy
После успешной работы сервиса на статичных изображениях мы хотели подключить к нему поддержку изменения размера анимированных GIF, и эту работу OpenCV не сделает за нас. Мы решили добавить в Lilliput ещё один враппер Cgo поверх giflib, чтобы Lilliput смог изменять размер анимированных GIF целиком, а также выдавать первый кадр в формате PNG.
Изменение размера GIF оказалось не таким простым, поскольку стандарт GIF предусматривает использование палитр по 256 цветов в каждом кадре, а модуль изменения размера работает в пространстве RGB. Мы решили сохранять палитру каждого кадра, а не вычислять новые палитры. Чтобы конвертировать RGB обратно в индексы палитры, мы снабдили Lilliput простой таблицей-справочником, которая брала некоторые биты RGB и использовала результат как ключ в таблице индексов палитры. Это работало хорошо и сохраняло оригинальные цвета, хотя такой подход не означает, что Lilliput способен создавать GIF только из исходных файлов этого формата.
Мы также пропатчили giflib, чтобы было легче декодировать только по одному фрейму за раз. Это позволило декодировать фрейм, изменить размер, затем закодировать и сжать его, прежде чем переходить к следующему. Так уменьшается потребление памяти модулем изменения размера GIF. Это несколько усложняет Lilliput, потому что ему нужно сохранять некоторые состояния GIF от кадра к кадру, но более предсказуемое использование памяти в Media Proxy выглядит явным преимуществом.
Враппер giflib в Lilliput исправляет некоторые проблемы, которые были в GIF-ресайзере Image Proxy, поскольку giflib даёт полный контроль над процессом изменения размера. Немалое число пользователей Nitro загружают GIF-анимированные аватары, которые глючат или имеют ошибки прозрачности после изменения размера в Image Proxy, но отлично работают после обработки Media Proxy. В целом, как выяснилось, у программ изменения размера есть проблемы с некоторыми аспектами формата GIF, так что они выдают визуальные глюки для фреймов с прозрачностью или частичных фреймов. Создание собственного враппера позволило решить проблемы, которые нам встречались.
Наконец, в Lilliput добавили Cgo-враппер на libavcodec, так что он смог останавливать видео и получать первый кадр клипов MP4 и WEBM. Эта функциональность позволит Media Proxy генерировать превьюшки для публикуемых пользователями видеофайлов, чтобы остальные люди на основании этой превьюшки принимали решение, запускать видео или нет. Извлечение первого кадра оставалось одним из последних факторов, который останавливал нас от добавления в клиент встроенного видеоплеера для публикуемых файлов и ссылок.
Больше Open Source
Теперь, когда мы удовлетворены работой Media Proxy, публикуем Lilliput под лицензией MIT. Надеемся, пакет пригодится тем, кому нужен производительный сервис по изменению размеров изображений, а эта статья сподвигнет других на создание новых пакетов Go.
Комментарии (11)
JekaMas
28.11.2017 22:03Также использовали go + openCV для ресайза картинок и объемы схожие.
Вы не рассматривали вариант openCV + gpu?
ilmarin77
29.11.2017 07:07А может без Go можно было обойтись? OpenCv + libuv?
JekaMas
29.11.2017 10:26Пробовали. Можно.
Но есть один довод за go — удобно балансировать нагрузку при обработке картинок. Хорошая конкурентная модель позволяет равномерно нагружать сервер.
В итоге получилось лучше и программисты писали быстрее. в
iSlava
29.11.2017 13:58Почему бы просто не сжимать/ресайзить картинки на клиентской стороне?
JekaMas
29.11.2017 14:48То что сразу приходит на ум — увеличение времени загрузки страниц. Для интернет-магазина с >10000 товаров я бы точно не стал такое делать.
iSlava
29.11.2017 18:17+2Дискорд — это чат, где пользователи могут загружать картинки. В моем предложении отправитель загружает пожатую + оригинал. Интернет-магазин тут не при чём.
Denai
Круто что картинки сжимаются, и экономят при этом ресурсы, но но в чём проблема их потом отображать нормально? Постоянно по 10+ секунд грузятся, нажимаешь на картинку, не можешь вытерпеть и тыкаешь «открыть оригинал», чтоб посмотреть её в браузере. И в браузере она открывается быстрее, чем в дискорде догружается уменьшенная превьюшка.
Я понимаю что это перевод, но как же странно выглядят такие статьи про скорость и крутые решения, когда у тебя в открытом дискорде картинки чуть грузятся…
ru1z
Попробуете расширение Imagus, оно по ссылкам показывает картинку. Обычно получается шустрее.