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

Привет, Хабр! Я Александр Калошин из компании Last.Backend. Это статья по мотивам совместного доклада на GolangConf 2023 моего и Константина Пастор-Гертье.

Написали собственный Kubernetes и PagerDuty

Компания Last.Backend появилась в 2014 году, когда Docker уже существовал, но про него никто не знал. Да, это был мир без Docker-контейнеров и Kubernetes, и без трушного DevOps. Но мы тогда поняли, каким будет будущее по части контейнеризации и сделали  собственный opensource-аналог Kubernetus, который появился практически одновременно с ним.

Но у нашего санкт-петербургского стартапа (внезапно :)) оказалось меньше связей и денег, чем у Google, поэтому весь мир пользуется Kubernetes, а не нашей реализацией. Но нам всё равно есть что предложить и рассказать.

У стартапов часто заканчиваются деньги и тогда приходит время заняться чем-то ещё. У нас этим стал DevOps — мы начали помогать компаниям создавать инфраструктуры, налаживать CI и пайплайны. Подняли мы не одну сотню инфраструктур для различных компаний и проектов – от малых до гигантских. Поднимая инфраструктуру, приходилось писать дополнительный софт — в основном, на Go. А разработав, мониторить и сопровождать.

На сегодняшний день мы мониторим сотни серверов и десятки Kubernetes-кластеров. До 2022 года мы использовали PagerDuty. А когда по независимым от нас причинам больше не смогли, сделали собственный аналог PagerDuty или Opsgenie — кому что привычнее.

Как и зачем маленькой команде писать большие проекты

И вот наступает 2022 год и мы понимаем что не можем качественно реагировать на происходящие инциденты. Тогда решили быстренько написать аналог PagerDuty/OpsGenie. Да такой, чтобы не стыдно было самим пользоваться и другим показать. 

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

Спойлер: оказалось, что для этих целей нашей маленькой команде нужен бойлерплейт, кодогенерация и всякое такое.

Так перед нашей микрокомандой, а разработчиков у нас меньше, чем девопсов, встала задача в минимальные сроки сделать довольно сложное, а главное, отказоустойчивое решение. 

Мы решили руководствоваться принципом Парето, что 20% усилий дают 80% результата и использовать Бритву Оккама с принципом отсекай лишнее. Из этого мы вывели для себя пять простых правил:

  • Пиши мало и по делу,  то есть,  не придумывай кейсов, которые никогда не случатся .Зачастую команды пытаются предугадать все сюжетные перипетии жизни из которых по большому счету практически ничего не случается.

  • Кодогенерируй — не нужно писать одно и то же 50 раз.

  • Используй мозг и код других — мы гуглим перед тем, как что-то писать и используем наработки других людей. Мы изучили Kubernetes, Prometeus и Docker полностью — как они написаны изнутри. А если бы в мире существовал нормальный Бойлерплейт и кодогенератор, мы бы их никогда не писали. А почему нам показалось что их нет, расскажем чуть ниже.

  • Используй правильные технологии. Например, если на сервере надо держать миллиард соединений, не стоит использовать для этого РНР.  Да, есть умельцы, которые  и это сделают. Но на наш взгляд, существуют более правильные технологии, которые проще приведут к лучшему результату. Правильные вещи должны быть в правильных местах. 

  • Не усложняй. Не надо делать инкапсуляцию поверх инкапсуляции. Разбираться в этом другому человеку потом будет сложно. Два часа он потратит только на то, чтобы понять, что здесь написано. Чем проще код, тем лучше. Любой человек из нашей команды может открыть файл, посмотреть и понять, что там — мы считаем, что это хорошо. 

Кажутся эти правила довольно банальными. Но, к сожалению, даже такие банальности соблюдать не так просто. Поэтому зачастую они и не соблюдаются. Мы же все прекрасно это знаем :) 

Решения для микросвервисов

Мы задумались об архитектуре заранее и выбрали микросервисы. Они на 60-80% (зависит от  толщины) состоят из одного и того же — запуск приложения, парсинг конфигураций, подключение к базам данных, коннекторам и транспортам. 

Логичный вопрос: если везде микросервисы устроены одинаково, то можно просто взять за основу типовую архитектуру. Мы так и сделали и написали много микросервисов для своего проекта.

Преимущества микросервисной архитектуры всем известны: 

  • масштабируемость, 

  • отказоустойчивость, 

  • скорость написания,

  • простота деплоя. 

Но есть и специфические проблемы: 

  • зависимости, 

  • дистрибуция, 

  • трудности деплоя. 

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

Микросервисы усложняют дистрибуцию и число зависимостей. Например, один человек подправил в одном месте, потом в Go модах и это всё надо импортировать. Это добавляет множество сложностей. Появляются кастомные реплейсы в Go модах. То есть эти изменения одновременно легки и сложны в деплое. 

