Через год после закрытия проекта я открыл его код и устроил себе code review. Это был автомобильный маркетплейс объявлений, который я в одиночку спроектировал, написал, задеплоил и сопровождал: React‑фронтенд, три бэкенда на NestJS, PostgreSQL, чат на Socket.IO, интеграция платежей, Kubernetes в GKE, CI/CD, CronJob'ы и E2E.

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

Коротко — 8 находок:

  1. Два корректных pod'а ломают реалтайм: без Redis-адаптера Socket.IO сообщения теряются между репликами.

  2. Guard на каждом событии — это аутентификация, а не авторизация: любой залогиненный мог читать чужой чат по UUID.

  3. Транзакция не защищает баланс: «прочитал → посчитал → записал» даёт двойное списание (lost update).

  4. Webhook был идемпотентным только на бумаге: проверка статуса вне атомарного обновления.

  5. Видимость объявлений зависела от ночного CronJob, а не от даты при чтении.

  6. CronJob не гарантирует «ровно один раз»: нужны concurrencyPolicy: Forbid и идемпотентные задачи.

  7. Тег :latest + rollout restart = деплой, который нельзя откатить.

  8. Границы роутов не совпали с границами состояния: 22 сущности в глобальном Redux.

Дальше — разбор каждой с кодом и исправлением, плюс что сработало и что бы я сделал иначе.

Сразу о рамках. Реальной нагрузки у проекта не было: он прошёл софт‑ланч, а потом я его закрыл, потому что коммерческого интереса оказалось недостаточно. Платёжные функции были реализованы, но для пользователей так и не включены. Kubernetes по нагрузке был не нужен. Часть описанных ошибок не проявилась именно потому, что трафик был маленьким. Поэтому здесь не будет RPS и историй про масштаб. Будет разбор собственных решений: почему каждое казалось разумным, что с ним не так и как я сделал бы сейчас.

Система

Пять независимо деплоящихся единиц и три базы PostgreSQL:

Сервис

Стек

Профиль работы

База

Публичный фронтенд

React, TypeScript, CRA, nginx

SPA

—

Фронтенд админки

React

Внутренний инструмент

—

Основной API

NestJS, TypeORM

Stateless REST, публичный трафик

своя

API админки

NestJS, TypeORM

Операторы, роли, аудит

своя + доступ к основной

Чат

NestJS, Socket.IO

Долгоживущие соединения

своя

Каждый сервис — отдельный Helm‑чарт в своём namespace в GKE, ingress на Traefik, HPA на каждом деплойменте. Образы собирает kaniko в GitLab CI, доступ пайплайнов к кластеру — через GitLab Agent. Фоновые задачи — три CronJob, по одному на бэкенд.

Почему не модульный монолит, если нагрузки не было? Я делил систему не по доменам, а по профилю работы: короткие stateless‑запросы публичного API, внутренний инструмент с правом менять балансы (его не хотелось держать в одном процессе и за одним ingress с публичным API), долгоживущие соединения чата.

Граница при этом протекла. API админки работал не только со своей базой, но и напрямую с основной: два писателя, одна схема, сущности TypeORM в двух репозиториях, сверяемые вручную. Разделение по деплою и безопасности я получил, по данным — нет. Гонки между менеджером и пользователем исключались организационно (баланс менялся только по обращению клиента) — это гарантия процесса, а не базы.

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

Дальше — восемь находок из code review. Формат у всех одинаковый: решение, почему оно казалось разумным, что с ним не так, как я сделал бы сейчас.

1. Два корректных pod'а ломают реалтайм

Сначала о двух решениях в чате, которые я бы сохранил. Чат не ходит в основной API: у него своя база с проекцией пользователя, которая лениво создаётся из claims JWT, и свои списки блокировок. Пока у пользователя валидный токен, чат работает, даже если основной API недоступен. И сообщение сначала сохраняется, потом рассылается:

@SubscribeMessage(events.MESSAGE_ADD)
async handleNewMessage(@UserWS() user, @MessageBody() data, @ConnectedSocket() client) {
  const { chat_uuid, newMessage } = await this.messageService.addMessage(user, data);
  client.to(chat_uuid).emit(events.MESSAGES_NEW_MESSAGE, { chat_uuid, newMessage });
  return { event: events.MESSAGES_NEW_MESSAGE, data: { chat_uuid, newMessage } };
}

