Последние пару лет в свободное от Настоящей Работы время я в роли CTO/соло-бэкендера участвовал в создании Stry — фитнес-стартапа с подписной моделью. Теперь, когда наша команда официально объявила о прекращении дальнейшего развития проекта, пришло время порефлексировать и поделиться полученным опытом. В этой статье я в двух словах представлю продукт, детально опишу архитектуру проекта и расскажу о наших (моих?) основных технических успехах и неудачах. Поехали!
Дисклеймер: в этой статье не будет практически ничего про бизнес-составляющую проекта — только разработка, только хардкор. Возможно, когда-нибудь мы напишем про это отдельно, кто знает!
Коротко про продукт
Цель проекта — соединить людей, занимающихся фитнесом, с профессиональными тренерами со всего мира, прошедшими тщательный отбор. Клиент скачивает приложение, заполняет анкету, выбирает тренера, оформляет подписку — и получает доступ к чату с тренером, еженедельные программы тренировок, составляемые тренером вручную (!) под свои индивидуальные запросы, и интерфейс для прохождения тренировки с видео-демонстрацией упражнений, трекингом времени и так далее.
По итогу в сторах мы разместили два приложения: одно — приложение для клиентов, где можно выполнять тренировки, и второе — для тренеров, где эти тренировки можно составлять. Позже к ним присоединилось несколько вариантов веб-лендинга.

Стек и архитектура
Клиентское приложение (iOS, Swift) обращается напрямую в клиентский API-сервис (Python, FastAPI), тренерское (iOS/Android, React Native) — в тренерский API-сервис (тоже Python, FastAPI).
API-сервисы развёрнуты в managed Kubernetes-кластере (DigitalOcean), в качестве базы данных используется MongoDB (об этом решении — ниже), развёрнутая в этом же кластере вручную через StatefulSet (дёшево и сердито!). Там же развёрнут простенький сервис-воркер для выполнения асинхронных/отложенных задач, написанный вручную (чтобы не затягивать Celery и к нему какое-то дополнительное хранилище помимо MongoDB). Сервисы автоматически перевыкатываются на каждый зелёный коммит в мастер посредством Github Actions.
По мере развития проекта к этим четырём мастодонтам прибавилось несколько вспомогательных сервисов. Например, tusd для заливки больших файлов (фото и видео с тренировок), Jaeger для профилирования, ClickHouse для хранения событий, присылаемых платёжной системой Stripe — на всякий случай (сам Stripe хранит полные JSON-ы событий лишь некоторое непродолжительное время), Elasticsearch + Kibana для логов. Плюс ещё пара самописных сервисов для всяких разных нужд и Telegram-бот, который мы используем в качестве админки, алертера и б-г знает чего ещё.
Модных инструментов вроде Helm/Terraform/you name it для управления этим зоопарком завезено не было, потому что бесплатного девопса фиг найдёшь уровень сложности проекта и интенсивность разработки не такие, чтобы в этом была острая необходимость. Я спокойно написал все YAML-ы для Kubernetes ручками.

К этому всему — ещё с десяток интеграций со всякими разными внешними сервисами: вышеупомянутый Stripe для платежей, Agora для видеозвонков внутри приложения, PubNub для чата, Amplitude для аналитики, Appsflyer для атрибуции, DigitalOcean Spaces в качестве CDN и ещё по мелочи.
Всего в проекте вышло порядка 35 000 строк Python-кода бэкенда, примерно столько же Swift-кода клиентского приложения, ещё примерно столько же TypeScript-кода тренерского приложения, ну и ~3 000 строк YAML-ов для кубернетиса.
Что пошло по плану
Теперь, когда я сформулировал достаточно контекста, время перейти к, собственно, вынесенным из этого опыта урокам! Сначала я пройдусь по идеям, которые оказались удачными, а потом перейду к ошибкам и вовсе несделанным вещам, которые стоило бы сделать.
CI/CD
Когда стартуешь проект, соблазн закинуть свежую версию на сервер руками через scp или git pull и рестартануть по ssh очень велик. Но это, конечно, неудобно, и тем более неудобно, чем выше темп разработки.
Поэтому в первую очередь я сделал автотесты и автовыкатку в продакшн-окружение. Это было очень удобно, и за 500+ автовыкаток у меня не было ни одной проблемы с тем, что выкатилось что-то не то или выкатилось преждевременно.

