Если вы хоть немного следите за экосистемой 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 | Где живёт |
|---|---|---|
|
|
Чарт сервиса |
|
|
Чарт сервиса |
|
|
Чарт сервиса |
|
|
Платформа (Terraform) |
|
Фильтр URLRewrite в |
Чарт сервиса |
|
(аналога нет — рефакторинг или удаление) |
— |
Конкретный пример: так теперь выглядит базовая аутентификация (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 — похожим на удаление, а не на миграцию.