Когда 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)
Поехали дебажить
kubectl get crd myresources.operator.example.com NAME CREATED AT myresources.operator.example.com 2026-02-11T15:23:50Z
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"}kubectl get crd myresources.operator.example.com
-o jsonpath='{.spec.scope}'
Clusterkubectl 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.
Ладно, смотрим что 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 Foundkubectl пытается создать ресурс по namespaced URL:
.../namespaces/my-namespace/myresources.
.../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.
- в контексте есть 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)
return diskcached.NewCachedDiscoveryClientForConfig(
config,
discoveryCacheDir,
httpCacheDir,
time.Duration(6*time.Hour) // было 10*time.Minute
)- на больших кластерах с сотнями 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 хранит кэш в двух местах:
~/.kube/cache/discovery/<server-host>/
- файлы serverresources.json для каждой group-version
- содержат информацию о ресурсах: kinds, shortNames, verbs, scope
- HTTP cache
~/.kube/cache/http/<server-host>/
kubectl apply -f test.yaml
kind: MyResource
~/.kube/cache/discovery/<host>/operator.example.com/v1alpha1/serverresources.json?
- Если кэш валиден, kubectl использует закэшированную информацию
- Если кэш устарел или отсутствует, делает GET запрос к API-серверу:
GET /apis/operator.example.com/v1alpha1
Проблема при смене scope
Когда CRD меняет scope с Namespaced на Cluster:
"namespaced": true
"namespaced": false
.../namespaces/<ns>/myresources
404
Почему kubectl подставляет namespace
- ресурс 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{})
Ссылки
- https://serverfault.com/questions/1049006/what-does-kubectl-store-in-the-cache
- https://jonnylangefeld.com/blog/the-kubernetes-discovery-cache-blessing-and-curse
- https://github.com/kubernetes/kubernetes/pull/107141
- https://github.com/kubernetes/kubernetes/issues/107077
- https://github.com/kubernetes/kubernetes/blob/master/staging/src/k8s.io/cli-runtime/pkg/genericclioptions/config_flags.go