PunchNLNG unveils AI tool to cut MRI scan timeThe Jerusalem PostWearable counter‑drone tech enters frontline service across US, Ukraine and IDF unitsBollywood HungamaMahakavya Shri Ramayan Katha producer Prakash Mahobiya alleges ‘negative marketing’; hints at big-budget Ramayana saying, “We never got a chance to reach our audience”ESPNTransfer rumors, news: Man United, Arsenal, Chelsea battle for Freiburg strikerSeeking AlphaVirTra secures five-year sole-source IDIQ contract with U.S. Customs and Border ProtectionNU.nlOnvrede in Japan groeit na arrestatie Amerikaanse militair voor moord op vrouwkickerIm Team von Pizarro, Ribéry und Draisaitl: Ex-Herthaner Demme spielt Icon LeagueInquirerSara Duterte: Mom prefers Baste for national politicsNU AchterklapHalle Berry ontkent wurggreep bij haar zoon: 'Zou mijn kind nooit pijn doen'CNN Türk"Fon" ödemesi hangi formülle olacak?UOLTelevangelista americano Jim Bakker, envolvido em escândalos de fraude e sexo, morre aos 86 anos20 MinutenXena (24): «Sie trauen mir als Frau den Chefposten nicht zu»
The Daily Newsstand · Free, Always
Tuesday, October 6, 2026

Один разработчик, пять сервисов, три базы: что я понял об архитектуре, когда пришлось самому всё деплоить и чинить

Translate

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

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

Сразу о рамках. Реальной нагрузки у проекта не было: он прошёл софт‑ланч, а потом я его закрыл, потому что коммерческого интереса оказалось недостаточно. Платёжные функции были реализованы, но для пользователей так и не включены. 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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.