Все совпадения случайны, я всё придумал, никогда такого не было, и вот опять.
Однажды, морозным ноябрьским утром 2024 года, я, как новоиспечённый тимлид не такой уж и маленькой команды, поверил в себя и в то, что спустя полгода изучения проекта и передачи дел от предшественника я полностью понимаю, как там всё устроено. Судьба обычно наказывает за самоуверенность. Не стал исключением и мой случай.
Для понимания контекста добавлю, что я НЕ девопс — мне пришлось этим заниматься, и всё пишу через призму своего понимания проблемы. Возможно, сейчас придёт корифей AWS/CDK и скажет, что всё можно было сделать проще. Другими словами, не является индивидуальной инвестиционной рекомендацией.
Действующие лица
Мы — форекс/CFD-брокер. Ядро платформы:
-
GraphQL-монолит на Node.js — единственная точка входа для клиентского кабинета и CRM;
-
PostgreSQL — основная база (клиенты, платежи, KYC);
-
MySQL (Aurora), в девичестве “mtdb”, — reporting-база торговых серверов MetaTrader: счета, сделки, балансы. Живёт в приватной подсети VPC;
-
торговые серверы MT4/MT5, которые хостятся у внешнего провайдера — то есть вне нашего VPC;
-
вся инфраструктура описана в AWS CDK, и — важная деталь — VPC, сабнеты, security groups, балансировщики и обе базы жили в одном CloudFormation-стеке
core. Запомните это, оно выстрелит во втором акте.
У нас есть Reporting-плагин — он просто пишет в базу всё, что происходит в недрах MT4/MT5-сервера: сделки, открытие и закрытие позиций, балансы, кредиты, комментарии — вообще всё. Это полезно: многие аналитические данные нельзя достать запросом из API, не рискуя нарваться на N+1 и 429.
Есть на Пикабу старая история “Чужой код”, если не читали — сходите, это две минуты. Там прораб принимает чужой недострой на острове и находит комнату, доверху забитую швабрами, распёртыми между полом и потолком. Швабры, как потом выясняется, держат потолок с цистернами ядовитого газа, гигантский вентилятор стоит на случай утечки — сдуть газ с острова, а воздушный шар — чтобы улететь, когда не поможет и вентилятор. Были такие швабры и у нас: плагин Reporting подключается к базе через туннель на Windows (!!!) машине. Объяснение: база не публичная, слушает приватную подсеть; адрес MT-сервера уже не раз менялся, а добавить новый адрес в белый список доступа к базе ВСЕ БОЯЛИСЬ (как выяснилось — правильно делали). А сервер с виндой был в той же подсети одной ногой. К слову сказать, как убеждённый юниксоед, я даже не знал, что венда умеет в NAT, однако же вот, век живи — век учись.
Акт первый, в котором я смотрю страху в лицо
Я: пора взглянуть страху в лицо. Открываю infrastructure-проект на CDK, нахожу нужное место, добавляю адрес, изменяю флаг. “Хм, не такой уж ты и крутой, Бэтмен”, — говорю себе я голосом наркомана, избивающего монашку.
Первый подход был сделан размашисто: в конфиге стейджа переключили mtdb: public: true. Код стека на тот момент обрабатывал этот флаг незамысловато:
if (props.public) { db.connections.allowDefaultPortFromAnyIpv4()}
Да, это ровно то, что вы подумали: продовый MySQL с портом 3306, открытым на 0.0.0.0/0. Я залил на стейджинговый кластер — всё сработало.
Конечно, на прод я это деплоить не стал, сделал по уму: явная security group, ingress только для IP торгового сервера, опасная ветка закомментирована. Но в диффе остался артефакт, мимо которого я не могу пройти:
instanceProps: { // ... publiclyAccessible: props.public, // Must be set here to work}
“Must be set here to work”. Пять слов, за которыми — несколько часов чьей-то жизни и первая встреча с главным злодеем этой статьи. Я тогда, конечно, решил, что злодей — сам флаг. Ничего подобного: publiclyAccessible у Aurora спокойно меняется на живом инстансе, в CloudFormation это вообще “No interruption”. А вот то, на что этот флаг опирается, — subnet group — поменять у живого инстанса нельзя, только пересозданием. При этом публичному инстансу нужна subnet group, где все сабнеты публичные. Наш деплой тогда прошёл, потому что CloudFormation тихо заменил инстансы: смена раскладки по сабнетам — это replacement, дифф был маленький, звёзды сошлись, никто ничего не заметил. Мы записали это себе как “ну, работает же”. А subnet group кластера после того деплоя осталась собранной из смеси публичных и приватных сабнетов — запомните и это, выстрелит там же, во втором акте.
Акт второй, в котором любое наше изменение ломает всю сеть
Проходит три месяца, на дворе февраль 2025-го. Хостер MT4 снова переезжает — его IP нужно добавить в whitelist. Задача на полторы минуты: одна строчка в YAML. Заодно, раз уж всё равно деплоить базу, — поднять ей класс инстанса, t3.small -> t3.large.
Коммит уходит в прод. Через 10 минут в трекере появляется тикет: “CRM and Dashboard are down” — всё отдаёт 504, клиенты не могут зайти в кабинет, сотрудники — в CRM.
Сначала я пытался справиться сам. Потом плюнул на все регламенты и позвал нашего спеца по AWS CDK — он вообще не был назначен на проект, но мне было уже не до назначений: посадил его за свой комп со всеми доступами. Через два часа он позвал своего начальника, и досиживали мы уже втроём, до глубокой ночи. Когда человек, которого ты позвал спасать прод, зовёт своего начальника — это как объявление в самолёте срывающимся женским голосом: “Если в салоне есть пилот, просьба пройти в кабину”. Восстановление заняло больше двенадцати часов, стоило нам порядка 50 тысяч долларов и закончилось тем, что инстанс базы удалили руками и создали заново.
Что пошло не так? Разбираем слоями.
Слой 0: “одна строчка” тащила за собой три месяца
Стек core не деплоился три месяца. За это время в репозиторий успела влиться миграция CDK v1 -> v2, которая на проде ещё ни разу не применялась. “Добавить один IP” на деле означало “впервые применить к стеку, владеющему всей сетью, мажорную миграцию тулинга”. Дифф, который выглядел как одна строчка в YAML, на уровне CloudFormation был совсем не одной строчкой.
Слой 1: замена, которая не может завершиться
Смена класса инстанса тут, как ни странно, ни при чём — это обычный ребут, “some interruption”. Подвох приехал вместе с миграцией v1 -> v2: она включает новые feature-флаги, и как минимум один из них, @aws-cdk/aws-rds:lowercaseDbIdentifier, меняет идентификатор инстанса. А вот смена идентификатора для CloudFormation — уже replacement: создать новый инстанс, удалить старый. Новый инстанс должен родиться publiclyAccessible, для чего, как мы помним, все сабнеты его subnet group должны быть публичными. А subnet group — та самая, ноябрьская, из смеси публичных и приватных. Причём конкретный сабнет для инстанса в RDS указать нельзя: задаётся только subnet group, набор сабнетов по зонам доступности. Можно разве что жёстко запинить зону доступности, но у нас она запинена не была — так что и зону, и сабнет в ней при создании выбирал сам RDS. В ноябре нам выпал публичный сабнет — потому тогда и проскочило. В феврале выпал приватный. Иными словами, наше описание инфраструктуры было недетерминированным: один и тот же код мог развернуться и в рабочую конфигурацию, и в нерабочую, смотря какая зона выпадет. Создание падает. CloudFormation начинает rollback. Rollback тоже не может пройти чисто — и стек зависает в UPDATE_ROLLBACK_FAILED, в позе, из которой штатного выхода нет.
Каждая следующая попытка “поправить и накатить” упиралась в тот же замороженный стек, а стек владеет VPC, сабнетами и security groups всех сервисов. Отсюда и ощущение, вынесенное в заголовок акта: любые наши изменения как будто ломали всю сеть. Сеть не ломалась. Ломалась возможность что-либо менять в стеке, которому принадлежит сеть.
Слой 2: почему легли ВСЕ сервисы, если база — “репортинговая”
Вот это, на мой вкус, самая поучительная часть.
Название “reporting-база” обманывает: GraphQL-монолит ходит в этот MySQL синхронно при обработке обычных пользовательских запросов — карточка клиента, список торговых счетов, балансы. А CRM и клиентский кабинет — тонкие фронтенды поверх монолита.
И ключевое: база была недоступна не “честно” (connection refused — мгновенно, приложение быстро отдаёт ошибку), а по-сетевому. Security group в AWS не отказывает — она молча выбрасывает пакеты. Для приложения это соединение, которое висит до таймаута. Дальше каскад, знакомый любому, кто ронял прод:
-
Каждый запрос, которому нужен MySQL, висит минуту вместо миллисекунд.
-
Пул соединений забивается висящими попытками мгновенно (наш к тому моменту был — внимание — 5 коннектов на инстанс; за 11 дней до этого мы уже словили из-за него отдельный часовой инцидент и подняли лимит, но против висящих намертво коннектов любой лимит конечен).
-
Виснут уже все запросы, включая те, кому MySQL не нужен: воркеры и event loop заняты ожиданием.
-
Healthcheck’и тоже не отвечают -> оркестратор убивает таски и поднимает новые -> новые на старте опять тянутся к базе и виснут -> балансировщик остаётся без живых таргетов и отдаёт 504.
Медленная смерть заразнее быстрой. Быстрый отказ зависимости деградирует функциональность; молчаливый таймаут съедает общие ресурсы и убивает всё.
Выход в итоге был грубый: остановить всё, руками удалить инстанс базы, дать CloudFormation дожить свой rollback, создать инстанс заново — уже сразу правильным. Через неделю в трекере появился тикет “проверить, что бэкапы всех баз реально восстановимы, с имитацией disaster recovery”. По содержанию тикета несложно догадаться, какие ощущения испытывал тот, кто удалял продовую базу руками.
Постмортема тогда не написали. Тикет “CRM and Dashboard are down” по сей день висит в статусе Open, без единого комментария. Иногда я захожу на него посмотреть. Он смотрит на меня. Эта статья — тот самый постмортем, с опозданием на полтора года.
Акт третий, в котором мы перестаём полагаться на везение
Лето 2026-го, совсем другая часть инфраструктуры: VPC для self-hosted CI-раннеров. Задача — расширить его с двух зон доступности на три. Чистое “изменение сетевых настроек”. Три попытки за два дня.
Попытка 1. Просто меняем AZ-раскладку в CDK. Деплой падает: перекройка сабнетов /17 -> /18 означает, что новый сабнет пересекается по CIDR со старым, который ещё жив посреди апдейта. CloudFormation не умеет перекраивать сабнеты на месте. Rollback.
Попытка 2. Ладно, новый CIDR всему VPC — пусть заменит целиком. Тоже rollback: при замене VPC под тем же логическим ID старый Internet Gateway остаётся тем же логическим ресурсом, и прицепить его к новому VPC нельзя, пока он прицеплен к старому. Курица и яйцо.
Бонусом — инцидент в миниатюре, зеркалящий прошлый: пока стек метался в роллбэках, раннеры продолжали запускаться в полусозданный VPC без интернет-шлюза, их сетевые интерфейсы держали security groups и сабнеты, и CloudFormation полтора часа крутился в деадлоке зачистки. Rollback, к слову, не восстановил рабочую сетевую конфигурацию — миф о том, что “откатится само”, был официально похоронен.
Попытка 3, победная. Переименовываем construct: Vpc -> Vpc3az. Для CloudFormation это не “изменение”, а новое сетевое дерево целиком: он создаёт его полностью, переключает потребителей и только потом сносит старое. Create-before-delete, blue-green на уровне IaC. Деплой проходит. Старый осиротевший VPC потом тихо снесли руками — без даунтайма.
Одна причина на все три акта
Сетевые примитивы AWS — сабнеты, CIDR, привязка IGW, subnet group у базы — на месте не меняются, а IaC старательно делает вид, что меняются: вот же строчка в конфиге, редактируй. На деле каждая такая правка — замена ресурса. Пока ресурс никому не нужен, замена проходит незаметно; но если он общий и на нём кто-то живёт, новый экземпляр под тем же логическим ID застревает в конфликте с собственным ещё живым предшественником.
Дальше — вопрос везения. В ноябре 2024-го дифф был мал, замена проскочила, и мы даже не поняли, как нам повезло. В феврале 2025-го замена застряла, стек-владелец всей сети завис, приложения умерли от молчаливых таймаутов, и до ночи мы чинили всё руками. К лету 2026-го до нас наконец дошло, что подбрасывать эту монетку дальше не обязательно.
Что мы изменили
-
Сетевое — только пересозданием. Менять CIDR/AZ/раскладку по сабнетам на месте запрещено; хочешь другую сеть — новый construct id, create-before-delete, потом снос старой. Правило записано дословно: to replace a CDK VPC, rename the construct — never mutate CIDR/AZs in place.
-
cdk diffперед каждым деплоем, деплой только конкретного стека. Смотрим на каждый[-]как на предложение что-нибудь уничтожить — потому что это оно и есть. -
Не копить дрейф. “Одна строчка в YAML” деплоит не строчку, а всё, что накопилось с прошлого раза. Три месяца недеплоенного core-стека — это не стабильность, это заведённая пружина.
-
База не должна жить в стеке с VPC. Радиус поражения стека = всё его содержимое плюс все, кто из него импортирует. Стек, где вместе лежат сеть и данные, превращает любую мелочь в русскую рулетку.
-
Быстрые отказы вместо молчаливых таймаутов. Агрессивные connect-таймауты к базам, разумные пулы, healthcheck, не зависящий от внешних зависимостей. Зависимость должна уметь умирать громко.
-
Бэкапы считаются существующими после restore-учений, а не после настройки. Проверили через неделю после инцидента; надо было — до.
-
Постмортем пишется сразу. Инцидент без постмортема — это оплаченный урок, за которым не пришли. Мы за своим пришли через полтора года — лучше поздно, но не советуем.
И на всякий случай, для читателей в чёрных шляпах: всё описанное давно неактуально. Windows-машины с туннелем больше нет, доступ к базе — точечный whitelist в явной security group, сеть с тех пор пересобрана. Копать тут нечего.
Вместо заключения
Если встретите в чужом коде комментарий вроде // Must be set here to work — остановитесь и выясните, почему it must. Не исключено, что это те самые швабры, которые держат потолок, — и прежде чем их выносить, стоит спросить, зачем на острове гигантский вентилятор и надут ли воздушный шар. В пяти таких словах обычно закопан чей-то потерянный день. В нашем случае — двенадцать часов и пятьдесят тысяч долларов. Автору оригинальной истории про швабры — респект и уважуха, я рассказывал её разным людям под виски и под кофе, наверное, тысячу раз как минимум.
ссылка на оригинал статьи https://habr.com/ru/articles/1068794/