Перейти к содержимому

Envoy Gateway: 15 строк вместо 47 annotations

Константин Потапов
18 мин

Пятница, 18:30, canary на Nginx Ingress не завёлся. Через месяц тот же split в HTTPRoute заработал с первого раза.

Лето 2024. Пятница, 18:30. Canary в проде не работает.

Задача простая: 10% трафика на новую версию API. Nginx Ingress, пример из документации:

# То, что я думал будет работать
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-canary
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
  rules:
    - host: api.company.com
      http:
        paths:
          - path: /v2
            backend:
              service:
                name: api-v2
                port:
                  number: 8080

Применил. Сто процентов на новую версию. Поменял weight на 90. Снова сто.

Четыре часа: документацию семь раз, поиск «nginx ingress canary not working», версия контроллера 0.21 вместо нужных 0.22+, обновление сломало ещё два Ingress, отдельный ресурс с тем же host, path оказался regex, не prefix.

В 23:00 завелось. 47 строк YAML и 12 annotations. Половину смысла я не понимал.

В понедельник: «давайте ещё JWT на этот эндпоинт».

Тот же canary месяцем позже

На dev-кластере попробовал Envoy Gateway скептически.

# То, что работает с первого раза
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api-route
spec:
  parentRefs:
    - name: my-gateway
  hostnames:
    - api.company.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /v2
      backendRefs:
        - name: api-v1 # 90% трафика
          port: 8080
          weight: 90
        - name: api-v2 # 10% трафика (canary)
          port: 8080
          weight: 10

15 строк. Ноль annotations. С первого раза.

Через неделю попросили JWT. С Nginx это ещё annotations, внешний auth и вечер на токены. Здесь:

# Добавил 12 строк, JWT работает
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: jwt-policy
spec:
  targetRef:
    kind: HTTPRoute
    name: api-route
  jwt:
    providers:
      - name: auth0
        issuer: https://company.auth0.com/
        audiences:
          - api://myapp
        remoteJWKS:
          uri: https://company.auth0.com/.well-known/jwks.json

Применил. Заработало. К Nginx Ingress annotations я больше не возвращался.

Из чего это состоит

Envoy Proxy от Lyft. На нём Istio. HTTP/1.1, HTTP/2, HTTP/3, gRPC, WebSocket. Least request и consistent hashing. Circuit breaking, retry, timeout без плагинов. Rate limit и JWT нативно. Метрики в Prometheus, трейсы в Jaeger.

Gateway API: официальный API Kubernetes для входящего трафика. Ingress старше и держится на строках в annotations. У каждого контроллера свой диалект. Переезд означает переписывание этих строк. Здесь поля типизированы, ошибка видна до применения, смена реализации это смена gatewayClassName.

Control plane читает Gateway и HTTPRoute, переводит в xDS, крутит поды Envoy, обновляет конфиг без рестарта, дружит с cert-manager, external-dns, Prometheus.

Envoy ProxyKubernetesGateway APIcert-manager
Nginx Ingress
Envoy Gateway
Строк YAML
47 строк
15 строк
68%
Annotations
12 магических строк
0
100%
Время на настройку
6 часов + гугл
10 минут
67%

JWT и лимит без Redis

Сто запросов в минуту на пользователя. В Nginx для лимита часто нужен Redis и Lua. Здесь два манифеста.

# JWT: 12 строк
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: jwt-policy
spec:
  targetRef:
    kind: HTTPRoute
    name: api-route
  jwt:
    providers:
      - name: auth0
        issuer: https://mycompany.auth0.com/
        audiences:
          - api://myapp
        remoteJWKS:
          uri: https://mycompany.auth0.com/.well-known/jwks.json
 
---
# Rate Limiting: 15 строк
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: rate-limit
spec:
  targetRef:
    kind: HTTPRoute
    name: api-route
  rateLimit:
    type: Local
    local:
      rules:
        - clientSelectors:
            - headers:
                - name: x-user-id
                  type: Distinct
          limit:
            requests: 100
            unit: Minute

JWKS кешируется сам. Счётчики в памяти. Метрики сколько отсекли, сколько пустили.

gRPC

Nginx gRPC умеет. Health check и метрики по методам приходится достраивать. Envoy родился в Lyft вокруг gRPC.

apiVersion: gateway.networking.k8s.io/v1
kind: GRPCRoute
metadata:
  name: user-service
spec:
  parentRefs:
    - name: my-gateway
  hostnames:
    - grpc.example.com
  rules:
    - matches:
        - method:
            service: myapp.v1.UserService
            method: GetUser
      backendRefs:
        - name: user-service
          port: 9090

Latency GetUser и ошибки CreateUser видны отдельно. Health check, retry, circuit breaking рядом.

Nginx добавил gRPC позже. У Envoy это родная среда.

Не берите Envoy Gateway, если сервисов один-два, кластер старше 1.25, команда не готова переписывать Ingress, нужен mTLS между всеми сервисами (это Istio) или хочется один Ingress на весь кластер и забыть.

Берите, если нужны canary, JWT, лимиты, переносимость манифестов и метрики из коробки.

Как устроено внутри

┌─────────────────────────────────────────────────────┐
│  Kubernetes Cluster                                 │
├─────────────────────────────────────────────────┤
│                                                     │
│  ┌─────────────────────────────────┐                │
│  │ Control Plane (envoy-gateway)   │                │
│  │                                 │                │
│  │  ┌──────────────────────────┐   │                │
│  │  │ Gateway API Controller   │   │                │
│  │  │ (watches Gateway, Route) │   │                │
│  │  └────────┬─────────────────┘   │                │
│  │           │                     │                │
│  │           ▼                     │                │
│  │  ┌──────────────────────────┐   │                │
│  │  │ xDS Translator           │   │                │
│  │  │ (Gateway API → Envoy cfg)│   │                │
│  │  └────────┬─────────────────┘   │                │
│  │           │ xDS (gRPC)          │                │
│  └───────────┼─────────────────────┘                │
│              │                                      │
│              ▼                                      │
│  ┌─────────────────────────────────┐                │
│  │ Data Plane (envoy-proxy pods)   │                │
│  │                                 │                │
│  │  ┌────────┐  ┌────────┐         │                │
│  │  │ Envoy  │  │ Envoy  │  ...    │                │
│  │  │ Pod 1  │  │ Pod 2  │         │                │
│  │  └───┬────┘  └───┬────┘         │                │
│  │      │           │              │                │
│  └──────┼───────────┼──────────────┘                │
│         │           │                               │
│         │  Ingress Traffic (HTTP/gRPC)              │
│         ▼           ▼                               │
│  ┌─────────────────────────────────┐                │
│  │ Backend Services (Pods)         │                │
│  └─────────────────────────────────┘                │
│                                                     │
└─────────────────────────────────────────────────────┘

Один под control plane в envoy-gateway-system. N подов Envoy принимают трафик. Data plane масштабируется отдельно. Конфиг горячий. Формат xDS общий: контроллер можно сменить.

Nginx Ingress читает YAML сам. Здесь YAML читает control plane и по gRPC говорит прокси, что делать.

От нуля до canary

Нужны Kubernetes 1.25+ и kubectl.

# Gateway API CRDs
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.0.0/standard-install.yaml
 
# Envoy Gateway
kubectl apply -f https://github.com/envoyproxy/gateway/releases/download/latest/install.yaml
 
# Проверяем
kubectl get pods -n envoy-gateway-system
 
# Должен быть pod: envoy-gateway-xxxxx (STATUS: Running)

После установки появится GatewayClass envoy-gateway.

# Создаём файл echo-app.yaml
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: Namespace
metadata:
  name: demo
 
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-v1
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: echo
      version: v1
  template:
    metadata:
      labels:
        app: echo
        version: v1
    spec:
      containers:
        - name: echo
          image: hashicorp/http-echo:latest
          args: ["-text=Hello from v1"]
          ports:
            - containerPort: 5678
 
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-v2
  namespace: demo
spec:
  replicas: 2
  selector:
    matchLabels:
      app: echo
      version: v2
  template:
    metadata:
      labels:
        app: echo
        version: v2
    spec:
      containers:
        - name: echo
          image: hashicorp/http-echo:latest
          args: ["-text=Hello from v2 (canary!)"]
          ports:
            - containerPort: 5678
 
---
apiVersion: v1
kind: Service
metadata:
  name: echo-v1
  namespace: demo
spec:
  selector:
    app: echo
    version: v1
  ports:
    - port: 80
      targetPort: 5678
 
---
apiVersion: v1
kind: Service
metadata:
  name: echo-v2
  namespace: demo
spec:
  selector:
    app: echo
    version: v2
  ports:
    - port: 80
      targetPort: 5678
EOF
 
# Проверяем
kubectl get pods -n demo
# Должно быть 4 пода: echo-v1-xxx (2 шт), echo-v2-xxx (2 шт)
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: demo-gateway
  namespace: demo
spec:
  gatewayClassName: envoy-gateway
  listeners:
    - name: http
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: Same
EOF
 
# Ждём, пока Gateway станет готовым
kubectl wait --for=condition=Programmed gateway/demo-gateway -n demo --timeout=300s
 
# Получаем IP/Hostname
export GATEWAY_IP=$(kubectl get gateway demo-gateway -n demo -o jsonpath='{.status.addresses[0].value}')
echo "Gateway IP: $GATEWAY_IP"

В GKE, EKS, AKS Gateway получит внешний IP. В minikube и kind нужен kubectl port-forward.

cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: echo-route
  namespace: demo
spec:
  parentRefs:
    - name: demo-gateway
  hostnames:
    - echo.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: echo-v1
          port: 80
          weight: 90  # 90% трафика на v1
        - name: echo-v2
          port: 80
          weight: 10  # 10% трафика на v2 (canary)