Настройка CI/CD с использованием современных инструментов (Gitlab CI / Github Actions, Kubernetes) для нового проекта занимает час, если у вас набита рука, и рабочий день, если вы этого никогда не делали. Нет никаких причин не катать продакшн на каждый коммит, пока вы в фазе начальной разработки. После релиза на пользователей можно дополнительно потребовать зелёные тесты перед выкаткой. Более сложные релизные процессы — удел зрелых проектов с большим RPS или какой-то ещё спецификой, накладывающей требования на надёжность.
Очередь задач
Как я упомянул выше, поскольку не хотелось затягивать дополнительные инфраструктурные компоненты вроде RabbitMQ, которые к тому же никто в команде не умеет эксплуатировать, то с учётом небольшой планируемой нагрузки (сервис дорогой, следовательно, аудитория вряд ли будет хайлодовой) было принято решение самому написать простенький воркер, который будет поллить коллекцию в MongoDB и выполнять задачи по мере их поступления. Была предусмотрено, конечно, и горизонтальное масштабирование (оно ни разу не потребовалось).
В этом решении у меня было много сомнений и я постоянно чувствовал, что изобретаю велосипед. Но я решил, что я сделаю предельно простой велосипед и буду писать код так, чтобы его легко было мигрировать на любой другой движок, как только это потребуется (это тоже не потребовалось).

И что же вы думаете? В итоге очередь задач имени меня прекрасно проработала два года, никак не давая о себе знать! А благодаря самописности я смог залезть в неё и сделать очень крутую фичу для тестов — перематывание времени с выполнением асинхронных задач. Но об этом в следующем разделе.
Код воркера был так прост, что я могу привести его практически целииком:
class MongoTaskQueue(CoreDAO, Generic[TaskType]):
...
async def main_loop(self, worker_id: str):
""" Основной цикл воркера (упрощённо). """
while True:
op_id = f"{worker_id}{bson.ObjectId()}"
now = self._clock.now_utc()
now_timestamp = int(now.timestamp() * 1e9)
doc = await self._lock_and_fetch_doc(op_id, now_timestamp)
if doc is None:
await asyncio.sleep(0.05)
continue
task = self._deserialize_task(doc)
task_type = type(task).__name__
# тут выполнение таски и возвращение в очередь в случае ретрая
...
await self._delete_completed_task(op_id)
return
async def _lock_and_fetch_doc(self, op_id: str, now_timestamp: int) -> Any:
""" Находим документ, который ещё не взят в работу, и помечаем,
что взят. MongoDB делает это для нас атомарно. """
query = self._prepare_fetch_query(now_timestamp)
update = self._prepare_lock_update(op_id, now_timestamp)
doc = await self._async_collection.find_one_and_update(
query, update, sort=[("execution_ts_utc", ASCENDING)]
)
return doc
def _prepare_fetch_query(self, now_timestamp: int) -> Any:
timeout_ts = int(self.task_timeout.total_seconds() * 1e9)
return {
"$and": [
{"execution_ts_utc": {"$lte": now_timestamp}},
{
"$or": [
# Документ ещё не взят в работу:
{"locked_by": {"$exists": False}},
# или взят в работу слишком давно - считаем, что
# произошла ошибка и нужно ретраить:
{"locked_ts_utc": {"$lte": now_timestamp - timeout_ts}},
]
},
],
}
def _prepare_lock_update(self, op_id: str, now_timestamp: int) -> Any:
return {
"$set": {
"locked_by": op_id,
"locked_ts_utc": now_timestamp,
}
}
Тестовый фреймворк
Пока проект прототипируется, тесты не звучат как хорошая идея. Архитектура меняется на ходу, код пишется и переписывается, и поддержка качественной базы тестов рискует слишком сильно всё замедлить. Но полный отказ от тестов — это тоже крайность: вы вынуждены будете постоянно тестировать одни и те же сценарии руками. А с учётом автовыкатки (см. выше) «постоянно» — это буквально постоянно, каждый рабочий день. Поэтому необходимо в самом начале выработать подход к тестам, с которым вы пройдёте через фазу разработки прототипа и первые несколько итераций доработок.
В первую очередь я отказался от юнит-тестов. Если архитектура рискует поменяться, а компоненты регулярно переписываются, от юнит-тестов совсем мало пользы (за редким исключением).
То ли дело интеграционные тесты. Если мы делаем API-сервис для приложения, на уровне запросов и ответов стабильность получается гораздо выше (особенно если мы умеем хорошо проектировать API с первого раза). Поэтому я написал в своём коде на Python эмулятор клиента: методы этого класса делали запросы в FastAPI аналогично тем, какие бы делало настоящее приложение, и я мог описывать сценарии на высоком уровне абстракции. Лишь пара вспомогательных методов делала что-то, что не смогло бы сделать приложение (например, создавала тестового пользователя в базе данных).
class UserClient:
def __init__(self, users_app: App):
...
def authenticate_with_google(self, auth_code="auth_code", **kwargs) -> None:
# создаём пользователя с нужными кредами в базе данных
forge_google_user(auth_code=auth_code, **kwargs)
...
def get_profile(self) -> ResultWrapper[UserProfile]:
response = self.http_client.get("/user/profile")
return ResultWrapper(response, UserProfile)
def update_profile(self, update: UserProfileDiff) -> ResultWrapper[UserProfile]:
response = self.http_client.put(
"/user/profile", json=update.dict(exclude_unset=True)
)
return ResultWrapper(response, UserProfile)
def get_subscription_status(self) -> ResultWrapper[ClientSubscription]:
response = self.http_client.get("/subscription/status")
return ResultWrapper(response, ClientSubscription)
...
Для результатов этих методов я завёл враппер, позволяющий и получить pydantic-объект с проверкой кода ответа (для большинства тестов), и напрямую залезть в Response (для тестов ошибочных сценариев). Он позволил коду всех тестов оставаться в равной мере опрятным и читабельным.
class ResultWrapper(Generic[T]):
def __init__(self, response: httpx.Response, model: Type[T]):
self.response = response
self._model = model
@property
def json(self) -> Any:
return self.response.json()
@property
def object(self) -> T:
self.assert_ok()
return self._model.parse_raw(self.response.content)
def assert_ok(self):
assert (
self.response.status_code == status.HTTP_200_OK
), f"{self.response.status_code} {self.response.text}"
Ещё один нюанс — работа со временем и отложенными задачами. Одной из особенностей нашего проекта была значительная привязка к календарю. Тут и созвоны клиентов с тренерами, назначаемые на определённое время, после которого появляются всякие опции вроде оценить качество связи, и еженедельный цикл составления тренировок, и многое другое. Поэтому вместо традиционных для Python моков datetime я написал отдельный объект Clock, через который шла вся работа со временем и который в тестах обретал дополнительную функциональность «перематывания» времени — не просто замены системного времени на нужное мне для теста, но и выполнения всех отложенных задач, которые были запланированы на перематываемый временной интервал. Это оказалось ОЧЕНЬ удобно.
def test_...(self, users_app, trainers_app):
clock = get_mock_clock()
clock.init_now(dateutil.parser.isoparse("2015-10-21T07:28:00Z"))
# симулируем прохождение онбординга
...
# перемещаемся на неделю вперёд
clock.advance(timedelta(days=7))
# проверяем, что отправились напоминания о звонке с тренером
...

