Хабрахабр

Продуктовая разработка на Go: история одного проекта

Меня зовут Максим Рындин, я тимлид двух команд в Gett – Billing и Infrastructure. Всем привет! Я расскажу, как в 2015-2017 годах мы переходили на этот язык, почему вообще его выбрали, с какими проблемами столкнулись во время перехода и какие решения нашли. Хочу рассказать про продуктовую веб-разработку, которую мы в Gett ведем преимущественно на языке Go. А о текущей ситуации расскажу уже в следующей статье.

Сейчас Gett представлен в 4 странах: Израиль, Великобритания, Россия и США. Для тех, кто не знает: Gett — это международный сервис заказа такси, который был основан в Израиле в 2011 году. В конце 2016 года в Москве открылся глобальный офис R&D, который работает в интересах всей компании.
Основные продукты нашей компании — это мобильные приложения для клиентов и водителей, веб-портал для корпоративных клиентов, где можно заказать машину, и еще куча внутренних админок, через которые наши сотрудники настраивают тарифные планы, подключают новых водителей, мониторят случаи мошенничества и многое другое.

В 2011 году основной продукт компании представлял из себя монолитное приложение на Ruby on Rails, потому что в то время этот фреймворк был очень популярен. Были успешные примеры бизнесов, довольно быстро разработанных и запущенных на Ruby on Rails, поэтому он ассоциировался с успехом в бизнесе. Компания развивалась, к нам приходили новые водители и пользователи, нагрузки росли. И стали появляться первые проблемы.

Поэтому конечная точка, отвечавшая за прием координат от водителей, практически всегда была самой высоконагруженной. Чтобы в клиентском приложении отображать местоположение машины, и чтобы её движение выглядело как плавная кривая, водители должны довольно часто посылать свои координаты. Масштабироваться можно было только экстенсивно, добавляя новые серверы приложений, а это дорого и неэффективно. А фреймворк веб-сервера в Ruby on Rails с этим справлялся плохо. На время это решило проблему. В итоге мы вынесли функциональный сбор координат в отдельный сервис, который изначально был написан на JS. RPM, сервис на Node.js перестал нас спасать. Однако по мере роста нагрузки, когда мы подошли к 80 тыс.

У всех сотрудников в компании была возможность за день написать прототип, который должен был собирать координаты водителей. Тогда мы объявили хакатон. Здесь приведены бенчмарки двух версий того сервиса: работавшей на проде и переписанной на Go.

Практически по всем показателям сервис на Go показывал лучшие результаты. Cервис на Node.js использовал кластер, это технология использования всех ядер машины. То есть эксперимент был плюс-минус честным. Хотя Node.js имеет недостаток в виде однопоточного runtime’а, однако он никак не влиял на результаты.

Мы разрабатывали всё больше функциональности, и однажды столкнулись с такой проблемой: когда добавляешь какой-то кусочек кода в одном месте, то может что-то сломаться в другом месте, где проект сильно связан. Постепенно наши продуктовые запросы росли. Но в результате ухудшилась производительность: когда при исполнении кода интерпретатором Ruby on Rails встречается сетевой запрос, он блокируется и воркер простаивает. Решили побороть эту напасть с помощью перехода на сервис-ориентированную архитектуру. А операций сетевого ввода-вывода у нас становилось всё больше.

В результате мы решили принять Go в качестве одного из основных языков разработки.

Во-первых, у нас очень разные требования к продукту. Поскольку наши машины ездят в трёх странах с абсолютно разным законодательством, нужно реализовывать и очень сильно различающиеся наборы функциональности. Например, в Израиле законодательно требуется, чтобы стоимость поездки считал таксометр — это прибор, который проходит обязательную сертификацию раз в несколько лет. Когда водитель начинает поездку, то нажимает кнопку «поехали», а когда заканчивает, то нажимает кнопку «стоп», и вводит показанную таксометром цену в приложение.

Здесь мы можем сами настраивать политику формирования цены. В России таких жестких законов нет. Порой, когда мы хотим реализовать одну и ту же функциональность, то сначала выкатываем её в одной стране, а потом адаптируем и выкатываем в других странах. Например, привязывать её к длительности поездки или к расстоянию.

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

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

Фреймворк

Ruby on Rails построен по архитектуре MVC. На момент перехода мы очень не хотели от неё отказываться, чтобы облегчить жизнь тем разработчикам, которые умеют программировать только на этом фреймворке. Смена инструментария и без того не добавляет комфорта, а если при этом ещё и меняешь архитектуру приложения, это всё равно, что человека, не умеющего плавать, столкнуть с лодки. Мы не хотели так травмировать разработчиков, поэтому взяли один из немногих на тот момент MVC-фреймворков, который называется Beego.

