Об одном использовании gRPC: HTTP-прокси pog-server

от автора

HTTP-прокси — это программа для для выполнения HTTP-запросов клиента с другого IP-адреса.

gRPC — система передачи данных на HTTP/2-транспорте и в качестве языка интерфейсов использующая Protocol Buffers.

Я разработал HTTP-прокси pog-server, выложил в Open Source и хочу поделиться историей разработки. Собственно байты переносятся посредством gRPC:

пользователь <=> pog-client <=gRPC=> pog-server <=> конечный HTTP-сервер 

Зачем

В наше время программисту приходится использовать прокси-сервера. Я пользовался одним, пока не потребовался доступ к ChatGPT: так у меня стало 2 прокси-сервера.

Затем мне потребовался Terraform. Он заработал под одним прокси-сервером примерно вот так:

$ export HTTPS_PROXY=http://west.catbo.net:18080 $ terraform init 

; однако вместе с этим я делал запросы к Google API, и тот забраковал прокси-сервер. Так мне пришлось балансировать, когда и какой прокси-сервер использовать.

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

Так как проекты у нас на работе на GCP, то идеальным выбором стал бы сервис Cloud Run, играющий роль прокс-сервера.

Осталось только написать код. Как говорится, «let’s make this world a better place».

gRPC. Предыдущий опыт

На предыдущей работе один из сервисов был вдохновлен gRPC: информация от центральной ноды до edge-серверов и обратно передавалась в формате Protocol Buffers. Однако полноценно использовать gRPC было невозможно, потому что в качестве траспорта был не HTTP/2, а RabbitMQ. Все это работало, но эксплуатировать было неудобно из-за отсутствия инструментов вроде grpcurl. Поэтому в итоге от Protocol Buffers перешли к JSON-ам, а для запроса информации с центральной ноды вообще прямыми HTTP-запросами обошлись. Мораль: если и использовать gRPC, то только в полном составе, «wanna be gRPC» не работает.

Второй мой контакт с subj приключился на собеседовании: меня спросили про умения в gRPC, и тут я понял, что полноценного опыта у меня не было на тот момент. Было видно, что собеседующий немного раздосадован (их проект связан с блокчейном, а там соединения server-server повсюду и gRPC весьма уместен). Хоть на результате собеседования это и не сказалось, осадок у меня остался.

Реализация

При написании прокси я был вдохновлен статьей Michał Łowicki, в которой он показывает как с помощью 100 строк кода написать прокси-сервер. По факту обработчик из 2 функций выполняет всю работу:

func handleTunneling(w http.ResponseWriter, r *http.Request) {     dest_conn, err := net.DialTimeout("tcp", r.Host, 10*time.Second)     if err != nil {         http.Error(w, err.Error(), http.StatusServiceUnavailable)         return     }     w.WriteHeader(http.StatusOK)     hijacker, ok := w.(http.Hijacker)     if !ok {         http.Error(w, "Hijacking not supported", http.StatusInternalServerError)         return     }     client_conn, _, err := hijacker.Hijack()     if err != nil {         http.Error(w, err.Error(), http.StatusServiceUnavailable)     }     go transfer(dest_conn, client_conn)     go transfer(client_conn, dest_conn) }  func transfer(destination io.WriteCloser, source io.ReadCloser) {     defer destination.Close()     defer source.Close()     io.Copy(destination, source) } 

Знай себе за-deploy сервис на Cloud Run, и задача будет выполнена (и gRPC не потребуется). Что я немедля и сделал. Однако, не заработало: обработчик должен вызываться с методом CONNECT, а он (предусмотрительно?) забанен на Cloud Run.

Ну хорошо, значит а) напрямую нельзя и без gRPC не обойтись и б) даже хорошо, меньше «мамкиных» инженеров смогут выходить через GCP.

При реализации достаточно было разделить обе вышеуказанные функции на клиентскую и серверную части (относительно gRPC), что я постепенно и сделал.

Особенности реализации и оперирования

h2c-формат

Для каждого проксированного соединения создается один поток внутри gRPC-соединения. Соответственно, для интерфейса нам подойдет только bidirectional streaming. Интерфейс выглядит так:

service HTTPProxy {   rpc Run(stream Packet) returns (stream Packet) {} } 

Относительно Cloud Run это означает, что нужно включить на сервисе формат h2c, потому что нужна полноценная поддержка HTTP/2. Без этого флага все работает по HTTP/1 и никакого стриминга.

Проверим, что у нас сервис работает в правильном формате:

$ gcloud run services describe pog-server --format=export | grep -A3 ports         ports:         - containerPort: 8080           name: h2c         resources: 

Вообще, в gPRC-спецификации жестко зафиксирован только (расово верный) формат h2, но в реальности работает только h2c (радуемся тому что есть).

Управление соединениями

Каждое логическое HTTP-соединение реализуется тремя физическими:

  1. пользователь <=> pog-client

  2. pog-client <=gRPC=> pog-server

  3. pog-server <=> конечный HTTP-сервер

Как только произошел разрыв на первом или третьем, необходимо закрыть остальные 2, иначе будет утечка (соединений, памяти). Интересна разница как закрывать gRPC соединение между клиентом и сервером: если на клиенте достаточно вызвать соответствующий stream.CloseSend() или аналог, то на сервере такой возможности нет, и единственный способ закрыть соединения это просто выйти из обработчика gRPC.

Кол-во текущих HTTP-сессий это важная метрика, её можно посмотреть так:

$ curl -is http://localhost:18080/metrics | grep tunnelling # HELP pog_client_tunnelling_connections_total Number of connections tunneling through the proxy. # TYPE pog_client_tunnelling_connections_total gauge pog_client_tunnelling_connections_total 28 # HELP tunnelling_connections_total Number of connections tunneling through the proxy. # TYPE tunnelling_connections_total gauge tunnelling_connections_total 28 

HTTP-сервер и gRPC-сервер на одном порту

Вообще, на Go сейчас существуют 3 реализации gRPC:

  • go-grpc:

    • это оригинальная реализация, со своей реализацией сервера HTTP/2

    • много разных плюшек внутри (codecs & plugins)

  • стандартный сервер «net/http» (только HTTP/2-соединения) + обработчик из grpc-go func ServeHTTP(w http.ResponseWriter, r *http.Request):

    • позволяет на одном порту вешать и сервер gRPC, и сервер HTTP

    • до сих пор способ помечен как экспериментальный, см. код

  • хипстерский:

    • утверждают, что новый дизайн позволяет писать gRPC-код так же просто, как и HTTP

    • иная генерация кода из интерфейса (с Go-шаблонами)

    • стандартный сервер «net/http» и своя обработка gRPC

    • много документации как тестить с помощью curl, grpcurl и прочее

pog-server по умолчанию запускается во втором режиме, чтобы можно было читать Prometheus-метрики на /metrics.

Тестирование с помощью grpctest

За время написания кода не нашел аналога «net/http/httptest», написал свой вариант grpctest. Эта библиотека позволяет писать unit-тесты для gRPC-сервисов, при этом клиентский и серверный код отлаживаются в одном процессе, например:

func TestGStacks(t *testing.T) {    // создаем сервер    server := grpc.NewServer()    RegisterGStacksSvc(server)     // запускаем    sc, err := grpctest.StartServerClient(server)    require.NoError(t, err)    defer sc.Close()     // делаем запрос    client := pb.NewGoroutineStacksClient(sc.Conn)    resp, err := client.Invoke(context.Background(), &pb.Request{})    require.NoError(t, err)     // проверяем результат    fmt.Println(resp.Data) } 

Разное

  • Метод CONNECT используется только для запроса HTTPS-адресов, а для HTTP нужно реализовывать иначе, см. handleHTTP. Для публичного интернета HTTP малоактуален, потому (пока?) не реализовано.

  • В целом, для технологии gRPC мало существует практических интрументов типа «установил и пользуешься»; кроме как grpcurl все остальные предполагают опять же программировать (могу ошибаться). Надеюсь, pog-server улучшит ситуацию.


ссылка на оригинал статьи https://habr.com/ru/articles/822189/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *