Привет, Хабр! Меня зовут Максим Уймин, в этой статье я расскажу про почту, про распределенные очереди, немножко про FUSE и файловые системы.

Почта Mail

Сначала крупными мазками нарисую архитектуру почты Mail, над чем вообще мы работаем.

Пользователь отправляет письмо, лукапит well‑known DNS адрес. У нас есть SMTP‑трафик и HTTP‑трафик. В любом случае после лукапа пользователь попадает на почтовый сервер. Почтовый сервер делает свою магию, и письмо от юзера попадает в storage. В качестве почтового сервера мы используем OpenSource программу Exim. Вы можете зайти на GitHub, поставить себе такой же.

В фокусе статьи будет кейс внештатной ситуации в центре обработки данных. Мы поставили себе задачу, чтобы пользователь этого не замечал, почта должна продолжать работать штатно. Сразу небольшая оговорка — storage у нас уже отказоустойчивый. Мы его оставим за рамками. На одном из прошлых HighLoad++ про него был доклад Виктора Могилина. Сегодня обсудим только среднюю часть — письма в процессе доставки.

У всех бывают проблемы с дата‑центрами:

  • Иногда дата‑центры горят,

  • Иногда затапливаются,

  • Периодически отключается питание.

Посмотрим, какая вообще цена ошибки для почты.

Электронная почта — цена ошибки

Помимо большого количества рассылок, почта — это:

  • Билеты на самолёт,

  • Деловая переписка,

  • Чеки покупок в интернете,

  • Фактор авторизации Госуслуг, банков, соцсетей,

  • Для некоторых — даже хранилище бэкапов.

Всю эту информацию пользователь не хочет потерять. Он ждет, что электронная почта будет работать стабильно и надежно. Поэтому еще раз: наша цель — почтовые сервера должны работать безотказно при любых обстоятельствах. При этом у нас есть 1.000.000 писем/мин и Exim в основе почтовых серверов. Менять его мы пока не собираемся, у нас много проприетарных патчей на нем. Моя роль в этой системе — это разработка распределенной очереди. Я этим проектом занимаюсь три года из своих пяти лет в почте.

О чём поговорим

  • Почтовый сервер Exim — краткое знакомство с ядром Почты Mail.

Минимальная теория, которую нужно знать про почтовый сервер Exim, чтобы делать отказоустойчивые инсталляции.

  • Распределённая почтовая очередь — архитектура системы, процесс создания, поведение при отказе.

  • Эксплуатация — как запустили в K8s, за какими метриками следим.

Почтовый сервер Exim — что надо знать

  • OpenSource,

  • Написан на чистом C, со всеми вытекающими.

В частности, я там видел функции длиной по нескольку тысяч строк.

  • Создает очень много дочерних процессов для каждой операции с письмом

Причем эти дочерние процессы короткоживущие, это не какой‑то prefork. Например, у нас возникает задача — принять письмо. Exim fork'ает для этого отдельный процесс, принимает письмо и умирает или, чтобы предпринять попытку доставки — точно также, процесс живет очень короткое время.

  • Stateful

В процессе приема письма Exim сохраняет письмо на диск, это состояние неразрывно связано с почтовым сервером.

  • Зато он «фичастый»

Могло показаться, что я ругаю Exim, но на самом деле это не так. Дело в том, что электронная почта — это десятки стандартов, которые регулируют общение по протоколу, плюс практика применения этих стандартов, и практика применения часто стандартам напрямую противоречит. Например, в стандарте написано «максимальная длина строчки такая‑то», но, если ты ее поставишь по стандарту, у тебя почта перестанет ходить, пользователи будут в шоке от такого, потому что фактически эту часть стандарта почему‑то забывают.

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

Рассмотрим процесс приема письма. Приходит пользователь, устанавливает TCP‑соединение, отправляет письмо. Exim записывает его на файловую систему, под капотом локальный диск. Как только записал на файловую систему, отдает сразу 250 Ok пользователю, пользователь идет по своим делам.

https://www.exim.org/exim-html-current/doc/html/spec_html/ch-how_exim_receives_and_delivers_mail.html

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

Какие проблемы могут быть в случае внештатных ситуаций на стороне ЦОДа с такой схемой?

Последствия для Почты

