
Переезд большого сервиса с Perl на Golang едва ли кому‑то покажется простой задачей. А теперь представьте, что это главная страница Яндекса, на которую ежедневно заходят миллионы пользователей. И что продукт постоянно дорабатывается, а значит, нельзя взять и остановить разработку на пару лет переезда. Представили? Сложно? А вот, оказывается, всё возможно.
Привет! Меня зовут Вячеслав Круглов. Я руковожу одной из команд разработки бэкенда главной страницы Яндекса. Расскажу, как мы переписывали бэкенд с Perl на Go, поделюсь интересными подробностями переезда, а также сравню компоненты и продуктовые блоки. Эта статья — расшифровка моего доклада на GolangConf 2024. Посмотреть запись выступления вы можете на Ютубе.
Перед началом хочу сделать важную ремарку. Над переездом Главной страницы трудилось множество людей. Выражаю огромную благодарность всем, благодаря кому появилась эта статья.
Зачем нам понадобилось менять язык
Главную страницу ya.ru видели многие, но это далеко не единственное, чем мы занимаемся. В техническом смысле это отдельный сервис, который работает в разных странах и обслуживает множество доменов. А помимо веб‑страниц бэкенд обслуживает разнообразные приложения под iOS и Android, NTP (New Tab Page) в разных браузерах, и еще несколько десятков вспомогательных ручек: для установки cookies, сохранения настроек, получения конфигов, и так далее.
Короче говоря, бэкенд у нас очень большой.
Мы, как сервис, родились примерно 25 лет назад. И тогда Go ещё не было даже в зачатке. Поэтому писали на технологии, которая хорошо подходила под те задачи в то время — Perl. До переезда на Go в нашем сервисе на Perl было примерно 1 миллион строк кода и десятки тысяч RPS.
Давайте заглянем под капот нашей системы на примере главной страницы Яндекса в России ya.ru. На странице есть данные, которые мы называем продуктовыми блоками. Это данные из внешних сервисов: погода, котировки валют, и многое другое. Набор этих данных может отличаться в зависимости от домена, страны или региона.

Упрощенно флоу обработки запроса главной страницей выглядит так: мы получаем запрос пользователя, делаем подзапросы в источники — другие сервисы Яндекса —, получаем их ответы, обрабатываем и отдаём верстке. Всё быстро, надёжно, отказоустойчиво.
И Perl был для этого хорош, пока не появились проблемы:
-
Трудности найма: язык старый и людей, которые его знают или учат, мало.
-
Язык почти не развивается: на Perl не пишут новых библиотек или инструментов.
-
Много legacy, сложно поддерживать: за всё время существования сервиса накопилось много legacy кода, который тяжело поддерживать.
Сложный синтаксис, отсутствие типизации: это тоже доставляет неудобства. Например, люди, пишущие на Go, с трудом смогут понять, что на иллюстрации ниже написано в сигнатуре функции:
sub respond ($;$$@) {
my ($req, $headers, %args) = @_[0, 2 .. $#_];
return undef unless ref $_[1]
}
Или ещё один пример, на котором кроме слова stack
тоже мало что можно разобрать:
@$$out = map { _push_to_stack($stack, $_, (my $o = {}), $s); $o } @$$in;
А вот еще один пример реального кода из нашего проекта. Конечно, он не весь такой. Но для подобных участков мой коллега придумал забавное определение: стиль кода «кошка прошлась по клавиатуре»:
($template =~ m, $,o) ||
($parts[$i] =~ m,^ ,o) ||
(($template =~ m,<[^<>]+$,o) &&
($parts[$i] =~ m,^>,o)) ||
(($template =~ m,%]$,o) &&
($parts[$i] =~ m,^[%,o))
Эти причины и привели к тому, что мы решили: Perl нам больше не подходит, и начали выбирать новый язык.
Как выбирали новый язык
Для начала мы составили список требований к новому языку:
-
популярный;
-
подходит для высокой нагрузки;
-
поддерживается внутри Яндекса, чтобы интегрироваться с системами сборки;
-
наличие хотя бы минимальной экспертизы в команде, чтобы прямо сейчас начать что‑то делать без найма дополнительных людей;
-
желательна статическая типизация, потому что её отсутствие часто мешает.
Изначально был выбран С++. Но когда начали переписывать главную страницу, оказалось, что разрабатывать на этом языке быстро не получается. Нового кода на Perl в единицу времени появлялось больше, чем мы переписывалось на «плюсы». График не сходился, эксперимент признали неудачным, и мы добавили новое требование:
-
Простота разработки, чтобы можно было быстро писать код.
«Плюсы» по этому критерию не подходили, и уже в следующей итерации мы выбрали Go.
Начали переезд
Для примера покажу процесс переезда на Go ручки бэкенда, которая обрабатывает запросы за веб‑страницей. В других ручках (для мобильных приложений, NTP, и пр.) процесс будет отличаться в деталях, но в общем останется таким же.
До начала переезда у бэкенда была примерно следующая схема: запрос приходит из балансера, попадает в Nginx, и далее в сервис, объединяющий бэкенд и серверную верстку (Perl+Front). Внутри этого сервиса Perl готовит json с данными и передаёт их в вёрстку, которая отвечает на запрос в формате HTML.

Важно отметить: основная сложность в нашей системе — это быстро сделать подзапросы в множество внешних по отношению к нам сервисов. Для этого в Perl была реализована крутая транспортная система.
Прежде, чем переписывать это всё на Go, мы задумались, стоит ли нам переписывать транспортную систему. Решили посмотреть, какие уже есть готовые инструменты. Оказалось, что в Яндексе есть готовый транспорт AppHost. К тому же, часть внешних источников, к которым мы обращаемся, уже находились под ним. И, самое важное, его можно добавить как обвязку, оставив Perl.
Внедрили AppHost

AppHost — это система управления сетевыми запросами. Расскажу в общих словах, как он работает. Более подробно можно прочитать в другой статье.
-
Балансер делает запрос в AppHost. AppHost роутит его между разными сервисами, основываясь на описании в виде графа.
-
Граф описывается в json в виде узлов и связей между ними.
-
Каждый узел — это связка бэкенд + путь запроса в нём.
-
Ребро между узлами — это зависимость. То есть, какому узлу какие данные из каких других узлов нужны, чтобы запуститься.
Например, представим себе простой граф и опишем каждый узел:

У каждого узла есть описание зависимостей: они описывают, какие данные этому узлу нужны от каких других узлов. Это и есть ребра графа.

AppHost работает по сетевой схеме «звезда». То есть, он делает запросы в каждый узел по очереди в соответствии с описанием графа на json. В конце мы описываем, какие данные нужны, чтобы отдать ответ пользователю.

Нам понравилось, как это всё выглядит и работает, поэтому мы решили внедрить AppHost.
Таким образом, после внедрения AppHost у нас появился первый самый простой граф, который функционально почти не отличается от первоначальной схемы. Он состоял из одной вершины — сервиса Perl+Front — и 0 ребер.

Далее мы разделили Perl и фронтенд на два узла, которые теперь общаются между собой по сети. Perl пересылает json для вёрстки во фронт.

Следующим шагом мы написали первый маленький узел на Go. Он делает подготовительную работу перед отправкой запросов в Perl — удаление лишних заголовков или чистку cookies.

Что делать с Perl?
Теперь вокруг Perl у нас AppHost, и дальше нужно было переписать логику самого сервиса. С этим была очевидная проблема: чтобы переписать сервис с Perl на Go, он не должен изменяться во время переписывания.
В идеальном мире хотелось бы оставить продуктовую разработку целиком, переписать все, а потом переключить. Но так, к сожалению, не бывает. Мы не можем себе позволить на год или два остановить продуктовое развитие сервиса. Переписывать его нужно по частям, учитывая всё, что будет меняться по дороге.
Моя любимая аналогия на эту тему звучит так: переписывать большой сервис на другой язык — все равно, что перебирать двигатель Боинга прямо в полете.
В результате мы придумали и внедрили такое решение:
-
Разделили бэкенд на «продукт» и «платформу».
-
Платформа — это часть, которая реализует базовые компоненты, нужные для работы продуктовых блоков. Например, геолокация пользователя, язык, данные об устройстве.
-
Продукт — это часть, реализующая сами продуктовые блоки, в которых содержатся данные для ответа пользователю.
Сначала мы переписали платформу, проведя аналогию с рельсами, а потом переписали продуктовые блоки, которые как поезд поедут по этим рельсам.
Продуктовые блоки
На странице ya.ru есть полоса с данными о погоде и котировках. Это и есть пример продуктовых блоков.

Попробуем представить фрагмент графа для AppHost, который будет работать с продуктовыми источниками. В нём есть узел, который делает запросы в источники (SETUP), и узел, который обрабатывает ответы из этих источников и передаёт их во фронтенд (PROCESS).

Продуктовые блоки с точки зрения бэкенда:
-
В Perl — объекты внутри json. Каждый продуктовый блок — это отдельный объект в ответе бэкенда.
-
В Go — protobuf. Каждый продуктовый блок — это отдельное сообщение в protobuf.
Переходя на Go, мы решили использовать protobuf вместо json. Во‑первых, protobuf легче по объему данных. Во‑вторых, он позволяет фиксировать контракт между бэкендом и фронтендом. И в‑третьих, protobuf — это ещё и нативный формат для AppHost.
Go очень способствовал решению перейти на protobuf, потому что предоставляет удобные инструменты для работы с ним, а в Perl этот формат поддерживался очень ограниченно. Например, очень пригодился protoc, который позволяет генерировать структуры на Go из proto‑описания.
Базовые компоненты
Базовые компоненты редко изменяются и нужны для всех продуктовых блоков. Например, геолокация нужна для запросов про погоду и пробки. АБ‑флаги, опции требуются вообще для всех продуктовых блоков. Поэтому базовые компоненты должны вычисляться в самом начале.
Многие базовые компоненты уже были алгоритмически реализованы в Яндексе. Например, вычисление геолокация уже было сделано в виде библиотеки на «плюсах». Чтобы это не переписывать на Go, мы решили подключить их в свой проект с помощью CGO. А чтобы сделать это ещё проще, использовали SWIG. Этот инструмент позволяет описать класс, который хотим сбиндить в Go, и биндинги генерируются автоматически.
Вернёмся к графу и поставим узел, который вычисляет базовые компоненты (INIT). Так базовые компоненты будут передаваться в продукт (участок графа обведен красным), и у нас получится почти готовый граф — целевое состояние, в которое мы хотим прийти после переезда.

Но у нас всё еще есть Perl. Мы оставили два бэкенда — Perl и новую схему с Go — и собрали новый граф такого вида. В нем бэкенды на двух языках работают параллельно:

Мы также научили фронтенд принимать данные из двух бэкендов сразу. Если какой‑то продуктовый блок приходит из Go, фронтенд берёт его оттуда. Если в Go его нет, то берёт из Perl. Такая схема позволила сделать плавный переезд и переносить по одному блоку с одного языка на другой.
Тестирование базовых компонент
К базовым компонентам предъявляются высокие требования по отсутствию багов. От них зависят вообще все продуктовые блоки. Например, если мы ошибемся при вычислении языка, то пользователь получит что‑то такого рода:

А если ошибёмся в геолокации, то получим массу пользовательских жалоб.
Go, как язык, способствует покрытию компонентов тестами благодаря своим особенностям:
-
великолепный фреймворк для юнит‑тестов, которого в Perl не было;
-
генерация тестовых функций средствами IDE;
-
легко измерять покрытие;
-
генерация моков для интерфейсов.
Лирическое отступление про генерацию моков
Мы выбрали подход, в котором функции в качестве аргументов принимают приватные интерфейсы. Чтобы такие функции тестировать, нам нужен инструмент, который будет генерировать моки для этих интерфейсов. Посмотрим на пример: есть функция вычисления языка, которая зависит от значения в cookies, и описание интерфейса с методом, возвращающим их.
type cookiesGetter interface {
GetCookies() models.Cookies
}
func GetLocale(cookies cookiesGetter) models.Locale {
...
}
Чтобы протестировать эту функцию, нужно сгенерировать mock для интерфейса cookiesGetter. В первой итерации мы использовали для этого утилиту mockery, но впоследствии перешли на gomock.
func Test_GetLocale(t *testing.T) {
// mockery
mockCookiesGetter.On("GetCookies").Return(...)
// gomock
mockCookiesGetter.EXPECT().GetCookies().Return(...)
}
Разница в том, что в mockery название метода передаётся строковым литералом, а в gomock — просто как вызов метода. Второй подход намного лучше и удобнее, потому что лучше ловить ошибки на этапе компиляции, чем на этапе прогона тестов (это было справедливо на момент начала переезда с Perl на Go несколько лет назад; сейчас в mockery также можно использовать простой вызов метода).
Помимо этого, gomock нативно поддерживается в яндексовой системе сборки, а mockery — нет.
Но тестов оказалось недостаточно. К сожалению, в Perl не всё было покрыто юнит‑тестами, а придумать тест‑кейсы для компонент, написанных годы назад, не получалось.
Сравнение компонентов
Мы решили не ловить баги с помощью пользователей или тестировщиков. Ведь у нас два параллельно работающих бэкенда. В real‑time берём и сравниваем базовые компоненты из Go и базовые компоненты из Perl. Для этого решили написать новый узел COMPARE.

Этот узел берет json из Perl, protobuf из Go и сравнивает прямо на лету на каждом запросе. Затем разница записывается в лог. Анализируя этот лог, мы понимали, что и в каких компонентах на Go нужно исправить.
Этот подход сильно ускорил разработку, но хотелось ускориться ещё сильнее. Базовые компоненты были всё ещё в работе, разница по ним оставалась, но продуктовые блоки на Go хотелось переносить уже прямо сейчас, не дожидаясь, что базовые компоненты будут готовы на 100%.
Мы нашли решение: превратили узел COMPARE в SELECTOR, который не только сравнивает компоненты, но и выбирает верный вариант. В результате SELECTOR принимает базовые компоненты из Go и Perl, сравнивает, и если они отличаются, то в protobuf Go подставляет значение из json, который пришёл из Perl.

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

Мы разделили Perl на «полторы» части. Главный бэкенд на Perl оставили работать по‑прежнему параллельно с Go, в нем вычисляя все продуктовые блоки. Однако мы добавили другой узел на Perl, не такой большой и тяжелый, который вычислял только базовые компоненты.
Однако, получилось не очень. К сожалению, попытка упростить бэкенд на Perl привела к тому, что базовые компоненты в новом узле стали вычисляться неправильно. Вместо того, чтобы чинить баги, посаженные в Perl, мы решили просто временно откатиться к предыдущей схеме, в которой бэкенды работают последовательно.
Затем мы написали ещё одну сравнивалку, на этот раз для самих продуктовых блоков, уже без отдельного узла, и чуть‑чуть умнее. На схеме ниже она работает в узле PROCESS.

У нас есть довольно развесистое описание продуктовых блоков в protobuf, в каждом из которых множество полей. Писать функции для сравнения блоков вручную было неудобно — занимает слишком много времени. Поэтому, как обычно, отдали это на откуп кодогенерации. Опять же, Go отлично этому способствует: простая утилита для кодогенерации на основании protobuf генерирует функции сравнения с соответствующим кусочком json из Perl.
Отключили Perl
Мы перенесли продуктовые блоки из Perl в Go. Далее для каждого базового компонента и продуктового блока завели АБ‑флаг, по АБ‑флагу выключили процессинг в Perl и включили в Go. Получилось не работать вслепую, а проводить АБ‑эксперименты, принимая решения по критерию неухудшения метрик.
Финальным экспериментом мы отключили Perl и убрали его из конфига графа. Финальный граф стал выглядеть так:

Так мы и переехали на Go.
Результаты
Процесс занял больше трёх лет и до сих пор ещё полностью не завершён. Причём мы повторяли его несколько раз. Ведь одна из основных фишек AppHost — это возможность обрабатывать разные запросы разными графами. Поэтому запросы из веб‑сервисов мы обрабатываем одним графом, а запросы из приложений — другим. Всего у нас несколько десятков графов. Например, мы приняли эксперименты на графе с вебом, а потом сделали всё то же самое для графа приложений.
Сейчас всё самое основное уже работает на Go — веб, приложения, NTP. Однако на Perl все еще осталось несколько продуктов и множество вспомогательных ручек. Что‑то из этого мы переписываем прямо сейчас, что‑то перепишем в будущем. Какие‑то из продуктовых сервисов с течением времени потеряют актуальность и перестанут поддерживаться, например, старые версии приложений — поэтому мы их не планируем переписывать.
Каких же результатов мы добились, переписав бэкенд на Go?
Мы сэкономили вычислительные ресурсы, потому что Go расходует их оптимальнее, но честно и точно измерить экономию не получается. За три года нашего переезда многое поменялось, и сравнить состояние ДО и состояние ПОСЛЕ просто невозможно.
Плюс сократилось время ответа бэкенда примерно на 20%. Приятный бонус, хотя делали мы это всё не ради этого.
Зато мы можем померить нетехнические метрики. Например, число стажёров за год в нашей команде выросло с 1 до 10+. У нас была проблема с их наймом. Найти стажёра, который пишет на Perl, было весьма проблематично. Сейчас на команду из 15 бэкендеров приходится больше 10 стажеров каждый год. Это очень много. Молодые ребята приходят, очень быстро учатся, развиваются, многие остаются в команде.
Я пытался придумать, какой метрикой можно описать качество кода, и решил посчитать количество комментариев с WTF в коде на Perl и на Go. Оказалось, что в Perl их было 110, а в Go — ни одного.
grep -iRh --include "*.pm" ”WTF" .
# ??? WTF?
# WTF CODE?! АЫАЫАЫАЫ
# WTF??? but i beleve them
# WTF CODE?! /s?[,;]+s?/ => /[,;s]+/
Также раньше мы не умели мерить покрытие тестами. Сейчас в Go мы это хорошо умеем и делаем. И это касается не только юнит‑тестов: мы разработали способ измерять покрытие функциональными и интеграционными тестами.
Даже самую сложную систему можно переписать. Go этому способствует: мы переписали на него главную страницу Яндекса! У нас всё получилось, и у вас получится.
Автор: yaches