После обновления ingress-nginx — 404: возвращаю заголовки без Critical-аннотаций
После обновления контроллера Pod приложения здоров, Service доступен, а сайт отвечает 404. В Ingress остались две безобидные строки с CSP и Cache-Control — внутри configuration-snippet. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», в такой ситуации сначала проверяю, попал ли маршрут в конфигурацию контроллера. Покажу, как установить причину, перенести постоянные заголовки в custom-headers и сохранить запрет опасных аннотаций.
Сначала выясняю, кто вернул 404
Начиная с ingress-nginx controller v1.12.0, которому соответствует Helm chart 4.12.0, значение annotations-risk-level по умолчанию изменилось с Critical на High. Одновременно включили --enable-annotation-validation по умолчанию. Аннотации configuration-snippet и server-snippet относятся к Critical: прежнего разрешения snippets теперь недостаточно. Это конкретное изменение версии, а не универсальное объяснение любого сбоя после обновления. [Изменения v1.12.0](https://github.com/kubernetes/ingress-nginx/releases/tag/controller-v1.12.0).
При запуске нового Pod контроллер перечитывает существующие Ingress. В v1.12.0 ошибка проверки риска может остановить добавление объекта во внутреннюю модель маршрутов. Если другой Ingress не обслуживает этот host/path, запрос попадёт в default backend, обычно отвечающий 404. Сам объект при этом остаётся в Kubernetes. Именно поэтому kubectl get ingress выглядит успокаивающе, хотя обслуживать запрос уже нечему. Это следует из [кода обработки Ingress](https://github.com/kubernetes/ingress-nginx/blob/controller-v1.12.0/internal/ingress/controller/store/store.go) и [описания default backend](https://kubernetes.github.io/ingress-nginx/user-guide/default-backend/).
Я сопоставляю фактический образ и аргументы Deployment, ConfigMap контроллера и сообщения всех его реплик. Ищу группу ConfigurationSnippet или ServerSnippet рядом с risky annotation. Затем проверяю ingressClassName, host, path и готовые EndpointSlice нужного Service. Названия ниже относятся к нашему примеру; в вашем кластере они могут отличаться. ```bash kubectl -n ingress-nginx get deploy ingress-nginx-controller -o yaml kubectl -n ingress-nginx get cm ingress-nginx-controller -o yaml kubectl -n ingress-nginx logs -l app.kubernetes.io/component=controller --since=20m --prefix | rg 'risky annotation|ConfigurationSnippet|ServerSnippet' kubectl -n vektor get ingress portal -o yaml kubectl -n vektor get endpointslice -l kubernetes.io/service-name=portal ```
Здесь легко перепутать разные механизмы. Отказ admission webhook при новом apply сам по себе не удаляет старый Ingress. Неудачный reload из-за синтаксиса nginx.conf может оставить у работающего NGINX предыдущую конфигурацию. А 404 от самого приложения вообще требует другой диагностики. Для нашего случая нужны совпавшие признаки: ошибка риска после rollout и отсутствие ожидаемого маршрута в nginx -T. Одна страница с надписью nginx доказательством не служит.
Стенд «Вектор»: почему исчезают шесть маршрутов
Возьму конкретный модельный стенд для условного ООО «Вектор», производственной компании на 120 рабочих мест. Это учебная реконструкция, а не отчёт о клиентских испытаниях: параметры заданы для разбора, результаты ниже ожидаемые, не измеренные. В конфигурации — Kubernetes 1.30.8, три узла по 4 vCPU и 8 ГиБ RAM, две реплики ingress-nginx, восемь Ingress на независимых доменах. Эти ресурсы — параметры примера, а не требования контроллера. Переход: chart 4.11.4/controller 1.11.4 → chart 4.12.0/controller 1.12.0. Kubernetes 1.30 указан в матрицах обеих веток. [Матрица v1.11.4](https://github.com/kubernetes/ingress-nginx/blob/controller-v1.11.4/README.md#supported-versions-table), [матрица v1.12.0](https://github.com/kubernetes/ingress-nginx/blob/controller-v1.12.0/README.md#supported-versions-table).
Шесть Ingress обслуживают закрытые веб-интерфейсы и содержат одинаковый snippet. Два остальных обходятся без него. Для простоты у интерфейсов все ресурсы загружаются со своего origin, нет inline-скриптов, inline-стилей и встраивания в iframe. Заголовки до обновления заданы так: ```yaml metadata: annotations: nginx.ingress.kubernetes.io/configuration-snippet: | more_set_headers "Content-Security-Policy: default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"; more_set_headers "Cache-Control: no-store"; ``` В старых values включено controller.allowSnippetAnnotations: true, а annotations-risk-level явно не задан. После обновления разрешение snippets сохраняется, но новый порог High их уже не пропускает.
Когда обе реплики заменены и заново построили конфигурацию, ожидаемая картина такая: шесть доменов уходят в default backend, два продолжают работать. Пересекающихся правил на этих доменах нет. Проверка приложения напрямую через Service даёт штатный ответ. Я бы именно с этой развилки начинал разбор: переустановка приложения, увеличение числа его Pod и переключение DNS здесь только добавят изменений к исходной неисправности. Причина находится на этапе построения маршрута.
Почему я не оставляю annotations-risk-level: Critical
У разрешения snippets два независимых условия: allow-snippet-annotations разрешает сам механизм, annotations-risk-level допускает категорию риска. Для configuration-snippet нужны разрешённые snippets и порог Critical. Контроллер оценивает возможность аннотации, а не добрые намерения автора двух строк. Поэтому безобидный Cache-Control получает ту же категорию, что и гораздо более свободная конфигурация. [Таблица рисков аннотаций](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations-risk/).
Временное повышение порога может вернуть прежнее поведение, но после исключения Ingress из внутренней модели иногда потребуется ещё его обновление или перезапуск контроллера. Одна правка ConfigMap не гарантирует мгновенное восстановление. Главное же — настройка относится ко всему этому контроллеру. Разрешение достанется и другим пользователям, которые могут создавать обслуживаемые им Ingress. Я не считаю разумным расширять их возможности ради двух фиксированных response headers.
Риск тоже не надо описывать страшилками. Наличие snippet не означает, что кластер уже взломан. На выделенном контроллере с единственным доверенным владельцем конфигурации граница доверия другая, чем в общем кластере нескольких команд. Но для постоянных заголовков мне всё равно удобнее ограниченный механизм custom-headers. Перенос произвольного текста в глобальный server-snippet лишь меняет владельца опасной настройки; отключение webhook или опустошение blocklist подменяет исправление ослаблением проверок. [Настройки доверия к snippets](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/configmap/#allow-snippet-annotations).
Выношу постоянные заголовки в отдельный ConfigMap
У custom-headers категория Medium и область действия location. Она проходит порог High и позволяет задавать заголовки ответа через ссылку на ConfigMap. Ниже итоговый фрагмент Helm values контроллера; его нужно объединить с полными values вашего релиза. allowSnippetAnnotations — поле Helm, остальные два параметра находятся в controller.config. Сам список разрешённых имён ещё не добавляет заголовки в ответы. [Синтаксис custom-headers](https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#custom-headers). ```yaml controller: allowSnippetAnnotations: false config: annotations-risk-level: "High" global-allowed-response-headers: "Content-Security-Policy,Cache-Control" ```
В namespace приложения создаю portal-response-headers-v1. Сохраняю прежние значения: одновременно чинить маршрутизацию и ужесточать CSP неудобно — при следующем сбое вы не поймёте, какое изменение виновато. В data лежат только значения HTTP-заголовков, без more_set_headers, завершающей директиву точки с запятой и параметра always. ```yaml apiVersion: v1 kind: ConfigMap metadata: name: portal-response-headers-v1 namespace: vektor data: Content-Security-Policy: "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" Cache-Control: "no-store" ```
В существующем Ingress удаляю configuration-snippet и добавляю одну ссылку. Host, TLS, paths и backend сохраняю. Это фрагмент объекта, а не полный манифест для создания нового Ingress. Такой набор действует на locations, созданные из этого Ingress; соседние Ingress автоматически его не наследуют. ```yaml metadata: name: portal namespace: vektor annotations: nginx.ingress.kubernetes.io/custom-headers: "vektor/portal-response-headers-v1" ``` ConfigMap держу рядом с приложением. Доступ на изменение его значений выдаю осознанно: автор политики может сломать интерфейс даже без права писать произвольные директивы NGINX.
Две мелочи здесь способны устроить второй инцидент. В v1.12.0 сравнение с allowlist чувствительно к регистру: Content-Security-Policy должен одинаково называться в обоих местах. Значение заголовка лучше писать одной строкой; YAML-блок | обычно добавляет перевод строки, который валидатор не принимает. Недоступный ConfigMap, запрещённое имя или невалидное значение могут привести к LocationDenied и 503. Это не всегда ситуация «заголовок просто проигнорировали». [Проверки customheaders v1.12.0](https://github.com/kubernetes/ingress-nginx/blob/controller-v1.12.0/internal/ingress/annotations/customheaders/main.go).
Где CSP и Cache-Control лучше оставить приложению
custom-headers генерирует more_set_headers. Эта директива заменяет одноимённые заголовки, пришедшие от upstream, и без фильтра статусов действует также на 4xx/5xx. Дописывать always не нужно. Но запрос должен пройти соответствующий location: неизвестный host, отдельный обработчик ошибки или редиректа проверяются отдельно. Если приложение уже формирует CSP с nonce, статическая политика на ingress способна её затереть. Владельца каждого заголовка я стараюсь оставлять одного. [Шаблон контроллера](https://github.com/kubernetes/ingress-nginx/blob/controller-v1.12.0/rootfs/etc/nginx/template/nginx.tmpl), [семантика more_set_headers](https://github.com/openresty/headers-more-nginx-module#more_set_headers).
CSP зависит от устройства интерфейса. Политика из примера не подходит автоматически вашему порталу с внешними шрифтами, аналитикой и inline-кодом. Новый вариант я сначала проверяю через Content-Security-Policy-Report-Only, отдельно добавив это имя в allowlist: проверка идёт параллельно действующей CSP. Nonce должен согласовываться с HTML, быть новым и непредсказуемым для каждого HTML-ответа; постоянный nonce в ConfigMap теряет смысл. Если несколько CSP всё же дошли до браузера, они применяются совместно: второй заголовок не отменяет ограничения первого. [W3C: несколько политик](https://www.w3.org/TR/CSP3/#multiple-policies).
Для кэша я разделяю пользовательские данные и статику. no-store запрещает сохранение ответа HTTP-кэшами; no-cache допускает хранение, но требует проверки перед повторным использованием; private запрещает хранение общим кэшем. В аварийном восстановлении закрытого интерфейса no-store на всём Ingress — понятный компромисс, но он лишает кэша и статические файлы. Если нужны разные политики для HTML, API и версионированных assets, выбираю приложение либо отдельные Ingress для разных paths. Глобальное public, max-age=31536000 для личного кабинета недопустимо. [RFC 9111, директивы ответа](https://www.rfc-editor.org/rfc/rfc9111.html#section-5.2.2).
Глобальный add-headers я оставляю для действительно общей политики контроллера: единая CSP для разнородных приложений слишком быстро превращается в набор исключений. proxy-set-headers здесь вообще не решает задачу — это заголовки запроса к upstream. Если переносите настройку в NGINX самого приложения, учитывайте другую семантику: add_header требует always для произвольных статусов, а собственные add_header в location по умолчанию прекращают наследование таких директив из server. Проверяйте версию backend NGINX и его конфигурацию отдельно. [Направления передачи заголовков](https://kubernetes.github.io/ingress-nginx/examples/customization/custom-headers/), [документация add_header](https://nginx.org/en/docs/http/ngx_http_headers_module.html#add_header).
Применяю изменения и проверяю фактический ответ
Порядок важнее количества команд. Сначала добавляю allowlist контроллеру, затем создаю ConfigMap заголовков, потом одним изменением Ingress убираю snippet и ставлю custom-headers. Если snippets ещё используются другими маршрутами, окончательно выключаю их после переноса всех таких объектов. Итоговые values показаны выше. Новый Ingress не должен ссылаться на ещё не созданный ConfigMap. В GitOps эти зависимости разношу по этапам синхронизации.
Для одного объекта операция выглядит так; тот же результат обязательно фиксирую в исходном Helm-шаблоне или манифесте, иначе следующий reconcile вернёт старую аннотацию. Команда сохраняет остальные поля Ingress. После обработки изменения и успешного reload маршрут может восстановиться без перезапуска контроллера. ```bash kubectl -n vektor apply -f portal-response-headers-v1.yaml kubectl -n vektor patch ingress portal --type=merge -p '{"metadata":{"annotations":{"nginx.ingress.kubernetes.io/configuration-snippet":null,"nginx.ingress.kubernetes.io/custom-headers":"vektor/portal-response-headers-v1"}}}' ``` Такой patch предполагает, что проблема именно в показанном configuration-snippet; другие запрещённые аннотации нужно разобрать отдельно.
На автоматическое применение правок внутри header ConfigMap не рассчитываю. Официальный пример предупреждает: его изменение само по себе не вызывает reload контроллера. Поэтому следующую редакцию создаю как portal-response-headers-v2 и меняю ссылку в Ingress. Это даёт явное событие обновления и понятный откат на v1. Если изменяли содержимое существующего ConfigMap, документированный обходной путь — rolling restart Deployment контроллера; его выполняют с проверкой готовности реплик. [Оговорка о reload](https://kubernetes.github.io/ingress-nginx/examples/customization/custom-headers/).
Проверяю nginx -T каждой реплики: нужный server_name, location и две директивы more_set_headers должны находиться вместе. Затем делаю GET через внешний адрес, а не ограничиваюсь HEAD. В примере замените 192.0.2.10 на адрес балансировщика; сертификат должен быть доверен клиенту. --resolve сохраняет правильные имя хоста и TLS SNI. ```bash curl -sS -D - -o /dev/null --resolve portal.vektor.example:443:192.0.2.10 https://portal.vektor.example/ ``` После балансировщика проверяю реплики по отдельности, например через port-forward к каждому Pod. Один успешный запрос может попасть только на исправную реплику.
- Обычная страница: штатный статус, ожидаемая CSP, отсутствие неожиданных дубликатов заголовков.
- Приватный API: правильное поведение авторизации и Cache-Control: no-store, в том числе на отказе.
- Несуществующая страница приложения: его собственный 404 с нужной политикой, а не ответ default backend вместо всего сайта.
- Статика, вход и редиректы: рабочий интерфейс в браузере и подходящие правила кэширования.
Что считаю завершённым исправлением в 2026 году
Контрольный финал модельного «Вектора» — восемь обслуживаемых доменов вместо двух, шесть Ingress со ссылкой на ConfigMap вместо snippet, порог High и выключенные snippet-аннотации. На нормальной странице возвращаются прежние CSP и Cache-Control; ожидаемые ошибки приложения остаются ошибками. Это критерии приёмки сценария, не обещание измеренного времени восстановления. Проверка после замены реплик обязательна: конфигурация должна воспроизводиться с нуля, а не жить благодаря старому состоянию процесса.
В эксплуатации я оставляю версии ConfigMap в репозитории, проверку заголовков в smoke-тестах и понятного владельца CSP. Сначала восстанавливаю маршрут и прежнюю политику. Оптимизацию кэширования статики, ужесточение CSP и косметическую уборку values можно вынести в следующие изменения. Если одновременно трогать всё, расследовать отклонения придётся сразу на нескольких уровнях. Именно такого смешивания задач я стараюсь избегать при восстановлении сервиса.
Однако на сентябрь 2026 года здесь есть ещё обязательная работа: ingress-nginx завершил поддержку 24 марта 2026 года, новых исправлений и security patches больше не будет. Существующие установки продолжают работать, но запрет snippets не заменяет сопровождение проекта. Поэтому удаление нестандартных директив я использую и как подготовку к миграции на поддерживаемый контроллер. Для Gateway API проверяю реализацию ResponseHeaderModifier, включая операцию set, и повторяю тот же набор HTTP-проверок. Универсального победителя для всех инфраструктур нет. [Статус ingress-nginx](https://kubernetes.io/blog/2026/03/30/kubernetes-v1-36-sneak-peek/#ingress-nginx-retirement), [изменение заголовков в Gateway API](https://gateway-api.sigs.k8s.io/guides/user-guides/http-header-modifier/).
Частые вопросы
Достаточно ли оставить allowSnippetAnnotations: true?
Нет. Для configuration-snippet порог High недостаточен, поскольку аннотация относится к Critical. Для постоянных заголовков я заменяю её на custom-headers и сохраняю запрет snippets.
Почему после перехода на custom-headers появился 503?
Проверьте существование ConfigMap, точное совпадение имён заголовков с allowlist и отсутствие переводов строк в значениях. В v1.12.0 такие ошибки могут запретить location; причина будет в логах контроллера.
Нужно ли перезапускать ingress-nginx после каждого изменения заголовков?
Не обязательно. Создайте ConfigMap новой версии и измените ссылку в Ingress. После обработки события проверьте reload и ответ каждой реплики. Изменение только содержимого прежнего ConfigMap не гарантирует применения.
Я помогу разобрать 404 после обновления, перенести заголовки и подготовить миграцию с ingress-nginx. Обратитесь в «АйТи-Фреш» через itfresh.ru — начнём с ваших манифестов и фактической конфигурации контроллера.
Бесплатная консультация →

