Переписывание API означает сохранение лет принятия решений, включая те, которые никогда не попадали в документацию.
Для Resend это означало точное воспроизведение ответов об ошибках, поведения идеопотентности, лимитов запросов (rate limits), сохраненных данных и содержимого самих писем. Более быстрая реализация была бы полезна только в том случае, если разработчики могли бы и дальше полагаться на уже знакомое им поведение.
За семь недель и примерно 140 пулл-реквестов мы перевели новый сервис на Go от пустого проекта до работы с продакшн-трафиком.
К октюбрю Go обрабатывал весь наш трафик email API. Хотя трафик был разделен поровну между двумя сервисами, Go продемонстрировал примерно вдвое меньшую медианную задержку и примерно в семь раз меньшее потребление памяти на задачу.
Почему мы начали
Существующий сервис на Node имел режимы сбоев, которые мы хотели устранить.
Во время всплесков крупных запросов вся задача могла перестать реагировать на запросы. Одно из расследований показало, что это связано с парсингом и сериализацией больших тел запросов, включая запросы, которые уже отклонялись лимитером.
Парсинг и сериализация — это ресурсоемкие синхронные операции в Node.js, которые блокируют цикл событий (event loop) до их завершения. Работа, связанная с этими отклоненными запросами, могла мешать задаче обрабатывать остальной трафик.
Мы также столкнулись с тем, что тайм-ауты S3 проявлялись в виде ошибок 408 и 524 на стороне клиентов.
Эти проблемы сформировали требования к замене: более предсказуемое поведение под нагрузкой, меньшее использование ресурсов и лучший контроль над тем, сколько времени могут занимать отдельные операции.
Изменение реализации также потребовало пересмотра обработки запросов, тайм-аутов, пулов соединений и пропускной способности. Эти решения стали значительной частью миграции.
Добиваемся соответствия реализаций
Наша инженерная философия требует предсказуемого поведения и обратной совместимости. Во время переписывания этот принцип превращается в задачу тестирования: как доказать, что две реализации ведут себя одинаково?
Мы создали тестовый стенд (harness), который отправляет одинаковые запросы в Node и Go и сравнивает их поведение. Он проверяет ответы и ошибки, строки в базе данных, объекты S3, идеопотентность и лимиты запросов.
Проверка только HTTP-ответа оставляла бы слишком много без тестов. Оба сервиса могли вернуть успешный ответ, но при этом сохранять разные метаданные или генерировать разное содержимое письма.
Существующая реализация стала эталоном совместимости. Мы также намеренно сохранили некоторые особенности Node. Изменение неожиданного поведения все равно может сложить интеграцию, которая от него зависит.
Конвертация HTML в текст оказалась особенно полезным примером. Когда пользователь отправляет письмо, содержащее только HTML-часть, мы конвертируем его в текст для почтовых клиентов и получателей, которые предпочитают текстовые письма.
Библиотека Go, которую мы выбрали изначально, генерировала приемкаемый простой текст, но ее вывод отличался от нашей настроенной библиотеки Node. Элементы, помеченные как data-skip-in-text="true", появлялись в выводе Go, хотя должны были быть исключены. Ссылки также могли получать текст URL, который вывод Node не включал.
Эти различия изменили бы письмо, которое получает получатель.
Мы создали порт на Go для конкретного поведения конвертации HTML в текст, используемого нашим API. Изначальный порт точно соответствовал всем 60 тестовым фикстурам, сгенерированным на основе реализации в Node. Это дало нам конкретные доказательства для случаев, покрытых этими фикстурами, и основу для добавления новых.
Чему нас научил запуск
Как только мы добились соответствия поведения старой системы, следующей задачей было успешное развертывание новой системы. Чтобы этот запуск увенчался успехом, продакшн потребовал дополнительной настройки.
Новый сервис использовал меньше ресурсов на запрос, но нам все равно пришлось дважды удваивать размер задач в процессе развертывания.
Мы также внесли несколько операционных изменений:
- Добавили емкость в преддверии регулярного пика трафика в начале часа.
- Внедрили тайм-ауты для каждого отдельного запроса при загрузке в S3.
- Настроили пулы соединений Redis по мере роста трафика.
Каждая корректировка решала отдельное ограничение. Более низкое среднее потребление ресурсов не говорило нам о том, какая емкость нужна задаче во время всплеска. Более быстрая обработка запросов не отменяла необходимость ограничивать операции с хранилищем или учитывать соединения Redis.
Запуск дал нам данные из продакшн-среды, необходимые для принятия этих решений.
Предварительные результаты
Эти цифры получены на этапе 50% раскатки. Половина запросов уходила в Node, а половина — в Go.
Оба сервиса работали с одинаковым размером задач, и сервис на Go использовал примерно на 62% меньше ЦП на запрос. Медианная задержка для POST-запросов к /emails упала примерно на 50%, в то время как p99 снизилась примерно на 16%.
Значительная часть медленного «хвоста» приходится на время ожидания S3 и базы данных. Улучшение сервиса API сокращает часть длительности запроса; дальнейшее сокращение хвоста требует работы с этими зависимостями и тем, как мы их используем.
За кулисами с Дерихом и Кубо
1. До начала работы что казалось самым сложным?
Дерих: Неизвестные неизвестные. Это был не перенос между двумя похожими рантаймами. Мы перешли от рантайма, асинхронного по умолчанию, к синхронному по умолчанию, от пермиссивного языка к строгому и от типов, которые исчезают во время выполнения, к тем, которые не исчезают. Так что большая часть поведения нашего API жила в Node и использованных нами пакетах npm, а не в нашем собственном коде. Возьмем вложения: наш SDK для Node принимает Buffer, который JSON.stringify преобразует в {"type":"Buffer","data:[...]}. Node превращал это обратно в байты с помощью одного вызова Buffer.from(). В Go нет Buffer, поэтому мы воссоздали это преобразование вручную, включая правила приведения типов JavaScript.
Кубо: У каждой миграции есть свои трудности, но эта была особенно сложной. Отправка электронной почты — наш самый критический процесс, поэтому каждое изменение требовало тщательного рассмотрения, и мы потратили много времени на создание тестов, добавление метрик и настройку инфраструктуры. Кроме того, это публичный API, а это значит, что мы не контролируем то, что в него поступает: любой ввод, который работал раньше, должен продолжать работать. Это становится сложнее, когда вы переходите с JavaScript, который по умолчанию снисходителен, на Go, который строг. Так что задачей было не просто вернуть тот же HTTP-ответ. Нужно было убедиться, что само письмо прибыло точно так же, как и раньше.
2. Какая маленькая деталь вызывает у вас особую гордость? Что-то, что не отразилось бы на графиках производительности.
Дерих: Умный код, который мы удалили. Часть сервиса Node существовала только для защиты цикла событий, где один медленный запрос задерживает каждый запрос позади него. Например, наша проверка размера письма имела сложный трюк, позволявший избежать сканирования больших тел байт за байтом. В Go такой проблемы нет, поэтому после того, как порт стал стабильным, мы сократили этот трюк до той единственной части, которая все еще экономит реальный ЦП. Никто не увидит этого на графике, но это на одну вещь меньше, в которой придется разбираться следующему инженеру.
Kubo: Одна деталь, которой я действительно доволен, — это простой виджет, показывающий процентное соотношение HTTP-кодов состояния. Он дал нам возможность быстро увидеть, ведет ли себя новый сервис точно так же, как старый. Поскольку мы проводили развертывание для случайного процента запросов, у обоих сервисов должно было быть практически одинаковое распределение кодов состояния. У нас было множество тестов, покрывающих валидацию, но этот виджет дал нам еще больше уверенности в том, что во время раскатки все работает так, как ожидалось.
3. Что вас удивило в совместной работе над этим?
Derich: Скорость и автономность, которые были у нас в столь масштабной задаче. Это был первый реальный проект Кубо здесь, и при этом казалось, что мы работаем в команде уже много лет. Каждый из нас мог отвечать за свой участок, проводить эксперименты и отбрасывать те, что не сработали, не дожидаясь другого. Когда мы решили, что эндпоинт пакетной обработки тоже должен быть переписан на Go, путь от принятия решения до попадания трафика в продакшен занял около недели.
Kubo: Это был мой первый проект в Resend и первый совместный с Дерихом, но с первого дня казалось, что мы работаем вместе уже много лет. Мы часто обсуждали какое-то решение, причем каждый из нас добавлял что-то свое, пока оно не становилось лучше, чем любая из наших первоначальных идей. Я думаю, это многое говорит о здешней культуре, и мне очень понравилось работать над этим проектом.