База — источник истины, сокет — только ускорение доставки. Если emit не дошёл, сообщение появится при следующей загрузке диалога.

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

Токен клиент передавал в заголовке:

io(websocketApiURL, {
  transports: ['polling', 'websocket'],
  withCredentials: true,
  extraHeaders: { Authorization: `Token ${getAuthToken(authToken)}` },
})

В браузере extraHeaders применяются только к HTTP long‑polling: WebSocket API не позволяет задать собственные заголовки. Поэтому соединение обязано начинаться с polling — сервер проверял токен на handshake, после чего транспорт апгрейдился до WebSocket.

Polling в Socket.IO — серия HTTP‑запросов, которые должны попадать на один и тот же процесс. При нескольких репликах это требует sticky‑сессий, и они были настроены на уровне Traefik:

annotations:
  traefik.ingress.kubernetes.io/service.sticky.cookie: "true"
  traefik.ingress.kubernetes.io/service.sticky.cookie.name: server_id

В проде у чата было две реплики:

autoscaling:
  minReplicas: 2
  maxReplicas: 10

А комнаты Socket.IO по умолчанию живут в памяти процесса, и адаптера для обмена событиями между процессами (Redis или другого) не было.

Итог: покупатель подключён к pod'у A, продавец — к pod'у B. client.to(chat_uuid).emit(...) на pod'е A рассылает только своим сокетам. Сообщение сохранено, но в реальном времени до продавца не доходит и появится, только когда он заново откроет диалог. Ни ошибки, ни алерта, ни потерянных данных.

Во время софт‑ланча этого никто не заметил: локально всегда один процесс, а трафика, при котором собеседники надолго оказываются на разных pod'ах, не было. Нашёл я это чтением main.ts, где не оказалось useWebSocketAdapter. Sticky‑сессии создавали ощущение, что «с репликами всё учтено», но они закрывают только требование polling‑транспорта и не связаны с доставкой между процессами.

Сейчас я сделал бы так. Токен передаётся в auth handshake'а — это работает и для чистого WebSocket:

// клиент
io(websocketApiURL, { transports: ['websocket'], auth: { token: getAuthToken(authToken) } });

// сервер
handleConnection(socket: Socket) {
  socket.data.user = verify(socket.handshake.auth?.token, JWT_SECRET).data;
}

Межпроцессная доставка — через Redis‑адаптер:

export class RedisIoAdapter extends IoAdapter {
  private adapterConstructor: ReturnType<typeof createAdapter>;

  async connect() {
    const pub = createClient({ url: process.env.REDIS_URL });
    const sub = pub.duplicate();
    await Promise.all([pub.connect(), sub.connect()]);
    this.adapterConstructor = createAdapter(pub, sub);
  }

  createIOServer(port: number, options?: ServerOptions) {
    const server = super.createIOServer(port, options);
    server.adapter(this.adapterConstructor);
    return server;
  }
}

// main.ts
const adapter = new RedisIoAdapter(app);
await adapter.connect();
app.useWebSocketAdapter(adapter);

Если отказаться от polling, sticky‑сессии больше не нужны. Если polling оставить как fallback, они нужны и с адаптером: адаптер решает доставку событий, а не маршрутизацию HTTP‑запросов handshake'а.

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

2. Guard на каждом событии — это ещё не авторизация

Каждый обработчик чата был обёрнут так же, как REST‑эндпоинт в NestJS: guard, pipe для валидации DTO, filter для логирования. Ощущение защищённости было полным. Но guard проверял только, что пользователь залогинен:

@SubscribeMessage(events.CHAT_SUBSCRIBE)
async handleSubscribeChat(@UserWS() authUser, @MessageBody() data, @ConnectedSocket() client) {
  client.join(data.chat_uuid); // authUser не используется
}

async getMessages(data: GetMessagePayload) {
  // пользователь в метод не передаётся
  return this.messageRepository.find({ where: { chat: { uuid: data.chat_uuid } }, ... });
}

Любой залогиненный пользователь, знающий chat_uuid, мог подписаться на чужую комнату и прочитать историю. В addMessage собеседник вычислялся как «участник чата, который не я», но принадлежность самого отправителя к чату не проверялась. readMessages помечал сообщение прочитанным по последовательному числовому id без проверки владельца.

Частично это прикрывалось тем, что chat_uuid — UUID v4. Но это защита через неизвестность: UUID попадает в URL, логи и ответы API. Проект закрыт, поэтому показываю как есть.

