Дано:
несколько 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)
uyrij
14.01.2022 13:21https://github.com/NginxProxyManager/nginx-proxy-manager это конфиг, когда менеджер смотрит наружу а локальные сервисы остаются за ним. у вас , как я понял , три внешних сервиса, но если можно их связать локально, то этот докер образ будет полезен.????
grossws
16.01.2022 01:53Расскажите пожалуйста чем не хватило envoy и где были с ним затыки. У меня было ощущение что у них одна из фич была трансляция grpc over http/1.1 в нормальный grpc (которая grpc-web) и, соответственно, в качестве более простого grpc gateway должно тем более работать. Но так как сам не пробовал -- интересно что там.
korjavin
Не нужны вам динамические бекенды?
bobalus Автор
Я, пожалуй, в статье немного неясно описал цель и результат.
Вношу уточнение: решение не найдено (еще) поскольку самописное годится для экспериментов и в качестве инструмента для отладки: там альфа на альфе и сам автор grpc-proxy пишет, что для продакшна не стоит использовать.
Полезная нагрузка статьи в том, что описывается работа с gRPC-отражением чуть подробнее, чем reflection.Register(s).
Динамические в каком смысле? На лету менять прото-определения? Тогда и клиент должен подстраиваться постоянно. И это ничем не лучше, чем зафиксировать версию API и пробросить маршруты в конфиге.
korjavin
Скорее в смысле что бекенды (сервисы) поднимаются и опускаются, и масштабируются