Kubernetes port-forward: от SPDY к WebSockets изнутри

Я поддерживаю приложение, работающее поверх механизма проброса портов Kubernetes, поэтому слежу за KEP-4006 — протокол передачи данных там всё время меняется. Впервые я писал об этом в апреле 2024 года, примерно в момент выхода Kubernetes 1.30, а за шесть релизов с тех пор всё изменилось настолько, что тема заслуживает отдельного разбора. Бо́льшая часть изложенного ниже основана на проектном документе KEP, нескольких GitHub-тредах и трассировках трафика на живом кластере — остальное моя собственная интерпретация.

Краткая история субпротоколов

Механизм проброса портов (port-forward) использует имя субпротокола (subprotocol) задолго до начала текущей миграции: обе стороны соединения договариваются по этому имени о том, как читать байты после завершения HTTP-апгрейда.

Когда я читал KEP, первым делом меня сбило с толку имя субпротокола. В самом KEP оно написано одним образом (v2.portforward.k8s.io), но в логах kubectl port-forward в заголовке Sec-WebSocket-Protocol видно совсем другое — SPDY/3.1+portforward.k8s.io. А в самом коде при этом используется ещё более старая константа (portforward.k8s.io, имя версии v1). Всё это не противоречит друг другу, если понять, для чего служит каждое из имён — но единственный способ разобраться — откатиться назад и пройти историю шаг за шагом.

Исходное имя субпротокола — portforward.k8s.io, оно определено как PortForwardProtocolV1Name в client-go/tools/portforward/portforward.go:

const PortForwardProtocolV1Name = "portforward.k8s.io"

Эта единственная строка описывала весь формат передачи данных для SPDY: клиент запрашивал его в заголовках апгрейда, сервер возвращал в ответе, после чего обе стороны обменивались SPDY-фреймами, несущими потоки данных и ошибок проброса портов. Всё довольно просто — и переход на WebSockets по факту ничего не изменил в том, что переносят эти потоки. SPDY-фреймы по-прежнему описывают потоки, kubelet маршрутизирует их так же, как и раньше, контейнерный рантайм их завершает так же — просто транспортная обёртка вокруг фреймов теперь другая.

Изменение достаточно небольшое, поэтому команда сохранила модель v1-протокола и в KEP просто добавила новое имя для нового вида туннелирования — v2.portforward.k8s.io (считайте это меткой для транспортного изменения, а не новой протокольной моделью). Внутри WebSocket байты — те же SPDY v1-фреймы, а потоки внутри них ведут себя точно так же.

В заголовке Sec-WebSocket-Protocol клиент анонсирует, а сервер возвращает значение SPDY/3.1+portforward.k8s.io. Префикс SPDY/3.1+ — это транспортная обёртка туннелирования, суффикс — прежнее имя v1 без изменений. Именно по этому префиксу сервер выбирает между устаревшим SPDY-путём и новым WebSocket-путём.

Итого:

portforward.k8s.io

Оригинальное имя v1, по-прежнему описывает модель того, что течёт внутри потоков.

v2.portforward.k8s.io

Метка KEP для транспорта v2 (SPDY-фреймы, туннелируемые внутри WebSocket-сообщений); именно это имя выводит kubectl в логах.

SPDY/3.1+portforward.k8s.io

То, что kubectl анонсирует и сервер возвращает в Sec-WebSocket-Protocol; префикс выбирает путь туннелирования, суффикс — это имя v1.

На стороне клиента tunneling_dialer.go формирует v2-запрос, добавляя константу SPDY/3.1+ перед именем v1-субпротокола ещё до отправки апгрейда.

На стороне сервера streamtunnel.go принимает только субпротоколы с этим префиксом, отрезает SPDY/3.1+ и перестраивает апстрим-SPDY-запрос к kubelet, используя имя уже без префикса.

Хендшейк и четыре команды

Большинство команд kubectl — это обычные HTTP-запросы: kubectl get pods делает GET и получает JSON, запрос завершается сразу после того, как тело закончит стримиться. Но четыре команды так работать не могут — каждой нужно соединение, которое остаётся открытым и в которое обе стороны могут писать по мере поступления данных.

Переход на WebSocket использует тот же механизм Upgrade, что и браузеры для открытия WebSocket-соединения: клиент отправляет HTTP-запрос с заголовком Connection: Upgrade, сервер соглашается ответом 101 Switching Protocols, и с этого момента TCP-соединение работает по новому протоколу.

Запрос апгрейда для проброса портов — SPDY против WebSocket:

