Дано:

  • несколько 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.
Спасибо всем, кто уделил время изучению статьи. Буду рад обратной связи.

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


  1. korjavin
    14.01.2022 01:57

    Не нужны вам динамические бекенды?


    1. bobalus Автор
      14.01.2022 15:05

      Я, пожалуй, в статье немного неясно описал цель и результат.

      Вношу уточнение: решение не найдено (еще) поскольку самописное годится для экспериментов и в качестве инструмента для отладки: там альфа на альфе и сам автор grpc-proxy пишет, что для продакшна не стоит использовать.

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

      Динамические в каком смысле? На лету менять прото-определения? Тогда и клиент должен подстраиваться постоянно. И это ничем не лучше, чем зафиксировать версию API и пробросить маршруты в конфиге.


      1. korjavin
        14.01.2022 15:57

        Скорее в смысле что бекенды (сервисы) поднимаются и опускаются, и масштабируются


  1. uyrij
    14.01.2022 13:21

    https://github.com/NginxProxyManager/nginx-proxy-manager это конфиг, когда менеджер смотрит наружу а локальные сервисы остаются за ним. у вас , как я понял , три внешних сервиса, но если можно их связать локально, то этот докер образ будет полезен.????


    1. bobalus Автор
      14.01.2022 15:13

      Спасибо, установлю - отпишу результат.


  1. aleks_raiden
    14.01.2022 17:20

    А такие варианты не решат вопрос?


  1. grossws
    16.01.2022 01:53

    Расскажите пожалуйста чем не хватило envoy и где были с ним затыки. У меня было ощущение что у них одна из фич была трансляция grpc over http/1.1 в нормальный grpc (которая grpc-web) и, соответственно, в качестве более простого grpc gateway должно тем более работать. Но так как сам не пробовал -- интересно что там.