Однако отрисованная на сервере страница нам очень сильно не понравилась. Попробовали с помощью Beego, как на Ruby on Rails, сделать серверный рендеринг. Пришлось выкинуть один компонент, и сегодня Beego выдает с бэкенда только JSON, а всю отрисовку выполняет React на фронте.

Некоторым разработчикам было очень тяжело переходить со скриптового языка к необходимости компилировать. Beego позволяет собирать проект автоматически. А задача уже закрыта. Были смешные истории, когда человек реализовывал какую-то фичу, и только на код-ревью или вообще случайно узнавал, что, оказывается, нужно делать Go-сборку.

Отношение к этой идее у нас неоднозначное, потому что если в пути опечатка, — например, пересобрался рутер, — то тем, кто не искушен в таком подходе, тяжело найти ошибку. В Beego рутер генерируется из комментария, в который разработчик пишет путь к экшенам контроллеров. Люди, порой, не могли разобраться в причинах даже после нескольких часов увлекательного дебага.

База данных

В качестве БД мы используем PostgreSQL. Есть такая практика — контролировать схему базы данных из кода приложения. Это удобно по нескольким причинам: все о них знают; их легко развёртывать, база всегда синхронизирована с кодом. И эти плюшки мы тоже хотели сохранить.

И бывает очень велик соблазн добавить в таблицу колонку, в которой может оказаться 10 млн записей. Когда у вас есть несколько проектов и команд, иногда для реализации функциональности приходится залезать в чужие проекты. Чтобы это предотвращать, мы сделали предупреждение об опасных миграциях, которые могут заблокировать базу на запись, и давали разработчикам средство, чтобы это предупреждение убрать. А человек, который не погружен в этот проект, может не догадываться о размере таблицы.

Миграция

Мигрировать мы решили с помощью Swan, который представляет собой пропатченный goose, в который внесли пару доработок. Эти двое, как и многие инструменты миграции, всё хотят делать в одной транзакции, чтобы в случае проблем можно было легко откатиться. Иногда бывает, что нужно построить индекс, а таблица заблокировалась. В PostgreSQL есть параметр concurrently, который позволяет этого избегать. Проблема в том, что если в PostgreSQL начинаешь строить индекс на этом concurrently, да ещё и в транзакции, то выскочит ошибка. Сначала мы хотели добавить флаг, чтобы не открывать транзакцию. А в итоге поступили так:

COMMIT;
CREATE INDEX CONCURRENTLY huge_index ON huge_table (column_one, column_two);
BEGIN;

Теперь, когда кто-то добавляет индекс с параметром concurrently, ему выпадает эта подсказка. Обратите внимание, commit и begin не перепутаны местами. Этот код закрывает транзакцию, которую открыл инструмент миграции, затем накатывает индекс с параметром concurrently, а после этого открывает еще одну транзакцию, чтобы инструмент что-то закрыл.

Тестирование

Мы стараемся придерживаться behaviour driven-разработки. В Go это можно сделать с помощью инструмент Ginkgo. Он хорош тем, что в нем есть привычные для BDD ключевые слова, «describe», «when» и другие, а также позволяет просто проецировать текст, написанный продакт-менеджером, на тестовые ситуации, которые хранятся в исходном коде. Но мы столкнулись с проблемой: люди, которые пришли из мира Ruby on Rails, считают, что в любом языке программирования есть нечто, похожее на factory girl — фабрику для создания начальных условий. Однако в Go ничего подобного не было. В итоге мы решили, что не будем изобретать велосипед: просто перед каждым тестом, в хуках до и после выполнения теста наполняем базу нужными данными, а потом её чистим, чтобы не было побочных эффектов.

Мониторинг

Если у вас есть production-сервис, к которому обращаются люди, то нужно отслеживать его работу: нет ли пятисотых ошибок, быстро ли обрабатываются запросы. В мире Ruby on Rails для этого очень часто используют NewRelic, и многие наши разработчики хорошо им владели. Они понимали, как работает инструмент, куда нужно посмотреть, если есть какие-то проблемы. NewRelic позволяет анализировать время обработки запросов по HTTP, выявлять медленные внешние вызовы и запросы в базу данных, отслеживать потоки данных, предоставляет интеллектуальный анализ ошибок и выдаёт предупреждения.

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

