АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

После переезда с Ingress-NGINX на Gateway API часть адресов отдаёт 404: что изменилось в regex и rewrite

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~23 мин чтения
После переезда с Ingress-NGINX на Gateway API часть адресов отдаёт 404: что изменилось в regex и rewrite
Иллюстрация к статье «После переезда с Ingress-NGINX на Gateway API часть адресов отдаёт 404: что изменилось в regex и rewrite».

Манифесты применились без единой ошибки. Gateway в статусе Programmed, все HTTPRoute — Accepted=True, главная страница открывается, мониторинг зелёный. А через сутки прилетает: «не проходит оплата», «QR-код с афиши ведёт в пустоту», «форма записи отдаёт 404». Ниже — почему HTTPRoute, который выглядит точной копией Ingress, на самом деле матчит запросы по другим правилам, почему rewrite с regex в Gateway API так часто заканчивается 404, и как я проверяю миграцию до того, как её увидят пользователи.

Симптом: 404 при полностью зелёных статусах

Это самая неприятная категория аварий — та, где всё формально работает. Kubernetes принял ваши HTTPRoute, контроллер их разложил, статус Accepted стоит True, ни одного события в namespace. Проблема не в том, что маршрут не создался. Проблема в том, что он создался, но матчит другое множество запросов, чем матчил старый Ingress. Разница видна только на конкретных URL — и, как правило, на тех, которые никто не открывает руками: вебхуки, коллбэки платёжных шлюзов, эндпоинты обмена с CRM, старые ссылки из писем и QR-кодов, куски API, которые дёргает мобильный клиент трёхлетней давности.

Механика простая. Ingress-NGINX в итоге превращается в конфиг nginx с директивами location, и правила сопоставления там — nginx-овые. Gateway API — это спецификация, которая описывает матчинг явно и довольно строго, а реализует его уже конкретный контроллер: Envoy Gateway, Istio, Cilium, Traefik, NGINX Gateway Fabric. Спецификация живёт на gateway-api.sigs.k8s.io, и никакого требования «вести себя как ingress-nginx» в спецификации нет и быть не может. Поэтому перенос «один в один» по внешнему виду YAML — это перенос синтаксиса, а не поведения.

Чаще всего в первые дни после переезда отваливается вот это:

Проверка главной страницы и одного-двух популярных разделов ничего не доказывает. Именно эти адреса при любой ошибке матчинга продолжают работать — они короткие, в нижнем регистре и почти всегда попадают в первое же правило.
Памятка: Симптом: 404 при полностью зелёных статусах — схема
Памятка: Симптом: 404 при полностью зелёных статусах. Открыть схему в полном размере

Ingress-NGINX: regex — это префикс, и он без учёта регистра

Первое, что надо понять про уходящий контроллер: включив regex, вы получаете не то, что написали. Ingress-NGINX генерирует location с модификатором регистронезависимого регулярного выражения и якорем в начале строки. То есть путь /[A-Z]{3} превращается не в «ровно три заглавные буквы и всё», а в «строка начинается с трёх любых букв, регистр не важен, дальше может быть что угодно». Под такое правило попадают /uuid, /UUID, /abcdef, /ipsum-lorem — практически весь сайт.

В официальной документации ingress-nginx это сказано прямым текстом про аннотацию use-regex: «When this annotation is set to true, the case insensitive regular expression location modifier will be enforced on ALL paths for a given host regardless of what Ingress they are defined on». Обратите внимание на две вещи: case insensitive — это не опция, а constant; и ALL paths for a given host — это не про один Ingress, а про весь хост.

Практический вывод: годами ваши пользователи и интеграции могли ходить в верхнем регистре, с лишним суффиксом, со слэшем и без — и всё работало, потому что nginx был снисходителен. Никто этого не замечал, потому что 200 в ответ вопросов не вызывает. Список «реально используемых URL» у вас в манифестах не написан. Он написан в логах.

