Историю нашумевшей технической ошибки «Додо Пиццы», партнера Яндекс.Кассы, нам рассказал системный архитектор компании Андрей Моревский — сразу передаю микрофон автору.
Еду я в «Сапсане» на открытие первой в Санкт-Петербурге пиццерии «Додо», как вдруг получаю оповещение о множественных отменах оплаченных заказов. И не просто множественных — наша система за час умудрилась откатить якобы оплаченные заказы на 8 миллионов рублей!
Сейчас эта история вызывает только улыбку, но в то утро было совсем не смешно. Поэтому хочу поделиться некоторыми техническими подробностями инцидента и сделанными выводами, а заодно немного рассказать про систему обработки заказов «Додо Пиццы».
В то утро мы сразу же связались с командой Яндекс.Кассы, и узнали, что такие ситуации возникают обычно с двумя-тремя транзакциями и разрешаются в индивидуальном порядке. Дело выглядело непростым, ведь по каждой подобной операции команде платежной системы приходится связываться с определенным банком и обмениваться запросами. Особенно обидно было осознавать, что мы вернули деньги, которых не получали — это были тестовые заказы.
Наш сервер отменил платежей на 7,84 миллиона рублей. Для сети с ежегодным оборотом почти в 3 миллиарда это серьезные деньги. К тому же, это более 10% от привлеченных за последний раунд инвестиций. Согласитесь, слишком серьезная цена для одной ошибки.
В тот же день основатель сети Федор Овчинников сообщил об инциденте в социальных сетях, и история быстро разошлась по новостным сайтам.
И сразу спойлер — все закончилось хорошо
Пока мы делали все возможное для возврата средств, читатели недоумевали, как такое вообще произошло и азартно перебирали варианты возможной расправы над «программистами». Да что там, я лично получил с десяток тревожных сообщений от знакомых: ребята интересовались, все ли у меня хорошо и предлагали вакансии «на всякий случай».
За выходные к проблеме подключились все, вплоть до высшего руководства Яндекс.Кассы. Команда платежной системы смогла договориться с банками-партнерами об операциях по отмене ошибочного возврата. Это была действительно большая работа: пришлось вручную перебрать более десяти тысяч транзакций.
Деньги нам в итоге вернули, хотя мы и потеряли на банковских комиссиях 150 тысяч рублей за переводы, еще 40 тысяч ушло на SMS-уведомления клиентов..
В поисках причин и следствий
Сегодня эта система круглосуточно обслуживает 183 пиццерии в девяти странах. За пять лет разработки мы прошли путь от примитивного блока приема заказов до полноценной облачной ERP-системы, которая управляет заказами, работой на кухне, планированием графиков, запасами, финансами — практически всеми аспектами нашего бизнеса.
Мы тестируем Dodo IS на нескольких окружениях: есть «песочницы» для демонстрации продуктовым менеджерам, есть интеграционные контуры. Перед выкладкой в продакшн финальная версия тестируется аналитиками и QA в окружении stable. Для проверки мы используем реальные данные, которые регулярно копируем с «боевой» базы. Разумеется, все данные при пересечении границы продакшн-**среды** обезличиваются.
На stable-окружении мы стараемся полностью воспроизводить боевые условия — в том числе отмену не привязанных к заказам платежей. В реальных условиях такие платежи могут иметь место из-за ошибок в процессе оплаты или из-за некорректно отработавшей процедуры отмены заказа пользователем. Чтобы проверить, как произойдет отмена в тестовой среде, по расписанию запускается специальная задача, которая и подчищает хвосты.
За день до инцидента произошло сразу два неудачных совпадения, в лучших традициях законов Мерфи:
-
из-за ошибки в конфигурации оказалось, что фоновая задача смотрит не на имитацию платежного сервиса, а на реальное подключение к Яндекс.Кассе;
- при этом фоновая задача смотрела на версию тестовой базы, в которой были обезличенные платежные транзакции, но не было соответствующих им заказов.
Поэтому задача очистки добросовестно запустилась, прошлась по всем транзакциям и обнаружила те, которые нужно отменить. И в Яндекс.Кассу пришло десять тысяч запросов на отмену.
Про виновных, ответственность и доверие
Ни на одной встрече с руководством компании не поднимался вопрос о наказаниях виновных, даже по прошествии некоторого времени.
«Безусловно, мы сделаем серьезнейшие выводы из этой критической ошибки. Мы не будем наказывать людей — мы просто сделаем все, чтобы такого больше не повторилось.»
Сообщение на странице Федора Овчинникова в Facebook и ВКонтакте сразу после инцидента.
Страх наказания рано или поздно парализует работу любой компании. Уверен, многим из вас встречались компании, где пишут много документов и писем, чтобы оказаться как можно дальше от «области поражения». Где ни один менеджер не готов принять ни то что смелое, но даже пустяковое решение без 20-ти согласований. Я считаю, что такие компании не способны к созиданию и развитию, им остается лишь годами «доедать» ресурсы, созданные их более смелыми предшественниками-первопроходцами.
Наше право на ошибку не означает право на работу спустя рукава и халтуру, это прежде всего доверие и уверенность, что ни один сотрудник не может допустить ошибку из злого умысла.
«Если есть вероятность того, что какая-нибудь неприятность может случиться, то она обязательно произойдёт.»
Закон Мерфи.
Доверие делает с людьми волшебные вещи — у нас не было ни одного сотрудника, который не принял бы этот инцидент близко к сердцу, не прожил его как собственную боль, не предложил бы помощь.
Теперь самое интересное — что делать
Обеспечивая бурный рост Dodo IS, мы нередко предпочитали скорость разработки всему остальному. Иногда это происходило в ущерб системной логике, архитектуре и инфраструктуре.
В итоге система получилась монолитной и сильно-связанной. Код обработки платежей и взаимодействия с эквайерами находился непосредственно на клиентском сайте, вместе с UI и контроллерами. А значит любые изменения в контроллерах могли прямо или косвенно повлиять на платежную логику. Более того, расположение платежной логики в общем репозитории привело к уже-сами-знаете-какому инциденту. Потеря денег была всего лишь побочным эффектом от работ в других частях системы, по сути не связанных с обработкой платежей.
Последние полгода мы занимаемся реинжинирингом системы и переводим наш монолит на рельсы SOA (Service-Oriented Architecture). Сегодня каждый в компании — от программистов до управленцев — понимает, что технический долг необходимо возвращать.
В рамках перевода системы на SOA мы выделяем отдельный сервис обработки платежей — платежный шлюз. Этот сервис инкапсулирует всю платежную логику, включая взаимодействия с эквайерами. Фактически, мы разрабатываем собственный платежный агрегатор для собственных нужд. Платежный шлюз станет единой точкой проведения онлайн-платежей для клиентского сайта (dodopizza.ru) и других наших интернет-каналов продаж.
Мы решили сертифицировать платежный шлюз по PCI DSS Self-Assessment. Идея может выглядеть спорной (ведь карточные номера PAN мы не храним), но стандарт PCI DSS — это не бюрократическая формальность, а чек-лист, состоящий из правильных практик и советов по работе с sensitive-данными и написанный «кровью».
Платежный шлюз изнутри
У каждого платежного шлюза должна быть архитектура, описанная UML-диаграммой. Так выглядит компонентная модель нашего шлюза:
А вот что находится внутри IBackService, IPlugin и прочих интерфейсов:
Но сколько диаграмм не рисуй, а словами объяснять все равно придется :) Из чего же состоит шлюз, и какую роль выполняют его компоненты?
Клиентский сайт
Есть такой сайт dodopizza.ru, где и оформляется большая часть заказов. Сейчас сайт перенаправляет пользователя на страницу оплаты в зависимости от выбранного способа — например, на Яндекс.Кассу — и обрабатывает ответы от платежных систем. При необходимости бэкенд сайта вызывает бэкенд эквайера. Но в новой архитектуре сайт ничего не будет знать ни про страницы оплаты, ни про эквайринг. Он просто будет перенаправлять пользователя на платежный шлюз, который сам решит, куда его отправить дальше и как взаимодействовать с эквайером.
Платежный шлюз
Шлюз представляет собой RESTful-сервис, который принимает запросы на возвраты и оплату заказов, для чего предоставляет два API:
-
Back API предназначен только для вызовов со стороны Dodo IS и доступен только в DMZ.
- Public API открыт всему интернету — он обрабатывает запросы эквайеров и перенаправления пользователей с клиентского сайта.
Исходный код платежного шлюза располагается в специальном репозитории, закрытом от всех разработчиков. Для любого изменения исходников разработчику нужно оформить отдельную заявку. Сам сервис разворачивается на изолированных окружениях с повышенными требования к безопасности.
Плагины
Платежный шлюз содержит базовую логику обработки платежей, а специфичная логика интеграции с конкретными эквайерами находится в плагинах. Таким образом, работа по подключению нового эквайера или изменению списка имеющихся ведется точечно, с минимальным риском зацепить лишнее.
Сервисы данных и база данных
Платежный шлюз хранит информацию о платежах в собственной базе данных, закрытой от внешнего мира и от других частей Dodo IS. Доступ к ним невозможен даже для самого шлюза. У базы есть собственный API для управления сущностями, открытый только внутри платежного контура.
Чтобы нагляднее увидеть роль каждого компонента и представить, куда и как текут данные, предлагаю посмотреть на диаграмму потоков данных:
Если после просмотра диаграммы вы все еще не понимаете, как проходит платеж в новой архитектуре, посмотрите подробный пример под спойлером.
Сценарий начала оплаты
N | Шаг | Пример (Яндекс.Касса) |
1 | Клиент находится на Клиентском сайте и переходит к оплате заказа. | - |
2 | Клиентский сайт запрашивает у Платежного шлюза доступные для конкретной пиццерии безналичные способы оплаты, вызывая метод GetPaymentTypes. | - |
3 | Клиентский сайт отображает клиенту способы оплаты. Клиент выбирает способ оплаты. | Клиент выбирает оплату через Яндекс.Кассу |
4 | Клиентский сайт отправляет запрос на создание платежа в Платежный шлюз, вызывая метод CreatePayment. Передаются выбранный способ оплаты, идентификатор пиццерии, идентификатор заказа, сумма к оплате, URL'ы оповещения о статусе платежа, успешного возврата и неуспешного возврата. | - |
5 | Платежный шлюз создает платеж в статусе Draft. | - |
6 | Платежный шлюз валидирует платеж и присваивает ему статус Accepted или Rejected. | - |
7 | Платежный шлюз возвращает платеж Клиентскому сайту. | - |
8 | Если платеж отклонен (Rejected), то Клиентский сайт показывает клиенту ошибки и сценарий заканчивается. | - |
9 | Клиентский сайт определяет тип встраивания Платежного шлюза. Тип встраивания указан для каждого способа оплаты. если тип встраивания «через редирект», Клиентский сайт перенаправляет клиента на страницу оплаты Платежного шлюза PaymentPage, передавая идентификатор платежа. если тип встраивания «через фрейм», Клиентский сайт отображает клиенту фрейм, в котором открывает страницу оплаты Платежного шлюза PaymentPage, передавая идентификатор платежа. |
Тип встраивания для Яндекс.Кассы — «через редирект». Поэтому Клиентский сайт перенаправляет клиента на страницу оплаты Платежного шлюза PaymentPage, передавая идентификатор платежа. |
10 | Платежный шлюз присваивает платежу статус Started, если платеж находится в статусе Accepted. В противном случае, переходим к сценарию неуспешного завершения оплаты. | - |
11 | Платежный шлюз отображает страницу оплаты с анимацией ожидания. | - |
12 | Платежный шлюз валидирует платеж по идентификатору. Если валидация не пройдена, переходим к сценарию неуспешного завершения оплаты. | - |
13 | Платежный шлюз по способу оплаты определяет плагин, который будет проводить платеж через эквайера. Если плагин не найден, переходим к сценарию неуспешного завершения оплаты. | Выбирается плагин для Яндекс.Кассы. |
14 | Платежный шлюз вызывает у плагина метод StartPayment, передавая платеж. | - |
15 | Плагин выполняет свои специфичные действия, вызывает системы эквайера и возвращает шлюзу результат. | Плагин возвращает Платежному шлюзу результат «редирект» и URL страницы оплаты в Яндекс.Кассе |
16 | Платежный шлюз обрабатывает результат плагина: если результат — «ошибка», Платежный шлюз переходит к сценарию неуспешного завершения оплаты. если результат — «платеж проведен», Платежный шлюз возвращает ответ, полученный из плагина, и переходит к сценарию успешного завершения оплаты. если результат — «ожидание», Платежный шлюз возвращает ответ, полученный из плагина. если результат — «редирект», Платежный шлюз осуществляет перенаправление на URL, полученный из плагина, и переходит к сценарию ожидания оплаты. |
Платежный шлюз перенаправляет клиента на URL страницы оплаты в Яндекс.Кассе и переходит к сценарию ожидания оплаты. |
Сценария ожидания оплаты
N | Шаг | Пример (Яндекс.Касса) |
1 | Платежный шлюз прослушивает запросы эквайера через универсальный endpoint Acquiring. Этот же endpoint обрабатывает и редиректы клиента, инициированные эквайером. | Яндекс.Касса отправляет HTTPS POST на адрес pay.dodopizza.com/acquiring/yamoney/checkOrder или Яндекс.Касса отправляет HTTPS POST на адрес pay.dodopizza.com/acquiring/yamoney/checkAviso или Яндекс.Касса перенаправляет клиента на адрес pay.dodopizza.com/acquiring/yamoney/success |
2 | Получив запрос, Платежный шлюз извлекает из параметров запроса имя плагина и создает соответствующий плагин | Платежный шлюз по имени yamoney находит плагин для Яндекс.Кассы |
3 | Платежный шлюз авторизует запрос, вызвав метод плагина AuthorizeAcquiringRequest | Плагин проверяет подлинность запроса. |
4 | Платежный шлюз отправляет запрос на обработку плагина, вызвав метод ProcessAcquiringRequest. Плагин выполняет свои специфичные действия и возвращает шлюзу результат. | По параметрам запроса плагин выбирает соответствующий обработчик. CheckOrder: Плагин возвращает шлюзу результат «ожидание» и ответ для отправки Яндекс.Кассе CheckAviso: Плагин возвращает шлюзу результат «платеж проведен» и ответ для отправки Яндекс.Кассе success: Плагин возвращает шлюзу результат «редирект» и URL успешного возврата. |
5 | Платежный шлюз обрабатывает результат плагина: если результат «ошибка», Платежный шлюз переходит к сценарию неуспешного завершения оплаты. если результат «платеж проведен», Платежный шлюз возвращает ответ, полученный из плагина, и переходит к сценарию успешного завершения оплаты. если результат «ожидание», Платежный шлюз возвращает ответ, полученный из плагина. если результат «редирект», Платежный шлюз осуществляет перенаправление на URL, полученный из плагина, и переходит к сценарию ожидания оплаты. |
— |
На этом моменте меня попросили перестать копировать внутреннюю документацию в Хабр, поэтому закругляюсь. Буду рад, если статья натолкнет вас на какие-то мысли о собственной архитектуре или подскажет новые решения. И будет совсем здорово, если вы обнаружите что-то незамеченное нами и подскажете, что мы делаем не так. Но не будьте слишком суровы — возможно, многие нюансы не забыты, а просто остались за рамками статьи, тогда я поясню их в комментариях.
Автор: Яндекс.Деньги