Привет, Хабр! На связи Паша Емельянов, руководитель группы AGIMA. В этой статье я расскажу, как в одном из проектов мы переписали старый функционал, когда-то разработанный на PHP, в Golang, с какими проблемами столкнулись и как их решили.
Статья будет интересна как начинающим и средним студентам, так и системным архитекторам, поскольку здесь мы затронем как вопросы инфраструктуры, так и вопросы реализации конкретного сервиса для удовлетворения потребностей бизнес-заказчика.
Что случилось
Проект, о котором мы сейчас поговорим, — это крупный интернет-магазин бытовой техники.С самого начала нашей главной целью было оптимизировать и переписать старый устаревший код. Старый функционал работал на стеке PHP с базой данных MySQL. В нем хранились все товары, категории, характеристики – словом, вся информация, которая требовалась для решения бизнес-задач.
Сама проблема бизнеса заключалась в следующем: у нас есть маркетплейсы — Яндекс.
Маркет, Товары и другие площадки, и всем этим площадкам нужны данные о товарах разных категорий, которые магазин может предоставить покупателям в разных регионах.
Предыдущий вариант реализации не подходил по ряду причин:
- высокое потребление ресурсов;
- долгое поколение;
- много устаревшего кода, высокая сложность внесения доработок;
- невозможность горизонтального масштабирования.
Это очень долго и неэффективно.
При горизонтальном подходе мы можем легко, добавив больше ресурсов в кластер, получить большую пропускную способность и вычислительную мощность.
Новая услуга
После нескольких встреч с маркетологами и бизнесом наши аналитики зафиксировали задачи по разработке продукта:- Необходимо было быстро сгенерировать несколько файлов по нескольким регионам и категориям для всех потребителей без ограничений.
У старого сервиса были проблемы с производительностью.
Поэтому это условие было невозможно выполнить.
- Они хотели иметь возможность контролировать, в какое время и какие файлы будут обновляться.
- Они хотели в первую очередь генерировать файлы по требованию или с отложенным стартом.
- Нам также нужна была возможность гибко настраивать эти шаблоны и файлы с помощью загрузок, которые они могли бы отправлять на торговые площадки.
Технические требования также вытекали из требований к продукции.
Помимо горизонтальной масштабируемости, было важно, чтобы новая система на тот момент органично интегрировалась с основной сервисной инфраструктурой.
Это было связано с тем, что у заказчика уже была сервисная платформа.
Это каскад сервисов, обеспечивающих определенные критически важные бизнес-функции.
Внутри этого фасада и этой сервисной инфраструктуры было создано несколько микросервисов, которые уже успешно работают несколько месяцев и лет. Поэтому наиболее разумным решением был выбор технологий на базе текущей сервисной платформы, поскольку накладные расходы на создание новой сервисной платформы для одного микросервиса могут быть достаточно большими.
Система также должна была обеспечивать высокую производительность.
То есть, чтобы сервис позволял запускать хоть сто файлов на 100 регионов в час, хоть 200 файлов на 200 без каких-либо ограничений.
Другими словами, было важно, чтобы не было расхождений в доставке информации партнерам.
При этом система должна была потреблять минимальное количество ресурсов: чем больше ресурсов требовалось, тем выше нагрузка на оборудование; Чем выше нагрузка на оборудование, тем выше накладные расходы.
Это просто увеличит время, необходимое для создания файлов.
На основании всех входных данных мы приняли решение о том, как реализовать новый сервис.
Текущая сервисная платформа использовала Kubernetes. Также у команды была хорошая экспертиза в Golang — мы любим его за высокую скорость и низкое потребление ресурсов, большое количество наработок для ускорения разработки, хорошую совместимость с оркестраторами и количество метрик производительности, собираемых «из коробки».
А в качестве базы данных для хранения настроек наиболее популярной для этого стека была PostgreSQL. Для оркестровки будущего сервиса мы в итоге использовали готовый и отлаженный Kubernetes, на котором уже работает несколько десятков микросервисов.
И учитывая соответствующий опыт и разработки, мы решили писать на GoLang. СУБД – PostgreSQL; наряду с этим — Aerospike для кэша, S3 — для хранения файлов.
Скелет микросервиса через Go-kit
При разработке любого микросервис Первым делом размечаем API. Это означает, что мы должны указать, какие ссылки сможет использовать пользователь, SPA или какой-либо другой сервис извне.Все это мы описали в соответствии со стандартами Google в формате Protobuf3. В качестве инструмента, позволяющего оптимизировать и ускорить разработку, мы использовали разработки по генерации кода на Готовый комплект И протоген .
Он позволяет практически полностью сгенерировать микросервис, который уже можно интегрировать в сервисную платформу.
В этом случае используется помеченный прото-файл или их набор, каждый из которых содержит помеченные конечные точки.
Служба, созданная таким образом, имеет на выходе все рабочие конечные точки.
Несмотря на то, что на этом этапе они возвращают некоторые обезличенные и случайные данные, это уже рабочая версия микросервиса, которую мы потом дорабатываем.
По сути, это скелет будущего микросервиса, и сейчас я вам расскажу, из чего он состоит. Пример файла Protobuf:
Здоровье.syntax = "proto3"; package prmexportpb; option go_package = "internal/prmexportpb"; import "google/api/annotations.proto"; import "protoc-gen-swagger/options/annotations.proto"; import "health.proto"; option (grpc.gateway.protoc_gen_swagger.options.openapiv2_swagger) = { info: { title: "Export service"; version: "1.0"; }; schemes: HTTP; consumes: "application/json"; produces: "application/json"; responses: { key: "404"; value: { description: "Returned when the resource does not exist."; schema: { json_schema: { type: STRING; } } } } }; service HealthService { // returns a error if service doesn`t live. The kubelet uses liveness probes to know when to restart a Container. rpc Liveness (LivenessRequest) returns (LivenessResponse) { option (google.api.http) = { get: "/liveness" }; option (grpc.gateway.protoc_gen_swagger.options.openapiv2_operation) = { tags: "HealthCheck" }; } // returns a error if service doesn`t ready. Service doesn`t ready by default. rpc Readiness (ReadinessRequest) returns (ReadinessResponse) { option (google.api.http) = { get: "/readiness" }; option (grpc.gateway.protoc_gen_swagger.options.openapiv2_operation) = { tags: "HealthCheck" }; } // returns buid time, last commit and version app rpc Version (VersionRequest) returns (VersionResponse) { option (google.api.http) = { get: "/version" }; option (grpc.gateway.protoc_gen_swagger.options.openapiv2_operation) = { tags: "HealthCheck" }; } }
прото syntax = "proto3";
package prmexportpb;
option go_package = "internal/prmexportpb";
message LivenessRequest {}
message LivenessResponse {
string status = 1;
}
message ReadinessRequest {}
message ReadinessResponse {
string status = 1;
}
message VersionRequest {}
message VersionResponse {
string BuildTime = 1;
string Commit = 2;
string Version = 3;
}
Вы можете прочитать о proto3 здесь .
Во-первых, на этом этапе сервис уже обернут трассировкой.
Это то, что нужно для отслеживания запросов внутри и снаружи сервиса.
Вы можете наглядно увидеть цепочку вызовов компонентов и время, потраченное на их выполнение.
Это позволяет быстро находить узкие места.
Рис.
1. Список следов
Рис.
2. Программное обеспечение отслеживает транспорт и конечную точку.
Пример переопределяющей функции для трассировки GET/liveness: // NewTracingService returns an instance of an instrumenting Service.
func NewTracingService(ctx context.Context, s Service) Service {
tracer := tracing.FromContext(ctx)
return &tracingService{tracer, s}
}
type tracingService struct {
tracer opentracing.Tracer
Service
}
func (s *tracingService) Liveness(ctx context.Context, req *LivenessRequest) (resp *LivenessResponse, err error) {
span, ctx := opentracing.StartSpanFromContextWithTracer(ctx, s.tracer, "Liveness")
defer span.Finish()
return s.Service.Liveness(ctx, req)
}
Во-вторых, Sentry связан со скелетом; это платформа для выявления ошибок.
То есть, если у нас внутри кода какая-то ошибка или наш сервис возвращает 500-ю ошибку, мы ее ловим и отправляем в Sentry для дальнейшего расследования.
А еще к сервису привязаны метрики производительности: в какой момент времени и какая метрика рассчитывается.
Например, количество запросов в секунду в тот или иной момент времени.
Пример переопределяющей функции для метода GET/liveness: func (s *sentryService) Liveness(ctx context.Context, req *LivenessRequest) (resp *LivenessResponse, err error) {
defer func() {
if err != nil {
log := s.getSentryLog(req, resp)
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTag("code", strconv.Itoa(getHTTPStatusCode(err)))
scope.SetTag("method", "Liveness")
scope.SetExtra("request", log["request"])
scope.SetExtra("response", log["response"])
})
sentry.CaptureException(err)
}
}()
return s.Service.Liveness(ctx, req)
}
Сюда же следует добавить, что в сервисе есть логирование всех компонентов через Go-kit с уровнями.
Получается, что на старте мы сразу добиваемся принципа Observability, который сыграет нам на руку при поддержке и сопровождении сервиса в дальнейшем.
При генерации скелета сервиса мы сразу получаем спецификацию в формате OpenAPI v3. Это глобальный стандарт, который используется для интеграции с огромным количеством API. Это спецификация yaml, которую любой разработчик может добавить в утилиту тестирования API и получить карту всех запросов и всех данных, которые API получает на входе и выходе.
Это очень удобно для интеграции, так как впоследствии мы привязали к нашему микросервису SPA-приложение на Angular — админку, через которую осуществляется управление загрузками и бизнес-задачами.
Пример можно найти здесь .
А при генерации сервиса у нас есть дополнительный транспорт — GRPC. Это протокол, который работает параллельно с HTTP и помогает другим микросервисам интегрироваться с ним, просто используя его в качестве зависимости: зависимость включается в другой микросервис, и нет необходимости вручную интегрироваться с каждой конечной точкой.
Когда мы добавили зависимость от другого сервиса, у которого есть клиент, все, что нам нужно сделать, — это просто использовать метод. Все остальное уже готово: func (s *service) GetPeriodicalExports(ctx context.Context) (models []model.Setting, err error) {
resp, err := s.setting.ReadPeriodicalSetting(ctx, &setting.ReadPeriodicalSettingRequest{})
if err != nil {
return
}
for _, settingRow := range resp.Data {
models = append(models, settingRow.Attributes.ToRepo(settingRow.Id))
}
return
}
Интеграция с источниками данных
Важной задачей проекта была интеграция с источниками данных.У клиента был сервис, работавший на Java. Там в подробном виде предоставлена вся информация о категориях, товарах и их характеристиках – все, что нам нужно.
Поскольку данная интеграция была реализована ранее, при создании нового сервиса мы воспользовались существующими функциями.
Однако позже для нас сделали доработки, чтобы мы могли получать весь каталог товаров, а это 700 тысяч позиций.
Раньше нам приходилось делать тяжелые запросы в базу данных с характеристиками, и это занимало много времени.
И теперь весь каталог можно получить менее чем за 30 минут. Здесь нам на помощь пришел Aerospike. Судя по ориентиры , он работает быстрее, чем Redis, и ТШО (Общая стоимость владения) ниже.
Но мы решили проверить эти данные, поэтому заранее написали программу, добавили туда основных производителей и сравнили скорость работы через Gobench вместе с хранилищем в памяти.
Aerospike был нашим фаворитом, и мы сохранили его.
Делаем заявку, добавляем товары и идем дальше.
Мы обязательно добавим трассировку и журналирование для достижения наблюдаемости.
func (s *service) ImportProducts(ctx context.Context) error {
replMap := s.GetReplacementMap()
resp, err := s.semService.GetProductsContent(ctx, &sem.GetProductsContentRequest{})
if err != nil {
return errors.Wrap(err, "sem initial getProducts err")
}
dataSize := len(resp.Products)
for dataSize > 0 {
for _, p := range resp.Products {
s.ReplaceSymbols(&p, replMap)
err = s.repository.Add(ctx, p)
if err != nil {
return errors.Wrap(err, "aerospike getProducts add err")
}
}
resp, err = s.semService.GetProductsContent(ctx, &sem.GetProductsContentRequest{ScrollId: resp.ScrollId})
if err != nil {
return errors.Wrap(err, "sem cycle getProductsContent err")
}
dataSize = len(resp.Products)
}
return nil
}
А для создания региональных загрузок нам нужна была информация по регионам.
Все регионы хранились в другом сервисе Golang. Мы с ним интегрировались по протоколу GRPC. На данный момент это были две необходимые интеграции, с которыми нам нужно было работать, чтобы обеспечить агрегацию данных.
Пример: func NewGrpcConn(ctx context.Context, conf configs.Controller) (*grpc.ClientConn, error) {
var opts []grpc.DialOption
if conf.GRPC.TLS.Enabled {
opts = append(opts, grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
InsecureSkipVerify: conf.GRPC.TLS.InsecureSkipVerify,
})))
} else {
opts = append(opts, grpc.WithInsecure())
}
conn, err := grpc.DialContext(ctx, fmt.Sprintf("%s:%d", conf.GRPC.Host, conf.GRPC.Port), opts.)
if err != nil {
return nil, errors.Wrap(err, "Unable to connect to controller")
}
return conn, nil
}
И получаем некоторые данные: func (s *service) GetById(ctx context.Context, id uint64) (model model.Setting, err error) {
resp, err := s.setting.ReadSettingById(ctx, &setting.ReadSettingByIdRequest{Id: id})
if err != nil {
return
}
model = resp.Data.Attributes.ToRepo(id)
return
}
Кэш от Aerospike
Нам также нужны были некоторые зависимости.Для того чтобы нам где-то хранить данные (например, региональные настройки, настройки шаблона, частоту генерации), требовалась база данных.
Во время разработки мы решили использовать ORM. Это зависимость, но для кода, которая облегчает доступ к базе данных и упрощает работу с ней.
Мы использовали ОРМ горм .
Он работал хорошо: с ним было легко интегрироваться, а все результаты и накладные расходы на каждый запрос были довольно низкими.
Будем и дальше использовать его в менее нагруженных проектах.
В высоконагруженных ситуациях, очевидно, следует использовать PGX .
От проблем.
Как мы уже говорили, мы использовали Key-value от Aerospike. Этот кеш нам нужен был для хранения наших 700 тысяч товаров, потому что каждый раз при каждой генерации загрузки бежать к сервису с товаром и запрашивать все заново было нелогично.
Причём среднее время получения информации по ним составило плюс-минус 30 минут, так как товаров много и сервисом с товарами пользуются все.
Поэтому нам нужен был кеш данных, который мы могли бы периодически обновлять, не останавливая наши бизнес-процессы.
Если бы нам нужно было заполнить кеш новыми данными или перезаписать старые, мы могли бы столкнуться с проблемой: если бы в источнике данных изменился какой-то тип — например, строка стала числом или число стало стройкой — то мы бы возникают ошибки при загрузке товаров.
Причём они неявные, которые сразу не улавливаются.
Как мы с этим справились: создали функции для ручной очистки хранилища.
Теперь, когда у нас не работает импорт товаров, мы получаем ошибку в Sentry и понимаем, что у нас изменилась структура и нам нужно полностью очистить хранилище.
В этом случае после очистки нам придется остановить генерацию, так как мы не сможем генерировать какие-либо файлы.
Это лучшее решение, которое мы придумали.
Но если вы столкнулись с такой же проблемой, то будет интересно узнать, как вы ее решили.
Напишите пожалуйста в комментариях.
Работа всей системы
Сейчас я расскажу вам, как все это работает. В проекте мы используем Cron и фоновый Runner. Для тех, кто не знает, Cron — это штука, которая запускает задачи по расписанию: каждую минуту, или каждые 5 минут, или каждую 5-ю минуту каждого 12-го часа, например.В первой версии сервиса, который мы реализовали, мы использовали функционал для выполнения фоновых задач.
Это значит, что внутри работающего сервиса, когда бы мы ни захотели, мы запускали какую-то функцию одновременно (например, каждый час).
Она сгенерировала для нас файлы и загрузила их на S3. Это решение было массово распространено на другие микросервисы.
И чтобы ускорить разработку, мы его позаимствовали.
Но так как позже столкнулись с проблемами, пришлось переделывать.
Механизма организации очередей в принципе не было, поэтому мы добавили внутри микросервиса небольшой FIFO (первым пришел — первым обслужен).
По сути, это очередь: первым пришел — первым вышел, выполнение в порядке поступления в очередь.
Но в результате работы с этим функционалом и очередью мы были ограничены в ресурсах на каждом поде.
Под — это абстрактный объект Kubernetes, представляющий группу из одного или нескольких контейнеров приложений.
У нас были большие проблемы с блокировкой запуска задач на остальных модулях, поскольку они работают в режиме максимальной доступности.
В данном случае у нас есть 2 или 3 запущенные реплики микросервиса.
С какими проблемами вы столкнулись?
В то же время, когда мы запускаем службу, у нас возникает проблема: у нас есть 3 модуля, генерирующие одни и те же файлы параллельно.Это очень нелогично и неудобно, пришлось с этим бороться, блокировать, использовать механизм Service Discovery. Это все были неверные решения, которые мы вскоре переработали.
Соответственно, мы потеряли возможность выполнять задачи параллельно.
То есть оставляем один под, генерируем, скажем, 15 задач.
При этом они генерируются по порядку, так как в процессе генерации все ресурсы, которые выделяются поду, заимствуются при генерации задач.
Это не очень хорошо; с этим тоже пришлось разобраться позже.
Еще одна проблема.
Когда сервис падал (например, что-то ломалось в кластере и падал наш под), все наши запущенные задачи также падали.
Плюс у нас не было механизма ограничения задач.
Это значит, что на одном сервисе мы не смогли ограничить количество одновременно выполняемых задач, причем достаточно простым способом.
Эту проблему тоже пришлось решать.
Плюс замороженные задачи: одна заморожена, остальные не двигаются, мы не можем ее просто убить, с ней нужно что-то делать.
Как они решили
Что мы придумали? Мы запускали задачи через сам Kubernetes. То есть мы создаем отдельную программу (она же репозиторий) и переносим в это новое приложение всю бизнес-логику, связанную с генерацией.Другими словами, у нас будет уже не сервис, а программа.
Принципиальное отличие сервиса от программы состоит в том, что сервис работает непрерывно и прослушивает входящие соединения или выполняет какую-то фоновую обработку, тогда как программа запускается один раз, выполняет работу и завершает свою работу.
Соответственно, эти микрозадачи следует запускать внутри кластера Kubernetes. Также мы планировали создать механизм очередного запуска задач (first in — first out), чтобы задачи всегда выполнялись в пределах своей позиции и в случае сбоя сервиса ничего не пострадало, а при перезапуске работа возобновилась.
Также необходимо было сделать ограничитель параллельного запуска одинаковых задач.
То есть эту логику тоже надо было реализовать.
Исходя из этого, нам понадобилось создать сборщик мусора, который будет очищать старые зависшие задачи, ведь задача может зависать в кластере Kubernetes — от этого никто не застрахован.
Что дальше
Вот и все.В статье я рассказал, какой сервис мы сделали, с какими проблемами столкнулись и как будем эти проблемы решать.
На данный момент можно сказать, что в планах реализация механизма взаимодействия с KubeAPI и доработка бизнес-логики под текущие задачи, а также их решение.
Обязательно напишу вторую часть, в которой расскажу о проблемах с новой реализацией, как мы их решали и тестировали на производстве.
Я также поделюсь своими мыслями о том, как бы мы их решили, если бы сделали это снова.
Теги: #postgresql #разработка #Go #golang #php #MySQL #разработка веб-сайтов #go-kit #Protobuf3 #protoc-gen
-
К Вопросу О Linux (Л)
19 Oct, 24 -
Обзор Редакторов Тегов Id3 В Mac Os X
19 Oct, 24 -
Программисты И Менеджмент
19 Oct, 24 -
Выпущена Бета-Версия Grails 1.1
19 Oct, 24