#client (kubectl)
POST /api/v1/namespaces/default/pods/my-pod/portforward HTTP/1.1
Host: kubernetes.default.svc
Connection: Upgrade
Upgrade: SPDY/3.1
X-Stream-Protocol-Version: portforward.k8s.io
#server
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: SPDY/3.1
X-Stream-Protocol-Version: portforward.k8s.io
#client (kubectl)
GET /api/v1/namespaces/default/pods/my-pod/portforward HTTP/1.1
Host: kubernetes.default.svc
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Protocol: SPDY/3.1+portforward.k8s.io
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: base64==
#server
HTTP/1.1 101 Switching Protocols
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: base64=
Sec-WebSocket-Protocol: SPDY/3.1+portforward.k8s.io

Между этими двумя обменами изменились два момента. Во-первых, HTTP-метод: SPDY апгрейдился через POST, WebSocket апгрейдится через GET — ничего драматичного, но это влечёт за собой последствия в RBAC, о которых я расскажу ниже. Во-вторых, строка субпротокола: в SPDY port-forward использовал portforward.k8s.io, а в WebSocket kubectl анонсирует SPDY/3.1+portforward.k8s.io и сервер возвращает то же самое.

SPDY/3.1+ — просто префикс в коде Kubernetes, и название достаточно точно описывает фреймирование: сообщение WebSocket несёт в себе немодифицированный SPDY-фрейм в качестве полезной нагрузки.

Аналогичное изменение субпротокола произошло и для kubectl exec, только со своими строками — у exec отдельное семейство субпротоколов, определённое в apimachinery/pkg/util/remotecommand/constants.go:

X-Stream-Protocol-Version: v4.channel.k8s.io

Sec-WebSocket-Protocol: v5.channel.k8s.io

Однако это изменения разного характера: exec получил новое имя субпротокола и новый формат фреймов, а port-forward сохранил старое имя субпротокола, добавив к нему префикс (SPDY/3.1+), и сохранил старый формат передачи данных.

Туннелирование против трансляции

Port-forward и exec/attach прошли через эту миграцию, но оказались по разные стороны одного проектного решения. Почему так получилось? По сути всё сводится к тому, как каждая команда работает со своими потоками.

WebSocket завершается на обработчике, который понимает внутренний субпротокол: демультиплексирует сообщения в потоки по каналам и восстанавливает их как SPDY для kubelet. Набор каналов фиксированный — субпротокол определяет пять из них: stdin, stdout, stderr, канал статуса ошибки с кодом завершения (появился в v4) и события изменения размера терминала. Каждый чанк в сети несёт байт-идентификатор потока, так что получатель читает тег и передаёт байты нужному обработчику канала. Транслятор может стоять посередине именно потому, что заранее знает о существовании этих каналов.

StreamTranslatorHandler работает на API-сервере в первой фазе и на kubelet во второй. Он читает субпротокол, отображает идентификаторы потоков на каналы ввода/вывода, демультиплексирует поток WebSocket-сообщений в отдельные SPDY-соединения к бэкенду по каждому каналу и прозрачно пробрасывает байты.

В случае port-forward WebSocket несёт SPDY-фреймы байт в байт, и обработчик на сервере, разворачивающий их, даже не смотрит на содержимое. Здесь нет фиксированного набора каналов: kubelet создаёт пару потоков (один для данных, один для ошибок) для каждого нового соединения, пришедшего через пробрасываемый порт, и уничтожает эти потоки, когда запрос завершается. TCP-соединение остаётся открытым между запросами, но потоки над ним появляются и исчезают.

TunnelingHandler работает на API-сервере. Он отрезает префикс SPDY/3.1+ от согласованного субпротокола, извлекает полезную нагрузку каждого WebSocket-сообщения и пересылает SPDY-фрейм апстриму к kubelet, не заглядывая внутрь. kubectl собирает SPDY-фреймы так же, как всегда, оборачивает каждый из них как полезную нагрузку WebSocket-сообщения, а kubelet на другом конце видит привычный SPDY-трафик. Три одновременных соединения через один port-forward создают шесть SPDY-потоков (три для данных, три для ошибок), разделяющих одно TCP-соединение. Потоки появляются и исчезают по мере открытия и закрытия соединений, но TCP-сокет остаётся открытым — и именно эта динамичность делает трансляцию неподходящим решением для port-forward. Транслятор должен знать, какие потоки существуют и какому каналу каждый из них соответствует, а в случае port-forward ни то ни другое заранее неизвестно. Туннелирование обходит эту проблему: SPDY-фрейм внутри WebSocket уже содержит идентификаторы потоков и сигналы открытия/закрытия потоков — серверу нужно лишь снять обёртку.

Почему SPDY пришлось уйти

По сути из-за прокси.

Экосистема прокси-серверов отказалась от SPDY гораздо быстрее, чем Kubernetes:

  • Google изначально создал SPDY как мультиплексируемый протокол поверх TCP, который в итоге лёг в основу HTTP/2.

  • В блоге Chromium Google объявил, что уберёт поддержку SPDY из Chrome, как только развернётся HTTP/2.

  • IETF опубликовал RFC 7540 — построенный на идеях SPDY, но пришедший ему на смену стандарт.

  • NGINX убрал модуль SPDY и заменил его модулем HTTP/2.

  • Envoy никогда не добавлял поддержку SPDY вовсе.

К концу 2010-х прокси-серверы и шлюзы перед кластерами Kubernetes уже не говорили на SPDY.

Почему тогда не перевести kubectl напрямую на HTTP/2? На мой взгляд, тому три причины.

Путь апгрейда в HTTP/2 через h2c изначально был скудным и почти не использовался (в итоге его признали устаревшим). По TLS HTTP/2 договаривается через ALPN, а не через HTTP Upgrade, поэтому стриминговый стек пришлось бы дополнить вторым механизмом согласования. К тому же net/http в Go не предоставляет пути HTTP/2-апгрейда ни в каком виде — Kubernetes пришлось бы поддерживать собственный HTTP/2-сервер, который всё равно не прошёл бы через прокси, умеющий только HTTP/1.1 плюс WebSocket-апгрейды. Слишком сложно.

Так что выбор пал на WebSockets, опубликованные в декабре 2011-го — раньше HTTP/2 и задолго до QUIC. WebSockets используют тот же HTTP Upgrade-хендшейк, на котором уже построен остальной стриминговый стек. Прокси и балансировщики нагрузки давно их поддерживают, потому что браузеры использовали их годами. Команда выбрала WebSockets именно потому, что развёрнутые прокси уже умеют с ними работать, а вся миграция затеяна ради прохождения трафика через прокси. Вот и всё.

Kubernetes держался за SPDY почти десятилетие после того, как Google объявил его устаревшим, — а экосистема прокси отказалась от него через пару лет после этого. Мигрировать стриминговый протокол в распределённой системе сложно, существующий код работал, и ничто не было достаточно срочным, чтобы кто-то взялся за эту работу раньше.

Фазы

WebSockets появляются в цепочке стриминга не сразу на всех участках. Команда Kubernetes разворачивает миграцию в две фазы, каждый раз сдвигая границу WebSocket на один хоп глубже. Полная цепочка стриминга выглядит так: kubectl → API-сервер → kubelet → контейнерный рантайм; каждая стрелка — отдельное TCP-соединение, которое переходит на WebSockets по своему собственному расписанию.

API-сервер транслирует (1.29+)

API-сервер принимает WebSocket от kubectl и транслирует его в SPDY-соединение к kubelet. С точки зрения kubelet ничего не изменилось: он видит тот же SPDY-трафик, что всегда, а контейнерный рантайм видит тот же SPDY-трафик, что всегда присылал kubelet. Всё трансляционное бремя ложится на API-сервер — для небольших кластеров это нормально, но начинает сказываться, когда control-plane обрабатывает много одновременных стриминговых сессий.

Kubelet транслирует (1.36+)

Трансляция уходит с control-plane на узлы. Теперь API-сервер прозрачно пробрасывает WebSocket к kubelet без изменений, а kubelet берёт на себя трансляцию протокола при взаимодействии с контейнерным рантаймом.

Оба пути сосуществуют во время скользящих обновлений (rolling upgrades), так что компоненты могут обновляться в любом порядке без нарушения стриминга. На участке между kubelet и контейнерным рантаймом SPDY сохраняется в обеих фазах — в KEP это объясняется явно: команда не будет переводить протокол стриминга на этом внутриузловом участке, он продолжит работать на SPDY.

По-моему, это разумно: для трафика, который никогда не покидает узел, аргумент совместимости с прокси, движущий всей миграцией, попросту не применим. Менять протокол там ничего не даст, а работы потребует немало.

Исправление RBAC

Если читать только проектный документ, легко пропустить проблему авторизации, которую миграция неожиданно ввела. Механически всё просто: апгрейд SPDY был POST-запросом, апгрейд WebSocket — GET-запросом, а RBAC отображает HTTP-методы на глаголы (verbs), с которыми работает политический движок.

RBAC интерпретировал POST как create. Разрешение «create на pods/exec» — это то, что операторы называют «этот пользователь может делать exec в поды».

RBAC интерпретирует GET как get. Разрешение «get на pods/exec» слабее; некоторые операторы выдают его как часть read-only-роли.

