Служба балансировки нагрузки gRPC с помощью Nginx
На данный момент мы многое узнали о том, как разрабатывать серверные веб-сервисы с помощью gRPC. Когда дело доходит до развертывания, нам следует учитывать одну важную вещь — балансировку нагрузки.
Крупномасштабное развертывание gRPC обычно включает несколько идентичных внутренних серверов и несколько клиентов. Балансировка нагрузки используется для оптимального распределения нагрузки от клиентов по доступным серверам.
Виды балансировки нагрузки
Существует два основных варианта балансировки нагрузки gRPC: на стороне сервера и на стороне клиента. Решение о том, какой из них использовать, является основным архитектурным выбором.
Балансировка нагрузки на стороне сервера
При балансировке нагрузки на стороне сервера клиент отправляет RPC балансировщику нагрузки или прокси-серверу, например Nginx
или Envoy
. Балансировщик нагрузки распределяет вызов RPC на один из доступных внутренних серверов.
Он также отслеживает нагрузку на каждый сервер и реализует алгоритмы справедливого распределения нагрузки. Сами клиенты не знают о внутренних серверах.
Балансировка нагрузки на стороне клиента
При балансировке нагрузки на стороне клиента клиент знает о нескольких внутренних серверах и выбирает один для каждого RPC. Обычно внутренние серверы регистрируются в инфраструктуре обнаружения служб, например Consul
или Etcd
. Затем клиент связывается с этой инфраструктурой, чтобы узнать адреса серверов.
Толстый клиент сам реализует алгоритмы балансировки нагрузки. Например, в простой конфигурации, где не учитывается нагрузка на сервер, клиент может просто выполнять циклический перебор между доступными серверами.
Другой подход — использовать внешний балансировщик нагрузки, в котором функции балансировки нагрузки реализованы на специальном сервере балансировки нагрузки. Клиенты запрашивают резервный балансировщик нагрузки, чтобы выбрать лучший сервер(ы) для использования. Тяжелая работа по сохранению состояния сервера, обнаружению сервисов и реализации алгоритма балансировки нагрузки объединена в резервном балансировщике нагрузки.
За и против
Одним из плюсов балансировки нагрузки на стороне сервера является простая реализация клиента. Все, что нужно знать клиенту, — это адрес прокси, никакого дополнительного кодирования не требуется. Этот подход работает даже для ненадежных клиентов, а это означает, что служба gRPC может быть открыта для всех из общедоступного Интернета.
Однако его минусом является добавление к вызову еще 1 дополнительного перехода. Все RPC должны пройти через прокси-сервер, прежде чем достичь внутреннего сервера, что приводит к более высокой задержке. Таким образом, такая балансировка нагрузки на стороне сервера подходит для случаев, когда имеется много клиентов из открытого Интернета, возможно, не заслуживающих доверия, которые хотят подключиться к нашим серверам gRPC в центре обработки данных.
С другой стороны, балансировка нагрузки на стороне клиента не добавляет к вызову дополнительных переходов и, таким образом, обеспечивает более высокую производительность в целом. Однако реализация клиента теперь становится сложной, особенно для подхода с толстым клиентом. Поэтому его следует использовать только для доверенных клиентов, иначе нам придется использовать внешний балансировщик нагрузки, чтобы стоять перед сетью границы доверия. Балансировка нагрузки на стороне клиента часто используется в системах с очень высоким трафиком и архитектуре микросервисов.
В этой статье мы узнаем, как настроить балансировку нагрузки на стороне сервера для наших gRPC
сервисов с помощью Nginx
.
Рефакторинг кода
Поскольку я собираюсь показать вам различные Nginx
конфигурации, в которых TLS можно включать и отключать на сервере и клиенте, давайте немного обновим наш код, чтобы включить новый аргумент командной строки.
Обновление сервера
На сервере давайте добавим новый логический флаг enableTLS
, который сообщит нам, хотим ли мы включить TLS на нашем сервере gRPC или нет. Его значение по умолчанию — false
.
func main() { port := flag.Int("port", 0, "the server port") enableTLS := flag.Bool("tls", false, "enable SSL/TLS") flag.Parse() log.Printf("start server on port %d, TLS = %t", *port, *enableTLS) ... }
Затем давайте вынесем перехватчики в отдельную serverOptions
переменную. Проверяем enableTLS
флаг. Только в случае, если он включен, мы загружаем учетные данные TLS и добавляем эти учетные данные в срез параметров сервера. Наконец, мы просто передаем параметры сервера вызову grpc.NewServer()
функции.
func main() { ... interceptor := service.NewAuthInterceptor(jwtManager, accessibleRoles()) serverOptions := []grpc.ServerOption{ grpc.UnaryInterceptor(interceptor.Unary()), grpc.StreamInterceptor(interceptor.Stream()), } if *enableTLS { tlsCredentials, err := loadTLSCredentials() if err != nil { log.Fatal("cannot load TLS credentials: ", err) } serverOptions = append(serverOptions, grpc.Creds(tlsCredentials)) } grpcServer := grpc.NewServer(serverOptions...) ... }
И это все для сервера. Давайте сделаем то же самое для клиента!
Обновить клиент
Сначала мы добавляем enableTLS
флаг в аргумент командной строки. Затем мы определяем transportOption
переменную со значением по умолчанию grpc.WithInsecure()
.
func main() { serverAddress := flag.String("address", "", "the server address") enableTLS := flag.Bool("tls", false, "enable SSL/TLS") flag.Parse() log.Printf("dial server %s, TLS = %t", *serverAddress, *enableTLS) transportOption := grpc.WithInsecure() if *enableTLS { tlsCredentials, err := loadTLSCredentials() if err != nil { log.Fatal("cannot load TLS credentials: ", err) } transportOption = grpc.WithTransportCredentials(tlsCredentials) } cc1, err := grpc.Dial(*serverAddress, transportOption) if err != nil { log.Fatal("cannot dial server: ", err) } authClient := client.NewAuthClient(cc1, username, password) interceptor, err := client.NewAuthInterceptor(authClient, authMethods(), refreshDuration) if err != nil { log.Fatal("cannot create auth interceptor: ", err) } cc2, err := grpc.Dial( *serverAddress, transportOption, grpc.WithUnaryInterceptor(interceptor.Unary()), grpc.WithStreamInterceptor(interceptor.Stream()), ) if err != nil { log.Fatal("cannot dial server: ", err) } laptopClient := client.NewLaptopClient(cc2) testRateLaptop(laptopClient) }
Только когда enableTLS
значение флага равно true
, мы загружаем учетные данные TLS из файлов PEM и меняем их transportOption
на grpc.WithTransportCredentials(tlsCredentials)
. Наконец, мы передаем transportOption
соединениям grpc. И клиент готов.
Протестируйте новый флаг
Теперь, если мы запустим make server
, мы увидим, что сервер работает с отключенным TLS.
А если мы запустим make client, он также будет работать без TLS, и все вызовы RPC будут успешными.
Если мы добавим -tls
флаг к make server
команде и перезапустим ее, TLS будет включен.
... server: go run cmd/server/main.go -port 8080 -tls ...
Если мы запустим make client
сейчас, запросы не будут выполнены:
Нам также необходимо включить TLS на стороне клиента, добавив -tls
флаг в make client
команду.
... client: go run cmd/client/main.go -address 0.0.0.0:8080 -tls ...
И теперь мы видим, что запросы снова успешны.
Обновить Makefile
Хорошо, теперь флаг TLS работает так, как мы хотели. Прежде чем добавлять Nginx
, давайте Makefile
немного обновим наш код, чтобы мы могли легко запускать несколько экземпляров сервера и клиента с TLS или без него.
Я удалю -tls
флаги, чтобы команды make server
и make client
выполнялись без TLS. И я добавлю еще 2 команды make для запуска 2 экземпляров сервера на разных портах. Допустим, первый сервер будет работать на порту 50051
, а второй сервер будет работать на порту 50052
.
... server: go run cmd/server/main.go -port 8080 client: go run cmd/client/main.go -address 0.0.0.0:8080 server1: go run cmd/server/main.go -port 50051 server2: go run cmd/server/main.go -port 50052 ...
Давайте также добавим еще 3 команды make для запуска клиента и серверов с помощью TLS. Команда client-tls
запустит клиент с TLS. Команда make server1-tls
запустит сервер TLS на порту 50051
, а затем make server2-tls
команда запустит другой сервер TLS на порту 50052
.
... client-tls: go run cmd/client/main.go -address 0.0.0.0:8080 -tls server1-tls: go run cmd/server/main.go -port 50051 -tls server2-tls: go run cmd/server/main.go -port 50052 -tls ...
Установка Nginx
Следующее, что нам нужно сделать, это установить Nginx
. Поскольку мы используем Mac, мы можем просто использовать Homebrew:
❯ brew install nginx
После установки nginx мы можем перейти в эту usr/local/etc/nginx
папку, чтобы настроить его. Давайте откроем nginx.conf
файл с кодом Visual Studio.
❯ cd /usr/local/etc/nginx ❯ code nginx.conf
Это конфигурация по умолчанию:
#user nobody; worker_processes 1; #error_log logs/error.log; #error_log logs/error.log notice; #error_log logs/error.log info; #pid logs/nginx.pid; events { worker_connections 1024; } http { include mime.types; default_type application/octet-stream; #log_format main '$remote_addr - $remote_user [$time_local] "$request" ' # '$status $body_bytes_sent "$http_referer" ' # '"$http_user_agent" "$http_x_forwarded_for"'; #access_log logs/access.log main; sendfile on; #tcp_nopush on; #keepalive_timeout 0; keepalive_timeout 65; #gzip on; server { listen 8080; server_name localhost; #charset koi8-r; #access_log logs/host.access.log main; location / { root html; index index.html index.htm; } #error_page 404 /404.html; # redirect server error pages to the static page /50x.html # error_page 500 502 503 504 /50x.html; location = /50x.html { root html; } # proxy the PHP scripts to Apache listening on 127.0.0.1:80 # #location ~ \.php$ { # proxy_pass http://127.0.0.1; #} # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 # #location ~ \.php$ { # root html; # fastcgi_pass 127.0.0.1:9000; # fastcgi_index index.php; # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; # include fastcgi_params; #} # deny access to .htaccess files, if Apache's document root # concurs with nginx's one # #location ~ /\.ht { # deny all; #} } include servers/*; }
Есть несколько вещей, о которых нам не нужно беспокоиться в этом уроке, поэтому давайте обновим этот файл конфигурации.
Конфигурация Nginx для небезопасного gRPC
Во-первых, давайте удалим пользовательскую конфигурацию, раскомментируем журнал ошибок, удалим конфигурацию для уровней журнала и идентификатора процесса, и скажем, на данный момент нам нужно всего лишь 10 рабочих подключений.
Одна важная вещь, которую нам нужно сделать, — это настроить правильное место для хранения журнала ошибок и доступа к файлам журнала. В моем случае Homebrew уже создал папку журнала для Nginx
at /usr/local/var/log/nginx
, поэтому я просто использую ее в настройках error/access log.
worker_processes 1; error_log /usr/local/var/log/nginx/error.log; events { worker_connections 10; } http { access_log /usr/local/var/log/nginx/access.log; server { listen 8080 http2; location / { } } }
Теперь в блоке сервера у нас есть listen
команда для прослушивания входящих запросов от клиента на порту 8080
. Это конфигурация по умолчанию для обычного HTTP-сервера. Поскольку gRPC использует HTTP/2
, нам следует добавить http2
в конце этой команды.
Давайте удалим имя сервера и кодировку, поскольку они нам сейчас не нужны. То же самое касается и журнала доступа, поскольку мы уже определили его выше. Давайте также удалим конфигурацию корневого HTML-файла по умолчанию и все, что находится после блока, location
поскольку они нам пока не нужны.
Хорошо, теперь мы хотим сбалансировать нагрузку входящих запросов на наши два экземпляра сервера. Поэтому мы должны определить upstream
для них. Я позвоню upstream pcbook_services
.
Внутри этого блока мы используем server
ключевое слово для объявления экземпляра сервера. Первый работает на localhost
порту 50051
, а второй работает на порту 50052
.
worker_processes 1; error_log /usr/local/var/log/nginx/error.log; events { worker_connections 10; } http { access_log /usr/local/var/log/nginx/access.log; upstream pcbook_services { server 0.0.0.0:50051; server 0.0.0.0:50052; } server { listen 8080 http2; location / { grpc_pass grpc://pcbook_services; } } }
Затем, чтобы направить все вызовы RPC в восходящий поток, в location
блоке мы используем grpc_pass
ключевое слово, за которым следует grpc://
схема и имя восходящего потока, то есть pcbook_services
.
Вот и все! Балансировка нагрузки для нашего незащищенного сервера gRPC завершена.
Давайте запустим nginx в терминале, чтобы запустить его.
❯ nginx ❯ ps aux | grep nginx quangpham 9013 0.0 0.0 4408572 800 s000 S+ 6:13PM 0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox nginx quangpham 9007 0.0 0.0 4562704 1124 ?? S 6:12PM 0:00.00 nginx: worker process quangpham 9006 0.0 0.0 4422416 612 ?? Ss 6:12PM 0:00.00 nginx: master process nginx
Мы можем проверить, запущен он или нет, используя команду ps
and grep
. Давайте проверим папку журнала:
❯ cd /usr/local/var/log/nginx ❯ ls -l total 0 -rw-r--r-- 1 quangpham admin 0 Oct 11 18:12 access.log -rw-r--r-- 1 quangpham admin 0 Oct 11 18:12 error.log
Как видите, создаются 2 файла журнала: access.log
и error.log
. На данный момент они пусты, поскольку мы еще не отправляли никаких запросов.
Теперь побежим make server1
запускать первый сервер на порту 50051
с TLS = false
. Затем на другой вкладке запустите make server2
второй сервер на порту 50052
, также с отключенным TLS. Наконец, мы запускаем make client
еще одну новую вкладку.
Выглядит неплохо. Все вызовы RPC успешны. Давайте проверим логи на наших серверах.
Получает server2
2 запроса на создание ноутбука.
И server1
получает 1 запрос на вход и 1 запрос на создание ноутбука. Отличный!
И через некоторое время на этот сервер поступает еще один запрос на вход. Это потому, что наш клиент все еще работает и периодически вызывает вход в систему для обновления токена.
Хорошо, теперь давайте посмотрим на файл access log nginx.
Вы можете видеть, что сначала происходит входной вызов, затем 3 создания вызовов для ноутбука и, наконец, еще один входной вызов. Итак, все работает именно так, как мы ожидаем.
Далее я покажу вам, как SSL/TLS
включить Nginx
.
Конфигурация Nginx для gRPC с TLS
В типичном развертывании серверы gRPC уже работают внутри доверенной сети, и только балансировщик нагрузки ( Nginx
в данном случае) доступен общедоступному Интернету. Таким образом, мы можем оставить наши серверы gRPC работающими без них TLS
, как и раньше, и добавлять TLS
только Nginx
.
Включите TLS на Nginx, но держите серверы gRPC небезопасными
Для этого нам нужно скопировать 3 файла pem в папку конфигурации nginx:
- Сертификат сервера
- Закрытый ключ сервера
- И сертификат центра сертификации, подписавшего сертификат клиента, если мы используем взаимный TLS.
Хорошо, теперь я перейду cd
к /usr/local/etc/nginx
папке и создам новую cert
папку. Затем я скопирую эти 3 файла pem из нашего pcbook
проекта в эту папку.
❯ cd /usr/local/etc/nginx ❯ mkdir cert ❯ cp ~/Projects/techschool/pcbook-go/cert/server-cert.pem cert ❯ cp ~/Projects/techschool/pcbook-go/cert/server-key.pem cert ❯ cp ~/Projects/techschool/pcbook-go/cert/ca-cert.pem cert
Хорошо, теперь все файлы сертификатов и ключей готовы. Давайте вернемся к нашему конфигурационному файлу nginx.
Чтобы включить TLS, нам сначала нужно добавить ssl
в listen
команду. Затем мы используем ssl_certificate
команду, чтобы указать Nginx
местоположение файла сертификата сервера. И используйте ssl_certificate_key
команду, чтобы указать расположение файла закрытого ключа сервера.
... server { listen 8080 ssl http2; ssl_certificate cert/server-cert.pem; ssl_certificate_key cert/server-key.pem; ssl_client_certificate cert/ca-cert.pem; ssl_verify_client on; location / { grpc_pass grpc://pcbook_services; } } ...
Поскольку мы используем взаимный TLS, нам также необходимо использовать ssl_client_certificate
команду, чтобы сообщить nginx расположение файла сертификата ЦС клиента. И, наконец, мы указываем ssl_verify_client
nginx on
проверить подлинность сертификата, который отправит клиент.
И мы закончили. Перезапустим nginx. Мы бежим nginx -s stop
, чтобы остановить его первыми. Затем мы запускаем его с помощью nginx
команды.
❯ nginx -s stop ❯ nginx
Наш сервер уже запущен, давайте запустим клиент!
Если мы просто запустим make client
, он будет работать без TLS, поэтому запрос завершится неудачно, поскольку Nginx
теперь он выполняется с включенным TLS.
Теперь вызовите make client-tls
.
На этот раз клиент работает с TLS, и все запросы выполняются успешно.
Имейте в виду, что наши серверы по-прежнему работают без TLS. Nginx
Итак, в основном происходит следующее: безопасно только соединение между клиентом и клиентом , а Nginx
к нашим внутренним серверам подключается через другое небезопасное соединение.
Получив Nginx
зашифрованные данные от клиента, он расшифровывает их перед отправкой на внутренние серверы. Поэтому вам следует использовать этот подход только в том случае, если серверы Nginx
и внутренние серверы находятся в одной доверенной сети.
Хорошо, но что, если они не находятся в одной доверенной сети? Что ж, в этом случае у нас нет другого выбора, кроме как включить TLS на наших внутренних серверах и настроить nginx для работы с ним.
Включите TLS на серверах Nginx и gRPC.
Давайте остановим текущие server1
и server2
, а затем перезапустим их с помощью TLS.
❯ make server1-tls ❯ make server2-tls
Теперь, если мы запустим make client-tls
немедленно, запрос завершится неудачно.
Причина в том, что, хотя рукопожатие TLS между клиентом и Nginx
успешным, рукопожатие TLS между Nginx
нашими внутренними серверами не удалось, поскольку внутренние серверы теперь ожидают безопасного соединения TLS, но Nginx
все еще используют небезопасное соединение при подключении к внутренним серверам.
Как вы можете видеть в журнале ошибок, сбой произошел при Nginx
разговоре с вышестоящими серверами.
Чтобы включить безопасное соединение TLS между nginx и вышестоящим сервером, в nginx.conf
файле нам необходимо изменить grpc
схему на grpcs
.
... server { ... location / { grpc_pass grpcs://pcbook_services; } } ...
Этого должно быть достаточно, если мы просто используем TLS на стороне сервера. Однако в этом случае мы используем взаимный TLS, поэтому, если мы просто перезапустим Nginx
сейчас и повторно запустим make client-tls
запрос, он все равно завершится неудачей, поскольку Nginx
он еще не настроен для отправки своего сертификата вышестоящим серверам.
У нас есть bad certificate
ошибка, как вы можете видеть в журнале.
Давайте посмотрим, что произойдет, если мы перейдем к коду сервера cmd/server/main.go
и изменим ClientAuth
поле с tls.RequireAndVerifyClientCert
на tls.NoClientCert
, что означает, что мы будем просто использовать TLS на стороне сервера.
func loadTLSCredentials() (credentials.TransportCredentials, error) { ... // Create the credentials and return it config := &tls.Config{ Certificates: []tls.Certificate{serverCert}, ClientAuth: tls.NoClientCert, ClientCAs: certPool, } return credentials.NewTLS(config), nil }
Затем перезапустите server1-tls
и снова server2-tls
запустите make client-tls
.
❯ make server1-tls ❯ make server2-tls ❯ make client-tls
На этот раз все запросы успешны. Это именно то, что мы ожидали!
Хорошо, а что, если нам действительно нужен взаимный TLS между nginx и восходящим потоком?
Давайте ClientAuth
снова изменим поле на tls.RequireAndVerifyClientCert
, перезапустим два внутренних сервера TLS и вернемся к нашему nginx.conf
файлу.
На этот раз мы должны дать указание Nginx
выполнить взаимный TLS с внутренними серверами, указав расположение сертификата и закрытого ключа. Мы используем grpc_ssl_certificate
ключевое слово для сертификата и grpc_ssl_certificate_key
ключевое слово для закрытого ключа.
Nginx
Если хотите, вы можете создать другую пару сертификата и закрытого ключа . Здесь я просто использую тот же сертификат и закрытый ключ серверов.
worker_processes 1; error_log /usr/local/var/log/nginx/error.log; events { worker_connections 10; } http { access_log /usr/local/var/log/nginx/access.log; upstream pcbook_services { server 0.0.0.0:50051; server 0.0.0.0:50052; } server { listen 8080 ssl http2; # Mutual TLS between gRPC client and nginx ssl_certificate cert/server-cert.pem; ssl_certificate_key cert/server-key.pem; ssl_client_certificate cert/ca-cert.pem; ssl_verify_client on; location / { grpc_pass grpcs://pcbook_services; # Mutual TLS between nginx and gRPC server grpc_ssl_certificate cert/server-cert.pem; grpc_ssl_certificate_key cert/server-key.pem; } } }
ОК, давайте попробуем.
Сначала остановите текущий процесс nginx, затем запустите новый. И make client-tls
снова запустите.
❯ nginx -s stop ❯ nginx ❯ make client-tls
На этот раз все запросы успешны.
Несколько мест маршрутизации
Прежде чем мы закончим, я хочу показать вам еще одну вещь.
Как вы уже видели, запросы на вход в систему и создание ноутбука теперь равномерно распределены между нашими двумя внутренними серверами. Но иногда нам может потребоваться разделить службу аутентификации и службу бизнес-логики.
Например, предположим, что мы хотим, чтобы все запросы на вход направлялись на сервер 1, а все остальные запросы — на сервер 2. В этом случае мы также можем указать Nginx
маршрутизацию запросов на основе их пути.
worker_processes 1; error_log /usr/local/var/log/nginx/error.log; events { worker_connections 10; } http { access_log /usr/local/var/log/nginx/access.log; upstream auth_services { server 0.0.0.0:50051; server 0.0.0.0:50052; } upstream laptop_services { server 0.0.0.0:50051; server 0.0.0.0:50052; } server { listen 8080 ssl http2; # Mutual TLS between gRPC client and nginx ssl_certificate cert/server-cert.pem; ssl_certificate_key cert/server-key.pem; ssl_client_certificate cert/ca-cert.pem; ssl_verify_client on; location /techschool.pcbook.AuthService { grpc_pass grpcs://auth_services; # Mutual TLS between nginx and gRPC server grpc_ssl_certificate cert/server-cert.pem; grpc_ssl_certificate_key cert/server-key.pem; } location /techschool.pcbook.LaptopService { grpc_pass grpcs://laptop_services; # Mutual TLS between nginx and gRPC server grpc_ssl_certificate cert/server-cert.pem; grpc_ssl_certificate_key cert/server-key.pem; } } }
Здесь мы просто копируем путь /techschool.pcbook.AuthService
и AuthService
вставляем его в это место. Затем мы меняем это имя восходящего потока на auth_services
. Он должен подключаться только к server1
порту at 50051
.
Затем мы добавляем еще один восходящий поток laptop_services
и заставляем его подключаться только server2
к порту 50052
. Затем продублируйте блок местоположения, измените имя восходящего потока на laptop_services
и обновите путь на techschool.pcbook.LaptopService
.
Хорошо, давайте попробуем это! Нам просто нужно перезагрузить компьютер Nginx
и запустить make client-tls
.
Теперь мы видим, что только запрос на вход поступает в server1
.
А все остальные запросы на создание ноутбука отправляются в server2
. Даже если мы запустим это, make client-tls несколько раз.
Так что это работает! На этом наша лекция о балансировке нагрузки gRPC с помощью Nginx
подошла к концу.