golang

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

  • вторник, 18 июня 2024 г. в 00:00:06
https://habr.com/ru/articles/822189/

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 улучшит ситуацию.