После смены метода роль, разрешающая get на pods/exec, начала допускать саму операцию. Роли, которые должны были давать пользователям возможность только просматривать поды, стали позволять им делать exec в эти поды. Не очень хорошо.

Новый feature gate (AuthorizePodWebsocketUpgradeCreatePermission) добавляет синтетический шаг авторизации поверх маппинга глаголов. Когда WebSocket-апгрейд приходит на pods/exec, pods/attach или pods/portforward, API-сервер требует create на субресурс, даже несмотря на то что базовый HTTP-метод — GET. По умолчанию включено начиная с 1.35.

Фолбэк

Совместная работа разных версий kubectl и API-сервера — это как раз та ситуация, где важен запасной механизм миграции. Когда более новый kubectl пытается использовать WebSockets против старого API-сервера, процесс выглядит так:

  1. kubectl отправляет WebSocket-апгрейд с SPDY/3.1+portforward.k8s.io

  2. Старый API-сервер не распознаёт субпротокол и отклоняет апгрейд

  3. kubectl повторяет попытку с SPDY-апгрейдом (один дополнительный round-trip)

  4. API-сервер принимает, стриминг начинается по SPDY

FallbackDialer содержит эту логику. Он оборачивает основной диалер туннелирования и резервный SPDY-диалер; предикат решает, должен ли сбой основного инициировать переключение на резервный.

Хронология релизов

История реализации KEP по версиям — каждый релиз добавлял gate, фолбэк-путь или и то и другое:

  • exec/attach/cp через WebSockets, альфа. Opt-in через KUBECTL_REMOTE_COMMAND_WEBSOCKETS=true и gate TranslateStreamCloseWebsocketRequests.

  • exec/attach/cp переходит в бету (включено по умолчанию). Port-forward появляется как альфа за KUBECTL_PORT_FORWARD_WEBSOCKETS=true и gate PortForwardWebsockets.

  • Port-forward переходит в бету, включено по умолчанию. Все четыре команды теперь используют WebSockets на участке kubectl–apiserver.

  • Исправление баги с WebSocket-апгрейдом через HTTPS-прокси (#126134).

  • Исправление проблемы с эскалацией привилегий через RBAC.

  • WebSockets распространяются на kubelet, бета.

В этом списке нет GA-релиза — для миграции такого масштаба это нормально. Каждый релиз добавлял новый feature gate, новый фолбэк-путь или оба сразу, и каждое нововведение успевало «отлежаться» один-два релиза перед тем, как команда продвигала следующее.

Что дальше (для меня)

Я наблюдаю, как моё приложение переходит на протокол 2011 года — старше HTTP/2, и уж тем более QUIC. Совместимость с прокси вынудила эту миграцию. Думаю, совместимость с прокси вынудит и следующую. Вот как мы сюда пришли:

RFC 9220 определяет WebSockets поверх HTTP/3, но выглядит для меня скорее как прокладка совместимости.

2009 — SPDY

Google создал SPDY как мультиплексируемый протокол поверх TCP.

2022 — HTTP/3

Следующий шаг Google, QUIC, стал HTTP/3 в виде RFC 9114. То, что началось со SPDY, теперь работает поверх UDP и ALPN вместо TCP и HTTP Upgrade.

2026 — WebTransport (черновик)

IETF разработал черновик WebTransport для двунаправленного стриминга поверх HTTP/3. Черновики ещё открыты, но Chrome, Firefox и Safari добавили поддержку в браузерах к марту 2026 года.

Интересная особенность WebTransport — он устраняет блокировку начала очереди (head-of-line blocking):

  • SPDY/WebSockets мультиплексируют поверх одного TCP-соединения. Один потерянный пакет останавливает все потоки до прихода повтора.

  • WebTransport обрабатывает восстановление после потерь отдельно для каждого потока. Потерянный пакет блокирует только этот поток.

Думаю, авторы KEP выбрали правильно. Спецификации WebTransport ещё не стали RFC, ни один прокси или шлюз за пределами браузера его не поддерживает. Вся причина, по которой Kubernetes отказался от SPDY, — совместимость с прокси; выбрать протокол, который прокси не умеют маршрутизировать, означало бы наступить на те же грабли.

SPDY, потом WebSockets, потом WebTransport. Kubernetes приземляется на WebSockets, пока IETF уже пишет черновик того, что придёт им на смену. Давление со стороны прокси, вероятно, вытеснит и WebSockets — может, не через пять лет, но вытеснит. А тот, кто будет это делать, напишет уже совсем другую статью.

© 2026 meganuke