Сегменты включают в себя request queuing, обработку промежуточными обработчиками (middleware), длительность пребывания в интерпретаторе Ruby on Rails и обращения к хранилищам. Слева диаграмма обработки запросов, каждый из которых разбит на сегменты.

Снизу справа — частота обработки запросов. Справа сверху выводится график Apdex.

И всё волшебным образом работает. Интрига заключается в том, что в Ruby on Rails для подключения NewRelic нужно добавить одну строчку кода и дописать в конфигурацию свои учетные данные. Такое возможно благодаря тому, что в Ruby on Rails есть monkey patching, которого нет в Go, поэтому приходится много чего делать вручную.

Это удалось сделать с помощью хуков, которые предоставляет Beego. В первую очередь мы хотели измерять длительность обработки запросов.

beego.InsertFilter("*", beego.BeforeRouter, StartTransaction, false)
beego.InsertFilter("*", beego.AfterExec, NameTransaction, false)
beego.InsertFilter("*", beego.FinishRouter, EndTransaction, false)

Единственный нетривиальный момент заключался в том, что мы разделили открытие транзакции и её именование. Почему мы это сделали? Хотелось измерять длительность обработки запроса с учётом затрат времени на маршрутизацию. При этом нужны отчёты, агрегированные по конечным точкам, на которые приходили запросы. Но на момент открытия транзакции у нас пока еще не определен шаблон URL, по которому произойдет совпадение. Поэтому при поступлении запроса мы открываем транзакцию, затем на хуке, после выполнения контроллера, именуем её, а после отработки — закрываем. Поэтому сегодня наши отчёты выглядят так:

Мы использовали ORM под названием GORM, потому что хотелось сохранить абстракцию и не заставлять разработчиков писать чистый SQL. У этого подхода есть как достоинства, так и недостатки. В мире Ruby on Rails есть ORM Active Record, которая очень избаловала людей. Разработчики забывают о том, что можно писать чистый SQL, и оперируют только вызовами ORM.

db.Callback().Create().Before("gorm:begin_transaction").
Register("newrelicStart", startSegment)
db.Callback().Create().After("gorm:commit_or_rollback_transaction").
Register("newrelicStop", endSegment)

Чтобы измерить длительность выполнения запросов в базе данных при использовании GORM, нужно взять объект db. Вызов Callback говорит о том, что мы хотим зарегистрировать обратный вызов. Он должен вызываться при создании новой сущности — вызове Create. Затем укажем, когда именно нужно запускать Callback. За это отвечает Before с аргументом gorm: begin_transaction — это некоторая точка в момент открытия транзакции. Далее мы с именем NewrelicStop регистрируем функцию startSegment, которая просто вызывает Go-агент и открывает новый сегмент обращения к базе данных.

То же самое мы должны сделать для закрытия сегмента: просто навесим Callback. ORM вызовет эту функцию перед тем, как мы откроем транзакцию, и тем самым откроет сегмент.

Для этого мониторинга мы написали обертку над стандартным клиентом, и то же самое сделали для вызова внешних сервисов. Кроме PostgreSQL мы используем Redis, с которым тоже не всё гладко. Вот что получилось:

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

Здесь обозначена схема запросов сервиса promotion: он обращается к четырём другим нашим сервисам и двум хранилищам. Что касается потоков данных, то благодаря нашим оберткам над HTTP-клиентом мы можем отслеживать запросы ко внешним сервисам.

Сегодня у нас более 75 % production-сервисов написаны на Go, активную разработку на Ruby не ведём, а только поддерживаем. И в связи с этим хочу отметить:

  • Опасения, что скорость разработки уменьшится, не подтвердились. Программисты вливались в новую технология каждый в своём режиме, но, в среднем, через пару недель активной работы разработка на Go становилась такой же предсказуемой и быстрой, как и на Ruby on Rails.
  • Производительность приложений на Go под нагрузкой приятно удивляет по сравнению c прошлым опытом. Мы ощутимо сэкономили на использовании инфраструктуры в AWS, в разы уменьшив количество используемых инстансов.
  • Смена технологии заметно взбодрила программистов, а это важная часть успешного проекта.
  • Сегодня мы уже ушли от Beego и Gorm, подробнее об этом будет в следующей статье.

Резюмируя, хочу сказать, что если вы пишете не на Go, страдаете от проблем высоких нагрузок и соскучились по движухе — переходите на этот язык. Только не забудьте договориться с бизнесом.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть