Service работает, адреса устарели: как перевести свои контроллеры на EndpointSlice
Service отвечает, мониторинг зелёный, обновление Kubernetes согласовано. А небольшой самописный контроллер всё ещё собирает upstream из core/v1 Endpoints. Если отключить контроллер, обновляющий эти объекты, ошибка проявится при следующей смене адресов Pod. Я, Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш», начинаю подготовку с поиска таких потребителей. Покажу, как связать обращения к API с конкретным приложением, переделать обработку адресов и проверить результат до изменения control plane.
1. Сначала разделите устаревание API и остановку обновлений
Endpoints объявлен устаревшим начиная с Kubernetes 1.33. EndpointSlice в версии discovery.k8s.io/v1 стабилен с 1.21. Но deprecated не означает, что объект завтра исчезнет: API Endpoints продолжает обслуживаться. По состоянию на 5 сентября 2026 года актуальный выпуск — Kubernetes 1.37.0, и старый endpoints-controller по умолчанию остаётся включённым. Само обновление до этой версии не обещает описанную поломку. [Объявление Kubernetes](https://kubernetes.io/blog/2025/04/24/endpoints-deprecation/), [параметры kube-controller-manager](https://kubernetes.io/docs/reference/command-line-tools-reference/kube-controller-manager/).
Проблема возникает, когда администратор или поставщик платформы отключает генерацию Endpoints. Объект может остаться доступным, GET продолжит возвращать успешный ответ, но адреса внутри перестанут следовать за Pod. Современный kube-proxy использует EndpointSlice, поэтому обычный Service способен исправно обслуживать трафик, пока ваш генератор конфигурации балансировщика направляет запросы на исчезнувшие backend. Исправность Service здесь ничего не доказывает. [Назначение EndpointSlice](https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/).
Я разделяю две задачи: убрать зависимость приложений и решить, можно ли отключать старые контроллеры во всём кластере. Первая нужна уже сейчас. Вторая требует проверки платформы, включая её внутренние компоненты. Например, переход подресурса service/proxy API-сервера на EndpointSlice отмечен только в changelog 1.37. Поэтому результат проверки самописного приложения нельзя автоматически переносить на весь control plane предыдущей версии. [Изменения Kubernetes 1.37](https://github.com/kubernetes/kubernetes/blob/v1.37.0/CHANGELOG/CHANGELOG-1.37.md).
2. Соберите список подозреваемых: код, права, запущенные приложения
Начинаю с репозиториев контроллеров, операторов, внутренних платформенных утилит и конфигураций мониторинга. Ищу обращения клиента, создание informer, динамические ресурсы с именем endpoints и прямые HTTP-запросы. Поиск только по YAML недостаточен: зависимость может находиться внутри библиотеки или контейнера, который никто давно не пересобирал. Отдельно просматриваю задания CI, CronJob и внешние агенты с kubeconfig. Они тоже читают Kubernetes API.
Следующий проход — Role и ClusterRole с доступом к endpoints в пустой API-группе. Учитываю wildcard: правило resources: ["*"] тоже даёт нужные права. Через RoleBinding и ClusterRoleBinding связываю разрешения с ServiceAccount, затем с Deployment, DaemonSet или Job. Наличие права ещё не доказывает использование. Оно лишь расширяет список кандидатов. Самая неприятная находка — несколько разных приложений под одной учётной записью: установить владельца обращения становится сложнее.
Результат сохраняю в коротком реестре: приложение, владелец, образ с digest, ServiceAccount, наблюдаемые namespace, способ чтения и место, куда публикуются адреса. Последнее принципиально. Один контроллер обновляет внешний балансировщик, другой пишет файл, третий передаёт список воркерам. Проверять придётся конечного потребителя. Исправленный informer бесполезен, если приложение ниже по цепочке продолжает держать старую конфигурацию.
- Первичный поиск в репозитории: ```bash rg -n -i 'CoreV1\(\)\.Endpoints|Core\(\)\.V1\(\)\.Endpoints|NewEndpointsInformer|list_namespaced_endpoints|list_endpoints_for_all_namespaces|/api/v1/.*endpoints|kind:[[:space:]]*Endpoints' . rg -n '"endpoints"|corev1\.Endpoints' . ```
- Выгрузка разрешений и привязок для анализа: ```bash kubectl get roles,clusterroles -A -o yaml > rbac-roles.yaml kubectl get rolebindings,clusterrolebindings -A -o yaml > rbac-bindings.yaml kubectl get pods -A -o custom-columns='NS:.metadata.namespace,POD:.metadata.name,SA:.spec.serviceAccountName' ```
- Проверьте и производителей Endpoints: записи create, update и patch могут поддерживать Service без selector или старую схему обнаружения внешних backend.
3. Подтвердите использование через метрики и аудит API
Метрики API-сервера дают быстрый ответ, используется ли ресурс. Смотрю apiserver_request_total для endpoints с разбивкой по verb и code. Метрика apiserver_requested_deprecated_apis дополнительно показывает факт обращения к устаревшему API, но не является счётчиком текущих потребителей. Username и userAgent в этих метриках нет: искать конкретное приложение по одному графику бессмысленно. Для этого нужен аудит. [Политика устаревания Kubernetes API](https://kubernetes.io/docs/reference/using-api/deprecation-policy/).
Для endpoints достаточно уровня Metadata: нужны субъект, операция, URI и результат, содержимое ответа собирать незачем. Правило ниже добавляю в начало существующего массива rules. Политику целиком им не заменяю. Kubernetes применяет первое совпавшее правило; ранний level: None способен скрыть обращения. Проверяю также работающий backend аудита: одного YAML на диске недостаточно. В управляемом Kubernetes использую экспорт аудитных журналов провайдера. [Настройка аудита](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/).
Окно наблюдения должно захватить рабочий цикл: деплой, перезапуск читателя, ночное задание, переключение лидера. Уже открытый watch не обязан оставить новую запись сразу после включения аудита. Поэтому ноль обращений за десять минут не закрывает вопрос. В журнале сопоставляю username, userAgent, sourceIPs и requestURI; успешные ответы отделяю от 403. Для своих приложений задаю осмысленный userAgent с именем и версией. Собственные диагностические kubectl тоже учитываю, иначе легко расследовать свои же запросы.
- Запрос PromQL, показывающий обращения к старому ресурсу: ```promql sum by (verb, code) ( rate(apiserver_request_total{ group="", version="v1", resource="endpoints" }[30m]) ) ```
- Фрагмент для начала rules существующей audit policy: ```yaml - level: Metadata resources: - group: "" resources: ["endpoints"] omitStages: ["RequestReceived"] ```
- Выборка из построчного JSON-журнала аудита: ```bash jq -r ' select((.objectRef.apiGroup // "") == "" and .objectRef.resource == "endpoints") | select(.stage == "ResponseStarted" or .stage == "ResponseComplete") | [.auditID, .user.username, .verb, .requestURI, .userAgent, .responseStatus.code] | @tsv ' audit.log ``` При подсчёте запросов объединяйте записи по auditID: один watch может дать несколько стадий.
4. Переделайте модель данных: Service соответствует множеству Slice
Главная переделка — отказаться от предположения «один Service — один объект с адресами». EndpointSlice выбираются в namespace сервиса по label kubernetes.io/service-name. Имя самого Slice произвольное. Я собираю все подходящие объекты, затем строю единый набор backend. Фильтрация по имени объекта или выбор items[0] создаёт ошибку, которая прекрасно прячется на небольшом тестовом сервисе. [API EndpointSlice](https://kubernetes.io/docs/reference/kubernetes-api/discovery/endpoint-slice-v1/).
Порты принадлежат Slice целиком. При именованном targetPort разные Pod могут иметь разные числовые порты, поэтому переносить один найденный порт на все адреса нельзя. Дедупликацию делаю по Service, имени сервисного порта, протоколу, addressType, IP и числовому порту backend. Если часть полей уже определена внешней группировкой, повторять их в ключе необязательно. IPv4 и IPv6 обрабатываю явно, IPv6 с портом форматирую через net.JoinHostPort. Порядок итоговых адресов стабилизирую сортировкой.
Для простого HTTP upstream выбираю такую политику новых соединений: ready не false, terminating не true. По контракту API отсутствующее ready трактуется как true, отсутствующее terminating — как false. Это осознанный выбор приложения. Для draining может понадобиться serving; сервис с publishNotReadyAddresses тоже требует отдельного решения. Не обещаю идентичность kube-proxy: его поведение при завершении Pod сложнее одного фильтра. [Определения условий и портов](https://raw.githubusercontent.com/kubernetes/api/v0.35.0/discovery/v1/types.go).
Ещё одна мелочь, которую легко превратить в ошибку: addresses выглядит как список независимых целей, но штатный контроллер публикует один адрес на endpoint, а семантика дополнительных адресов не определена. В контракте своего агрегатора явно ограничиваю поддерживаемые форматы. Для нестандартного производителя согласую правила отдельно. Чтение всех Slice не означает, что можно без разбора принимать любые объекты с нужной меткой.
- Посмотреть все части одного Service: ```bash kubectl -n vector-lab get endpointslices.discovery.k8s.io \ -l kubernetes.io/service-name=reports -o yaml ```
- Фрагмент reconcile на Go: sliceLister — lister informer, key содержит namespace и имя Service; ошибки обрабатываются вызывающим worker. ```go selector := labels.SelectorFromSet(labels.Set{ discoveryv1.LabelServiceName: key.Name, }) slices, err := sliceLister.EndpointSlices(key.Namespace).List(selector) if err != nil { return err } // Объединить slices, применить политику готовности, // убрать дубликаты и заменить опубликованный набор. ```
5. Сохраните корректность informer и выдайте новые права
Я выбираю стандартный client-go informer: factory.Discovery().V1().EndpointSlices(). Обработчики Add, Update и Delete кладут в очередь ключ Service; worker пересобирает его состояние из cache. В Update учитываю метку и старого, и нового объекта — привязка могла измениться. В Delete обрабатываю cache.DeletedFinalStateUnknown. Медленный вызов внешнего балансировщика выполняется в worker с повторными попытками. Объекты из lister не изменяю. [Контракты обработчиков client-go](https://github.com/kubernetes/client-go/blob/v0.35.0/tools/cache/controller.go).
Публикацию запускаю после успешного WaitForCacheSync. Если отдельное начальное состояние строят сами обработчики, дополнительно жду синхронизации их регистрации. Ошибка чтения не равна пустому набору. Но корректно полученный пустой набор после синхронизации должен удалить старый upstream. При разрыве watch нужна повторная синхронизация; самописный бесконечный reconnect без обработки истёкшего resourceVersion оставляет шанс навсегда потерять изменения. [Контракт SharedInformer](https://github.com/kubernetes/client-go/blob/v0.35.0/tools/cache/shared_informer.go), [семантика LIST/WATCH](https://kubernetes.io/docs/reference/using-api/api-concepts/).
В RBAC меняются и ресурс, и группа: discovery.k8s.io, endpointslices. Для informer одного namespace достаточно соответствующих Role и RoleBinding; кластерному наблюдателю нужны права на кластерный запрос. resourceNames с именем Service не подходит: Slice называются иначе. Label selector не ограничивает разрешения RBAC. Старые права убираю после переключения, проверяя другие привязки и wildcard: разрешения складываются, запретить доступ отдельной Role нельзя. [Правила RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/).
- Правило для роли читателя EndpointSlice: ```yaml rules: - apiGroups: ["discovery.k8s.io"] resources: ["endpointslices"] verbs: ["get", "list", "watch"] ```
- Проверка эффективного разрешения; выполняющий её администратор должен иметь право impersonation: ```bash kubectl auth can-i list endpointslices.discovery.k8s.io \ -n vector-lab \ --as=system:serviceaccount:vector-lab:upstream-sync ```
6. Учебный разбор «Вектора»: 240 записей ещё не означают 240 актуальных адресов
Возьму условное ООО «Вектор» с генератором upstream для сервиса отчётов. Это учебная модель, а не история раскрытого клиентского проекта. Проверку агрегатора я выполнил на Python 3.12.3 с синтетическими объектами discovery.k8s.io/v1. Kubernetes для этих вычислений не запускался. Для интеграционного этапа рассматриваю переход с 1.36.4 на 1.37.0; результаты сетевых испытаний нельзя подменять результатами этой модели.
Входные данные конкретные: namespace vector-lab, Service reports, IPv4-адреса от 10.42.0.1 до 10.42.0.240, порт http/TCP:8080. Три Slice содержат 100, 100 и 40 endpoint. Такое распределение задано вручную: штатный контроллер не обязан всегда упаковывать адреса именно так. Старый читатель моделируется сохранённым первоначальным набором. Новый агрегатор получает изменяемые Slice.
Первый вариант миграции читает только первый объект — получается 100 backend вместо 240. После объединения всех частей результат становится правильным. Затем добавляю дубликат адреса в другой Slice: уникальных целей остаётся 240. Удаление части с 40 адресами оставляет 200. В отдельной проверке ready: false у одного endpoint и terminating: true у другого дают 238 целей согласно выбранной политике новых соединений. Все эти проверки выполнены с утверждениями assert.
Самый показательный прогон — замена 24 адресов на новые из диапазона 10.43.0.1–10.43.0.24. Оба читателя по-прежнему показывают число 240. Но в старом наборе 24 устаревшие записи и отсутствуют 24 новые; агрегатор Slice возвращает актуальное множество. Разбор закончился исправлением объединения и проверкой состава адресов вместо одного счётчика. Он подтверждает логику обработки снимка, но не проверяет RBAC, очередность событий, восстановление watch и доставку конфигурации балансировщику.
- Форма одного объекта модели; остальные адреса и Slice устроены аналогично: ```yaml apiVersion: discovery.k8s.io/v1 kind: EndpointSlice metadata: name: reports-a namespace: vector-lab labels: kubernetes.io/service-name: reports endpointslice.kubernetes.io/managed-by: lab.itfresh.ru addressType: IPv4 ports: - name: http protocol: TCP port: 8080 endpoints: - addresses: ["10.42.0.1"] conditions: ready: true terminating: false ```
7. Переключайте потребителя и проверяйте смену адресов
Сначала включаю теневое чтение: Endpoints ещё управляет рабочим upstream, EndpointSlice вычисляет альтернативный набор без публикации. Сравниваю нормализованные множества после стабилизации. Мгновенное расхождение во время rollout возможно: независимые контроллеры не обновляют объекты одной транзакцией. В dual-stack или при усечении Endpoints свыше 1000 backend полного равенства ждать нельзя. Для таких случаев эталон задаю отдельно. [Ограничения Endpoints](https://kubernetes.io/docs/concepts/services-networking/service/#over-capacity-endpoints).
После переключения запрещаю приложению неявно возвращаться к Endpoints при любой ошибке. Иначе проблема будет замаскирована до окончательного отключения старого источника. Провожу rollout с подтверждённой сменой IP, удаление последнего backend, перезапуск читателя и разрыв соединения с API. Измеряю путь до фактического upstream. Допустимую задержку определяю требованиями приложения; универсальных обещаний вроде «обновится за секунду» здесь нет.
Отдельно проверяю Service без selector. Если внешний агент продолжает записывать Endpoints, новый читатель может зависеть от EndpointSlice mirroring. Тогда переводить нужно и производителя: он должен создавать и обновлять EndpointSlice напрямую. Сам mirroring тоже deprecated с 1.33. Отключать его вместе с endpoints-controller без этого прохода нельзя. [Service без selector](https://kubernetes.io/docs/concepts/services-networking/service/#services-without-selectors), [EndpointSlice mirroring](https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/#endpointslice-mirroring).
Финальный эксперимент с остановкой старого контроллера провожу на изолированном кластере целевой версии. При исходном --controllers=* настройка выглядит как --controllers=*,-endpoints-controller; endpointslice-controller остаётся включённым. Существующий нестандартный список сохраняю, изменение учитываю на всех экземплярах kube-controller-manager. План возврата включает исходную конфигурацию контроллера и приложения. До апгрейда мне важнее доказанная независимость читателей, чем экономия ресурсов от отключения старого механизма.
- Критерии готовности: каждому обнаруженному потребителю назначен владелец; рабочая версия читает EndpointSlice; повторный аудит охватил запуск и штатные задания.
- Проверки данных: несколько Slice, дубликаты, разные порты, поддерживаемые семейства адресов, условия готовности, удаление последнего Slice и изменение привязки к Service.
- Проверки восстановления: холодный старт, потеря watch, отказ доступа и повторная публикация после ошибки внешнего балансировщика.
- Проверка отключения: после остановки endpoints-controller меняются реальные IP Pod, новый потребитель получает их, старые адреса исчезают из конечного upstream.
Частые вопросы
Обновление до Kubernetes 1.37 автоматически отключает Endpoints?
Нет. По состоянию на 05.09.2026 API доступен, а endpoints-controller включён по умолчанию. Его отключение — отдельное изменение конфигурации.
Достаточно поменять API-группу и имя ресурса?
Нет. Нужно объединять все Slice сервиса, корректно учитывать порты, условия, дубликаты, удаления и восстановление наблюдения.
Почему после миграции Service без selector всё ещё зависит от Endpoints?
Возможно, адреса в EndpointSlice переносит mirroring controller из Endpoints. Тогда требуется перевести на EndpointSlice также производителя записей.
В rf-buh я предлагаю аудит потребителей Kubernetes API и помощь с миграцией самописных контроллеров на EndpointSlice. Разберу код, RBAC и порядок испытаний, чтобы до обновления у команды были проверяемые критерии готовности.
Бесплатная консультация →