Прежде чем что-то переносить, снимите с балансировщика или из access-логов реальный корпус путей за 30–90 дней. Это единственный достоверный источник того, что у вас на самом деле ходит. Манифесты описывают намерение, логи — факт.
После переезда с Ingress-NGINX на Gateway API часть адресов отдаёт 404: что изменилось в regex и rewrite — схема
Схема к статье. Открыть схему в полном размере

use-regex и rewrite-target «заражают» весь хост целиком

Дальше начинается самое коварное. Аннотация use-regex, поставленная на один-единственный Ingress, включает regex-режим для всех путей этого host — включая пути, описанные в совершенно других Ingress-объектах, возможно, в другом namespace, возможно, добавленных чужим Helm-чартом полгода назад. Вы правите один сервис, а меняете поведение маршрутизации у соседей.

То же самое делает rewrite-target, причём даже без явного use-regex. В документации это отдельным абзацем: «Additionally, if the rewrite-target annotation is used on any Ingress for a given host, then the case insensitive regular expression location modifier will be enforced on ALL paths for a given host regardless of what Ingress they are defined on». То есть один чарт с rewrite-target переводит в regex-режим весь домен.

Инвентарь «заражённых» хостов я собираю одной командой ещё до того, как открою первый манифест. Она выводит namespace, имя Ingress и host для всех объектов, где стоит use-regex или rewrite-target:

kubectl get ingress -A -o json | jq -r '.items[] | select(.metadata.annotations // {} | keys[] | test("use-regex|rewrite-target")) | "\(.metadata.namespace)/\(.metadata.name)\t\(.spec.rules[].host)"' | sort -u

Смотрите, что из этого получается на типичном манифесте:

Каждый host из этого списка — кандидат на сюрприз: на нём регистронезависимый regex-режим действует на все пути, включая описанные в чужих Ingress и Helm-чартах. Именно эти хосты гоняйте через проверку путей первыми.
Памятка: use-regex и rewrite-target «заражают» весь хост целиком — схема
Памятка: use-regex и rewrite-target «заражают» весь хост целиком. Открыть схему в полном размере

Gateway API считает пути иначе — и делает это строго

В Gateway API (группа gateway.networking.k8s.io, версия v1) тип сопоставления пути задаётся явно, и семантика описана в спецификации без пространства для трактовок. Exact: «Matches the URL path exactly and with case sensitivity. This means that an exact path match on /abc will only match requests to /abc, NOT /abc/, /Abc, or /abcd». То есть слэш на конце — уже другой путь, заглавная буква — уже другой путь. PathPrefix: «Matches based on a URL path prefix split by /. Matching is case-sensitive and done on a path element by element basis... For example, the paths /abc, /abc/, and /abc/def would all match the prefix /abc, but the path /abcd would not». Это принципиально: PathPrefix — посегментный, а не строковый. То, что в nginx матчилось как «строка начинается с», здесь матчиться перестанет.

RegularExpression в Gateway API есть, но это не спасательный круг. В спецификации он помечен как implementation-specific: «Matches if the URL path matches the given regular expression with case sensitivity. Since "RegularExpression" has implementation-specific conformance, implementations can support POSIX, PCRE, RE2 or any other regular expression dialect». Два следствия. Первое: регистр учитывается, автоматической регистронезависимости не будет. Второе: диалект и якорение зависят от контроллера. В Envoy Gateway это RE2 и полное совпадение с путём: если regex совпал только с частью :path, правило не сработает, так что шаблон, который в nginx ловил половину сайта, здесь поймает только точное соответствие. В NGINX Gateway Fabric (документация версии 2.7) тоже RE2, но шаблон автоматически якорится только к началу пути. У Cilium поддержку RegularExpression смотрите в отчёте конформности конкретной версии: в спецификации это не Core и не Extended, и реализация вправе его не поддерживать.

Отдельная мина — фильтр URLRewrite. Поле ReplacePrefixMatch в спецификации ограничено жёстко: «ReplacePrefixMatch is only compatible with a PathPrefix HTTPRouteMatch. Using any other HTTPRouteMatch type on the same HTTPRouteRule will result in the implementation setting the Accepted Condition for the Route to status: False». Никакой магии с превращением Exact или RegularExpression в переписываемый префикс не будет. Если вы привыкли к rewrite-target с группами захвата, это надо переписывать руками, а не переводить механически.

