Раздел 24 · Микросервисы (Go)

Неделя 7: Envoy gateway, ext_authz, NotificationService с Telegram

middle-senior~55 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Неделя 7: Envoy gateway, ext_authz, NotificationService с Telegram

Сервисы есть. Аутентификация есть. Не хватает единой точки входа в систему и нормальных уведомлений пользователю. Эту неделю собираем gateway на Envoy и полноценный NotificationService с интеграцией Telegram.

Цели недели

  1. Поднять Envoy как gateway, маршрутизировать /order, /auth, etc. в нужные сервисы.
  2. Настроить ext_authz, чтобы все запросы (кроме /auth/login и /auth/register) проверялись через AuthService.Whoami.
  3. Создать NotificationService с архитектурой как у других сервисов.
  4. Подписать NotificationService на order-paid и ship-assembled.
  5. Реализовать Telegram-клиента, отправлять уведомления конечному пользователю.

Зачем Envoy

До этой недели клиенты ходили напрямую в OrderService. На пять сервисов это уже неудобно:

  • надо помнить разные порты,
  • нет единого места для авторизации,
  • нет общего rate limiting и observability,
  • TLS terminate приходится делать в каждом сервисе.

Решение, API gateway. На рынке есть варианты: nginx, Envoy, Kong, Traefik. В курсе Envoy потому что:

  • C++-движок, очень быстрый,
  • поддерживает HTTP/1.1, HTTP/2, HTTP/3, gRPC, gRPC-Web,
  • встроенный ext_authz для делегирования авторизации,
  • xDS-протокол для динамической конфигурации (k8s service mesh),
  • богатая observability (статистика, tracing).

Конкуренты: nginx проще, но без gRPC и ext_authz “из коробки”. Traefik удобен в Docker. Kong популярен с плагинами на Lua.

Маршруты Envoy

# infra/envoy/envoy.yaml
static_resources:
  listeners:
    - name: edge
      address: { socket_address: { address: 0.0.0.0, port_value: 8080 } }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: edge
                http_filters:
                  - name: envoy.filters.http.ext_authz
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
                      grpc_service:
                        envoy_grpc:
                          cluster_name: ext_authz
                        timeout: 0.5s
                      include_peer_certificate: false
                      transport_api_version: V3
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                route_config:
                  name: edge_routes
                  virtual_hosts:
                    - name: edge
                      domains: ["*"]
                      routes:
                        - match: { prefix: /auth/login }
                          route: { cluster: auth_service }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              disabled: true
                        - match: { prefix: /auth/register }
                          route: { cluster: auth_service }
                          typed_per_filter_config:
                            envoy.filters.http.ext_authz:
                              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                              disabled: true
                        - match: { prefix: /auth }
                          route: { cluster: auth_service }
                        - match: { prefix: /order }
                          route: { cluster: order_service }
                        - match: { prefix: /payment }
                          route: { cluster: payment_service }
                        - match: { prefix: /analytics }
                          route: { cluster: analytics_service }
                        - match: { prefix: /inventory }
                          route: { cluster: inventory_service }

  clusters:
    - name: auth_service
      connect_timeout: 1s
      type: STRICT_DNS
      load_assignment:
        cluster_name: auth_service
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address: { socket_address: { address: auth, port_value: 50051 } }
    - name: ext_authz
      connect_timeout: 0.5s
      type: STRICT_DNS
      typed_extension_protocol_options:
        envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
          "@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
          explicit_http_config:
            http2_protocol_options: {}
      load_assignment:
        cluster_name: ext_authz
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address: { socket_address: { address: ext-authz, port_value: 5050 } }
    # ... order, payment, inventory clusters

Ключевой момент, disabled: true на роутах /auth/login и /auth/register. Без этого запрос на login заходит в ext_authz, тот делает Whoami, получает 401, и пользователь никогда не залогинится.

ext_authz: три варианта реализации

Вариант 1, отдельный gRPC-сервис (рекомендую)

Envoy общается с тобой по Authorization gRPC API. Ты пишешь маленький Go-сервис, который:

  1. принимает CheckRequest (с заголовками и метаданными),
  2. достаёт X-Session-Token,
  3. вызывает AuthService.Whoami,
  4. возвращает CheckResponse с OK или DENIED.
type AuthZ struct {
    auth authv1.AuthServiceClient
}

func (a *AuthZ) Check(ctx context.Context, req *envoy_auth.CheckRequest) (*envoy_auth.CheckResponse, error) {
    headers := req.GetAttributes().GetRequest().GetHttp().GetHeaders()
    token := headers["x-session-token"]
    if token == "" {
        return denied(codes.Unauthenticated, "no session"), nil
    }
    resp, err := a.auth.Whoami(ctx, &authv1.WhoamiRequest{SessionToken: token})
    if err != nil {
        return denied(codes.Unauthenticated, "invalid session"), nil
    }
    return ok(resp.UserUuid), nil
}

func ok(userUUID string) *envoy_auth.CheckResponse {
    return &envoy_auth.CheckResponse{
        Status: &status.Status{Code: int32(codes.OK)},
        HttpResponse: &envoy_auth.CheckResponse_OkResponse{
            OkResponse: &envoy_auth.OkHttpResponse{
                Headers: []*core.HeaderValueOption{
                    {Header: &core.HeaderValue{Key: "x-user-uuid", Value: userUUID}},
                },
            },
        },
    }
}

Преимущество, прокидывает user_uuid в downstream-сервисы через заголовок x-user-uuid, и им уже не нужно ходить в Auth.

Вариант 2, ext_authz через HTTP

Тот же протокол, но HTTP. Проще, но дороже по latency (HTTP/1.1 без multiplexing).

Вариант 3, Lua-скрипт

Без отдельного сервиса. Lua-скрипт прямо в Envoy:

function envoy_on_request(request_handle)
    local token = request_handle:headers():get("x-session-token")
    if not token then
        request_handle:respond({[":status"] = "401"}, "")
        return
    end
    local resp = request_handle:httpCall("auth_service",
        {[":method"]="POST", [":path"]="/whoami", [":authority"]="auth"},
        '{"session_token":"'..token..'"}', 1000)
    if resp[":status"] ~= "200" then
        request_handle:respond({[":status"] = "401"}, "")
    end
end

Подходит для очень простых случаев. Сложнее тестировать, без типизации.

В курсе берём gRPC ext_authz.

премиум-урок

Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.

Оплата пока не подключена: премиум и курсы открываются промокодом.