В итоге всё-таки пошли пилить микросервисы. Конечно, мы подумали, что не единственные, кому понадобилось в 2022 году резко написать много микросервисов. Наверняка, в мире ещё кто-то это уже придумал и выпустил в opensource.

Мы начали поиск в Google и сразу же нашли Go kit.

У проекта 25 000 звезд, 2 500 форков. Этот проект позиционируется как набор готовых инструментов, которые можно сразу использовать в работе, чтобы сфокусироваться на разработке бизнес-фич.

Но оказалось, что не всё так просто. Чем разбираться, как правильно имплементировать Go Kit, быстрее и проще написать его с нуля. 

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

Тогда мы нашли Go Micro:

Первый взгляд - то что надо: 21 000 звезд, 2 500 форков. Заявлено, что сервис помогает быстро разрабатывать микросервисы. Оказалось, не врут. В Go Micro простое подключение, и это ощутимое преимущество:

// create a new service
service := micro.NewService(
    micro.Name(""helloworld"),
    micro.Handle(new(Helloworld)),
)

// initialise flags
service.Init()

// start the service
service.Run()

Если хотите написать микросервис, то подключаете Go Micro и пишете new сервис, указываете название, discription, int и run. На этом всё. И это действительно работает. Если нужны плагины, то они даже об этом подумали.
Create file plugins.go

package main

import (
        _ "github.com/go-micro/plugins/v4/broker/rabbitmq”
        _ "github.com/go-micro/plugins/v4/registry/kubernetes"
        _ "github.com/go-micro/plugins/v4/transport/nats”
)

Build with plugins.go

go build -o service main.go plugins.go

Run with plugins

MICRO_BROKER=rabbitmq \
MICRO_REGISTRY=kubernetes \
MICRO_TRANSPORT=nats \
service

И для их подключения не надо делать чего-то экстраординарного — просто импортировать и можно использовать. Плагины группируются по категориям: Transport, Broker, Registry. Когда я увидел, что есть плагин Registry, это меня немного насторожило. Оказалось, что Go Micro  нельзя просто так взять и начать использовать, если не используешь Go Micro server. 

Ребята, которые разрабатывают этот проект, по всей видимости, ещё не определились, чего хотят: пилить opensource, развивать свой условный Go-Micro Cloud. Получается очень высокий уровень VendorLocking. И если внезапно в следующем году всё сделают платным и закроют код, то ты просто не сможешь это использовать и поддерживать дальше. Придётся всё полностью переписывать. 

А больше решений и нет. Кодогенераторы используют обертку. Есть некоторый набор кодогенераторов, но под капотом они используют Go Kit, который мы уже рассматривали. 

Очевидное решение — писать бойлерплейты. И многие так и поступают — копируют старый скелет приложения и делают новое приложение на основе старого. 

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

Пишем бойлерплейт

Инициализация приложения

Напомню, перед нами стояла задача написать тысячи микросервисов (сарказм), которые смогут обрабатывать 1 миллиард алертов в секунду — юмор не наш конёк, но мы пытаемся.

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

Самое лучшее на наш взгляд в Go Micro — простота подключения. Что может быть проще, чем создать application и его запустить? Если все микросервисы будут работать в таком режиме, это будет супер.  А ещё лучше, если мы сразу добавим туда систему логирования, чтобы не надо было думать, какие логгеры, логурсы и прочее подключать. 

Ещё мы сразу решили добавить туда систему логирования, чтобы не надо было думать, какие логеры, логурсы и прочее подключать. 

Эти логгеры можно настроить, чтобы ошибки, например, сразу уходили в Sentry.

Транспорт

Каждый микросервис, как правило, не живет в вакууме, и надо позаботиться о том, чтобы мы могли в него постучаться снаружи. Первым в голову приходит, конечно, GRPC/HTTP. 

Вы видите это на экране: Protobuf —> GPRC. Protobuf спецификация, на базе которой генерируется GRPC сервер и клиент.

Я думаю, многие с этим работали, технология достаточно понятная. Proto генерируется в сервер в некую обертку, которая должна запуститься и заработать.

На самом деле, это не совсем так. Придётся поработать руками. Надо прокинуть туда GRPC server и имплементировать методы, чтобы всё функционировало. Мы решили взять синтаксис GRPC в описании декларативного языка. Генерировать не только GRPC сервер, а каркас приложения, в котором подключен единый runtime и всё необходимое для работы приложения. Например, инициализацию сервера для GRPC. 

toolkit — это название бойлерплейта, а к runtime вернёмся чуть позже.

Манифест превращается в немного большую декларативную вещь, и на выход мы получаем приложение с уже готовым GRPC транспортом.

Подключаем HTTP и запускаем сервер

Если хотим подключить лендинги или что-то такое, или просто нужны HTTP-методы, то стоит подключить НТТР-сервер. Решили воспользоваться тем же способом — опцией сервера в protofile.

Мы добавляем НТТР в опцию серверов, а также добавляем декларацию middlewares, чтобы можно было сделать предварительные обработки. Но это, кстати, необязательно. С этим подходом не надо думать о том, как подключить НТТР-сервера. Конечно, каждый может нагуглить и написать собственную реализацию, но зачем писать одно и то же, если можно всё подключать опциями?

Чтобы приложение имело смысл, нужно написать пользовательскую логику обработчиков GPRC сервера. Все мы умеем писать handlers, но если вдруг забыли — покажем на примере. 

Для этого опишем структуру handlers, имплементируем GRPC-методы, которые описывали в proto-файле.

В этом примере мы также описали конструктор. Он необходим для подключения обработчиков к самому серверу. Конструктор должен возвращать структуру того же типа, что и сгенерировалась. Для этого мы структуру Handlers наследовали от сгенерированного интерфейса ExampleRpcServer.

Для регистрации handlers, мы регистрируем (устанавливаем) описанный конструктор, используя метод app.Server().GRPC().SetService(). Как видно из названия,  app — это наша структура приложения, на server — лежат наши сервера GRPC и HTTP. Обработчики мы добавляем в GRPC, поэтому берем его и в нём используем метод SetService.

На текущий момент мы умеем запускать сервис, у нас есть готовые транспорты, и мы умеем писать обработчики событий.  Что нам ещё нужно добавить в микросервисы? Правильно, плагины.

Добавляем плагины

Каждый микросервис умеет работать с какой-то базой данных или условной очередью. Для этого мы ввели понятие плагинов. Мы можем взять и обернуть подключение к базе данных, подключение к кэшу или что-то ещё в виде плагинов. Затем подключить их простыми опциями, где указываем префикс нашего плагина и что это за плагин.

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

Как мы используем в коде плагины? 

Например, берём тот же handler, возвращаемся к конструктору, о котором говорили ранее, и, просто подключив импорт плагина, получаем его напрямую в конструкторе из runtime toolkit. И можно его сразу обрабатывать в обработчиках handlers.

Простой пример: получить какое-то значение и вернуть назад вовне. Как мы получаем это в конструкторе, узнаем чуть позже.

Подключаем пакеты

А теперь настало время подключить пакеты.

Отличие пакетов от плагинов в том, что плагины готовим сами. Затем они просто берутся и используется. А пакеты — это логика приложения, то, что отличает одно приложение от другого.

Простые драйверы, как правило, никто не использует, так как нормальные люди не работают напрямую с базой данных из handlers. Есть различные паттерны проектирования, где используются сервисы, домены, репозитории, чистые архитектуры Golang. Мы  — вроде бы, адекватные люди, поэтому давайте будем исправлять код выше и писать нормально.

Например, опишем репозиторий и подключим его в main.go:

Описываем некоторый пакет репозитория, где по аналогии объявим декларацию методов, как будем работать с базой данных. Затем объявляем конструктор, где получаем на вход наш плагин и реализуем необходимые методы по работе с данными.

Пакеты мы регистрируем так: берём наше приложение,  и регистрируем пакеты через  RegisterPackage метод, отправляя туда конструктор описанного репозитория.

Наш runtime сам подхватывает все зарегистрированные пакеты, и мы можем использовать это в любом месте и в любом конструкторе.

Обновив handler, выкинем использование плагина и подключим сюда наш репозиторий. Указываем пакет и в конструкторе получаем из runtime репозиторий. Также обновляем handler, используя метод получения данных из репозитория. 

Итак. У нас есть возможность подключения серверов, декларация обработчиков и, собственно, сам запуск сервиса. Также мы умеем работать с плагинами и пакетами. В принципе, на этом как бы всё, но, на самом деле, нет.

Конфиги и другие сервисы

Так как мы пишем не один сервис, у нас их может быть несколько, расположенных в ряд, и нам надо научиться между ними общаться. Соответственно, нужно добавить работу с клиентами и по-хорошему иметь возможность конфигурировать наш сервис.

Начнём с конфигураций.

Мы объявляем и описываем структуру конфигурации, указываем тег envs дл автозаполнения через linux envs.

И, как и везде, для регистрации нам нужен конструктор. Дальше в maine file просто зарегистрируем конфиг. Теперь он будет доступен в любом месте, где необходимо его использование.

У декларативного описания  environments тегами в конфигурациях есть преимущества. Например, мы можем взять и просто вывести help на монитор, прочитать все полученные environments, доступные в нашем микросервисе и пользоваться этим.

Обратите внимание: у нас есть environment-ы не только нашего конфига, но и Postgres и Redis. Это environments тех плагинов, которые мы подключали ранее. Они работают в той же механике, что и пользовательская конфигурация. Наш runtime консолидирует их перед запуском, и мы получаем готовые конфигурационные настройки, чтобы можно было инициализировать эти плагины.

Мы описали protofile, и знаем, как у нас будет построен сервер, какие у него будут методы для GRPC и как мы их описали. Значит, можем сгенерировать клиент к сервису — и подключить его тоже опцией.

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

Как подключаются клиенты? Так как у каждого из нас сгенерированы микросервисы и клиент, мы его подключаем опцией, указываем название сервиса и пакеты к сгенерированному клиенту. Так как мы указываем go пакет, где лежит клиент. Мы можем подключить даже клиент сгенерированный не нашим toolkit, а который, например, уже раньше был представлен каким-нибудь другим сервисом.

Так декларативный файл превращается в элегантный, прекрасный пример, который покрывает 80-90% потребностей создания наших микросервисов. Самое время поговорить о том, как это работает.

Как это работает? 

Мы собрали *.proto file, у нас написан самостоятельный кодогенератор, который подключается в виде плагина к *.proto.

Всю эту конструкцию *.proto генерирует в файл, где происходит магия, которая делается за вас.

Мы инициализируем toolkit runtime, подключаем те самые плагины, которые указывали опциями, провайдим их. Эта часть магии отвечает за понимание, что в любом конструкторе будет доступно всё, что мы в него поместили. В том числе подключение серверов.

На выходе получаем облегчённый интерфейс, где доступен набор методов по регистрации плагинов, пакетов, конфигурации, и дополнительной настройки наших серверов. Мы решили возвращать только часть методов, чтобы в любимой IDE автокомплит не показывал методов, которые в целом и не нужны.

Runtime

Теперь поговорим о самом runtime.

Мы любим рассматривать то, что уже реализовано, и конечно не могли не знать про Uber FX. Взяли его за основу — именно он добавляет всю пользовательскую логику в runtime. Именно поэтому нам и нужны были все конструкторы и именно поэтому мы можем получить всё, что зарегистрировали в любом месте приложения.

У Uber FX есть несколько методов, в том числе provide. Это в нём мы декларируем все конструкторы через внутреннее устройство.  А Invoke у нас там, где мы запускаем предварительную конфигурацию. Так как мы используем плагины и клиенты, то их нужно инициализировать, ведь перед запуском сервера в приложении, нужно подключиться к базе данных, проверить, что всё доступно и только потом дать отмашку серверу, что он может принимать запросы. Если там что-то будет настроено неправильно, то выведется ошибка, и приложение завершит работу. С пакетами — аналогично. Для этого существует система Хуков.

У Uber FX есть ещё так называемые  OnStart/OnStop, которые немного ограничены. В них мы занимаемся запуском самих серверов, инициализацией клиентов после того, как будут подготовлены все плагины и пакеты в OnStart. Те, кто умеет правильно завершать приложение, должны уметь и правильно закрывать соединения. Для этого они обрабатывают эти вещи в OnStop.

У Uber FX система хуков ограничена, и она не предоставляет возможности запускать и инициализировать плагины до запуска приложения. Нам пришлось сделать свою обвязку. Вот пример:

Мы описываем простой контроллер, у которого есть hook, и который при запуске должен сделать запрос и отправлять его. Под капотом немного порефлектили.

У нас есть менеджер пакетов, который получает все зарегистрированные пакеты или плагины, в них определяет методы start и prestart. Они могут быть динамически описаны, а могут и нет. Всё зависит от потребностей ваших пакетов.

Если у hooks нужно обработать ошибки, вернуть error, toolkit runtime код это тоже поймёт. В целом суммарно мы получили прям boilerplate для приложений на go.

Есть масса преимуществ его использования. Вся начинка, используемая в качестве каркаса и плагинов находится в одном месте. Обновляется всё тоже в одном месте. Если выйдет какое-то улучшение плагина, то не надо идти и применять обновление во всех сервисах — оно само.

Естественно мы не описали всего, что сделали. Там под капотом ещё и генерация swagger и Dockerfile. Вроде бы была наработка по генерации helm-chart. Процесс не останавливается — toolkit выложен на github, и мы надеемся, что вы поможете нам сделать его лучше, хотя бы советом.

Если хотите посмотреть наше выступление на GolangConf 2023:

Комментарии (3)


  1. siberianlaika
    25.04.2024 09:12

    @undassa, опечатку поправьте, ссылка на Go Micro ведет тоже на Go Kit.


    1. Rombneromb
      25.04.2024 09:12

      Спасибо, поправили


  1. manyakRus
    25.04.2024 09:12

    слишком сложно у вас всё :-(
    у меня проще, любое подключение 1ой строчкой кода:
    mssql_gorm.StartDB()

    postgres_gorm.StartDB()

    nats.StartNats()

    https://github.com/ManyakRus/starter