Проблемы в основном связаны с локальным диском:

  • Потери писем и задержки в доставке

Если что‑то случится на стороне ЦОД, мы, как минимум, получим задержки в доставке, как максимум, потеряем письма. Объясню — от вылета одного диска мы в целом застрахованы, у нас RAID массивы, все такое. А вот если весь сервер вырубается, то письма, которые мы успели принять, но еще не успели доставить, вылетают вместе с сервером. Нет электричества — нет попыток доставки. В худшем случае сервер сгорает вместе с ЦОДом и все, письма потеряны. Это гипотетическая ситуация, но, как я уже сказал, цена ошибки может быть достаточно велика, поэтому нам такую ситуацию нужно исключить.

  • Проблема с администрированием — трудно перемещать письма между серверами, например, при выводе из нагрузки, смотри e‑mail.

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

  • Трудно переехать в K8s, потому что Exim — stateful

В K8s переехать не то, чтобы совсем нельзя, но так как это stateful, это вызывает дополнительные сложности. Хотелось бы попроще.

Решение: распределённая очередь

В итоге мы хотим получить систему, в которой пользователь придет в Exim, а Exim (producer) вместо локального диска положит письмо в распределенную реплицируемую очередь, и уже другой Exim (consumer), доставит письмо из распределенной очереди в storage либо в интернет.

Систему посередине назовем распределенной очередью и ей сегодня будем заниматься. В случае проблем на стороне ДЦ у нас Exim'ы должны просто фейловериться на другую реплику, реплика должна перевыбраться в мастера — это наша задача.

Задача: подменить очередь Exim

  • Репликация между ЦОД'ами обеспечивает сохранность данных,

  • Автофейловер обеспечивает устойчивость к отказу мастера,

  • Exim становится stateless, всё состояние хранится на брокерах очереди,

  • Письма не привязаны к почтовому серверу.

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

Свою распределенную очередь мы написали на Tarantool.

Пишем распределённую почтовую очередь на Tarantool

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

А почему, собственно, Tarantool?

Подробнее: https://habr.com/ru/companies/oleg-bunin/articles/579354/
  • Кворумная репликация и автофейловеры (первое наше требование) есть и там, и там.

  • Приоритеты доставки.

У нас есть разные письма, например, письма от пользователя к пользователю, а есть рассылки от организации к большому количеству пользователей. Логично доставлять письма от пользователей к пользователям с более высоким приоритетом. Можно ли такое сделать на Kafka? Если постараться, то можно: завести разные топики под разные приоритеты, выбирать топик под приоритет письма, но это довольно сложно.

  • Кастомные состояния сообщения

Например, карантин антиспама, когда антиспам говорит: «Я пока не знаю, это письмо спам или не спам, придержи его, пожалуйста, минут на 10, я подумаю еще» на Kafka организовать уже сложно. Сложно сделать какой‑то топик, в котором письмо не будет доставляться до конкретного времени t.

  • Кастомный мониторинг содержимого очереди

С кастомным мониторингом тоже проблема. Мы хотим наблюдать за тем, что письмо покинет очередь гарантированно в течение какого‑то отрезка времени, что оно не осядет в очереди навечно.

В итоге Tarantool позволяет всё это сделать, потому что у него на брокерах есть возможность исполнения Lua‑кода. По сути, какую логику вы хотите написать, такую можете сделать под конкретную задачу. Kafka — это готовая реализация. Она хорошая, но со своими ограничениями по протоколу. А к Tarantool можно относиться как к фреймворку, на котором можно любую очередь сварить под свою задачу. В частности, у Монса была статья и несколько докладов. Если вам интересна тема распределенных очередей — отличный материал.

Следующий пойнт, неочевидный сразу.

2 типа данных ⇒ 2 СУБД

У нас в очереди есть два разных типа данных. У них разные паттерны доступа, и логично сделать две разных СУБД. Сейчас докажу. У нас есть:

  1. Файлы писем

  2. Положение письма в очереди

    Подробнее про Zepto: https://habr.com/ru/companies/oleg-bunin/articles/737502/ ; Tarantool: https://www.tarantool.io/ru/

Файлы писем — это довольно большие структуры: у нас стоят лимиты до 70 МБ на письмо. Положение в очереди — это буквально сотни байт: какой consumer сейчас ответственный за доставку письма, сколько это письмо уже лежит в очереди, какой у него ID — примерно такая структура — это положение в очереди.

