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-соединение реализуется тремя физическими:
пользователь <=> pog-client
pog-client <=gRPC=> pog-server
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
улучшит ситуацию.