Теперь главное — откуда берутся 404 именно у связки regex + rewrite. В ingress-nginx было привычно написать path: /booking(/|$)(.*) и rewrite-target: /$2 — nginx сам подставлял группу захвата. В стандартном Gateway API групп захвата нет вообще. ReplacePrefixMatch с RegularExpression невалиден: Route получает Accepted=False, трафик на эти пути не маршрутизируется, и клиент видит 404 от Gateway. ReplaceFullPath формально принимается, но строку /$2 он подставляет буквально: бэкенд получает путь /$2 и честно отвечает 404. В NGINX Gateway Fabric есть открытое обсуждение поддержки групп захвата для RegularExpression, и конфигурация с /$2 там упирается в ошибку валидации. Если regex-rewrite действительно нужен, это делается только расширением конкретной реализации — в Envoy Gateway, например, через собственный ресурс HTTPRouteFilter с типом ReplaceRegexMatch:

apiVersion: gateway.envoyproxy.io/v1alpha1
kind: HTTPRouteFilter
metadata:
  name: booking-regex-rewrite
spec:
  urlRewrite:
    path:
      type: ReplaceRegexMatch
      replaceRegexMatch:
        pattern: '^/booking(/.*)?$'
        substitution: '\1'

В HTTPRoute такой фильтр подключается через filters с type: ExtensionRef (group gateway.envoyproxy.io, kind HTTPRouteFilter). Работает, но маршрут после этого привязан к Envoy Gateway намертво. Там, где хватает префикса, я предпочитаю PathPrefix /booking плюс ReplacePrefixMatch: / — это Core-синтаксис, одинаково понятный любому контроллеру.

И последнее, что стоит держать в голове, — приоритет правил. Спецификация задаёт порядок — сначала Exact, затем PathPrefix с наибольшим числом символов, затем совпадение по методу, затем по наибольшему числу заголовков, затем по наибольшему числу query-параметров. При равенстве побеждает более старый по времени создания Route, дальше — алфавитный порядок «{namespace}/{name}», а внутри одного HTTPRoute — первое совпавшее правило. Это детерминированно и это хорошо, но это не тот же порядок, в котором location разбирал nginx.

Статусы после применения я проверяю не глазами в дашборде, а выборкой по всем HTTPRoute сразу — Accepted=False из-за несовместимого фильтра иначе легко пропустить:

kubectl get httproute -A -o custom-columns=NS:.metadata.namespace,NAME:.metadata.name,ACCEPTED:'.status.parents[*].conditions[?(@.type=="Accepted")].status'
Accepted=False из-за несовместимого ReplacePrefixMatch не уронит кластер и не выдаст ошибку при kubectl apply. Route просто молча не будет обслуживать трафик, а пользователи увидят 404. Статус проверяется в status.parents[].conditions после каждого применения, а не только при первом деплое.

Разбор из практики: музыкальная школа «Нотный дом», сайт с онлайн-записью и пять расхождений

Клиент — музыкальная школа «Нотный дом», 48 рабочих мест: преподаватели, администраторы, бухгалтерия. Мы у них ведём офисную ИТ-часть, а сайт школы с онлайн-записью на пробные занятия и оплатой абонементов сделал и держит сторонний веб-подрядчик — в своём небольшом кластере Kubernetes за ingress-nginx. После новостей о завершении проекта ingress-nginx подрядчик прислал план переезда на Envoy Gateway, и школа попросила нас посмотреть его независимым взглядом: для них сайт и запись — это заявки от родителей, и неделя с 404 на форме записи прямо бьёт по набору.

