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

После обновления до Kubernetes 1.35 отвалились exec и port-forward: почему verb get больше не работает

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~22 мин чтения
После обновления до Kubernetes 1.35 отвалились exec и port-forward: почему verb get больше не работает
Иллюстрация к статье «После обновления до Kubernetes 1.35 отвалились exec и port-forward: почему verb get больше не работает».

Роль не меняли, сервис-аккаунт тот же, токен тот же — а kubectl exec в прод-под отвечает Forbidden. Знакомо? Это не сломанный вебхук и не протухший сертификат: в Kubernetes 1.35 появилась дополнительная проверка прав на потоковые подключения, и она включена по умолчанию. Ниже — что именно изменилось, как за минуту это диагностировать, какую роль выписать вместо cluster-admin и кого, кроме живых разработчиков, эта проверка бьёт молча по ночам.

Симптом: чтение работает, потоки — нет

Картинка всегда одна и та же. В субботу обновили control plane, в понедельник к девяти утра — очередь из тикетов «не могу зайти в под». При этом kubectl get pods работает, kubectl logs работает, kubectl describe работает. Умирают ровно три вещи: exec, attach и port-forward. Плюс, что замечают не сразу, kubectl cp — он внутри тот же remotecommand, что и exec.

Ошибка выглядит примерно так (точный текст немного плавает от версии kubectl к версии, но суть в слове create):

Error from server (Forbidden): pods "api-7f9c4d8b6-h2xkq" is forbidden:
User "dev@example.com" cannot create resource "pods/exec"
in API group "" in the namespace "prod"

У port-forward формулировка бывает мягче — что-то вроде unable to upgrade connection, и вот тут-то люди и уходят в сторону: начинают винить ingress, service mesh, апстрим-прокси, MTU. Теряют полдня. А проверяется всё одной командой.

Диагностика занимает пятнадцать секунд — достаточно спросить API-сервер от своего имени и от имени нужного субъекта (запись pods/exec и флаг --subresource=exec равнозначны):

kubectl auth can-i create pods --subresource=exec -n prod
kubectl auth can-i create pods --subresource=portforward -n prod
# для чужой учётки или сервис-аккаунта:
kubectl auth can-i create pods --subresource=exec -n prod \
  --as=system:serviceaccount:ci:deployer

Если на первую команду приходит no, а на kubectl auth can-i get pods --subresource=exec — yes, дальше можно не искать. Это оно. Не сеть, не kubelet, не сертификаты API-сервера к кубелету.

Если в роли есть get на pods/exec, но нет create — после 1.35 это ваш случай практически со стопроцентной вероятностью. Не тратьте время на сеть.

Откуда взялась проверка: SPDY, WebSocket и дыра, которой шесть лет

Чтобы понять, почему get внезапно перестал быть достаточным, надо вспомнить, как вообще устроены потоковые подключения в Kubernetes. Исторически exec, attach и port-forward апгрейдили HTTP-соединение до SPDY/3.1 — протокола, который Google похоронил больше восьми лет назад и который никогда не был стандартизован. Практическая беда SPDY в том, что современные прокси, гейтвеи и балансировщики его просто не умеют: как только между вами и API-сервером появляется приличный L7-прокси, kubectl exec и kubectl cp начинают загадочно виснуть. Ради этого и затеяли KEP-4006 — перевод двунаправленных потоков на WebSocket (RFC 6455).

Переезд шёл несколько релизов. Клиентская сторона: KUBECTL_REMOTE_COMMAND_WEBSOCKETS появился в 1.29 как альфа и был выключен, в 1.30 стал бетой и включился по умолчанию; KUBECTL_PORT_FORWARD_WEBSOCKETS — альфа в 1.30, бета и включён с 1.31. Серверная сторона: гейт PortForwardWebsockets — бета с 1.31, по умолчанию true. То есть примерно с 1.31 обычный kubectl уже ходит в кластер по WebSocket, а не по SPDY, и большинство про это даже не знает.