Исправление — одна проверка, которую вызывает каждый обработчик:

async assertParticipant(chatUuid: string, userId: number): Promise<ChatEntity> {
  const chat = await this.chatRepository
    .createQueryBuilder('chat')
    .innerJoin('chat.users', 'u', 'u.id = :userId', { userId })
    .where('chat.uuid = :chatUuid', { chatUuid })
    .getOne();

  if (!chat) throw new WsException(exceptions.NOT_ALLOWED);
  return chat;
}

Вывод: единообразная обвязка вокруг обработчиков отвечает на вопрос «кто ты». Вопрос «что тебе можно с этим ресурсом» — отдельная проверка в каждом обработчике, и без второго человека на ревью её легко пропустить везде сразу.

3. Транзакция, которая не защищает баланс

Контекст: монетизация была реализована полностью, но на софт‑ланче выключена флагами окружения (env.paymentsEnabled и другие убирали роуты). Оплата — через внутренний кошелёк: пополнение через LiqPay, покупка услуг с баланса. Поднятие объявления — временная метка top_at, закрепление и топ‑каталог — членство в группе плюс запись с expire_at.

Списание было устроено так:

if (PRICE > Number(car.user.balance)) throw new HttpException(NOT_ENOUGH_BALANCE, 422);

serviceRecord.user.balance = Number(serviceRecord.user.balance) - Number(PRICE);
car.top_at = new Date();

await getManager().transaction(async (m) => {
  await m.save(serviceRecord); // cascade сохраняет и user.balance
  await m.save(car);
});

Выглядит аккуратно: запись услуги, баланс и объявление сохраняются в одной транзакции. Но баланс прочитан до транзакции, новое значение посчитано в JS, а записывается абсолютное число. Транзакция гарантирует атомарность записи, но ничего не знает о том, что было прочитано до неё. Классический lost update:

  • два параллельных запроса на покупку видят баланс 100, оба проходят проверку и оба записывают 40 — две услуги за одно списание;

  • покупка, идущая одновременно с зачислением пополнения, записывает «старый баланс минус цена» поверх зачисленного депозита;

  • ручная корректировка из API админки пишет в ту же колонку по той же схеме.

Исправление — условный атомарный UPDATE внутри транзакции:

await dataSource.transaction(async (m) => {
  const res = await m.createQueryBuilder()
    .update(UserEntity)
    .set({ balance: () => 'balance - :price' })
    .where('id = :id AND balance >= :price')
    .setParameters({ id: user.id, price: PRICE })
    .execute();

  if (res.affected === 0) throw new HttpException(NOT_ENOUGH_BALANCE, 422);

  await m.insert(PayServiceEntity, { type, reason, price: PRICE, user: { id: user.id } });
  await m.update(CarEntity, car.id, { top_at: () => 'now()' });
});

В PostgreSQL второй конкурентный UPDATE той же строки ждёт блокировку, а после коммита первого перепроверяет условие уже на новой версии строки. Проверка и списание становятся одной операцией. Альтернатива — SELECT ... FOR UPDATE в той же транзакции.

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

4. Webhook, который я считал идемпотентным

Пополнение создаёт транзакцию в статусе PENDING с собственным UUID. Webhook проверяет подпись (sha1(secret + data + secret)), сверяет сумму и валюту и принимает только транзакцию в статусе PENDING:

if (existedTransaction.status !== TransactionStatus.PENDING)
  throw new HttpException(exceptions.TRANSACTION_IS_NOT_FOUND, HttpStatus.CREATED);

existedTransaction.status = data.status;
existedTransaction.user.balance = Number(existedTransaction.user.balance) + Number(data.amount);
await this.transactionRepository.save(existedTransaction);

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

Для конкурентной доставки защиты нет. Две доставки одновременно читают PENDING и обе проходят проверку. Иронично, что lost update из предыдущего раздела здесь частично маскирует проблему: обе доставки читают один и тот же баланс и записывают одно и то же значение, так что двойного зачисления, скорее всего, не будет. Но если пополнение несло с собой услугу, обе доставки затем пойдут её применять, а это снова гонка из раздела 3. Корректность держится на совпадении, а не на гарантии.

Настоящая защита — условный переход статуса, и зачисление в той же транзакции:

await dataSource.transaction(async (m) => {
  const res = await m.createQueryBuilder()
    .update(TransactionEntity)
    .set({ status: TransactionStatus.SUCCESS })
    .where('uuid = :uuid AND status = :pending', { uuid, pending: TransactionStatus.PENDING })
    .execute();

  if (res.affected === 0) return; // уже обработано другой доставкой

  await m.createQueryBuilder()
    .update(UserEntity)
    .set({ balance: () => 'balance + :amount' })
    .where('id = :userId')
    .setParameters({ amount, userId })
    .execute();

  if (serviceProperties) {
    // durable-запись о том, что услугу ещё нужно применить
    await m.insert(PendingOperationEntity, {
      type: 'APPLY_SERVICE',
      transactionUuid: uuid,
      payload: serviceProperties,
      status: 'PENDING',
    });
  }
});

Но атомарного перехода статуса недостаточно, если применение услуги — отдельный side effect после коммита. Транзакция PENDING → SUCCESS закоммитилась, баланс зачислен, процесс падает до применения услуги, провайдер повторяет webhook — а повторный запрос уже видит SUCCESS и ничего не делает. Деньги на балансе, услуги нет. Поэтому в той же транзакции, где меняется статус и зачисляется баланс, создаётся запись о необходимости применить услугу. Отдельный обработчик забирает такие записи и применяет услугу идемпотентно (ключ — UUID транзакции), отмечая запись выполненной. Если процесс упадёт после коммита, операция не потеряется, а повторный webhook по‑прежнему ничего не зачислит повторно.

В общем виде это transactional outbox. Для проекта такого размера полноценный outbox с брокером избыточен, и простой таблицы отложенных операций, которую разбирает тот же CronJob или фоновый воркер, достаточно — идея та же.

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

Вывод: проверка «если статус X, то перейти в Y» идемпотентна только тогда, когда проверка и переход — одна атомарная операция. А side effect после коммита должен быть durable, иначе между коммитом и его выполнением остаётся окно, в котором операция теряется.

5. Корректность продукта, которая жила в расписании

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

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

Сейчас проверка была бы при чтении (имена связей здесь условные):

qb.innerJoin('car.topSearch', 'ts', 'ts.expire_at > now()');

CronJob остаётся, но только для уборки: если он не отработает, данные будут грязнее, но выдача останется правильной.

Вывод: если задача по расписанию влияет на то, что видит пользователь, это уже не инфраструктура, а часть бизнес‑логики, и её отказ — продуктовый баг.

6. CronJob не гарантирует выполнение ровно один раз

Фоновые задачи (истечение услуг, архивация объявлений, чистка кодов подтверждения, временных файлов, старых сообщений) сначала планировались через node-cron, потом переехали в отдельный образ, который запускается как CronJob. Это закрыло очевидную ловушку: с node-cron внутри API и двумя репликами каждая задача выполнялась бы дважды.

Манифест выглядел так:

kind: CronJob
spec:
  schedule: "00 03 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: car-b
              command: ['npm', 'run', 'manager-cars:run']
          restartPolicy: OnFailure

concurrencyPolicy не задан (по умолчанию Allow), restartPolicy: OnFailure перезапускает упавший контейнер, а у Job есть backoffLimit (по умолчанию 6). Kubernetes CronJob не гарантирует выполнение ровно один раз: запуск может повториться после частичного выполнения, пересечься со следующим запуском или при определённых условиях быть пропущен. Поэтому задача должна быть безопасной при повторном и частичном выполнении, а там, где это возможно, идемпотентной.

Пример задачи, которая оказалась корректной почти случайно:

for (let start = 0; start < countChats; start += chatsPerInterval) {
  const page = await repo.find({
    where: { created_at: LessThan(daysAgo) },
    relations: ['users', 'messages'],
    skip: start,
    take: chatsPerInterval,
  });
  // удаляем часть строк этой страницы...
}

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

Для объёма этого проекта обе задачи заменил бы один идемпотентный запрос:

DELETE FROM message_entity WHERE created_at < now() - interval '30 days';

На большой таблице я бы удалял записи пакетами через ограниченную выборку id, чтобы не держать одну длинную транзакцию:

WITH batch AS (
  SELECT id
  FROM message_entity
  WHERE created_at < now() - interval '30 days'
  ORDER BY id
  LIMIT 1000
)
DELETE FROM message_entity
WHERE id IN (SELECT id FROM batch);

А в манифест я добавил бы:

spec:
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  jobTemplate:
    spec:
      backoffLimit: 2

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

7. Хороший код, который нельзя откатить