Масштаб честно небольшой: три публичных хоста (сайт, модуль записи, API для CRM и уведомлений платёжного сервиса), 11 объектов Ingress, два из них пришли с Helm-чартом мониторинга. Подрядчик прогнал ingress2gateway, поправил вывод руками: pathType: Prefix → PathPrefix, Exact → Exact, а правило с rewrite-target: /$2 на модуле записи переложил в URLRewrite с ReplaceFullPath. Всё применилось, все Route — Accepted=True, главная и форма записи открывались. Мы попросили до переключения поднять Gateway параллельно и прогнать через оба контура корпус путей: подрядчик выгрузил из access-логов за 60 дней около 1 300 уникальных адресов, дальше сравнивали коды ответов скриптом из следующего раздела.

Расхождений нашлось пять, и все из той же оперы, что описана в документации. Первое: платёжный сервис слал уведомления об оплате абонемента на /Payment/Notify с заглавными — в старом контуре хост был в регистронезависимом regex-режиме из-за того самого rewrite-target, и путь долетал; на Exact /payment/notify он стал 404, то есть оплаты перестали бы подтверждаться в CRM. Второе: QR-коды на весенних афишах отчётного концерта вели на /zapis-vesna, а правило было /zapis — строковый префикс nginx это прощал, посегментный PathPrefix нет. Третье: весь модуль записи под /booking/… — ReplaceFullPath подставлял строку /$2 буквально, и бэкенд отвечал 404 на все внутренние шаги формы, кроме первого экрана, который открывался по отдельному правилу. Ещё два расхождения — метрики и health-проба, попадавшие под чужой rewrite, — внешних пользователей не касались.

Чинили адресно, без «вернём regex на всё». Для уведомлений платёжного сервиса добавили второй match в то же правило — просить сервис поменять URL долго, а два лишних match ничем не рискуют. Под QR-коды завели явный Exact-маршрут /zapis-vesna: афиши уже напечатаны, и они будут висеть до конца сезона. Модуль записи переписали на PathPrefix /booking + ReplacePrefixMatch: / — групп захвата там по сути и не требовалось, нужен был просто срез префикса. Метрики и пробы вывели на явные маршруты. Итог: 9 HTTPRoute вместо 11 Ingress, переключение подрядчик сделал сменой DNS-записи в тихий час, обращений от родителей и администраторов после него не было. Наша часть работы — около четырёх часов на разбор плана, скрипт сравнения и разбор расхождений вместе с подрядчиком.

Ключевая цифра — не 11 и не 9, а 1 300. Столько адресов у школы реально ходило, а в манифестах было описано около двух десятков путей. Если сайт у вас делает подрядчик, попросите у него именно этот корпус и результат сравнения — это дешевле, чем разбираться с неподтверждёнными оплатами.

Мой порядок миграции: теневой Gateway и диффометр по кодам ответов

Я не верю в миграцию маршрутизации «по манифестам». Я верю в сравнение фактического поведения на реальных путях. Схема, которую применяю каждый раз, простая: старый контур не трогаем вообще, рядом поднимаем Gateway на отдельном hostname (или на том же, но с проверкой через Host-заголовок и --resolve), и гоняем через оба один и тот же список URL, сравнивая коды ответов. Всё, что разошлось, — в список на разбор.

Стартовые манифесты удобно получить через ingress2gateway — это проект SIG Network, версия 1.0 вышла 20 марта 2026 года, генерирует ресурсы Gateway API v1.5.0 и для провайдера ingress-nginx переводит больше 30 распространённых аннотаций, включая regex-матчинг и rewrite. Но README проекта прямо говорит, что копировать аннотации один в один он не призван, поэтому его вывод для меня черновик, а не результат:

ingress2gateway print --providers=ingress-nginx --namespace school-site > gateway-draft.yaml

Корпус путей собираю из логов, а не из головы. Если фронт за CDN — берите логи CDN, там будет честный регистр и честные суффиксы. Если логи уже уехали в Loki/ELK — доставайте оттуда, за 60–90 дней. Ниже — тот самый скрипт сравнения, который у меня лежит в снипетах и правится под каждый проект за пять минут.

