Вчера автор побывал на прекрасной конференции Backend Talks 360 от компании Yandex и не в силах спокойно спать, не ответив конструктивной критикой на доклад Ильи Абрамова о работе Яндекс.Диска, автор принялся писать очередной «В интернете кто-то не прав». Что из этого вышло — добро пожаловать под кат.
Почему загрузка больших файлов одним запросов для облачных хранилищ — это тупик
Одним из основных пунктов докладчика было балансирование нагрузки между «кладунами». Но в итоге доклад перерос в балансирование нагрузки между «балансерунами» и «кладунами».
Разумеется на диграмме нет «внешнего» балансировщика, которого не было и на слайдах докладчика. Обобщенная логика работы решения следующая:
-
Пользователь из балансеруна получает ссылку на кладуна;
-
На указанный кладун загружается файл (целиком).
Из-за разности в скоростях подключений всех пользователей гарантированно получаем при любых попытках в такой архитектуре что-либо балансировать перекосы по нагрузке на кладунов. Гарантированно будут перегруженные, и так же гарантированно будут недогруженные. Причем способ выбора не имеет значения.
Докладчик предложил решение в ходе которого удалось улучшить распределение за счет довольно больших и трудоемких решений, требующих сами по себе инфраструктуру, которой, как говорил докладчик итак не хватает.
Не будем указывать на то, что в случае ошибок сети, все загружаемые гигабайты можно будет выкидывать и заливать заново, поэтому попробуем исправить изначальную ошибку и покажем, что есть решение, при котором не нужны балансеруны вовсе, но при условии, что у всех кладунов есть общее хранилище (например nfs, главное, что бы у всех кладунов в группе было оно общим, а так же общая БД). Так как внешний балансировщик все равно есть, будем опираться именно на него.
Загрузка файлов блоками
Уже давно придумано решение, когда файл заливается блоками и после загрузки проверяется на целостность, так делает Google.drive, а так же некоторые другие облачные хранилища.
Работает блочная загрузка только при условии, что эти блоки всегда выровнены по степени двойки (на самом деле по размеру буфера, который пишется на диск операционной системой), в противном случае будет гарантированное повреждение при передаче (например один пишет пару байт в конце буфера, обнуляя все что писали до него из-за неправильного смещения и гонок).
Архитектура решения для такого варианта тривиальная
Все API при этом разделяется на 2 класса: все что меньше блока (маленькие файлы) и все что больше блока (большие файлы). Размер блока можно выбрать любой степенью двойки, например 4194304 (4 мегабайта).
При такой организации мелкие файлы пользователи будут спокойно загружать и не создавать лишней нагрузки на систему (иначе пришлось бы делать 3 запроса). Таких файлов большинство и поэтому нужна отдельная ручка для таких запросов.
В случае больших файлов (а иногда требуется загружать сотни гигабайт), жизненно необходимо гарантировать, что у пользователя всегда будет возможность продолжить загрузку. Поэтому для этого формируется отдельное API. Смысл в том, что в начале отправляется запрос на подготовку загрузки большого файла, в этом запросе сообщается размер загружаемого файла, контрольная сумма для сверки и другие необходимые и полезные пользователю свойства, например флаг «перед открытием удалить». Далее в один или несколько потоков загружаются блоки, а по завершении загрузки всех блоков — специальный запрос на завершение.
Общий алгоритм тогда такой (лишь один из вариантов):
-
Получить идентификатор для загрузки большого файла;
-
В один или несколько потоков загружать выровненные блоки, сообщая для каждого блока его смещение;
-
Дождаться завершения загрузки всех блоков;
-
По окончании загрузки — сообщить сервису об окончании, и начале процессе
удаления загруженноговалидации файла (проверки контрольной суммы, можно еще антивирусом погонять). Контрольную сумму можно сообщать на данном этапе, а не создания загрузки.
Такое API — простое для реализации любым разработчиком, да и Яндекс достаточно богатая компания, что бы предоставлять клиенты на различных языках программирования для интеграции своим пользователям.
Простой пример
Дабы не быть голословным, и развеять все сомнения у тех, кто может воскликнуть, что это не будет работать, автор подготовил простое приложение. Написано оно так же на простом языке программирования Rust.
Код доступен по ссылке .
Сервис реализует простейшее апи:
-
POST /api/upload/small-file/{user_id}/{display_file_name:.*} — Загрузка маленького файла на диск, display_file_name — это специальный параметр, который берет весь остаток пути.
В запросе ожидается заголовок x-sha-512. -
POST /api/upload/large-file/new — создать файл для загрузки, он будет считаться незавершенным, пока не будет завершен процесс загрузки. В запросе передается тело, в котором указывается: идентификатор пользователя, отображаемое имя файла, размер и контрольная сумма.
-
PUT /api/upload/large-file/{file_id}/chunk/{offset} — пишет блок по смещению, проверяя контрольную сумму полученного блока перед записью; Вернет 400, если контрольная сумма неверна.
В запросе ожидается заголовок x-sha-512 — контрольная сумма блока.
Данный запрос можно направлять на любого макладуна, так как запись осуществляется в непересекающиеся блоки. -
POST /api/upload/large-file/{file_id}/done — уведомление о завершении загрузки, синхронно (лучше это делать фоновой задачей) проверяет целостность файла, вернет 200, только если контрольные суммы совпали.
В чем главное преимущество данного API? Оно дружелюбно к повторам, если по каким-то причинам сервер не ответил, то клиент может отправить блок заново и не прерывать загрузку.
Как использовать написанное приложение
Для запуска нам потребуется PostgreSQL:
podman run --rm --name test-db \ -e POSTGRES_PASSWORD=xxXX12341234 \ -e POSTGRES_USER=user \ -p 5432:5432 \ postgres:17
После этого из корня проекта запускаем миграцию БД:
sqlx migrate run
Теперь можно запустить парочку инстансов:
cargo run --bin makladun -- -c config/makladun_local.yaml
И в другом терминале
cargo run --bin makladun -- -c config/makladun_local2.yaml
Если оба сервиса успешно запущены и будут писать в папку /tmp/makladun/1, убедитесь, что она создана. Вы можете смонтировать сетевой диск (smb, nfs), и пробовать загружать туда большие файлы (не рекомендую больше 100мб, так как почему-то расчет SHA-512 занимает много времени, консольная утилита делает быстрее на порядок), маленькие файлы работать перестанут, потому что перемещения между дисками нет, а править это на копирование нет желания.
Пример для загрузки файла:
cargo run --bin disk-io-client -- -u 2 -d "makladun" \ -f /tmp/makladun/makladun \ -p 4 \ -b http://localhost:8080 -b http://localhost:8081
Что сделает эта команда: от пользователя 2, создаст файл makladun в «облачном хранилище в корне», возьмет файл из /tmp/makladun/makladun и будет заливать его в 4 потока. Отправка запросов осуществляется на сервисы http://localhost:8080 и http://localhost:8081 случайно. Если поставите балансировщик, то рекомендую использовать round-robin стратегию балансировки, так как она даст наилучший результат. Ссылку тогда достаточно будет оставить одну.
Конфигурация устроена следующим образом:
http: port: 8080storages: - key: "1" path: "/tmp/makladun/1" # Можно указать множество хранилищ, в том числе сетевые диски # главное, что бы эта папка существовала. Выбирается случайно. # - key: "2" # path: "/tmp/makladun/2"chunkSize: 4096 # Размер блока в килобайтах, все что меньше - считается маленьким файломdb: host: "localhost" port: 5432 database: "postgres" schema: "public" user: "user" password: "xxXX12341234" additionalOptions: search_path: "public"
Вместо заключения
Илья Абрамов с командой проделали огромную работу, и на самом деле сложную. Но иногда решение лежит куда как ближе и проще. Не всегда переусложнение имеет смысл.
Возможно, что эта статья позволит Яндекс.Диску стать немного лучше и производительнее. 😉
ссылка на оригинал статьи https://habr.com/ru/articles/1036062/