Привет, Хабр! Меня зовут Максим Галаганов, я ведущий разработчик систем доставки почты в Mail. Занимаюсь в основном почтовым сервером, но сегодня расскажу о другой задаче — как мы меняли вендора антивирусного решения. API нового решения кардинально отличался от старого, и пришлось изрядно поизобретать, чтобы всё заработало.

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

Контекст: масштабы почты

Начну с чисел, которые определяют наши требования к системе. Почта Mail обрабатывает 600 миллионов новых писем в день. Максимальный размер письма — 70 мегабайтов, средний — 265 килобайтов. В минуту к нам прилетает миллион новых входящих писем, что даёт примерно 130 терабайтов данных в день.

Когда письмо принято почтовым сервером, оно сначала попадает в очередь. Если антивирус упадёт, то письмо дождётся в очереди и пойдёт на повторную проверку. Важная особенность нашей архитектуры: до антивируса письмо проходит через антиспам, который анализирует MIME-структуру, смотрит на расширения файлов и применяет различные эвристики. Письмо идёт в антивирус только если антиспам решил, что его нужно сканировать.

В результате такой фильтрации до антивируса доходит около 50 тысяч писем в минуту — всего 5 % от входящего потока. Остальное — это текстовые письма или контент, который антиспам считает безопасным.

Миграция: старый и новый API

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

Старый API (C++ IStream)

В старом решении был элегантный подход: мы реализовывали интерфейс IStream с методами Read и Seek, передавали объект сканеру и он сам читал данные через наши методы. Это позволяло читать файлы откуда угодно: с диска, по сети, из памяти.

class MyStream : public IStream {
public:
HRESULT Read (/*[out]*/ void pv, /[in]*/ unsigned long cb, /*[out]*/ unsigned long *pcbRead);
HRESULT Seek (/*[in]*/ LARGE_INTEGER dlibMove, /*[in]*/ unsigned long dwOrigin, /*[out]*/  ULARGE_INTEGER *plibNewPosition);
private:
.....
};

Новый API (Protobuf + UNIX socket)

Новое решение работает иначе. В клиентском запросе ScanFileRequest есть поле path — путь к файлу на локальном диске. То есть для сканирования файл должен физически лежать на диске. В ответе приходит ScanReport с рекурсивной структурой: если сканировался архив с вложенными архивами, то всё дерево детектов придёт в ответе.

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

message ClientMessage {
    optional uint32 serial = 2;
    optional ScanFileRequest          scan_file_request = 3;
.....
}

message ScanFileRequest {
    required string path = 1;
    optional uint32 scan_timeout_ms = 2;
.....
}

message ServerMessage {
    optional uint32 serial = 2;
    optional ScanReport scan_report = 4;
.....
}

message ScanReport {
    repeated Virus virus = 6;
    repeated ScanReport item = 20;
.....
}

Решение для почты: первая итерация

Посчитаем нагрузку: 50 тысяч запросов в минуту при среднем размере 265 килобайтов дают около 13 гигабайтов в минуту на весь кластер. Это вполне подъёмная задача.

Решили делать просто: скачиваем файл во временный, сохраняем в tmpfs (чтобы не упереться в IOPS потенциально перегруженных дисков) и отдаём на сканирование. Клиентский протокол у нас уже был реализован, делаем для него drop-in замену и пишем совместимый сервер.

Архитектура получилась стандартной для наших сервисов: Go-сервер развёртываем в Kubernetes, трафик вводим через proxy с DNS-балансировкой. Базы обновляем через rsync (обновления выходят примерно раз в час и должны применяться на лету). В поде два контейнера: сервер, принимающий запросы, и сканирующий движок. Они взаимодействуют через shared volumes.

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

Облако меняет правила игры

Когда к нашему сервису подключилось Облако Mail, масштабы изменились кардинально. Максимальный размер файла вырос со 100 мегабайтов до 100 гигабайтов. Требуемый RPS подскочил до 10 тысяч — это 600 тысяч сканирований в минуту. Причём в запросе от Облака прилетает не сам файл, а его хеш. С ним нужно идти на облачный стример и скачивать файл, поддерживается рандомный доступ.

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

Сервер v2: отмена сканирования

Первая проблема, которую нужно было решить: если HTTP-клиент отвалился, то нужно прервать сканирование, иначе будет утечка ресурсов.

