В поисках gRPC-шлюза

Kate

Administrator
Команда форума
Дано:

  • несколько gRPC-сервисов, каждый слушает свой порт.
  • сервисы могут доверенно подключаться друг к другу, для аутентификации используется Mutual TLS.
  • некоторые процедуры предназначены только для внутреннего пользования, доступ извне к ним должен быть ограничен
Найти:

  • единую точку входа для API (API Gateway) для gRPC, HTTP/2.
Дисклеймер: решение так и не найдено, зато проведено исследование gRPC-отражения (reflection). Много ссылок.

Возможные варианты:

  • nginx
  • Envoy
  • Traefik
  • Istio
  • самописное решение
В ходе просмотра первых трех возник вопрос - как настроить mTLS для внутренних подключений(между шлюзом и точкой назначения)? Вопрос я не решил.

Ссылки для изучения возможностей nginx и envoy:

https://www.nginx.com/blog/nginx-1-13-10-grpc/

https://habr.com/ru/post/351994/

https://www.nginx.com/blog/deploying-nginx-plus-as-an-api-gateway-part-3-publishing-grpc-services/

https://dropbox.tech/infrastructure/how-we-migrated-dropbox-from-nginx-to-envoy

Чем больше я копал, тем больше фрустрация меня одолевала. Kubernetes, Ingress, Istio, service mesh, темный лес - аааа.

В итоге решено было реализовать самописное решение на языке go.

Сказано - сделано.

Решение​

Приложение подключается к определенным в файле конфигурации точкам, считывает информацию о предоставляемых ими сервисах и типах сообщений, используя gRPC-reflection (на русском назовем отражением), суммирует все полученные сервисы и создает хэш-таблицу с именами метода в качестве ключа и gRPC-подключением в качестве значения.

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

для proxy и для grpc-отражения используются модули:
https://github.com/mwitkow/grpc-proxy
https://github.com/jhump/protoreflect

Создается gRPC-сервер, регистрируется прокси.

В завершение, регистрируется сервис gRPC-отражения со всеми собранными proto-определениями.

Файл конфигурации​

Файл конфигурации выглядит следующим образом:

endpoints:
- dial: "localhost:50051"
- dial: "localhost:50052"
- dial: "localhost:50053"
ca_cert: "../certs/ca.crt"
server:
listen: ":42001"
blacklist:
- "/grpc.examples.echo.Echo/ServerStreamingEcho"
Секция endpoints определяет внутренние подключения. В приведенном выше примере определены три: два в незащищенном режиме, один с использованием TLS. Также возможно опциями my_cert, my_key указать на ключевую пару клиента для использования режима mTLS.

Сервер настраивается в секции server.
В приведенном примере указан только привязываемый порт 42001, возможно использовать также опции my_cert, my_key для указания ключевой пары сервера и ca_cert для аутентификации клиентов.

Отражение​

Чтобы знать куда перенаправлять вызов по имени метода нам нужно получить список этих самых методов. Для этого используем протокол серверного отражения.

Документацию по данному протоколу можно найти здесь:
https://github.com/grpc/grpc/blob/master/doc/server-reflection.md

Оттуда можно узнать, что отражение предназначено для возможности получить proto-определения "на лету", без необходимости кодогенерации инструментом protoc.

Пожалуй, это самая полный документ, что мне удалось найти.
Содержание многих остальных включает описание и указание вызвать reflection.Register(s) для сервера. Поставленная же задача - обратная.

Из описания видно, что отражение - это gRPC-сервис с одним методом grpc.reflection.v1alpha.ServerReflection.ServerReflectionInfo.
Хоть метод и один, запрос определяется полем oneof message_request.
Оттуда нам потребуются запросы list_services и file_containing_symbol.

Для исследования запустим greeter_server из официального репозитория grpc-go/examples/helloworld и подключимся утилитой evans.

~$ evans --host localhost --port 50052 -r

______
| ____|
| |__ __ __ __ _ _ __ ___
| __| \ \ / / / _. | | '_ \ / __|
| |____ \ V / | (_| | | | | | \__ \
|______| \_/ \__,_| |_| |_| |___/

more expressive universal gRPC client


helloworld.Greeter@localhost:50052> package grpc.reflection.v1alpha

grpc.reflection.v1alpha@localhost:50052> service ServerReflection

grpc.reflection.v1alpha.ServerReflection@localhost:50052> call ServerReflectionInfo
host (TYPE_STRING) =>
✔ list_services
list_services (TYPE_STRING) => *
host (TYPE_STRING) => {
"listServicesResponse": {
"service": [
{
"name": "grpc.reflection.v1alpha.ServerReflection"
},
{
"name": "helloworld.Greeter"
}
]
},
"originalRequest": {
"listServices": "*"
}
}
host (TYPE_STRING) =>
✔ file_containing_symbol
file_containing_symbol (TYPE_STRING) => helloworld.Greeter
host (TYPE_STRING) => {
"fileDescriptorResponse": {
"fileDescriptorProto": [
"Ci9leGFtcGxlcy...base64encoding..=="
]
},
"originalRequest": {
"fileContainingSymbol": "helloworld.Greeter"
}
}

Находим в исходниках evans:
https://github.com/ktr0731/evans/blob/master/grpc/grpcreflection/reflection.go


Все просто: первым делом, получаем список сервисов, далее, получаем описания файлов, определяющих данный сервис.


Без зазрения совести(смотрим лицензии) копируем, добавляем обертку:

func DiscoverServices(conn *grpc.ClientConn) (error, []string, []*desc.FileDescriptor) {

stub := rpb.NewServerReflectionClient(conn)

rclient := grpcreflect.NewClient(context.Background(), stub)

// получить proto-файлы
fds, err := ListPackages(rclient)
if err != nil {
return err, []string{}, []*desc.FileDescriptor{}
}

// Да, далее дублирование кода(ListServices и ResolveService),
// в рамках исследования, допускаем
services, err := rclient.ListServices()
if err != nil {
return err, []string{}, []*desc.FileDescriptor{}
}

// получить список методов в виде строк /helloworld.Greeter/SayHello
var methods []string
for _, srv := range services {
sdes, err := rclient.ResolveService(srv)
if err == nil {
for _, m := range sdes.GetMethods() {
fullMethodName := "/" + srv + "/" + m.GetName()
methods = append(methods, fullMethodName)
}
}
}
return nil, methods, fds
}
В фунции main, при подключении к gRPC-сервису добавляем функцию для рекурсивного получения всех зависимостей и вызываем для сбора всех дескрипторов, исключая пакет grpc.reflection:

import "github.com/jhump/protoreflect/desc"
...
...
func main() {
....
// map of processed methods
mProcessed := map[string]struct{}{}
// mapping method => connection
mapping := make(map[string]*grpc.ClientConn)
// collected file descriptors
fdsCollected := make([]*desc.FileDescriptor, 0)

for _, cli := range appConfig.Endpoints {
...

err, methods, fds := DiscoverServices(conn)
if err != nil {
panic(err)
}

var processFds func(f *desc.FileDescriptor) []*desc.FileDescriptor
processFds = func(f *desc.FileDescriptor) []*desc.FileDescriptor {
var result []*desc.FileDescriptor
result = append(result, f)
for _, d := range f.GetDependencies() {
result = append(result, processFds(d)...)
}
for _, d := range f.GetWeakDependencies() {
result = append(result, processFds(d)...)
}
for _, d := range f.GetPublicDependencies() {
result = append(result, processFds(d)...)
}
return result
}

for _, f := range fds {
if strings.HasPrefix(f.GetPackage(), "grpc.reflection") {
continue
}

fdsCollected = append(fdsCollected, processFds(f)...)
}

Далее, в этом же цикле, собираем список методов и, если метод был найден для другого подключения ранее, плюемся:

for _, m := range methods {
log.Println("method discovered: ", m)
if strings.HasPrefix(m, "/grpc.reflection.") {
// ignore reflection.
continue
}
if _, ok := mProcessed[m]; ok {
panic("duplicate method discovered!: " + m)
}
mProcessed[m] = struct{}{}
mapping[m] = conn
}
На этом сбор proto-определений, gRPC-методов завершен.

Прокси​

Для реализации, как уже было написано выше, выбран пакет https://github.com/mwitkow/grpc-proxy

Берем базовый пример и на его основе пишем функцию director:

director := func(ctx context.Context, fullMethodName string) (context.Context,
*grpc.ClientConn, error) {

log.Println("somebody calling: ", fullMethodName)

// blacklist
for _, bl := range appConfig.Blacklist {
if strings.HasPrefix(fullMethodName, bl) {
log.Println("method is blacklisted")
return ctx, nil, grpc.Errorf(codes.Unimplemented, "blacklisted")
}
}
if conn, ok := mapping[fullMethodName]; ok {
log.Println("method registered")
md, ok := metadata.FromIncomingContext(ctx)
if ok {
outCtx, _ := context.WithCancel(ctx)
outCtx = metadata.NewOutgoingContext(outCtx, md.Copy())
return outCtx, conn, nil
}
}

log.Println("unknown method")
return ctx, nil, nil
}

Все, что она делает - ищет по имени метода подключение из хэш-таблицы и возвращает это подключение.

Далее, регистрируем для сервера:

var opts []grpc.ServerOption
opts = append(opts, grpc.CustomCodec(proxy.Codec()))
opts = append(opts, grpc.UnknownServiceHandler(proxy.TransparentHandler(director)))

Еще раз отражение​

Теперь уже надо подключить протокол отражения для прокси сервера: все собранные файлы есть, почему бы прокси-серверу не рассказать клиентам о всех доступных сервисах?

Здесь я столкнулся с недостатком документации: все сводится к вызову reflection.Register(s). Находим определение здесь:
https://github.com/grpc/grpc-go/blob/v1.43.0/reflection/serverreflection.go

Изучаем. Структура, определяющая gRPC-сервис отражения:

type serverReflectionServer struct {
rpb.UnimplementedServerReflectionServer
s GRPCServer

initSymbols sync.Once
serviceNames []string
symbols map[string]*dpb.FileDescriptorProto // map of fully-qualified names to files
}
Далее смотрим реализацию процедуры ServerReflectionInfoдля типа запроса ListServices. Видим, что вызывается в первую очередь метод getSymbols().

Вызове функции происходит следующим образом:

s.initSymbols.Do(func() {
serviceInfo := s.s.GetServiceInfo()

s.symbols = map[string]*dpb.FileDescriptorProto{}
s.serviceNames = make([]string, 0, len(serviceInfo))
....
...
})

return s.serviceNames, s.symbols

При первом вызове с gRPC сервера считывается сервисная информация, далее, оттуда выдергиваются имена сервисов и proto-дескрипторы.

По идее, надо всего-лишь заменить этот метод своей реализацией, чтобы вернуть символы и сервисы из уже собранных файлов.

Для этого:

Добавляем к структуре serverReflectionServer таблицу fds map[string]*desc.FileDescriptor. Ключом будет являться имя файла.
Вместо метода Register пишем RegisterCustomReflection, принимающем массив дескрипторов на вход:

func RegisterCustomReflection(s GRPCServer, fds []*desc.FileDescriptor) {
srs := &serverReflectionServer{
s: s,
fds: make(map[string]*desc.FileDescriptor),
}
for _, f := range fds {
if _, ok := srs.fds[f.GetName()]; !ok {
srs.fds[f.GetName()] = f
}
}
rpb.RegisterServerReflectionServer(s, srs)
}


переписываем функцию getSymbols следующим образом:

s.initSymbols.Do(func() {
s.symbols = map[string]*dpb.FileDescriptorProto{}
s.serviceNames = []string{}
fProcessed := map[string]struct{}{}
sProcessed := map[string]struct{}{}
for _, fd := range s.fds {
// for each file descriptor
// get services, push it's name to serviceNames
// done: processFile(fd)
ssvcs := fd.GetServices()
for _, svc := range ssvcs {
fqn := svc.GetFullyQualifiedName()

// if there is duplicated service fqn, don't add it to slice
// e.g. reflection used in multiple services
if _, ok := sProcessed[fqn]; !ok {
s.serviceNames = append(s.serviceNames, fqn)
sProcessed[fqn] = struct{}{}
}
}
s.processFile(fd.AsFileDescriptorProto(), fProcessed)
}

sort.Strings(s.serviceNames)
})
При подключении клиента evans отправляется запросListServices, ответ корректен. Но вот далее возникает ошибка "unknown file: ..". Поиск указывает на функцию fileDescEncodingByFilename. Для получения дескриптора файла вызывается proto.FileDescriptor(name), который как раз возвращает nil и это приводит к вышеупомянутой ошибке. Где proto.FileDescriptor() должен найти файл я разбираться не стал, поменял на следующее:

func (s *serverReflectionServer) fileDescEncodingByFilename(name string, sentFileDescriptors map[string]bool) ([][]byte, error) {
// use s.fds instead
fd, ok := s.fds[name]
if ok {
return fileDescWithDependencies(fd.AsFileDescriptorProto(), sentFileDescriptors)
}
return nil, fmt.Errorf("unknown file: %v", name)
}

В итоге заработало, evans считывает протокол отражения корректно.

bobalus@penguin:~/build/test$ evans --host localhost --port 42001 -r

______
| ____|
| |__ __ __ __ _ _ __ ___
| __| \ \ / / / _. | | '_ \ / __|
| |____ \ V / | (_| | | | | | \__ \
|______| \_/ \__,_| |_| |_| |___/

more expressive universal gRPC client


localhost:42001> show package
+--------------------+
| PACKAGE |
+--------------------+
| grpc.examples.echo |
| helloworld |
+--------------------+

localhost:42001> package helloworld

helloworld@localhost:42001> service Greeter

helloworld.Greeter@localhost:42001> call SayHello
name (TYPE_STRING) => friend
{
"message": "Hello friend"
}

helloworld.Greeter@localhost:42001>
Единственное, в списке пакетов отстутствует grpc.reflection.v1apha.
Как это починить - мне пока не известно, к тому же решено проект оставить как инструмент для отладки, а не как шлюз.

Репозиторий проекта:
https://github.com/shabunin/grpc-api-gw

Заключение​

Решение для API-шлюза мною найдено не было.
Считаю так, поскольку, во-первых, протокол gRPC-отражения на данный момент имеет версию альфа, документации мало.
Во-вторых, потому-что код я написал достаточно страшный. =)
Остается исследовать nginx, envoy, traefik.

 
Сверху