На прошлой неделе Яндекс.Диску исполнился год, и за этот год сервисом успели воспользоваться уже больше 8 000 000 пользователей.
А сейчас мы продолжаем рассказывать о том, сколько усилий понадобилось, чтобы всё это стало возможным. Недавно мы писали о том, как и почему команда Яндекс.Диска выбрала WebDAV для синхронизации десктоп-клиентов с сервером и начала работу над прототипом клиента Яндекс.Диска. Сегодня, как и обещали, — о том, как всё работает с серверной стороны.
Для правильной синхронизации нужно не только уметь заливать файлы, но и реанимировать заливку в случае прерванного соединения, а также научить клиент учитывать изменения в файлах.
Очевидно, что в случае, когда соединение с сервером прерывается, а потом восстанавливается, клиент должен уметь дозаливать файл на него. Есть два параметра, которые необходимо учитывать в таком случае: имя файла и его размер. Но для нас их недостаточно — с хранилищем одновременно могут работать несколько клиентов и файл может обновляться конкурентно. Поэтому понадобилось добавить ещё один параметр.
На тот момент мы уже начали разработку модуля синхронизации, который в процессе работы считал md5-хэш содержимого файла. И мы решили воспользоваться им как уточняющим параметром. Во-первых, клиент всегда имел эту информацию и, используя md5-хэш, мы не увеличивали нагрузку на него. Во-вторых, он лучше любого параметра, не зависящего от содержимого файла, — он даёт возможность проверять идентичность отправленного и полученного файла.
Перед отправкой файла на сервер клиент считает хэш. Дальше он заливает файл методом PUT, сообщая серверу этот хэш в HTTP-заголовке Etag. При получении такого запроса сервер сохраняет размер заливаемого файла и его md5 в специальную таблицу незавершённых заливок. В случае нормальной заливки всего содержимого на сервере вычислется md5 полученного файла и сравнивается с полученным от клиента — при их совпадениях файл принят верно и его можно сохранять.
В случае проблем с соединением — если оно было закрыто или при длительном таймауте — на сервере необходимо было сохранить в таблице реально принятый размер и залогировать в access.log неудачно завершённый запрос. Мы использовали в качестве фреймворка веб-сервера mochiweb и в процессе обработки проблем с обрывами соединений встретились с его особенностями. На любые ошибки библиотека реагировала вызовом 'exit(normal)`, что означает «тихое» завершение процесса. Это нормально, если перед нами стоит nginx для логирования запросов и если при подобном завершении соединения ничего делать не надо. Конечно, можно перехватить такое исключение. Но понять, какая именно из возможных проблем случилась, мы в таком случае можем разве что по наличию известных функций в стектрейсе. Нормальным этот метод не назовёшь, поэтому пришлось править библиотеку на предмет выдачи более вменяемых ошибок.
При обрыве соединения клиент не может полагаться на информацию о том, какое количество байт отправляемого файла реально попало на сервер. Поэтому нам пришлось сделать ещё одну доработку протокола — мы расширили метод HEAD, с помощью которого клиент запрашивает эту информацию, передавая серверу путь куда заливался файл, его размер и md5. Сервер ищет незавершённые закачки пользователя с такими же параметрами и отвечает клиенту, сколько реально закачано. После этого клиент должен возобновить закачку с указанного сервером места с помощью специального запроса — нового расширения метода PUT.
Кроме просто докачки файлов, мы захотели накладывать бинарные патчи файлов — дельта-обновления, — так, как это делается в rsync, но минимизируя нагрузку от этих операций на сервере. Мы разбиваем файл на блоки, по которым считаются быстрые и стойкие сигнатуры. Метод вычисления быстрых сигнатур — rolling checksum — мы позаимствовали из rsync. Сигнатуры блоков используются для поиска совпадающих частей файла, которые пересылать по сети не требуется. Комбинацию из размера блока, сигнатур и md5-файла мы называем дайджестом файла. Для того чтобы клиент смог определить, какие части обновлённого файла ему необходимо скачать или отправить на сервер, ему необходимо получить дайджест файла, хранящегося на сервере. Для этого опять пришлось расширить протокол — на этот раз методом получения дайджеста.
Что же касается самих дайджестов, получаемых с сервера, мы не хотели затормаживать процесс синхронизации их расчётом по запросу, поэтому было решено хранить их на сервере уже посчитанными.
Для начала мы попробовали считать дайджесты во время стриминга файлов в Erlang. Казалось, это уменьшит накладные расходы: порция данных уже лежит в памяти и передать её в модуль расчёта дайджеста выглядело дешёвым решением. К сожалению, из-за специфики работы с памятью в Erlang, это оказалось не так: данные копировались в драйвер, считавший хэши, промежуточные результаты копировались обратно в процесс-обработчик, а затем всё опять отправлялось в драйвер. Это оказалось чересчур ресурсоёмким. Разрабатывать специализиованный драйвер, который хранил бы всё промежуточное состояние внутри и не передавал бы его обратно в Erlang, не хотелось. Альтернативным решением стало складывать файл на диск как обычно, а дайджест считать после полного получения файла отдельной программой, написанной на C и запускаемой из Erlang как порт. Мы использовали этот подход и сократили время на расчёт дайджеста в 10 раз.
Для дельта-обновлений на сервере был расширен стандартный метод PUT, который принимает бинарный дифф и накладывает его на исходный файл. В таком диффе определены лишь две команды: скопировать часть исходного файла и вставить часть, пришедшую от клиента. Сервер занимается лишь простыми операциями, а весь тяжёлый анализ изменений в файлах находится на стороне клиента.
Для случаев, когда файл обновился на сервере, применяется тот же алгоритм поиска одинаковых частей. Клиенту могут потребоваться и несколько частей одного файла, поэтому мы поддержали запросы с указанием множества Range-ей, когда ответ приходит в виде multipart/byteranges, чтобы снизить количество обращений к метаданным файла.
Ещё один метод, который нужен для синхронизации — получить диффы дерева файлов, чтобы клиент мог определить, какие файлы успели обновиться на сервере. Эта задача отличается от обычного версионирования, поэтому методы, предлагаемые стандартом, нам не подошли, и пришлось ещё раз расширить протокол. Когда клиент хочет обновить файлы, он вызывает этот новый метод, указывая идентификатор имеющейся у него синхронизированной версии. А сервер отвечает идентификатором последней версии и списком изменений, произошедших в файловой структуре (не в самих файлах) с момента последнего обновления. Для этого мы храним историю всех изменений в файловой структуре для каждого пользователя.
Пожалуй, за исключением некоторых мелочей, это всё, чем занимается WebDAV-сервер в Яндекс.Диске. Мы довольны тем, что выбрали именно этот протокол. С одной стороны, он практически «из коробки» отвечал нашим нуждам и не требовал существенных доработок, а с другой — благодаря нему с Яндекс.Диском легко интегрировать многие утилиты и приложения.
А сейчас мы продолжаем рассказывать о том, сколько усилий понадобилось, чтобы всё это стало возможным. Недавно мы писали о том, как и почему команда Яндекс.Диска выбрала WebDAV для синхронизации десктоп-клиентов с сервером и начала работу над прототипом клиента Яндекс.Диска. Сегодня, как и обещали, — о том, как всё работает с серверной стороны.
Для правильной синхронизации нужно не только уметь заливать файлы, но и реанимировать заливку в случае прерванного соединения, а также научить клиент учитывать изменения в файлах.
Очевидно, что в случае, когда соединение с сервером прерывается, а потом восстанавливается, клиент должен уметь дозаливать файл на него. Есть два параметра, которые необходимо учитывать в таком случае: имя файла и его размер. Но для нас их недостаточно — с хранилищем одновременно могут работать несколько клиентов и файл может обновляться конкурентно. Поэтому понадобилось добавить ещё один параметр.
На тот момент мы уже начали разработку модуля синхронизации, который в процессе работы считал md5-хэш содержимого файла. И мы решили воспользоваться им как уточняющим параметром. Во-первых, клиент всегда имел эту информацию и, используя md5-хэш, мы не увеличивали нагрузку на него. Во-вторых, он лучше любого параметра, не зависящего от содержимого файла, — он даёт возможность проверять идентичность отправленного и полученного файла.
Перед отправкой файла на сервер клиент считает хэш. Дальше он заливает файл методом PUT, сообщая серверу этот хэш в HTTP-заголовке Etag. При получении такого запроса сервер сохраняет размер заливаемого файла и его md5 в специальную таблицу незавершённых заливок. В случае нормальной заливки всего содержимого на сервере вычислется md5 полученного файла и сравнивается с полученным от клиента — при их совпадениях файл принят верно и его можно сохранять.
В случае проблем с соединением — если оно было закрыто или при длительном таймауте — на сервере необходимо было сохранить в таблице реально принятый размер и залогировать в access.log неудачно завершённый запрос. Мы использовали в качестве фреймворка веб-сервера mochiweb и в процессе обработки проблем с обрывами соединений встретились с его особенностями. На любые ошибки библиотека реагировала вызовом 'exit(normal)`, что означает «тихое» завершение процесса. Это нормально, если перед нами стоит nginx для логирования запросов и если при подобном завершении соединения ничего делать не надо. Конечно, можно перехватить такое исключение. Но понять, какая именно из возможных проблем случилась, мы в таком случае можем разве что по наличию известных функций в стектрейсе. Нормальным этот метод не назовёшь, поэтому пришлось править библиотеку на предмет выдачи более вменяемых ошибок.
При обрыве соединения клиент не может полагаться на информацию о том, какое количество байт отправляемого файла реально попало на сервер. Поэтому нам пришлось сделать ещё одну доработку протокола — мы расширили метод HEAD, с помощью которого клиент запрашивает эту информацию, передавая серверу путь куда заливался файл, его размер и md5. Сервер ищет незавершённые закачки пользователя с такими же параметрами и отвечает клиенту, сколько реально закачано. После этого клиент должен возобновить закачку с указанного сервером места с помощью специального запроса — нового расширения метода PUT.
Кроме просто докачки файлов, мы захотели накладывать бинарные патчи файлов — дельта-обновления, — так, как это делается в rsync, но минимизируя нагрузку от этих операций на сервере. Мы разбиваем файл на блоки, по которым считаются быстрые и стойкие сигнатуры. Метод вычисления быстрых сигнатур — rolling checksum — мы позаимствовали из rsync. Сигнатуры блоков используются для поиска совпадающих частей файла, которые пересылать по сети не требуется. Комбинацию из размера блока, сигнатур и md5-файла мы называем дайджестом файла. Для того чтобы клиент смог определить, какие части обновлённого файла ему необходимо скачать или отправить на сервер, ему необходимо получить дайджест файла, хранящегося на сервере. Для этого опять пришлось расширить протокол — на этот раз методом получения дайджеста.
Что же касается самих дайджестов, получаемых с сервера, мы не хотели затормаживать процесс синхронизации их расчётом по запросу, поэтому было решено хранить их на сервере уже посчитанными.
Для начала мы попробовали считать дайджесты во время стриминга файлов в Erlang. Казалось, это уменьшит накладные расходы: порция данных уже лежит в памяти и передать её в модуль расчёта дайджеста выглядело дешёвым решением. К сожалению, из-за специфики работы с памятью в Erlang, это оказалось не так: данные копировались в драйвер, считавший хэши, промежуточные результаты копировались обратно в процесс-обработчик, а затем всё опять отправлялось в драйвер. Это оказалось чересчур ресурсоёмким. Разрабатывать специализиованный драйвер, который хранил бы всё промежуточное состояние внутри и не передавал бы его обратно в Erlang, не хотелось. Альтернативным решением стало складывать файл на диск как обычно, а дайджест считать после полного получения файла отдельной программой, написанной на C и запускаемой из Erlang как порт. Мы использовали этот подход и сократили время на расчёт дайджеста в 10 раз.
Для дельта-обновлений на сервере был расширен стандартный метод PUT, который принимает бинарный дифф и накладывает его на исходный файл. В таком диффе определены лишь две команды: скопировать часть исходного файла и вставить часть, пришедшую от клиента. Сервер занимается лишь простыми операциями, а весь тяжёлый анализ изменений в файлах находится на стороне клиента.
Для случаев, когда файл обновился на сервере, применяется тот же алгоритм поиска одинаковых частей. Клиенту могут потребоваться и несколько частей одного файла, поэтому мы поддержали запросы с указанием множества Range-ей, когда ответ приходит в виде multipart/byteranges, чтобы снизить количество обращений к метаданным файла.
Ещё один метод, который нужен для синхронизации — получить диффы дерева файлов, чтобы клиент мог определить, какие файлы успели обновиться на сервере. Эта задача отличается от обычного версионирования, поэтому методы, предлагаемые стандартом, нам не подошли, и пришлось ещё раз расширить протокол. Когда клиент хочет обновить файлы, он вызывает этот новый метод, указывая идентификатор имеющейся у него синхронизированной версии. А сервер отвечает идентификатором последней версии и списком изменений, произошедших в файловой структуре (не в самих файлах) с момента последнего обновления. Для этого мы храним историю всех изменений в файловой структуре для каждого пользователя.
Пожалуй, за исключением некоторых мелочей, это всё, чем занимается WebDAV-сервер в Яндекс.Диске. Мы довольны тем, что выбрали именно этот протокол. С одной стороны, он практически «из коробки» отвечал нашим нуждам и не требовал существенных доработок, а с другой — благодаря нему с Яндекс.Диском легко интегрировать многие утилиты и приложения.