Работает ли такая перемотка времени быстро? Конечно, нет! Но в маленьком проекте лишние секунды на прогон тестов стоят гораздо меньше, чем лишние дни разработчика на поиск багов или сочинение многословных тестов без высокоуровневого инструментария.
Единый компонент для описания зависимостей
В кодовой базе я много использовал паттерн Observer, чтобы избежать токсичных зависимостей и циклических импортов. Одни менеджеры объявляли у себя события и триггерили их при необходимости, другие — подписывались на них. Всё как у людей!
@inject # inject - это метод DI-фреймворка
class SubscriptionsManager:
def __init__(self) -> None:
self.on_subscription_activation = Observable[SubscriptionEvent]()
self.on_subscription_deactivation = Observable[SubscriptionEvent]()
@inject
class CancelInactiveSubscriptionTaskQueue(TaskQueue):
def __init__(self,
subscription_manager: SubscriptionManager,
) -> None:
subscription_manager.on_subscription_activation.add_handler(
self.schedule_cancelling_inactive_subscription
)
def schedule_cancelling_inactive_subscription(event: SubscriptionEvent) -> None:
self.submit_task(...)
Вскоре я заметил, что происходящее в кодовой базе — в значительной мере загадка для меня. Так, при этом событии должно произойти такое последствие, но где объявлен обработчик?.. Сколько их вообще, согласованы ли они друг с другом? И отладка, и рефакторинги стали болью из-за этих размазанных по коду зависимостей.
Я пошёл на эксперимент: завёл единый компонент Flow, который связывал друг с другом все остальные компоненты.
@inject
class Flow:
def __init__(self,
cancel_inactive_sub_tq: CancelInactiveSubscriptionTaskQueue,
subscription_manager: SubscriptionManager,
) -> None:
@subscription_manager.on_subscription_activation.add_handler
def schedule_cancelling_inactive_subscription(event: SubscriptionEvent) -> None:
cancel_inactive_sub_tq.submit_task(...)
Для максимального удобства я отсортировал обработчики в том порядке, в каком они должны срабатывать впервые для среднестатистического пользователя, дал им очень подробные имена и добавил логирование на каждый вызов (это оказалось особенно удобно в тестах).
Я переживал, что это будет компонент-помойка, создающий больше проблем, чем пользы. И на проекте в миллион строк так бы и было, но на моих 35 000 получилось очень даже неплохо: весь компонент занимает ~500 строк (что, на мой взгляд, вполне контролируемо), легко читается и даёт очень хорошее представление и о пользовательском флоу, и о связях между компонентами.
Что могло быть лучше
Выбор базы данных
Мы выбрали MongoDB, потому что у нас с фаундером был обширный и свежий опыт работы с этой базой данных на нашем предыдущем месте работы и не было такового с реляционными базами. Но мы не учли, что на наших масштабах преимущества MongoDB (масштабируемость, простота миграций) не перевешивают её недостатки (в частности, отсутствие реляций и джоинов).
Возможность нормально задавать внешние ключи, гарантировать целостность, делать JOIN и GROUP BY очень сильно упростили бы мне разработку.
Фокус на ключевой функциональности
Знаете, бывает такое, когда реализуешь какую-то незначительную функциональность и особо не стараешься — экономишь время, силы и абстракции для более важных вещей. Это отличная тактика, пока незначительная функциональность действительно незначительная.
Но как определить, что важно? Когда работаешь в специализированной команде в крупной компании, представление о важности тех или иных фич лучше всего формируется в скоупе команды. Сложность работы других команд, а то и вовсе чем занимаются другие команды, может оставаться загадкой («зачем для X нужно держать в штате семь человек?!»). Поэтому при переключении на создание целого продукта с нуля ошибиться очень легко.
Когда я садился писать фитнес-приложение, я в первую очередь задумался о тренировках. Я сделал красивые абстракции для упражнений, тренировок, недельных расписаний и прочего, тщательно продумал расширяемое API. А вот что я счёл неважным, так это онбординг. Что там, анкета из несколько вопросов? Чего здесь заморачиваться, положу ответы прямо в сущность User. Но в процессе работы над продуктом оказалось, что онбординг — это одна из ключевых компонент, определяющая финансовый перфоманс приложения и являющаяся постоянным подопытным кроликом продактов и маркетологов.