EOF
 
# Проверяем статус
kubectl get httproute -n demo
# STATUS: Accepted
# Если у вас внешний IP:
for i in {1..20}; do
  curl -H "Host: echo.example.com" http://$GATEWAY_IP/
done
 
# Если используете port-forward (локальный кластер):
kubectl port-forward -n demo svc/demo-gateway-envoy-gateway 8080:80 &
for i in {1..20}; do
  curl -H "Host: echo.example.com" http://localhost:8080/
done
 
# Результат (примерно):
# Hello from v1 (18 раз из 20 ≈ 90%)
# Hello from v2 (canary!) (2 раза из 20 ≈ 10%)

Лимит пять запросов в минуту на x-user-id:

cat <<EOF | kubectl apply -f -
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: rate-limit
  namespace: demo
spec:
  targetRef:
    group: gateway.networking.k8s.io
    kind: HTTPRoute
    name: echo-route
  rateLimit:
    type: Local
    local:
      rules:
        - clientSelectors:
            - headers:
                - name: x-user-id
                  type: Distinct
          limit:
            requests: 5
            unit: Minute
EOF
 
# Тестируем (делаем 10 запросов с одним user_id)
for i in {1..10}; do
  curl -H "Host: echo.example.com" -H "x-user-id: user123" http://$GATEWAY_IP/
done
 
# Первые 5 запросов: HTTP 200 OK
# Следующие 5: HTTP 429 Too Many Requests

Без Redis. Счётчики в памяти, между репликами синхронизируются. В проде для нескольких Gateway можно включить Global и Redis.

Грабли

Gateway API живой. Gateway, HTTPRoute, GRPCRoute стабильны как v1. SecurityPolicy и BackendTrafficPolicy экспериментальные, сверяйте матрицу. Версию Envoy Gateway в проде фиксируйте.

Сертификаты руками каждые 90 дней не нужны. cert-manager:

# Установка cert-manager
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.13.0/cert-manager.yaml
 
# ClusterIssuer для Let's Encrypt
cat <<EOF | kubectl apply -f -
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - http01:
          gatewayHTTPRoute:
            parentRefs:
              - name: demo-gateway
                namespace: demo
EOF
 
# Gateway с TLS
cat <<EOF | kubectl apply -f -
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: demo-gateway
  namespace: demo
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  gatewayClassName: envoy-gateway
  listeners:
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - name: example-com-tls
EOF

Метрики Envoy отдаёт сам: envoy_http_downstream_rq_total, envoy_http_downstream_rq_xx, envoy_http_downstream_rq_time, envoy_cluster_upstream_rq_retry. Дашборд: Envoy Gateway Overview.

В проде минимум две реплики Envoy, лимиты CPU и памяти, TLS, лимит запросов, circuit breaking, preStop, манифесты в Git.

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: production-gateway
  namespace: production
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  gatewayClassName: envoy-gateway
  listeners:
    # HTTP → HTTPS redirect
    - name: http
      protocol: HTTP
      port: 80
      hostname: "*.example.com"
 
    # HTTPS с автоматическими сертификатами
    - name: https
      protocol: HTTPS
      port: 443
      hostname: "*.example.com"
      tls:
        mode: Terminate
        certificateRefs:
          - name: wildcard-tls
 
---
# Автоматический редирект на HTTPS
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: https-redirect
  namespace: production
spec:
  parentRefs:
    - name: production-gateway
      sectionName: http
  hostnames:
    - "*.example.com"
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301

Миграция с Nginx

Тридцать Ingress в проде в один день не перевожу. Ставлю Envoy рядом, отдельный LoadBalancer, один некритичный сервис, неделя метрик. Потом DNS 10/90, 25, 50, 75, 100. Откат: переключить DNS. Nginx держу ещё две недели.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2
    nginx.ingress.kubernetes.io/rate-limit: "100"
spec:
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /api(/|$)(.*)
            backend:
              service:
                name: api-service
                port:
                  number: 8080
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api-route
spec:
  parentRefs:
    - name: production-gateway
  hostnames:
    - api.example.com
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /api
      filters:
        - type: URLRewrite
          urlRewrite:
            path:
              type: ReplacePrefixMatch
              replacePrefixMatch: /
      backendRefs:
        - name: api-service
          port: 8080
 
---
# Rate limiting через отдельную политику
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: BackendTrafficPolicy
metadata:
  name: api-rate-limit
spec:
  targetRef:
    kind: HTTPRoute
    name: api-route
  rateLimit:
    type: Local
    local:
      rules:
        - limit:
            requests: 100
            unit: Minute

Annotations стали полями. Regex стал PathPrefix. Лимит уехал в политику, которую можно повесить на другой маршрут.

Я не учу «annotations Nginx Ingress». Учу Gateway API. Это же знание работает с Istio Gateway, Cilium, Kong.

Локальный кластер и пятнадцать минут на пример выше обычно хватает, чтобы решить, стоит ли тащить это в прод.

См. также: