Копирование данных из S3 в EBS в 30 раз быстрее с помощью Golang.

У нас была очень простая проблема,

Скопируйте большое количество небольших изображений (несколько КБ) из S3 в EBS

У нас есть устаревшая служба, обрабатывающая изображения в томе EBS. Эти образы также были скопированы в S3. В случае аварии нам требовался способ быстрого восстановления из резервной копии. Нам также нужно было восстановить образы из S3 в изолированную среду, чтобы помочь с отладкой. Мы надеялись решить обе проблемы одним и тем же инструментом.

В нашем пути к решению этой проблемы мы

  1. Использовал AWS CLI, но обнаружил, что он очень медленный.
  2. Реализовал наивное решение в Go, которое эффектно вылетало.
  3. Понял немного больше о планировщике Go и о том, при каких условиях среда выполнения порождает новые потоки ОС.
  4. Изменили дизайн, чтобы получить инструмент, который был надежным и почти в 30 раз быстрее, чем интерфейс командной строки AWS.

Наивное решение

Первый подход, который мы попробовали, - это рекомендуемый способ копирования объектов из S3. Установите AWS CLI и выполните

aws s3 cp --recursive s3://<the-bucket> /data/<the-bucket>

На копирование корзины S3 с 250 000 маленьких изображений ушло 22 минуты. Скорость передачи данных, сообщаемая интерфейсом командной строки, составляла всего 4,1 МБ / с. Медленное время передачи было неприемлемо, особенно когда у нас были разработчики, ожидающие запуска среды отладки.

Быстрый поиск в Интернете показал, что мы можем установить для max_concurrent_requests и max_queue_size более высокое значение в конфигурации, чтобы ускорить процесс. Мы обновили конфигурацию, установив max_concurrent_requests на 1000 и max_queue_size на 3000 .

aws configure set default.s3.max_concurrent_requests 1000
aws configure set default.s3.max_queue_size 3000
cat ~/.aws/config
[default]
s3 =
    max_queue_size = 3000
    max_concurrent_requests = 1000

Повторный запуск копии не сократил время. У нас все равно ушло около 22 минут. В большинстве найденных нами сообщений stackoverflow и других говорилось о загрузке из EBS в S3. Несколько сообщений, которые мы нашли, были посвящены копированию больших объектов.

Конфигурация AWS CLI S3 не документирует параметры, которые могут иметь отношение к нашему конкретному варианту использования. (Если мы упустили вариант, сообщите нам об этом.)

Что делать дальше?

Основное наблюдение, связанное с AWS CLI, заключалось в том, что он не копирует одновременно объекты из S3. Итак, мы планировали использовать GNU Parallel + aws s3 cp. Вымотав день, мы решили остановиться здесь и продолжить на следующее утро.

Но по дороге домой я продолжал думать о возможном решении. Великолепная поддержка параллелизма в Go сделала его многообещающим выбором для написания небольшого инструмента для решения этой конкретной проблемы. Инструмент никогда не предназначался для использования в качестве окончательного решения. Единственная цель прототипа - получить некоторый опыт работы с Go и AWS SDK для Go.

Первой остановкой была документация по api Go AWS S3 SDK. В нем было 2 API, которые можно было использовать.

  • S3.ListObjectsV2Pages, чтобы получить список ключей в корзине.
  • S3.GetObject получить объект для определенного ключа

Далее нам нужен способ создания каталогов для заданного ключа. Для этого мы пишем DirectoryCreator, который отвечает за создание каталогов, которые не были созданы ранее. (Если каталог уже существует, os.MkdirAll не будет его воссоздавать.)

Затем мы пишем Copier, который отвечает за копирование и сохранение объекта для данного ключа в указанном каталоге.

Наконец, осталось разработать ядро. Здесь мы должны перечислить все ключи в корзине и скопировать объект для соответствующего ключа.

Из различных шаблонов, которые можно было использовать для разработки ядра, мы выбрали 2 возможных кандидата.

  • Шаблон сервера
  • Выкройка Worker (Fan-out)

Вы можете узнать больше о различных шаблонах параллелизма из следующих статей.

Решение, основанное на «шаблоне сервера»

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

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

Это хорошо сработало для ведра с небольшим количеством изображений. Но вылетел при попытке скопировать тестовую корзину с 250 000 изображений. Была замечена следующая ошибка:

runtime: program exceeds 10000-thread limit
fatal error: thread exhaustion 
Followed by a huge runtime stack dump

Поиск ошибки ограничения потока привел к появлению поста группы Google Брэда Фитцпатрика, в котором были указатели для выяснения первопричины.

