Всем привет! После моей последней публикации на Хабре, у меня на сервисе произошёл Хабра-эффект и количество зарегистрированных пользователей у меня на каталоге уценённых товаров https://dns.ebulgakov.com/ увеличилось вдвое. Настало время задуматься об организации и масштабируемости архитектуры для поддержания увеличенного количества пользователей и упрощения внедрения новых функций. К тому же у меня в планах запустить ещё и полноценное мобильное приложение.

Основные критерии

  • Апгрейд архитектуры должен быть бесплатным в финансовом плане или почти бесплатным

  • Каждая её часть должна быть независима и легко заменяема 

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

  • Критически важные параметры производительности сервиса упасть не должны

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

  • Создание отдельного тестового контура для тестирования всей архитектуры

Выбор масштабируемости 

Расти вертикально - просто и весело. Заливай деньгами потребности сервера и, в целом, на среднесрочную дистанцию у тебя есть запас прочности. А в долгосрок ещё и не все сервисы доживают. В итоге, это путь конечен и даже самые мощные сервера имеют свою конечную конфигурацию, хотя и при виде цен на их аренду волосы начинают шевелиться на всём теле. Куда сложнее, но намного вернее расти горизонтально. В случае увеличения нагрузки, как сезонные распродажи или органический прирост - мы пропорционально увеличиваем количество серверов или наращиваем кластер базы данных. Это намного лучше, чем за месячную аренду сервера отдавать цену подержанной Лады Весты или при чрезмерно раздутой базе данных пытаться делать шардинг без гарантированного успеха.

Вот именно по причине нежелания вкладывать деньги в проект, который не приносит денег, было принято решение с самого начала расти горизонтально. В моём случае, я сразу знал, что буду распиливать функционал сервиса, хотя и не знал когда. К счастью, я озаботился выбором БД заранее, и в моём случае - это MongoDB, которая растет горизонтально почти автоматически, потихоньку отжирая место на диске. Мои три ReplicaSet не дадут соврать. 

Вычленям сущности

Конечная архитектура на текущий момент
Конечная архитектура на текущий момент
  • Сам production api-сервер на который мы шлём запросы

  • Тестовый api-сервер. Точная копия production, но с доступом к тестовым БД

  • Cache-сервер, который будет кешировать ответы с БД 

  • DB-сервер, лучше кластер

  • CDN, чтобы отдавал быстро статику. Лучше ближе к конечному пользователю

  • Proxy 1 - проксируем забаненный web-server в РФ

  • Proxy 2 - проксируем забаненный auth-server в РФ

  • Load Balancer 1 (в планах) - прикрываем api-server, чтобы он голым интерфейсом наружу не высовывался и для ротации

Считаем денежки

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

Сразу скажу, что стоимость VDS сейчас настолько до неприличия дешевая, что их в расчёт я просто не беру. Даже арендуя 2-3-4 сервера в месяц это нисколько не бьет по карману. Но этот вариант с VDS мы оставим, как плаг Г, когда бесплатных альтернатив не будет. 

На текущий момент я могу отказаться от Load Balancer 1. У меня в ротации только один сервер и нагружен он примерно… на 1% :-) Я даже могу давать аптайм “пять девяток”.

Можно пока вычеркнуть Proxy 2. Надеюсь, что смогу нарулить всё DNS настройками, но пока в это слабо верю. Поэтому с вероятностью “пять девяток” мне точно нужно будет его делать.

Начинаем пилить

Сам Web-сервер облегчается по-максимуму. Оставляем только отображение данных полученных от API, и средства для идентификации пользователя.

Самое простое на начальном этапе - это вынести общение с базой данных в отдельный сервер. Общение с БД будем осуществлять через API-интерфейс этого сервера. Для меня это важный довод. Если на сервисе, где хостится API уберут хоббийный план, то я просто перееду на следующий (да-да, это я про тебя, Heroku). Redis c DB будут жить теперь временно там. 

Следующи�� этапом выносим DB и Redis в отдельный сервисы с хоббиным планом. Теперь у нас сервере будет крутиться только API интерфейс. Это значит, что можно взять самый дешевый сервер или даже какую-нибудь хоббийную SaaS-платформу и хостить API уже там. 

Дальше нужно вынести сервер авторизации. На самом деле, можно было бы использовать тот же самый API сервер, но у меня уже есть Clerk Auth и было бы кощунством его не использовать.

Дальше нужно настроить CDN. Дергать изображения напрямую с dns-shop - отжирать у них трафик. Так ребята и обидеться могут, и обрежут мне доступ к ним по origin или по какому-нибудь другому способу. Поэтому буду хранить все картинки у себя на CDN и поближе к пользователю.

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

Аутентификация крупным планом

Вот ради этого стоило внедрять Clerk. Насколько же это приятно отдать на исполнение специальному сервису работу с JWT ключами, отзывами ключей, поддержанием жизни сессий и всеми прочими сложными, с точки зрения безопасности, процессами, которые большинство из нас очень не любят или не хотят заниматься. Также не нужно заботиться как и где хранить пароли пользователей. И вишенкой на торте - у них есть тестовая среда для хранения ��естовых пользователей. В связке с тестовым api-сервером и базой у меня получился полноценный тестовый контур.

Безопасность данных

