Yesterday

Когда kubectl врёт


Сегодня мне для нашего кубернетис оператора надо было поменять Namespaced scope на Cluster scope у одного CRD.


Казалось бы рутинная задача:

  • удалить старый CRD
  • накатить новый с scope: Cluster (через ArgoCD сменой версии)
  • обновить манифесты custom resources, чтобы не было namespace в метаданных.

Поднял локальный kind-кластер, протестировал всё летает.
Накатил на stage-кластер готово, CRD установился, статус Established.
Применяю тестовый манифест:

kubectl apply -f test.yaml

И тут...

Error from server (NotFound): error when creating "test.yaml": 
the server could not find the requested resource 
(post myresources.operator.example.com)

Поехали дебажить

CRD в кластере есть:

kubectl get crd myresources.operator.example.com
NAME                                 CREATED AT
myresources.operator.example.com     2026-02-11T15:23:50Z

Статус Established:

kubectl get crd myresources.operator.example.com -o 
jsonpath='{.status.conditions[?(@.type=="Established")]}'
{"lastTransitionTime":"2026-02-11T15:23:50Z",
"message":"the initial names have been accepted",
"reason":"InitialNamesAccepted",
"status":"True",
"type":"Established"}

Scope правильный:

kubectl get crd myresources.operator.example.com 
-o jsonpath='{.spec.scope}'
Cluster

List работает:

kubectl get --raw /apis/operator.example.com/v1alpha1/myresources
{"kind":"MyResourceList","apiVersion":"operator.example.com/v1alpha1",
"items":[]}

А apply нет. 404. Не найдено. Бред.

Начинаем копать

Первая мысль "может API ещё не зарегистрировался до конца?"
Жду минуту. Пять минут. Десять минут.
Та же херня.

Вторая мысль "может права? RBAC?"
Проверяю:

kubectl auth can-i create myresources.operator.example.com
yes


Права есть. API отвечает. CRD Established.

А apply возвращает 404. Бред.

Ладно, смотрим что kubectl вообще пытается сделать.
Включаю verbose режим:

kubectl create -f test.yaml -v=9 2>&1 | grep -E "POST |Request Body|namespaces/"

И вот тут я вижу ЭТО:

Request Body: {"apiVersion":"operator.example.com/v1alpha1","kind":"MyResource","metadata":{"name":"my-test-resource","namespace":"my-namespace"}, ...}

curl -v -XPOST ... 'https://.../apis/operator.example.com/v1alpha1/namespaces/my-namespace/myresources?...'

POST https://.../apis/operator.example.com/v1alpha1/namespaces/my-namespace/myresources 404 Not Found

Стоп.

kubectl пытается создать ресурс по namespaced URL:

.../namespaces/my-namespace/myresources.

Но ресурс-то у меня теперь cluster-scoped !!!!!!!

Для него URL должен быть

.../apis/operator.example.com/v1alpha1/myresources (без /namespaces/).

Более того, kubectl сам подставляет namespace в тело запроса, хотя в моём манифесте его нет!

Откуда namespace в запросе?

Проверяю текущий контекст:

kubectl config get-contexts
CURRENT   NAME                           NAMESPACE
*         my-staging-cluster-context     my-namespace

Ага. У меня в контексте задан default namespace my-namespace.

kubectl видит:

  • в контексте есть namespace
  • в манифесте metadata.namespace не указан
  • думает: "ресурс же namespaced, надо подставить namespace из контекста!"
  • подставляет namespace и строит URL для namespaced ресурса

Но откуда kubectl взял, что ресурс namespaced?

Хер ли ты такой инициативный-то?

Пришлось углубиться в документацию, стаковерфлоу и немного потыкать бесплатный джемини...

Discovery cache вот где собака зарыта

я уже дед, если так говорю? да?😢

kubectl делает discovery при первом запросе к новой API-группе запрашивает список всех ресурсов этой группы и их параметры:

  • какие есть kinds
  • какие shortNames (например, po для pods, svc для service)
  • namespaced или cluster-scoped
  • какие verbs доступны (create, get, list, etc.)

Эта информация сохраняется в кэше:

~/.kube/cache/discovery/<server-host>/v1/serverresources.json