И вот тут вылезла старая дыра. RFC 6455 требует, чтобы WebSocket-рукопожатие начиналось методом GET — иного варианта спецификация не предусматривает. А API-сервер Kubernetes отображает HTTP-метод на RBAC-глагол механически: POST становится create, GET становится get. Итог: SPDY-запрос на exec шёл через POST и требовал create, а точно такой же по смыслу WebSocket-запрос шёл через GET и требовал всего лишь get. Обычная роль «только чтение» с verbs [get, list, watch] на все ресурсы — а такие в проде пишут постоянно — тихо давала право выполнять команды внутри контейнеров. Первый отчёт об этом завели ещё в 2019 году (issue #78741, его тогда быстро закрыли), а в 2025-м тема всплыла заново в issue #133515 — люди заметили, что метод запроса у kubectl exec сменился с POST на GET. Исправление пришло отдельным PR #134577 в цикле 1.35.

В 1.35 (релиз «Timbernetes», 17 декабря 2025) дыру закрыли: при запросе на апгрейд соединения к pods/exec, pods/attach или pods/portforward API-сервер выполняет дополнительную, синтетическую проверку глагола create. Формулировка из CHANGELOG-1.35 близка к дословной: подресурсы pods/exec, pods/attach и pods/portforward теперь требуют create и для SPDY, и для WebSocket-запросов; раньше SPDY требовал create, а WebSocket — только get. Управляет этим фичегейт AuthorizePodWebsocketUpgradeCreatePermission: стадия Beta, значение по умолчанию true, появился сразу в Beta в 1.35. В документации к 1.37 он всё ещё Beta, а KEP-4006 целится в stable в 1.37 — так что проверяйте таблицу фичегейтов именно своей версии. Сам changelog прямо просит: до апгрейда на 1.35 убедиться, что все самописные Role и ClusterRole, которые должны давать exec, attach или port-forward, содержат глагол create.

Это не регрессия и не «сломали обратную совместимость из вредности». Это закрытие эскалации привилегий. Если ваша роль «работала» с одним только get — значит, она несколько лет давала больше прав, чем вы думали.
После обновления до Kubernetes 1.35 отвалились exec и port-forward: почему verb get больше не работает — схема
Схема к статье. Открыть схему в полном размере

Как мы проверяли готовность: УК «ЖКХГрад», личный кабинет жильцов в k8s у подрядчика

Сразу оговорюсь, чтобы не было иллюзий: у управляющей компании на 13 рабочих мест собственного Kubernetes нет и быть не должно. У «ЖКХГрад» мы ведём офис — рабочие места, почту, 1С, резервное копирование. А личный кабинет жильцов (показания счётчиков, заявки в диспетчерскую, квитанции и онлайн-оплата) разработал и хостит подрядчик: небольшой кластер на kubeadm из одного control-plane и трёх worker-узлов, четыре namespace — prod, stage, ci и monitoring. Подрядчик запланировал обновление 1.34 → 1.35, а директор УК попросил нас как ИТ-службу заказчика оценить риски: в период передачи показаний, с 20-го по 25-е число, простой кабинета означает звонки в диспетчерскую и очередь в офисе.

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

#!/usr/bin/env bash
set -euo pipefail
kubectl get clusterroles -o json > /tmp/cr.json
kubectl get roles -A -o json  > /tmp/r.json

jq -r '
  .items[]
  | . as $role
  | ($role.rules // [])[]
  | select(((.resources // []) | any(startswith("pods/exec") or startswith("pods/attach") or startswith("pods/portforward"))) or ((.resources // []) | index("*")))
  | select(((.verbs // []) | index("create")) == null)
  | select(((.verbs // []) | index("*")) == null)
  | [$role.kind, ($role.metadata.namespace // "-"), $role.metadata.name, ((.resources // []) | join(",")), ((.verbs // []) | join(","))]
  | @tsv
' /tmp/cr.json /tmp/r.json | sort -u

Самописных объектов в кластере оказалось немного: 9 Role и 3 ClusterRole, не считая встроенных и приехавших из Helm-чартов. Выхлоп — пять строк. И разбираются они, что важно, на три принципиально разные кучки, лечить которые надо по-разному.

Первая кучка — роль support-troubleshoot для двух разработчиков подрядчика в namespace prod: они заходят в под бэкенда, когда жилец жалуется, что показания «не ушли». Там exec нужен по делу, create дописали — минута работы. Вторая кучка — роботы, и она важнее: сервис-аккаунт CI, который после выкладки запускает миграции базы через kubectl exec, и сервис-аккаунт ночной CronJob, выполняющей pg_dump внутри пода с PostgreSQL перед выгрузкой копии в объектное хранилище. Обе роли содержали только get на pods/exec. После апгрейда первая сломала бы ближайший релиз, а вторая — молча, в 03:00, все ночные копии базы лицевых счетов. Третья кучка — две роли, которые я рекомендовал НЕ чинить: веб-дашборд мониторинга с правилом resources ["*"], verbs ["get","list","watch"] и наш собственный аудиторский аккаунт с таким же шаблоном. До 1.35 оба фактически давали shell в прод-поды с персональными данными жильцов. Этот пункт я вынес отдельной строкой в письмо директору УК: закрытие дыры — аргумент за апгрейд, а не против.

Итог: около двух часов на аудит и отчёт, подрядчик внёс правки в роли до обновления, фичегейт не трогали, cluster-admin никому не выдавали. Обновление прошло вне окна передачи показаний, миграции в CI и ночной дамп отработали с первого раза — это мы проверили утром по логам CronJob и по размеру свежей копии. Для УК такой формат вообще правильный: разработку держит подрядчик, а заказчику нужен кто-то, кто сможет независимо проверить, что апгрейд не сломает кабинет жильцов и резервное копирование.

Люди про сломанный exec придут и пожалуются. Роботы — нет. Начинайте аудит с сервис-аккаунтов бэкапа и CI, а не с разработчиков: если ночной дамп базы лицевых счетов тихо перестанет создаваться, узнаете вы об этом в худший момент.
Памятка: Как мы проверяли готовность: УК «ЖКХГрад», личный кабинет жильцов в k8s у подрядчика — схема
Памятка: Как мы проверяли готовность: УК «ЖКХГрад», личный кабинет жильцов в k8s у подрядчика. Открыть схему в полном размере

Правильное лечение: минимальная роль на 12 строк

Никакой магии здесь нет. Нужно, чтобы в правиле, покрывающем подресурсы потоков, стоял глагол create. Я держу get рядом с ним осознанно: сам WebSocket-запрос физически является GET, к тому же часть инструментов ещё может свалиться на SPDY, и я не хочу гадать, какой транспорт выберет конкретная версия клиента. Два глагола вместо одного ничего не стоят, а нервов экономят много.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: dev-troubleshoot
  namespace: prod
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]
  - apiGroups: [""]
    resources: ["pods/exec", "pods/attach", "pods/portforward"]
    verbs: ["get", "create"]

Отдельно про роли с масками. Классическая заготовка «только чтение» выглядит как resources ["*"] с verbs [get, list, watch] — и именно она раньше нечаянно раздавала exec. Соблазн велик: дописать create в тот же блок и разойтись. Не делайте так ни при каких обстоятельствах. Вы выдадите create на все ресурсы кластера разом: секреты, сервис-аккаунты, роль-биндинги. Если exec действительно нужен — режьте правило на два: широкое на чтение и узкое на подресурсы потоков, как в примере выше.

Проверка после правки — той же командой auth can-i, но обязательно от имени реального субъекта, а не от себя-админа. Имперсонация требует прав на impersonate, у админа кластера они есть:

# точечно
kubectl auth can-i create pods --subresource=exec -n prod \
  --as=dev@example.com --as-group=contractor:developers

# полный список того, что субъект может в namespace
kubectl auth can-i --list -n prod \
  --as=system:serviceaccount:ci:deployer

# и сразу боевая проверка
kubectl exec -n prod deploy/api -- sh -c 'echo ok'

Два yes и один ok — вопрос закрыт. Если can-i отвечает yes, а живой exec всё равно Forbidden, вот тогда уже смотрите на вебхуки авторизации и внешние прокси: у Teleport, Rancher и подобных обвязок бывает своя прослойка со своими правилами.

Роли, поставляемые Helm-чартами операторов, правьте через values чарта, а не kubectl edit. Иначе следующий helm upgrade вернёт всё обратно, и вы будете искать причину заново.

Три способа сделать хуже (и один законный обходной путь)

Способ первый и самый популярный: выдать cluster-admin «на время, пока разберёмся». Разберутся через час, снимут через полгода — если вообще вспомнят. Я за пятнадцать лет ни разу не видел, чтобы временный cluster-admin сняли в тот же день. Если совсем горит и надо разблокировать релиз прямо сейчас — выдайте встроенную роль edit в конкретном namespace: в ней create на pods/exec уже есть, а радиус поражения на порядок меньше.

Способ второй: выключить фичегейт и забыть. Технически это делается правкой манифеста статик-пода на каждом control-plane узле:

# /etc/kubernetes/manifests/kube-apiserver.yaml
spec:
  containers:
    - command:
        - kube-apiserver
        - --feature-gates=AuthorizePodWebsocketUpgradeCreatePermission=false

После сохранения файла kubelet перезапустит под API-сервера сам; делать это надо на всех узлах control plane, иначе поведение будет плавать от запроса к запросу — в зависимости от того, на какой инстанс попал клиент. Считаю такой шаг допустимым ровно в одном сценарии: у вас сотни ролей, окно обслуживания закончилось, и нужна неделя на аккуратный аудит. С тикетом, с дедлайном и с ответственным. Гейт сейчас Beta и включён по умолчанию; по обычной практике Kubernetes при переходе фичи в GA гейт фиксируют в true, а потом удаляют — жить с ним выключенным вечно не получится.

Способ третий, и вот он самый обидный, потому что выглядит умным: заставить клиента откатиться на SPDY.

KUBECTL_REMOTE_COMMAND_WEBSOCKETS=false kubectl exec -it -n prod deploy/api -- sh

Это не помогает. Совсем. SPDY ходит через POST, POST всегда отображался в create — то есть роль без create по SPDY не работала никогда, и в 1.35 changelog прямо фиксирует: create требуется для обоих транспортов. Единственное, для чего этот флаг ещё годится, — отладка совместимости со старыми прокси, если вдруг WebSocket не проходит через ваш периметр.

И честное замечание про managed-кластеры. В EKS, GKE и AKS у вас нет доступа к флагам kube-apiserver, а фичегейт задаётся именно там. Значит, рассчитывать на обходной путь нельзя: только правка RBAC. И значит, аудит ролей надо делать ДО того, как провайдер подтянет control plane на 1.35 в вашем канале обновлений, а не после.

Правку --feature-gates надо раскатать на ВСЕ control-plane узлы. Полумера даёт худший из миров: exec работает через раз, и вы будете ловить это как «плавающий баг сети».
Памятка: Три способа сделать хуже (и один законный обходной путь) — схема
Памятка: Три способа сделать хуже (и один законный обходной путь). Открыть схему в полном размере

Кого цепляет, кроме живого kubectl

Под удар попадает всё, что внутри использует client-go с пакетами remotecommand и portforward, — а это половина экосистемы. Из того, что я разбирал руками или видел у коллег: k9s и Lens (шелл в под из UI), веб-терминалы в ArgoCD, Rancher и Headlamp, консоль в Kubernetes Dashboard, Telepresence и mirrord, все обвязки вокруг kubectl cp, самописные операторы, которые лезут в под за диагностикой, и — отдельной строкой — бэкапные хуки. Velero с pre/post-хуками, скрипты, которые перед снапшотом делают FLUSH TABLES WITH READ LOCK или pg_backup_start внутри контейнера, любые ночные джобы с exec.

Разница в том, как вы об этом узнаёте. Разработчик, у которого не открывается шелл, приходит к вам в течение десяти минут. Ночная джоба ретраится трижды, пишет Forbidden в лог, который никто не читает, и уходит спать. Копия при этом формально создаётся — просто без корректного сброса на диск. Поэтому порядок приоритетов у меня такой: сначала сервис-аккаунты бэкапов, потом сервис-аккаунты CI/CD, потом уже люди. Люди сами напомнят.

С дашбордами есть нюанс, который стоит понимать заранее. Одни ходят в API от имени пользователя (прокидывают его OIDC-токен), и тогда проверка create применяется к конкретному человеку — чинится его роль. Другие работают под собственным сервис-аккаунтом, и тогда проверку проходит или не проходит сам дашборд: либо шелл пропадёт у всех разом, либо, если сервис-аккаунту когда-то выдали широкое чтение, окажется, что через дашборд exec был доступен любому, кто в него вошёл. У «ЖКХГрад» был как раз второй случай — именно поэтому такую роль мы не чинили, а выносили на обсуждение. С CI похоже: у раннеров GitLab, Jenkins или GitHub Actions в кластере свой сервис-аккаунт, и падение шага с exec или kubectl cp выглядит в пайплайне как обычная ошибка деплоя.

Практический приём для инвентаризации по субъектам, а не по ролям: пройтись auth can-i по списку сервис-аккаунтов, которые реально что-то делают в кластере.

for ns in $(kubectl get ns -o name | cut -d/ -f2); do
  for sa in $(kubectl get sa -n "$ns" -o name | cut -d/ -f2); do
    ans=$(kubectl auth can-i create pods --subresource=exec -n "$ns" \
      --as="system:serviceaccount:$ns:$sa" 2>/dev/null || echo err)
    getv=$(kubectl auth can-i get pods --subresource=exec -n "$ns" \
      --as="system:serviceaccount:$ns:$sa" 2>/dev/null || echo err)
    if [ "$getv" = "yes" ] && [ "$ans" != "yes" ]; then
      echo "BROKEN: $ns/$sa"
    fi
  done
done

Вывод BROKEN — это ровно те аккаунты, у которых exec работал до 1.35 и перестал после. Прогон по небольшому кластеру на несколько namespace занимает секунды, на крупном — пару минут. По каждому такому аккаунту дальше решение принимаете вы: дописать create или, наоборот, порадоваться, что закрылась лишняя привилегия.

Проверьте бэкапные хуки в первую очередь. Они ломаются молча, а обнаруживается это в момент, когда вы пытаетесь восстановиться.

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

Если вы ещё на 1.33 или 1.34 — у вас идеальная позиция, потому что аудит можно сделать заранее и без единого простоя. Порядок действий, который я теперь вписываю в регламент апгрейда любого кластера, укладывается в один вечер. Если вы уже на 1.35 и всё горит — начинайте с третьего пункта, он снимает боль за минуты, а остальное доделаете спокойно.

Отдельно про календарь. Kubernetes 1.35 «Timbernetes» вышел 17 декабря 2025 года; апстрим поддерживает минорную версию патчами около 14 месяцев, то есть ориентировочно до начала 2027-го — точную дату end of life смотрите на странице релизов kubernetes.io, она иногда сдвигается. Облачные провайдеры (EKS, GKE, AKS) добавляют новые версии по своим календарям с задержкой в несколько недель или месяцев, у AKS вдобавок есть LTS-версии с иным жизненным циклом. Смысл простой: если у вас или у вашего подрядчика managed-кластер на автообновлении, 1.35 приедет сама, и лучше встретить её с готовыми ролями.

На что можно смело забить: на разговоры о том, что «Kubernetes опять всё сломал». Изменение маленькое, локализованное, чинится одним глаголом в манифесте роли, и оно объективно делает кластер безопаснее — просто вскрывает то, что у большинства и так было настроено неправильно. На что забивать нельзя: на сервис-аккаунты. Именно они превращают пятнадцатиминутную задачу в аварию через две недели.

Практический вывод: 1.35 не отобрал у вас права — он перестал выдавать лишние. Разделите список сломавшегося на «починить» и «оставить сломанным» до того, как начнёте раздавать create направо и налево.
Порядок действий: Чек-лист перед апгрейдом и что делать, если уже обновились — схема
Порядок действий: Чек-лист перед апгрейдом и что делать, если уже обновились. Открыть схему в полном размере

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

Достаточно ли добавить только create, или get на pods/exec тоже нужен?

Добавляйте оба. Документирована именно дополнительная проверка create поверх обычной авторизации, а обычная авторизация WebSocket-запроса идёт по методу GET. Роль только с create теоретически может сработать, но проверять это в проде — сомнительное развлечение: два глагола вместо одного не расширяют привилегии ни на грамм, потому что и get, и create на pods/exec означают ровно одно и то же право — выполнить команду в контейнере.

Можно ли откатиться на SPDY, чтобы не трогать роли?

Нет. SPDY-запрос идёт методом POST и отображается в глагол create, то есть роль без create по SPDY не работала никогда. Changelog 1.35 фиксирует это прямо: create требуется для обоих транспортов. Переменная KUBECTL_REMOTE_COMMAND_WEBSOCKETS=false полезна только для отладки совместимости со старыми прокси.

Надо ли править встроенные роли edit, admin, view?

Нет. У встроенных edit и admin право create на pods/exec есть изначально — они работают как работали. У view exec нет и быть не должно. Ломаются только самописные роли, чаще всего собранные по шаблону «get, list, watch на всё».

У меня managed-кластер в облаке. Как отключить эту проверку?

Своими силами — никак. В EKS, GKE и AKS доступа к флагам kube-apiserver нет, а фичегейт задаётся именно там. Единственный путь — привести RBAC в порядок. Поэтому аудит ролей нужно проводить до того, как провайдер подтянет control plane на 1.35 в вашем канале обновлений.

Как быстро найти все роли, которые сломаются?

Двумя способами, и лучше обоими. Первый — выгрузить Role и ClusterRole в JSON и через jq отфильтровать правила, где есть pods/exec, pods/attach или pods/portforward, но нет create. Второй, более честный, — пройтись kubectl auth can-i с флагом --as по всем сервис-аккаунтам и сравнить ответы для get и create: где get даёт yes, а create — no, там всё и сломается.

Стоит ли вообще откладывать переход на 1.35 из-за этого?

Нет. Правка занимает минуты на роль, а изменение закрывает реальную эскалацию привилегий, которая жила в кластерах годами. Откладывать апгрейд разумно по другим причинам — совместимость операторов, CSI-драйверов, ingress-контроллеров, — но не из-за RBAC на потоки.

Мы не разработчики, кабинет клиентов у подрядчика в Kubernetes. Что проверить нам как заказчику?

Попросите у подрядчика три вещи до апгрейда на 1.35: вывод аудита Role/ClusterRole на pods/exec, pods/attach, pods/portforward без create; список сервис-аккаунтов CI и резервного копирования с ответом kubectl auth can-i create pods --subresource=exec; план проверки ночных бэкапов после обновления. Отдельно договоритесь о дате вне пиковых периодов — для УК это окно передачи показаний. Если своей экспертизы нет, такую проверку может провести ИТ-подрядчик заказчика с доступом только на чтение.

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

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

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

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

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

Источники

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