На api-сервере сразу настроил CORS, чтобы только сайт и его зеркало смогли дергать с него данные. Хотя я нигде адрес api-сервера не показываю, но если кто-то любознательный всё-таки его найдет, то стащить данные у него будет не так просто. Фиговым листочком я тут постарался прикрыться.

Хотя я понимаю, что для людей, кто захочет стянуть данные никакой cors не остановит, поэтому я ещё передаю с запросом пользователя secret key. Учитывая, что все запросы передаются по backend, то пользователь никогда не увидит этого ключа. 

Итого у нас уже два фиговых листочка. Со стороны это уже даже похоже на какую-то безопасность. Есть ещё проблема с передачей такого ключа с мобильного приложения, где он будет лежать внутри приложения и каждый кто сможет это приложение разобрать - сможет получить прямой доступ к api. Возможно, есть способ с использованием Native SDK, чтобы не хранить ключик на телефоне. Либо на время жизни запуска приложения будет создаваться ключик, который будет перегенерироваться при каждом запуске приложения. Идей масса, приложения ещё нет, так что кажется, что третьей статье быть :-) 

Никому нельзя доверять

Наверное, при упоминании api не лишним будет напомнить, что мы никогда не доверяем тому, что к нам пришло через request. NoSQL-инъекциям столько же лет, сколько существует NoSQL базы данных. Даже если мы отправляем данные от пользователя по закрытому каналу от доверенного источника. Когда мы работаем с документоориентированной системой типа MongoDB, любой пришедший в API объект нужно деструктурировать до его примитивов, сверить каждый примитив с предполагаемым типом и пересобрать объект заново из этих свойств и примитивов и только потом уже класть в БД. К счастью, в современном мире есть zod и стало куда легче проверять объекты, но сама необходимость в этом не отпала. В моём случае это критично в многократном размере, так как код API лежит в открытом исходном коде. К счастью, в моём текущем решении у меня нет проблем с проверкой на инъекции при аутентификации, так как я использую Clerk.

Ещё один тип атаки - это IDOR. И никаких получений данных о пользователе через “/user/:id” если мы говорим про текущего пользователя. Даже если id - это hex. Даже если мы его никому не показываем. Только всё через JWT токен.

Держим руку на пульсе

У меня теперь зоопарк сервисов и серверов, которые живут в отрыве друг от друга. Чтобы хоть как-то понимать, что происходит - нужно снимать телеметрию и ошибки. На протяжении многих лет я использую Sentry для логирования ошибок. Для меня это это стандарт де-факто. Есть ещё Honeybadger, но лично мне он не нравится из-за более скудных данных приходящих с ошибкой.

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

Сложности с которыми я столкнулся 

Vercel не работает с Bun. В некоторых библиотеках включая Clerk backend обнаружилась циклическая зависимость, которая прекрасно разруливалась локально и на том же Vercel в окружении node, но которая рушила всё приложение в Bun. Надеюсь, в Версель это скоро починят, но пока последний открытый баг-репорт на эту тему был 10 дней назад. При переезде на Render.com таких неприятностей не обнаружилось. Ну и отвратительное логирование ошибок на Vercel. Ошибка в логах “Requested module is not instantiated yet” - и всё, делай с этим, что хочешь. Информацию про циклическую зависимость я узнал только из Sentry.

Ключи и секреты. Я реально ощущал себя волком из игры, где нужно было ловить яйца. В какой-то момент я уже не понимал на какой сервер какие ключи нужно закидывать. Сейчас у меня есть отдельный файлик с описанием где и какой ключ должен лежать. К тому же очень помогло создание env.ts файла, который при сборке тебя бьёт по рукам, если какую-то env-переменную забыл прикрепить. При своей относительной простоте экономит часы дебага в рантайме.  

Я с самого начала писал сервис так, чтобы его можно было бы не затратно по времени распилить, но даже у меня это вышло на более чем 20 энд-поинтов. Даже в этом случае, когда вы думаете, что у вас очень маленький pet-проект, возможно вы даже не представляете какой у вас уже здоровенный 25-ти конечный API-интерфейс. 

Заключение

Много работы было проведено. Не написаны ещё все unit-тесты для всех энд-поинтов. Есть ещё что покрывать в Playwright. Но это всё технический долг, который я ещё успею закрыть. 

Из плюсов, что на этом этапе у меня готов рабочий API и я могу расчехлить Expo и React Native, ��тобы начать писать первый прототип приложения. Работы будет много, вызовы предстоят действительно сложные, поэтому третья статья должна быть не менее интересная. 

Всем известно, что если в посте нет рекламы, то весь пост - реклама. Рекламирую я себя. Если вы хотите себе в команду такого Senior Frontend Engineer или Frontend Team Lead, то дайте знать в ЛС или любым другим способом с моего сайта https://ebulgakov.com Теперь закругляюсь. Всем пока!

Ссылки

Ссылка на репозиторий с Web-Server: https://github.com/ebulgakov/dns-markdown-next 

Ссылка на репозиторий с API-Server: https://github.com/ebulgakov/dns-markdown-api 

Разъяснение IDOR атаки: https://habr.com/ru/articles/848116/ 

Введение в NoSQL инъекции: https://habr.com/ru/companies/xakep/articles/143909/