Миграция с ingress-nginx на Envoy Gateway: 8 уроков

Если вы хоть немного следите за экосистемой Kubernetes, вы наверняка слышали о завершении поддержки ingress-nginx. Проект больше не поддерживается, значит его нужно чем-то заменить. Очевидный кандидат — что-то, связанное с Gateway API, потому что это новый стандарт, на который сейчас переходят все.

Я попробовал несколько вариантов на отдельном EKS-кластере — HAProxy, Traefik — но в итоге остановился на Envoy Gateway: для нашего сценария он показался мне наиболее понятным и стабильным решением.

За время миграции я наступил на немало граблей. Это мои заметки.

Коротко о главном

  • Поднимайте один общий Gateway на аудиторию (shared-public, shared-private), а не по одному на каждый сервис.

  • Сам HTTPRoute прост; редирект с HTTP на HTTPS — это второй HTTPRoute, прикреплённый к слушателю http.

  • Аннотации nginx не отображаются один к одному — они распределяются между SecurityPolicy, BackendTrafficPolicy и ClientTrafficPolicy, причём часть из них живёт на уровне платформы, а не в чарте сервиса.

  • TLS-секреты из других пространств имён требуют ReferenceGrant в пространстве имён, где хранится секрет.

  • Argo Rollouts с Gateway API требует плагина rollouts-plugin-trafficrouter-gatewayapi. Планируйте это как отдельную задачу.

Как устроена миграция

У нас монорепозиторий Helm-чартов для всех внутренних приложений, которые мы деплоим через ArgoCD. В каждом чарте был шаблон Ingress, который на время миграции я защитил флагом ingress.enabled. Стратегия:

  • Добавить шаблон HTTPRoute (и блок gateway: в values) в каждый чарт, включаемый через gateway.enabled: true.

  • Переключать сервисы на Envoy Gateway по одному: сначала staging, потом production через 1–2 недели. Да, это смена DNS-записи.

  • На переходный период оставлять в чарте оба ресурса — Ingress и HTTPRoute — чтобы можно было откатиться без изменения кода. После того как всё устоится, выставить ingress.enabled: false.

  • Вывести из эксплуатации ingress-nginx, когда всё переедет на Envoy Gateway.

Урок 1: не давайте каждому приложению свой Gateway

В первой версии шаблонов каждый чарт создавал собственный ресурс Gateway:

# как НЕ надо — Gateway на каждое приложение
gateway:
  enabled: true
  gatewayClassName: envoy-gateway-private
  listeners:
    - name: example-app-http
      hostname: app.example.com
      port: 80
      protocol: HTTP

Технически это работает, но в итоге вы получаете отдельный LoadBalancer на каждый сервис, разрозненное терминирование TLS и сертификаты, разбросанные по пространствам имён. Примерно через месяц я удалил все эти Gateways и свёл их к двум общим: shared-public и shared-private, живущим в пространстве имён envoy-gateway-system. Чарты сервисов присоединяются к ним через parentRef:

gateway:
  enabled: true
  parentRef:
    name: shared-public
    namespace: envoy-gateway-system
  sectionName: https
  hostnames:
    - app.example.com

Если начинаете с нуля — не рендерьте ресурс Gateway из чартов сервисов вообще. Поместите его в отдельный платформенный чарт, которым управляет кластер. Чарты сервисов должны владеть только HTTPRoute.

Урок 2: HTTPRoute — это простая часть

Когда общие Gateways готовы, шаблон HTTPRoute получается почти до неприличия простым:

{{- if .Values.gateway.enabled -}}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .Values.appName }}-httproute
spec:
  parentRefs:
    - name: {{ .Values.gateway.parentRef.name }}
      namespace: {{ .Values.gateway.parentRef.namespace }}
      sectionName: {{ .Values.gateway.parentRef.sectionName }}
  hostnames:
    {{- range .Values.gateway.hostnames }}
    - {{ . | quote }}
    {{- end }}
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: {{ .Values.appName }}
          port: 3000
{{- end }}

Вот и всё. Никаких аннотаций, никаких специфических для контроллера настроек, никакого мини-языка конфигурации nginx.ingress.kubernetes.io/* (об этом чуть позже). Самая чистая часть всей миграции.

Урок 3: редирект на HTTPS — это отдельный ресурс

Если вы привыкли к nginx.ingress.kubernetes.io/ssl-redirect: "true", эквивалент в Gateway API — это второй HTTPRoute, прикреплённый к слушателю http, который отдаёт 301 на https:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: {{ .Values.appName }}-https-redirect
spec:
  parentRefs:
    - name: {{ .Values.gateway.parentRef.name }}
      namespace: {{ .Values.gateway.parentRef.namespace }}
      sectionName: {{ .Values.gateway.httpsRedirect.sectionName }} # http
  hostnames: {{ .Values.gateway.hostnames }}
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301

Концептуально это чище — маршрут, который просто делает редиректы, — но на каждый публично доступный сервис теперь приходится помнить о двух ресурсах. Я обернул это в флаг gateway.httpsRedirect.enabled, так что включение опционально. Большинство наших сервисов всё равно работают только по HTTPS.

Урок 4: sectionName вас подставит

Это ошибка, которая ударила меня сильнее всего. parentRef.sectionName выбирает, к какому слушателю на общем Gateway прикрепляется ваш маршрут: обычно это http (порт 80) или https (порт 443). Ошибитесь — и:

  • HTTPRoute успешно прикрепится (статус покажет Accepted: True).

  • Маршрут существует, конфигурация выглядит правильно.

  • Приложение просто не работает.

Я однажды выкатил sectionName: http для HTTPRoute, который должен был обслуживать HTTPS, увидел, что маршрут принят, и пришлось исправлять это отдельным коммитом. Ни схема, ни статус Gateway никак не сигнализировали об ошибке — слушатель просто молча не совпадал с тем, что ожидала нагрузка.

Что бы меня спасло: линтер или pre-commit хук, проверяющий sectionName по списку известных слушателей на shared-public / shared-private.

Урок 5: запускайте Ingress и HTTPRoute параллельно

Я оставил шаблон Ingress в каждом чарте под флагом ingress.enabled. Переключение сервиса означает смену DNS с hostname nginx ingress на hostname Envoy Gateway, после чего я смотрю за поведением день и выставляю ingress.enabled: false. Чарт несёт оба ресурса всё время, пока длится миграция — это добавляет техдолг (в конце нужно всё почистить), зато даёт операционную уверенность.

Сейчас в каждом чарте по два шаблона и два блока values для одной и той же логической вещи. Когда Envoy Gateway выйдет на устойчивый режим и пыль осядет, весь старый код будет вырезан.

Урок 6: не всё переводится напрямую

Когда я мигрировал первый nginx Ingress, я пытался мысленно сопоставить каждую аннотацию с полем Gateway API. Это работает не совсем так. Аннотации расходятся по трём разным типам ресурсов Envoy Gateway, причём один из них вообще не принадлежит чарту сервиса:

Аннотация nginx Ingress Эквивалент Envoy Gateway Где живёт

auth-type: basic + auth-secret

SecurityPolicy.spec.basicAuth

Чарт сервиса

enable-cors + cors-*

SecurityPolicy.spec.cors

Чарт сервиса

proxy-read-timeout / proxy-send-timeout

BackendTrafficPolicy.spec.timeout

Чарт сервиса

proxy-body-size

ClientTrafficPolicy на слушателе

Платформа (Terraform)

rewrite-target

Фильтр URLRewrite в HTTPRoute

Чарт сервиса

configuration-snippet

(аналога нет — рефакторинг или удаление)

—

Конкретный пример: так теперь выглядит базовая аутентификация (basic auth):

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: SecurityPolicy
metadata:
  name: example-app-basic-auth
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: HTTPRoute
      name: example-app-httproute
  basicAuth:
    users:
      name: example-app-auth # Secret с записями в формате htpasswd

Формат секрета совпадает с nginx-конвенцией (записи в стиле htpasswd), так что существующие учётные данные работают без изменений.

Таймауты и ограничения на размер тела запроса разнесены по двум разным типам политик, что меня удивило. BackendTrafficPolicy покрывает таймауты на стороне upstream (запрос, idle-соединение, TCP-подключение) — он идёт в чарт сервиса и прикрепляется к HTTPRoute. ClientTrafficPolicy покрывает вещи на уровне слушателя, например максимальный размер тела запроса, — он относится к Gateway-слушателю, которым мы управляем в Terraform вместе с остальной платформой.

Урок 7: для TLS-секретов из других пространств имён нужен ReferenceGrant

Общий Gateway живёт в envoy-gateway-system. Секрет с TLS-сертификатом — в другом пространстве имён. По умолчанию Gateway API запрещает кросс-неймспейсные ссылки на секреты: логично, что случайный Gateway в произвольном пространстве имён не должен читать любые секреты по своему усмотрению.

Решение — ReferenceGrant в пространстве имён, которому принадлежит секрет. Он явно разрешает ресурсам Gateway из envoy-gateway-system читать конкретный секрет:

apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
  name: domain-cert-grant
  namespace: default # здесь живёт секрет с сертификатом
spec:
  from:
    - group: gateway.networking.k8s.io
      kind: Gateway
      namespace: envoy-gateway-system
  to:
    - group: ""
      kind: Secret
      name: domain-cert

Мы управляем этим как ресурсом Terraform (domain_cert_reference_grant), чтобы грант создавался вместе с сертификатом, на который он ссылается. Потеря гранта одновременно сломает TLS на всех публичных hostname — именно такие вещи нужно держать в управляемой инфраструктуре, а не применять разово через kubectl apply.

Несколько неочевидных моментов из документации:

  • ReferenceGrant живёт в целевом пространстве имён, не в исходном. Согласие даёт пространство имён секрета, а не пространство имён Gateway.

  • Ограничивайте грант именованными секретами. Можно опустить to.name, разрешив доступ к любому Secret, но не делайте так — это обессмысливает всю защиту.

  • Статус Gateway сообщает о проблемах с ссылками. Если Gateway показывает ResolvedRefs: False — смотрите сюда в первую очередь. У меня ушло на это больше времени, чем должно было.

Урок 8: канареечные деплои — это отдельный проект

У нас несколько сервисов используют Argo Rollouts для канареечных деплоев.

Argo Rollouts имеет встроенную поддержку маршрутизации трафика через nginx: он клонирует стабильный Ingress, навешивает на него аннотации nginx.ingress.kubernetes.io/canary: "true" и постепенно меняет вес по ходу Rollout. Встроенного эквивалента для Gateway API нет; нужен плагин rollouts-plugin-trafficrouter-gatewayapi, устанавливаемый как init-контейнер в контроллер Rollouts, плюс HTTPRoute с двумя backendRefs (stable + canary), веса которых плагин меняет во время выполнения.

Последнее — контроллер, изменяющий ресурс под управлением ArgoCD в рантайме, — означает, что нужны правила ignore-differences в ArgoCD для полей весов, иначе вы будете воевать с рекончайлером. Плагин удобно добавляет лейбл во время активного rollout, чтобы упростить это.

Эту миграцию я ещё не сделал. Текущий план: установить плагин Rollouts, когда всё остальное переедет на Envoy Gateway, мигрировать последний сервис с каналами, а затем выключить nginx-контроллер.

Чёткое разделение между платформой и приложением

Gateway — это забота платформы. Им управляет Terraform, он живёт в envoy-gateway-system, и на каждую аудиторию (публичная/приватная) приходится один общий LoadBalancer. Терминирование TLS, порты слушателей, привязки к группам целей ALB, ReferenceGrant для секрета с сертификатом — всё это живёт в репозитории платформы и принадлежит платформенной команде.

Маршрут — это забота приложения. Чарт сервиса содержит HTTPRoute (иногда два, иногда ещё SecurityPolicy для аутентификации или CORS) — и всё. Тем, кто владеет сервисом, не нужно знать, на каком порту работает их слушатель или как терминируется TLS; они просто прикрепляются к нужной секции.

Именно это разделение делает финальный шаг — выключение ingress-nginx — похожим на удаление, а не на миграцию.

© 2026 meganuke