Я даже не знал о его существовании.

Кэш позволяет kubectl не дёргать API-сервер при каждом kubectl get po, а сразу знать, что po = pods.

Проверяю discovery для моего ресурса:

kubectl get --raw /apis/operator.example.com/v1alpha1

Вывод:

{
  "resources": [
    {
      "name": "myresources",
      "singularName": "myresource",
      "namespaced": false,
      "kind": "MyResource",
      "verbs": ["delete","deletecollection","get","list","patch","create","update","watch"]
    }
  ]
}

API-сервер правильно отвечает:

"namespaced": false.

Значит проблема в кэше kubectl.

Он закэшировал старую версию CRD (когда тот был namespaced), и теперь считает, что ресурс всё ещё namespaced!

TTL кэша discovery

До Kubernetes v1.22 (август 2021) TTL кэша discovery в kubectl составлял 10 минут.
Многие до сих пор думают, что достаточно подождать 10 минут, и кэш обновится сам. Хуй вам.

С этого коммита

(PR #107141, merged в августе 2021)

дефолтное значение TTL увеличили до 6 часов:

return diskcached.NewCachedDiscoveryClientForConfig(
    config, 
    discoveryCacheDir, 
    httpCacheDir, 
    time.Duration(6*time.Hour)  // было 10*time.Minute
)


Изменение вступило в силу:

  • для kubectl с Kubernetes v1.22 (август 2021)
  • для всех client-go клиентов с Kubernetes v1.25

Причина:

  • на больших кластерах с сотнями CRD каждый discovery-запрос может включать сотни GET запросов для всех group-versions. Это вызывало client-side rate limiting и тормозило kubectl.

Увеличение TTL снизило частоту discovery, но создало новую проблему:
устаревший кэш может жить до 6 часов, и ждать не имеет смысла.

Решение 1: Применить с пустым cache-dir

Самый быстрый способ заставить kubectl не использовать старый кэш:

kubectl apply -f test.yaml --cache-dir=/tmp/kubectl-cache-fresh

kubectl создаст свежий кэш в /tmp/kubectl-cache-fresh, сделает discovery заново, увидит, что ресурс cluster-scoped, и применит манифест корректно:

myresource.operator.example.com/my-test-resource created

После первого успешного apply новый кэш уже содержит правильную информацию.
Дальше можно работать без --cache-dir.

Решение 2: Сбросить namespace в контексте

Если не хочется трогать кэш, можно убрать default namespace из контекста:

kubectl config set-context --current --namespace=""
kubectl apply -f test.yaml

Без namespace в контексте kubectl не будет подставлять его автоматически, и запрос пойдёт на cluster-scoped URL.

После применения можно вернуть namespace:

kubectl config set-context --current --namespace="my-namespace"


Решение 3: Очистить кэш вручную

Удалить старый кэш для данного API-сервера:

# Посмотреть, где кэш
ls ~/.kube/cache/discovery/

# Удалить кэш для нужного хоста (подставьте свой каталог, я вообще всё дропнул))
rm -rf ~/.kube/cache/discovery/<host-from-above>
rm -rf ~/.kube/cache/http/<same-host>

kubectl apply -f test.yaml

kubectl при следующем запросе сделает discovery заново.

Решение 4: Обновить discovery через api-resources

Официальный способ форсировать обновление discovery cache без удаления файлов:

kubectl api-resources --api-group=operator.example.com
kubectl apply -f test.yaml

Команда kubectl api-resources принудительно обновляет discovery cache для указанной API-группы.
Это безопаснее, чем удаление файлов кэша вручную, и работает для конкретной группы, не затрагивая остальные.

Как работает discovery cache **.

Материал со звёздочками для самых любознательных.

Разберём подробнее, чтобы понять, почему эта проблема вообще возникает.

Архитектура кэша

kubectl хранит кэш в двух местах:

  • Discovery cache
~/.kube/cache/discovery/<server-host>/
    • файлы serverresources.json для каждой group-version
    • содержат информацию о ресурсах: kinds, shortNames, verbs, scope
  • HTTP cache
 ~/.kube/cache/http/<server-host>/
    • кэш HTTP-запросов к API-серверу

Процесс работы

  • Пользователь запускает
kubectl apply -f test.yaml
  • kubectl читает манифест, видит
kind: MyResource
  • Проверяет локальный кэш:
    • есть ли файл
~/.kube/cache/discovery/<host>/operator.example.com/v1alpha1/serverresources.json?
    • валиден ли этот файл (не истёк ли TTL)?
  • Если кэш валиден, kubectl использует закэшированную информацию
  • Если кэш устарел или отсутствует, делает GET запрос к API-серверу:
   GET /apis/operator.example.com/v1alpha1
  • Сохраняет результат в кэш с TTL = 6 часов

Проблема при смене scope

Когда CRD меняет scope с Namespaced на Cluster:

  • Старый кэш содержит
 "namespaced": true
  • Новый API отдаёт
"namespaced": false
  • но kubectl использует старый кэш (TTL ещё не протух)
  • kubectl строит URL для namespaced ресурса:
.../namespaces/<ns>/myresources
  • Для cluster-scoped ресурса такой endpoint не существует и он выплёвывает
404

Почему kubectl подставляет namespace

Когда kubectl видит:

  • ресурс namespaced (по кэшу)
  • в манифесте нет metadata.namespace
  • в текущем контексте задан default namespace

Он делает вывод: "пользователь забыл указать namespace, подставлю из контекста".

Для cluster-scoped ресурсов это некорректное поведение, но kubectl этого не знает, потому что кэш врёт.

Discovery cache и масштабирование

Эта проблема часть более широкой темы: discovery cache как ограничение расширяемости Kubernetes.

Проблема с большим количеством CRD

На кластерах с сотнями CRD (например, с Crossplane, GCP Config Connector, Azure Service Operator) discovery превращается в бутылочное горлышко:

  • kubectl запускает discovery для всех, сука, group-versions в кластере
  • Если у вас 500 CRD, это 500+ GET-запросов
  • Каждый kubectl get pods может инициировать discovery (если кэш устарел)
  • Это вызывает client-side rate limiting:
   Waited for 1.140352693s due to client-side throttling, not priority and fairness

Увеличение TTL решило одну проблему (частые discovery-запросы), но создало другую (долгоживущий устаревший кэш).

Будущие улучшения

Обсуждаются следующие подходы:

  • Инкрементальный discovery обновлять только изменившиеся group-versions
  • server-side filtering позволить клиенту запрашивать discovery только для нужных kinds
  • Configurable TTL сделать TTL настраиваемым через флаг

Подробнее:

Выводы и практические советы

  • При смене scope CRD сразу очищайте кэш, используйте --cache-dir или запустите kubectl api-resources
  • На CI/CD не используйте общий кэш между пайплайнами или очищайте перед каждым запуском
  • При траблшутинге kubectl первым делом включайте -v=9 и смотрите на URL запросов
  • Если видите "client-side throttling" проблема в discovery, не в API-сервере
  • Не ждите 10 минут, это устаревшая информация (до Kubernetes v1.22), сейчас TTL = 6 часов
  • Официальный способ обновить кэш (форсирует re-discovery)
 kubectl api-resources --api-group=<ваша-группа> 

Альтернатива для операторов

Если пишете оператор, который программно работает с cluster-scoped ресурсами:

  • используйте typed clients вместо dynamic clients
  • если нужен dynamic client, явно указывайте scope через REST mapper
  • не полагайтесь на автоматическую подстановку namespace

Пример:

import (
    "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
    "k8s.io/apimachinery/pkg/runtime/schema"
)

gvr := schema.GroupVersionResource{
    Group:    "operator.example.com",
    Version:  "v1alpha1",
    Resource: "myresources",
}

// Cluster-scoped resource  без namespace
obj, err := dynamicClient.Resource(gvr).Create(ctx, &unstructured.Unstructured{
    Object: map[string]interface{}{
        "apiVersion": "operator.example.com/v1alpha1",
        "kind":       "MyResource",
        "metadata": map[string]interface{}{
            "name": "my-instance",
            // НЕТ namespace!
        },
    },
}, metav1.CreateOptions{})

Ссылки