Файлы писем мы записали один раз, потом почти не меняем. Мы можем добавлять/удалять заголовки в письмо, тела вообще не меняем. Положение в очереди может меняться довольно часто. Письмо может переезжать от одного почтового сервера к другому, и эти изменения будут отражаться в структуре положения очереди.

Файлов писем у нас много. Одно письмо — это 2−3 файла. Exim хранит заголовки и тела писем в отдельных файлах. А положение в очереди объединяет все эти структуры в единую сущность с уникальным ID.

В итоге, файлы писем мы решили сохранить в Zepto. Это наша in‑house технология. На одном из прошлых HighLoad про нее был доклад. Если интересно, можете почитать статью по мотивам доклада. Нам важно, что Zepto — это точно та же технология, которая используется у нас в итоге в storage. Получается, что мы имеем те же самые гарантии сохранности. А положение в очереди, как я уже сказал, мы сделали на Tarantool, написали свое Lua‑приложение, и из него мы ссылаемся по ID на файлы писем в Zepto.

Рассмотрим схему работы с брокерами распределенной очереди.

Брокеры распределённой очереди

У нас есть Exim и две СУБД (Tarantool и Zepto). Приходит пользователь с письмом в Exim, Exim его получает, и первое, что он делает, это записывает файлы этих писем в Zepto. В ответ он получает массив Zepto ID, объединяет их в единую сущность с ID письма и отправляет в индекс в Tarantool.

На этом этапе можно видеть, что конвертик переместился в БД. Мы считаем, что письмо легло в очередь. Как только письмо легло в очередь, Exim может отвечать юзеру 250 Ok и локальную копию удалять. То есть он завершил свою часть работы — он принял письмо.

Далее consumer вступает в дело. Первое, что он делает, он поллит из индекса новые письма и берет на них аренду. Аренда — это блокировка с известным TTL. Так мы гарантируем, что никто другой с этим письмом не будет ничего делать в течение какого-то известного времени. Как только Exim взял аренду, он скачал себе массив Zepto ID и по этим Zepto ID может выкачать файлы писем из Zepto.

Дальше происходят попытки доставки. Тут возможны два варианта:

1. Неудачная попытка

Мы попытались доставить получателю, у нас не получилось, временная ошибка. Что делать в этом случае? Удаляем локальную копию письма и отстукиваем в индекс, что мы снимаем аренду, нам это письмо больше не нужно, пусть кто-нибудь другой попозже попытается повторить.

2. Успешный вариант.

Мы также удаляем локальную копию письма, когда получатель его получил. В этом случае из индекса письмо уже можем совсем удалить. Получается, что в случае успешной доставки мы из очереди полностью удаляем письмо. Оно остается только в ящике у получателя.

Последнее небольшое усложнение к этой схеме. Роли producer и consumer на самом деле логические. Физически никто не мешает producer’у и consumer’у быть одним и тем же физическим процессом.

Мы только что рассмотрели серверную часть распределенной очереди. Давайте посмотрим клиентскую часть.

Клиент почтовой очереди, совместимый с Exim

Как нам заставить Exim работать со всей этой замечательной системой?

Решение в лоб — это написать свою клиентскую библиотеку, слинковать ее с Exim и делать библиотечные вызовы.

У этого решения есть одно главное преимущество — оно очень простое с точки зрения понимания. И целый ряд недостатков:

  • Дифф с апстримом

Во-первых, мы создаем дополнительный дифф апстримной версией Exim. Мы буквально недавно помержили где-то 6 лет диффа с апстримом, и очень не хочется повторять это. Это было очень больно.

  • Разработка зависит от Exim

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

  • Сложно держать коннекты

Я говорил, что почтовый сервер очень много форкается. Схема с библиотекой плохо ложится на эту модель многопоточности.

  • Сложные паттерны конкурентного доступа

С конкурентным доступом те же проблемы.

Рассмотрим альтернативный вариант.

Клиент: файловая система

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

Какие преимущества и недостатки есть у такого решения?

Недостатки:

  • Надо писать свою ФС, а это не самая частая экспертиза на рынке,

  • Системные вызовы дороже библиотечных.

Но и преимуществ тоже много:

  • Drop-in-замена реализации.

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

  • Независимый цикл разработки

По сути, нам нужно только знать, как файлы писем называются, и все.

  • Нет проблем с дочерними процессами Exim

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

Пишем свою ФС на FUSE и Tarantool

Если мы решили писать файловую систему, у нас есть два пути:

  1. Написать приложение в юзерспейсе на фреймворке FUSE,

  2. Написать свою кернельную реализацию, то есть модуль в ядре OS.

FUSE-вариант быстрее наладить и проще писать, поэтому мы остановились на нем, но чуть-чуть пожертвовав производительностью, ядро всегда будет быстрее.

Фреймворк FUSE — ФС в юзерспейсе

Как работает фреймворк FUSE? Вот так, по верхам:

Слева процесс — это клиент. Он отправляет системный вызов в ядро, попадает в подсистему VFS, а дальше FUSE вступает в дело.  FUSE — это модуль ядра и библиотека libfuse с драйвером файловой системы в юзерспейсе.

Драйвер файловой системы и FUSE модуль ядра общаются через коннект, там просто RPC API. При том RPC API у всех файловых систем одинаковое. Подсистема VFS гарантирует одинаковый интерфейс для всех файловых систем. По сути, чтобы написать файловую систему на FUSE, надо с libfuse слинковаться, и свою реализацию RPC API сделать.

Подробнее про мой опыт с FUSE.

У меня недавно выходила большая статья. Мы не успеем покрыть весь опыт с FUSE сейчас. Скажу только, что в целом это была занимательная история. Если вам интересна внутрянка, welcome.

Свою файловую систему мы тоже решили писать на Tarantool.  Звучит страшно, сейчас докажу, что на самом деле нет.

Tarantool как движок для С-приложений

Как это работает в принципе?

У нас бинарь Tarantool делает dofile на Lua-файлик, то есть он его исполняет, а под капотом Lua делает dlopen на shared либу, и в shared либе уже на C реализована вся наша логика по работе с libfuse.

Зачем здесь Tarantool? Дело в том, что он нам сильно сэкономил время на разработку.

  • Структуры для хранения данных

Во-первых, он предлагает структуры данных из коробки. Если бы мы хотели писать файловую систему совсем с нуля, первое, что нам нужно было бы решить, это вопрос хранения локальных данных. Мы бы озаботились индексацией, деревья крутили бы. А в Tarantool, пожалуйста: спейсы, индексы, все структуры данных готовы — бери, пользуйся.

  • Линеаризация — операции над данными последовательны

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

  • Можно писать фрагменты на ЯП высокого уровня — Lua

Кроме того, мы куски кода можем писать на языке высокого уровня. Необязательно это делать на  C.

  • Асинхронная модель многозадачности (файберы) из коробки,

  • Логи, метрики, демонизация из коробки.

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

Рассмотрим архитектуру файловой системы.

Архитектура MQFS: Tarantool + FUSE

Прошу прощения, сейчас будет сложно.

В центре наши структуры данных — файлы dentry и inode. Эти структуры данных находятся под капотом в принципе у любой файловой системы. Но в нашем случае они объединяются в единую структуру сообщения. Сообщение — это как минимум 2 файла письма (заголовки и тело), то есть это 2 inode и 2 dendry.

Вокруг этих структур данных у нас есть API, которые предоставляет Tarantool. Поверх них мы сделали свои обертки. Кстати, Tarantool у нас запущен с wal_mode='none'. Это значит, что он вообще не пишет данные на диск, и файловая система получается полностью stateless. Я говорил в начале, что мы берем письма в аренду, и получается, что если Tarantool крашнется, нам на самом деле не обязательно сохранять текущее состояние, письма просто вернутся в индекс по истечению аренды.

Вокруг внутренних API Tarantool мы сделали свои обертки. Curl — это HTTP клиент для нашей Zepto, box — это API Tarantool для работы непосредственно с данными. Мы ее обернули своим файликом, модулем tnt.c. Кто работал с «сишным» API Tarantool, знает, что там нужно много энкодить, декодить msgpack’ов. Этот модуль отвечает за сериализацию, десериализацию при общении с box. Вокруг net.box мы построили mqdi-client.lua, чтобы ходить в наши индексные Tarantool.

Вокруг наших внутренних API у нас строится модель данных, Data Access Layer. В парадигме DDD — это модель, набор тех юзкейсов, которые мы можем делать с нашими письмами. К этой модели можно получить доступ двумя способами:

1. Через файловую систему — тот самый RPC API сервер, о котором я говорил.

У нас крутится долгоживущая корутина в фоне. Она в неблокирующей манере поллит соединение с FUSE-модулем ядра, вычитывает запросы, дергает RPC API и сериализует ответ. При этом подавляющее большинство запросов у нас выполняются синхронно, потому что они неблокирующие — та самая линеаризуемость Tarantool из коробки.

2. Наше высокоуровневое API. 

Оно умеет сейчас буквально два метода: мы можем заинсертить письмо и удалить письмо. При этом под капотом этих методов мы работаем напрямую с box. То есть мы не делаем системных вызовов, чтобы не создавать обратных связей в духе «файловая система сама решила заинсертить письмо, поэтому сделала syscall сама к себе, и через FUSE и drv.c работает как-то с сообщениями». Это довольно сложно даже звучит, поэтому мы реализовали способ без системных вызовов работать с box напрямую с каким-то ограниченным набором операций. 

Этим высокоуровневым API у нас пользуются еще две долгоживущих корутины — это consumer, который берет письма в аренду, и deadline_keeper, который следит за тем, чтобы аренда не истекла.

В сборе система выглядит так — то, что мы хотели получить, и что получили в итоге:

Посередине две базы — Tarantool и Zepto. Работает со всем этим файловая система MQFS, которая стоит рядом с Exim’ами. То, что в середине — наша распределенная очередь. При этом от паттерна consumer-producer в итоге вообще отказались на данный момент. Мы вернулись к тому, что у нас было изначально — один Exim и пишет в очередь, и доставляет из очереди. То есть он получился «потоковым процессором». При этом MQFS, потому что wal_mode='none', и Exim, потому что письма теперь не привязаны к почтовому серверу, в сумме получились stateless. Их гораздо проще в облако задеплоить. Почтовая очередь у нас пошардирована — 60 репликасетов, то есть 60 мастеров, c фактором репликации x3.

Посмотрим, как система себя ведет на отказ.

Поведение при внештатных ситуациях в ЦОД

  • Zepto — автофейловер на шардах с отказавшим мастером

  • MQDI Tarantool — автофейловер на шардах с отказавшим мастером

В случае отказа Zepto и MQDI мастера сделают фейловер.

  • Exim — пользователи отваливаются по тайм-ауту, ретраят письма

Письма у нас получаются глобально в двух состояниях: 

  1. Либо мы не успели отдать юзеру 250 Ok, тогда юзер отваливается в случае отказа ЦОДа по тайм-ауту и просто поретраит свое письмо, 

  2. Либо мы отдали юзеру 250 Ok, и в этом случае у нас гарантированно письмо легло в очередь.

Поэтому Exim получаются отказоустойчивые. 

  • MQFS — аренда истекает, письма доставляются

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

Допустим, у нас сейчас всего 6 ЦОДов, поэтому в случае нарушения работы ЦОДа 1/6 нагрузки перераспределится между остальными. 

Закончили с разработкой. Буквально чуть-чуть про эксплуатацию.

Запускаем свою ФС в Kubernetes

Мы всю эту историю успели задеплоить в K8s. Самое интересное — это, конечно, файловая система в K8s.

Место файловой системы в K8s

Какое у файловой системы место в K8s? Это самое необычное относительно простого stateless-приложения.

Как это делают обычно? Если бы мы писали в K8s storage общего назначения, мы бы в pod'е, либо через манифест напрямую, либо через Storage Class, попытались бы себе получить объект Persistent Volume Claim, у которого под капотом DaemonSet’ом задеплоен Persistent Volume плагин, выдающий Volume Claim данного типа. С Volume плагином Kubelet по gRPC общается. У этого плагина в памяти есть структура данных Persistent Volume. И также плагин делает mount файловой системы, наконец-то до неё добрались, в какую-то директорию, а потом биндмаунтом подключает её в под.

В общем, всё очень сложно. 

Но нам не нужно было строить storage общего назначения, у нас задача проще — у нас написана файловая система, нам её в K8s надо запустить. 

Например, можно взять и запустить sidecar’ом. Мы в итоге остановились на этом варианте. У нас общий volume у двух контейнеров в поде, в который мы монтируем файловую систему. Работает. Если вам не нужен storage общего назначения, можно сэкономить на разработке Persistent Volume плагина.

Как смонтировать FUSE в K8s

Тоже интересный вопрос, потому что mount — syscall привилегированный. 

Какие проблемы есть?

  • Нужен открытый seccomp-профиль 

Решение

Он открыт по умолчанию, если не закрывали, то будет работать.

  • Нужен доступ к девайсу /dev/fuse на ноде, чтобы установить соединение с FUSE-модулем ядра. 

Решение

Есть два варианта: Hostpath volume или K8s device plugin. 

K8s device plugin считается более секьюрным. На GitHub есть несколько опенсорсных реализаций, можете взять любую. Мы пробовали оба способа, оба работают. Но в итоге на device plugin остановились.

  • Нужен capability CAP_SYS_ADMIN

Тут гром среди ясного неба должен раздаться, потому что ни одна ИБ такое не пропустит. CAP_SYS_ADMIN — это, по сути, рутовые права. 

Решение:

Тут мы применили волшебную команду: unshare -U -m -r #cmd

Что это такое? Мы создаем namespace, который ограничивает область видимости нашей файловой системы. Ядро говорит: «Ну, ладно, если твой mountpoint никто не увидит, ты сам себя ограничил, тогда можешь монтировать все, что хочешь, у тебя внутри namespace есть CAP_SYS_ADMIN». Получается, в непривилегированном контексте мы смогли исполнить привилегированный syscall.

Мониторинг системы

Буквально капельку про мониторинг, самое кастомное, за чем мы следим. 

Latency на FUSE запросы

Тут видно, что подавляющее большинство запросов выполняются в пределах 100 µs, потому что они неблокирующие. Когда мы только планировали к запуску систему, звучали мнения, что FUSE очень медленный, запросы могут занимать секунды, но FUSE может быть достаточно быстрым, чтобы экспериментировать в проде.

В топе два запроса, которые сотни миллисекунд занимают: FLUSH ходит в сеть несколько раз, FORGET не ходит, но FORGET асинхронный, поэтому он может выполняться сколько угодно по времени. Его направляет к нам не пользователь, а ядро. То есть пользователь ничего не видит от долгих FORGET.

Ошибки

У нас нулевая толерантность к ошибкам, кроме двух, которые мы добавили в исключение. Это на самом деле не ошибки, их может быть сколько угодно.

  1. LOOKUP.
    Ответ: No such file or directory.

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

  1. SETLK
    Ответ: Resource temporarily unavailable

Здесь то же самое примерно — юзер просит, а поставь мне, пожалуйста, блокировку на файл. ФС отвечает, что нет, он заблокирован другим процессом, приходи позже. Тоже не ошибка.

Мониторинг, сколько письма у нас лежат в очереди

Это наша самая кастомная метрика. Обращу внимание, график с 99% начинается.

Когда письмо ложится в очередь, мы у него засекаем timestamp, округляем до минут и триггерами считаем, сколько сейчас в очереди писем, которые легли в эту минуту. Получается массив счетчиков, которые мы и инкрементим, и декрементим, и они в итоге падают в ноль. Этот массив счетчиков мы пуляем в Prometheus и строим распределение, а сколько у нас сейчас в очереди писем, которые легли за последние пять минут или за последний час. Если письма лежат дольше двух часов, мы мониторим это и case-by-case разбираемся.

Заключение

  • Изоляция Exim от слоя хранения позволила повысить отказоустойчивость и пережить выключение одного ЦОДа без простоя, копии данных хранятся в разных ЦОДах.

  • Файловая система — слой абстракции между приложением и дисками. На этом слое можно реализовывать свою бизнес-логику.

  • Tarantool мы использовали в двух ролях — как in-memory СУБД и как сервер приложений на С/Lua. В обоих ролях нам было хорошо, он решает наши задачи.

  • FUSE позволяет ускорить разработку файловых систем, с ним можно экспериментировать в проде.

  • Unshare позволяет выполнять привилегированные системные вызовы в непривилегированном контексте (изолированной среде Linux namespaces).

Ссылки для изучения

Комментарии (0)