Это же коснулось промокодов («да это ж просто строчка с флагом, использована она или нет») и системы подписок. В результате код ключевых компонент написан сумбурно и пребывает в постоянном ожидании рефакторинга, а тщательно вылизанный код консервативных компонент практически не подвергается изменениям.
Нейминг
Когда заходит речь про нейминг, многие думают о советах вроде «назовите переменную user_index вместо i». На деле нейминг переменных в конкретных функциях далеко не так важен, как нейминг сущностей и выработка общих соглашений уровня всей кодовой базы.
При старте проекта крайне важно определиться с неймингом. И важно это не только для разработчиков, но и для бизнеса тоже — эффективность коммуникации между членами команды напрямую зависит от того, говорят ли они на одном, ёмком и однозначном языке.
И с этим мы справились плохо. Пример. У нас два приложения и, соответственно, два класса пользователей. Клиенты именуются user, а тренера — trainer. Но при этом также user — это общая сущность уровня аутентификационного фреймворка. То есть тренеру тоже соответствует некий user, в котором сохранены способы аутентификации. Если бы мы задумались об этом заранее, мы могли бы назвать клиентов athlete и получить гораздо более понятный нейминг во многих частях кодовой базы.
Другая проблема коснулась именования состояний, в которых пребывает пользователь. Мы пропустили много моментов, когда надо было придумать термины бизнесового уровня, поэтому в какой-то момент тикеты на разработку стали огромным полотном перечислений типа «если пользователь прошёл первую половину экранов онбординга, не пропустил экран X и нажал кнопку Y, сделать Z». Полотно перечислений условий в тикете превращалось в полотно перечислений условий в коде. Вместо этого нам стоило продумать конечный автомат бизнесовых состояний пользователя, реализовать его в коде и привязывать поведение к нему.
Заключение
Хотя наша история стартапа не закончилась беспрецедентным успехом, миллионами долларов на счетах и корпоративами на Мальдивах, опыт создания продукта с нуля мне как разработчику был очень полезен и я рекомендую каждому при возможности попробовать сделать что-то своё. Причём не как пет-проект в одиночку, а именно как бизнес — с командой из других специалистов, релизами в сторах и хотя бы единицами живых пользователей.

Сидя в крупных компаниях на готовых процессах и пайплайнах, построенных бог знает кем бог знает когда, мы привыкаем к тому, что инфраструктура строится за нас. Мы можем получить представление о её недостатках, но легко упускаем достоинства, так как не знаем, какие проблемы были ею изначально решены. Мы рвёмся повторить то, к чему привыкли, на новых местах, часто не задумываясь, нужны ли нам аналогичные решения для других масштабов. И только опыт построения чего-либо с нуля может дать нам по-настоящему глубокое, системное представление о проблемах и решениях в нашей области инженерии.
Возвращаясь к названию статьи, $115 000 — это сумма всех инвестиций, что мы смогли поднять за время работы — от friends & family и венчурных инвестиционных фондов, и по совпадению также примерная зарплата, которую я мог бы получить, вложив затраченное время в работу по найму. Стоило того?
Автор: saluev