Миграции были устроены правильно. Раз в основную базу писали два сервиса, действовало правило «только расширение»: новые колонки nullable или с default, удаление и переименование — отдельным релизом.

public async up(queryRunner: QueryRunner): Promise<void> {
  await queryRunner.query(
    `ALTER TABLE "car_entity" ADD "top_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
  );
  await queryRunner.query(`CREATE INDEX "IDX_car_top_at" ON "car_entity" ("top_at")`);
}

API админки, ещё не знающий о top_at, продолжает создавать и читать объявления. Это первая половина expand/contract, и она же делает возможным откат кода без отката схемы.

Сам откат кода при этом был невозможен. Образы собирались без версионного тега (:latest или без тега), деплой — kubectl rollout restart с imagePullPolicy: Always. Это перезапуск на тот же тег: новый ReplicaSet отличается от старого только аннотацией времени рестарта, поэтому kubectl rollout undo возвращает тот же тег и снова тянет последний образ. Вернуть предыдущую версию можно было только пересборкой.

Причина у этого решения была практическая: образы большие, и хранить по образу на каждый коммит не хотелось. Но размер реестра решается не отказом от версий, а политикой очистки: в GitLab Container Registry cleanup policy оставляет, например, последние 5–10 тегов и удаляет остальные. К тому же реестр хранит слои, а не образы целиком: если зависимости лежат в Dockerfile отдельным слоем до кода, новый тег добавляет только изменившиеся слои. Для отката нужна не вся история, а несколько последних версий.

Вторая деталь — стратегия выкатки. В процессе релиза я сознательно отказался от rolling update: не хотел, чтобы старая и новая версии обслуживали трафик одновременно, и выкатывался с плановым простоем — фронт в режим обслуживания, миграции, перезапуск, фронт обратно. Но это решение жило в процессе, а не в манифестах: стратегия в Deployment не задана, значит, сам Kubernetes по‑прежнему делал RollingUpdate. От рассинхрона версий защищал режим обслуживания на фронте, а не кластер, и сокет‑клиенты, подключающиеся к чату напрямую, этой защитой не покрывались.

Ещё в манифестах нашлось:

  • нет readiness‑проб: pod попадает в endpoints сразу после старта контейнера, ещё до того, как приложение начало слушать порт; нет и liveness‑проб, так что зависший процесс никто не перезапустит;

  • нет resources.requests: HPA считает утилизацию относительно requests, и работал он только потому, что GKE Autopilot проставляет requests по умолчанию.

containers:
  - name: car-b
    image: registry.gitlab.com/.../api:{{ .Values.image.tag }}  # $CI_COMMIT_SHORT_SHA
    readinessProbe:
      httpGet: { path: /health, port: 3333 }
      periodSeconds: 5
    resources:
      requests: { cpu: 250m, memory: 256Mi }
      limits: { memory: 512Mi }

Выкатка — helm upgrade --set image.tag=$CI_COMMIT_SHORT_SHA, откат — helm rollback.

Вывод: обратимость — свойство всей цепочки «схема → образ → выкатка». Миграции у меня были обратимы, а цепочка в целом — нет.

8. Границы роутов не совпали с границами состояния

Фронтенд — SPA на React и TypeScript с Redux Toolkit, redux‑saga и redux-injectors. Каждая из 22 сущностей (Car, Search, Favorites, MyCars, Payments, ActiveChat и другие) — модуль со slice, saga, селекторами и хуком внедрения:

const useInjectCar = () => {
  useInjectReducer({ key: sliceKey, reducer });
  useInjectSaga({ key: sliceKey, saga });
};

Идея — регистрировать редьюсеры и саги только на роутах, которые их используют. В маркетплейсе сущности оказались сквозными: модалка чата на каждой странице, авторизация везде, сущность авто — в карточке, редактировании и кабинете. У каждой страницы появился список зависимостей, который приходилось синхронизировать. Забыл одну — страница ломается, причём только при прямом заходе по ссылке.

Все внедрения переехали в корень:

const App: React.FC = () => {
  useInjectEntities(); // все 22 сущности
  ...
};

Модульная структура осталась, ленивая загрузка — нет. Все саги‑наблюдатели работают на любой странице, а код состояния попадает в главный бандл, хотя сами страницы грузятся через React.lazy.

Корень проблемы — в модели данных. Большая часть этого «состояния» была серверными данными: загрузка, кеширование, инвалидация, повторные запросы. Сегодня я отдал бы это TanStack Query: компонент сам объявляет, какие данные ему нужны, кеш общий, и вопрос «на каком роуте зарегистрировать модуль» исчезает. Redux остался бы только для действительно клиентского состояния, если бы оно вообще понадобилось. Попутно ушла бы значительная часть шаблонного кода саг.

С SEO похоже: canonical, hreflang и около тридцати статических sitemap поддерживались вручную. Индексация работала, но сегодня я отдал бы это фреймворку (Next.js).

Вывод: паттерн разделения кода был правильным сам по себе, но граница, по которой он делил систему, не совпала с тем, как данные реально используются.

Что сработало

Кроме уже упомянутых (чат без синхронных зависимостей, сохранение до рассылки, миграции «только расширение»), я сохранил бы два решения.

OpenAPI здесь был не документацией, а контрактом на этапе компиляции. Клиенты для основного API и чата генерировались openapi-generator из спецификации и собирались в CI раньше приложения, поэтому часть несовместимых изменений бэкенда превращалась в ошибку TypeScript при сборке фронта.

Отдельные базы для production, development и E2E. Около 90 E2E‑тестов поднимали настоящее NestJS‑приложение и очищали все таблицы перед прогоном. Такой хелпер безопасен только потому, что структурно не может дотянуться до нужных данных. Чего не было — нагрузочного тестирования и сценария с двумя пользователями на разных репликах чата. Поэтому находку № 1 сделало чтение кода, а не тест.

Kubernetes: был ли он нужен

По нагрузке — нет, Docker Compose на виртуалке справился бы. Я выбирал Kubernetes как платформу, которой хотел уметь управлять. Он дал перезапуск упавших pod'ов, расписание на стороне платформы и одинаковый деплой пяти сервисов. Взамен потребовал сопровождения Helm‑чартов, раннера, агента и Traefik, а находка № 7 прямо следует из того, что платформу я настраивал сам и без ревью. Я выбрал бы его снова, но команде без той же цели не рекомендовал бы.

Что бы я сделал иначе

Решение

Чем обернулось

Что сделал бы сейчас

Токен в заголовке сокета, две реплики без адаптера

Сообщения не доходят между pod'ами

handshake.auth, только WebSocket, Redis‑адаптер

Guard на каждом событии чата

Аутентификация без авторизации на ресурс

Проверка участия в каждом обработчике

Баланс «прочитал → посчитал → записал»

Lost update

Условный атомарный UPDATE

Проверка PENDING в webhook

Идемпотентность только для последовательных доставок

Атомарный переход статуса + баланс + запись pending operation в одной транзакции

Видимость по группе + ночной CronJob

Корректность зависит от расписания

expire_at > now() при чтении

CronJob без concurrencyPolicy, пагинация при удалении

Корректность держалась на повторных запусках

Forbid, идемпотентный DELETE пакетами

:latest + rollout restart

Нет отката

Тег по SHA + cleanup policy реестра, helm upgrade / helm rollback, пробы

redux-injectors по роутам

Не совпало с границами данных

TanStack Query для серверного состояния

Общая база у API админки

Связность через схему

Админка как модуль основного API со своей авторизацией

Построить всё до проверки рынка

Инженерия выдержала, продукт — нет

Проверять спрос системой на порядок меньше

Вместо вывода

Когда отвечаешь за кусок системы, архитектуру легко оценивать по диаграмме. Когда сам её деплоишь, откатываешь, мигрируешь данные и разбираешь сбои, критерии меняются. Хорошее архитектурное решение — не то, которое правильно выглядит на схеме, а то, последствия которого ты понимаешь и умеешь контролировать.

Большинство найденных ошибок не были сложными. Просто рядом не было второго инженера, который спросил бы: «а что будет при двух pod'ах?» или «почему этот пользователь имеет право читать этот chat_uuid?». В команде эти вопросы задают на ревью. В одиночку их приходится задавать себе самому — лучше раньше, чем через год.

Короткое видео с обзором продукта: https://youtu.be/dFaDsj-0L7o

Об авторе. Я Виталий Фалькевич, Software Architect / Tech Lead. Проектирую и довожу до продакшена веб-системы — от фронтенда до инфраструктуры. Здесь делюсь архитектурными решениями и ошибками из собственной практики. Обсудить статью, архитектурное ревью или сотрудничество можно в LinkedIn.