В Go реализовали это через req.Context(). Для этого пришлось перейти на синхронный протокол, отказаться от мультиплексирования запросов в коннекте к движку. Теперь, если контекст клиентского запроса отменяется, мы закрываем коннект к UNIX-сокету движка, что приводит к прерыванию сканирования. Сервис-супервизор, внутри которого выполняются сканирования, обнаруживает потерю коннекта с клиентом и убивает воркер-процессы, занятые обслуживанием запросов, ответы на которые больше не нужны.

Сервер v3: гибридный движок

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

Небольшие файлы, которых большинство, по-прежнему можно для проверки скачивать целиком. Для остального не обойтись без аналога старого интерфейса на IStream. Его мы и сделали, обозвав «гибридным” движком. 

Для этого вендор дал нам отдельную программу-оболочку над сканирующим ядром, которая загружает из библиотеки наши хуки. Это лучше, чем использовать FUSE или LD_PRELOAD, потому что позволяет убить процесс сканера, как только он нашёл вирус, и сразу ответить клиенту.

Для больших файлов, прилетающих в «гибридный» движок, запускается отдельный процесс-сканер, который читает файл Range-запросами к облачному стримеру. Прочитанные чанки помещаются в LRU-кеш. Раз больших файлов немного по сравнению с общим объёмом, то памяти на кеш можно не жалеть и подбирать размер чанка для оптимального чтения по сети. Сканер думает, что он читает с диска в блокирующем режиме.

Процессы-сканеры вынесли в отдельный контейнер, чтобы потенциально вызванный ими OOM не повлиял на обработку запросов другими движками. Для отмены сканирования горутина canceller блокируется на чтении одного байта из клиентского соединения после того, как запрос был успешно распакован. Если что-то прилетает (байт или ошибка), то это нарушение протокола: закрываем соединение и отменяем контекст команды из os/exec, что приводит к kill -9 на процесс-сканер.

Эксплуатация: на что смотрим

В проде мониторим множество метрик. Самые важные — это подвисшие запросы к HTTP-серверу и дисковому движку, чтобы понять, где возникает затор.

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

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

OOM всех процессов отслеживаем отдельно — в Kubernetes, если в контейнере много процессов, OOM одного процесса не приводит к рестарту пода. Также важно помнить, что tmpfs учитывается в квоте на память контейнера, и он может стать причиной eviction'а.

Ошибки скачивания, тайминги, клиентские ошибки — всё стандартно. Но есть специфическая метрика — детекты. Если антивирус активно работает, но не находит вирусы, значит что-то идёт не так. У меня был случай, когда забыл в RPM spec добавить Requires на библиотеку — антивирус запустился и стал сканировать воздух, потому что проигнорировал ошибку на dlopen(). Пришлось делать init-контейнер для проверки, что все библиотеки загружаются и нужные символы на месте.

Тротлинг скорости скачивания тоже может понадобиться — антивирус это фоновый процесс, и будет обидно, если он положит облако, скачивая слишком много файлов одновременно.

Как выбрать архитектуру

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

Первый: «Нужна ли нам собственная очередь запросов, или пусть она будет у клиентов?» Если очередь у вас, то клиенты могут насыпать огромное количество файлов и сделать это вашей проблемой. Лучше договариваться, чтобы клиенты тоже следили за нагрузкой.

Максимальный размер файла кардинально влияет на архитектуру. Одно дело — сканировать файлы по 70 мегабайтов, совсем другое — по 100 гигабайтов. Требования ко времени ответа тоже важны: вы не можете ждать, пока 100 гигабайтов скачается на диск, а потом за 30 миллисекунд проверится.

Данные лежат в файловой системе или blob-хранилище? Если хранилище поддерживает рандомное чтение — вам повезло, можно эффективно сканировать большие файлы.

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

Результаты

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

После запуска сервиса обнаружилось много неожиданных внутренних клиентов — helpdesk, support и другие службы, которым тоже нужно сканирование. Если делаете сервис, а не просто интеграцию, то сразу всем сделаете хорошо.

Канал коммуникации с вендором очень важен при интеграции на больших объёмах. И помните: сообщая о недетектируемых вирусах или ложных срабатываниях, вы улучшаете антивирусный движок для всех. Как-то нам в bug bounty принесли хитро и намеренно повреждённый архив — после репорта вендор поправил детект, и этот паттерн стал определяться у всех пользователей.