В одном из ответов была команда grep, показывающая количество горутин, выполняющих системный вызов. В нашем случае у нас было 9180 горутин, которые выполняли системный вызов во время паники.

$ grep -C 1 '\[runnable\]'  time.txt | egrep -v '^(--|goroutine |$)' | sed 's/(.*//' | sort | uniq -c
   1 github.com/aws/aws-sdk-go/private/protocol/...
   1 internal/poll.convertErr
  69 internal/poll.runtime_pollWait
  71 net/http.
   1 net/textproto.canonicalMIMEHeaderKey
   1 strings.ToLower
6056 syscall.Syscall
3124 syscall.Syscall6

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

Когда количество запускаемых потоков достигает 10 000, среда выполнения запускает панику и аварийно завершает работу программы. Мы можем вызвать debug.SetMaxThreads, чтобы увеличить лимит потока, но это просто маскирует основную проблему. Требуется другое решение.

Ключевые выводы из «паттерна сервера» заключаются в том, что

  • Создавать неограниченное количество горутин - плохая идея
  • Блокировка горутины при системном вызове может привести к тому, что среда выполнения создаст новый поток.

Лучшее решение, основанное на «шаблоне рабочего»

В шаблоне worker несколько горутин могут читать из одного канала, распределяя объем работы между ними.

Это означает, что мы будем вызывать s3Client.ListObjectsV2Pages, а затем отправлять ключи в буферный канал. Затем пул рабочих горутин будет читать из буферизованного канала и копировать объект, соответствующий полученному ключу.

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

Полученные результаты

В следующих тестах мы использовали 250 000 небольших изображений S3 bucket в качестве источника.

На экземпляре c5.large «работающему» интерфейсу командной строки Go потребовалось около 4 минут для завершения копирования; тогда как AWS CLI занял ~ 22 минуты. Количество одновременных запросов было установлено на 2000, а размер очереди был установлен на 4000. Это обещало 5-кратное сокращение затраченного времени.

Запуск теста на экземпляре c5.4xlarge показал дальнейшее снижение примерно до 50 секунд с использованием Go CLI; тогда как AWS CLI все еще занимал ~ 21 минуту. Количество одновременных запросов и размер очереди остались на уровне 2000 и 4000 соответственно.

Результаты повторения теста на инстансах разных размеров показаны ниже.

Мы видим, что интерфейс командной строки Go становится быстрее по мере увеличения размера экземпляра. Чем больше экземпляры, тем выше выделенная полоса пропускания EBS. Но дополнительная пропускная способность и ядра не использовались интерфейсом командной строки AWS.

Go CLI может лучше использовать более высокую выделенную полосу пропускания EBS и большее количество ядер, доступных в более крупных инстансах EC2. Это показано на графике загрузки ЦП и графике сетевого ввода.

Хотя мы видим, что Go CLI становится быстрее в больших экземплярах, мы видели, что используются только 12 из 16 ядер. При запуске на 8-ядерном экземпляре были задействованы все ядра. Хотя было бы интересно узнать, почему это так, мы остановились на этом, поскольку достигнутого ускорения было достаточно. Мы могли бы изучить этот вопрос в другой день.

Не было никаких веских причин, почему мы выбрали 2000 в качестве максимального количества одновременных запросов для теста. Чтобы определить подходящую ценность для использования, мы повторно провели тест на экземпляре c5.4xlarge с различными значениями параллелизма и размера очереди. Наилучший результат был, когда максимальный параллелизм был установлен на 500, а размер очереди - на 3000.

Лучшая часть решения на основе рабочих заключалась в том, что мы никогда не видели ошибки времени выполнения с ограничением в 10 000 потоков.

Заключение

Мы начали с очень простой задачи перемещения большого количества очень маленьких изображений из корзины S3 в том EBS. AWS CLI, хотя и очень универсальный, оказался для нас недостаточным. Мы решили написать простой интерфейс командной строки Go, чтобы посмотреть, сможем ли мы добиться большего.

Нам удалось добиться большего: мы сократили время, затрачиваемое на наш тест, с ~ 22 минут с использованием интерфейса командной строки AWS до ~ 38 секунд. Все это было достигнуто всего за 130 строк кода Go. Это демонстрирует, насколько легко написать решение с высокой степенью параллелизма на Go.

Https://github.com/venkssa/s3copier содержит полный код копира вместе с системой отслеживания статистики, которая регистрирует прогресс, достигнутый CLI.

Дополнительная информация