#!/usr/bin/env bash
# paths.txt — уникальные пути из access-логов за 60 дней
OLD_IP=10.20.0.11    # ingress-nginx
NEW_IP=10.20.0.31    # Gateway API
HOST=school.example.ru

printf '%-46s %6s %6s\n' PATH OLD NEW
while read -r p; do
  old=$(curl -s -o /dev/null -w '%{http_code}' \
        --resolve "$HOST:443:$OLD_IP" "https://$HOST$p")
  new=$(curl -s -o /dev/null -w '%{http_code}' \
        --resolve "$HOST:443:$NEW_IP" "https://$HOST$p")
  [ "$old" = "$new" ] || printf '%-46s %6s %6s\n' "$p" "$old" "$new"
done < paths.txt

Дальше — по списку расхождений. Регистр лечится дополнительным match в том же правиле (дублировать backendRefs не нужно, matches — это список). Пропавший «хвост» лечится либо отдельным маршрутом, либо честным RegularExpression, если ваш контроллер его поддерживает и вы приняли, что маршрут стал непереносимым. Rewrite — только ReplacePrefixMatch и только поверх PathPrefix.

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: payment-notify
spec:
  parentRefs:
    - name: public-gw
  hostnames:
    - api.school.example.ru
  rules:
    - matches:
        - path:
            type: Exact
            value: /payment/notify
        - path:
            type: Exact
            value: /Payment/Notify   # legacy-вариант платёжного сервиса
      backendRefs:
        - name: crm-api
          port: 8080
Не переключайтесь сменой ingressClassName на живом объекте. Держите два контура параллельно хотя бы неделю: откат на DNS занимает минуты, откат на пересозданном Ingress — полчаса и нервы.
Порядок действий: Мой порядок миграции: теневой Gateway и диффометр по кодам ответов — схема
Порядок действий: Мой порядок миграции: теневой Gateway и диффометр по кодам ответов. Открыть схему в полном размере

Что чинить в первую очередь, а на что можно спокойно забить

Скажу прямо: соблазн «сделать как было» — сохранить регистронезависимость и префиксный regex на всём — я считаю вредным. Вы потратите время на то, чтобы законсервировать поведение, которое никто сознательно не выбирал: оно возникло как побочный эффект аннотаций nginx. Плюс RegularExpression в Gateway API implementation-specific, и вы своими руками прибьёте маршруты к конкретному контроллеру — ровно та зависимость, от которой уходили.

В первую очередь чините то, где на другом конце провода не вы. Внешние интеграции, уведомления платёжных сервисов, обмен с CRM, мобильные клиенты старых версий, ссылки в печатных материалах и QR-кодах. Там вы не можете попросить «поправьте регистр в запросе» — точнее, можете, но это займёт недели, а 404 у вас уже сегодня. Для них — явный дублирующий match, и живём дальше.

На что можно забить с чистой совестью: на регистр в путях, которые дёргает только ваш собственный фронт (правится в одном месте одним коммитом), на «красоту» маршрутов вроде /api-something, которое случайно матчилось на /api и по-хорошему вообще не должно было работать, и на попытки один в один воспроизвести приоритет location из nginx — в Gateway API приоритет описан в спецификации и он предсказуемее. Не бойтесь, что маршрутов станет больше: девять явных HTTPRoute читаются лучше, чем одиннадцать Ingress с аннотациями, которые тихо влияют друг на друга через общий host.

И про сроки. Сопровождение community-проекта ingress-nginx завершилось в марте 2026 года (анонс — ноябрь 2025, KubeCon + CloudNativeCon North America). Уже развёрнутые инсталляции не перестают работать в одну ночь, паниковать не нужно. Но обновлений безопасности для них больше нет, а входной контроллер — это то, что смотрит наружу. Заложите миграцию в план на ближайший квартал, а не «когда-нибудь». И не путайте: коммерческий NGINX Ingress Controller от F5 — отдельный живой продукт, к завершению community-проекта он отношения не имеет.

Если у вас в кластере входной контроллер без обновлений безопасности и он же смотрит в интернет — это не «технический долг», это риск. Миграция на Gateway API решает и его тоже, а не только вопрос новых фич.

Частые вопросы

Почему после миграции 404 получают только некоторые адреса, а сайт в целом работает?

Потому что короткие популярные пути в нижнем регистре попадают в правило при любой семантике матчинга. Ломается то, что раньше матчилось «случайно»: другой регистр, лишний суффикс, слэш на конце, путь под чужим host-wide rewrite. Такие адреса обычно дёргают интеграции и старые клиенты, а не люди в браузере, поэтому проблема всплывает не сразу.

Можно ли в Gateway API вернуть регистронезависимое сопоставление путей?

Штатного переключателя нет: и Exact, и PathPrefix, и RegularExpression в спецификации описаны как регистрозависимые. Практических выхода два — добавить дополнительный match с нужным вариантом написания в то же правило (matches — это список, backendRefs дублировать не нужно) или использовать RegularExpression, если ваш контроллер его поддерживает. Второй вариант делает маршрут непереносимым между реализациями.

Чем PathPrefix в Gateway API отличается от pathType: Prefix в Ingress-NGINX?

PathPrefix сопоставляет посегментно: /abc, /abc/ и /abc/def попадут под префикс /abc, а /abcd — нет. В ingress-nginx, особенно когда на хосте включён regex-режим, сопоставление ведёт себя как строковый префикс, и /abcd матчился. Именно на этом теряются адреса вида /static-cdn при правиле /static.

Почему HTTPRoute применился, но трафик через него не идёт?

Смотрите status.parents[].conditions. Типовая причина — фильтр URLRewrite с ReplacePrefixMatch поверх match типа Exact или RegularExpression: спецификация требует, чтобы реализация выставила Accepted=False. kubectl apply при этом отработает без ошибки, маршрут просто молча не обслуживает запросы. Вторая частая причина — ResolvedRefs=False из-за отсутствующего сервиса или недостающего ReferenceGrant для бэкенда в другом namespace.

Почему rewrite с группами захвата, перенесённый из rewrite-target, даёт 404?

В стандартном URLRewrite Gateway API групп захвата нет. ReplacePrefixMatch поверх RegularExpression делает Route невалидным (Accepted=False), а ReplaceFullPath подставляет /$2 буквально — бэкенд получает несуществующий путь и отвечает 404. Решение — переписать правило на PathPrefix + ReplacePrefixMatch, если нужен срез префикса, либо использовать расширение конкретной реализации, например HTTPRouteFilter с ReplaceRegexMatch в Envoy Gateway.

Можно ли доверить миграцию ingress2gateway целиком?

Как генератор черновика — да, версия 1.0 переводит больше 30 аннотаций ingress-nginx, включая regex и rewrite. Но поведение хост-широкого регистронезависимого regex-режима и «случайно» работавших путей по манифестам не восстановить: их видно только в access-логах. Поэтому после генерации обязательно сравнение кодов ответов старого и нового контура на реальном корпусе путей.

Ingress-NGINX перестал работать в марте 2026? Надо срочно всё переносить?

Нет, уже развёрнутые инсталляции продолжают работать — завершилось сопровождение проекта, а не выключились контроллеры. Но обновлений, в том числе безопасности, для community-версии больше нет, а это компонент на периметре. Разумный горизонт — квартал: спокойно собрать корпус путей, прогнать параллельный контур, переключиться через DNS. И не путайте с коммерческим NGINX Ingress Controller от F5 — это отдельный продукт, он живой.

На какой контроллер Gateway API переходить?

Единого правильного ответа нет, выбор зависит от того, что у вас уже есть. Если в кластере Cilium как CNI, логично включить его встроенную поддержку Gateway API. Если есть или планируется service mesh — Istio. Если нужен просто входной шлюз — Envoy Gateway или NGINX Gateway Fabric. Ключевое при выборе: поддерживает ли контроллер RegularExpression и в каком диалекте (у Envoy Gateway и NGF — RE2, но якорение разное), какие Extended-фильтры реализованы и есть ли расширения под regex-rewrite, если без него никак.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи