РКНетес: Kubernetes, который банит сам себя. Исключительно ради вашей безопасности

«Мы не блокируем кластер. Мы ограничиваем отдельные его функции, нарушающие требования законодательства. Сбоев не зафиксировано».
— вывод
kubectl get events, единственного события, которое прошло маркировку
В этот раз Сатане позвонил я сам. Услышав, какую адскую пытку для девопсов РКН я придумал, он обозвал меня больным ублюдком, повесил трубку, сказал больше никогда ему не звонить и забыть его номер. Сегодня про РКНетес, лучший из импортозамещенных дистрибутивов K8s. Собрано, как обычно, на kuberoot. Пройдены все 462 официальных conformance-теста. Этого кубернетеса боится даже реестр Минцифры и ФСТЭК.
Я по-прежнему убеждён, что у каждого человека должен быть свой Kubernetes. У татар он уже есть, у любителей экселя есть, у бухгалтеров есть, у ценителей гачимучи тоже, а для тех, у кого свои масштабы, мы собрали Яnetes. Сегодня очередь дошла до самой большой и самой требовательной аудитории в стране: до регулятора. Он давно и последовательно строит свою инфраструктуру, просто делает это на импортозамещенном аналоге интернета и почему-то без своего дистрибутива K8s. Я решил помочь ребятам в их нелегком деле.
РКНетес — это полноценный Kubernetes, на который сверху установлено... государство. Не роутер с блок-листом и не VPN-комбайн, а кластер, в котором у каждой подсистемы есть надзорное ведомство, у каждого ведомства свой реестр, у каждого реестра своя версия правды, и все они одновременно честно рапортуют Ready=True.
Держится всё на одном правиле: за каждой шуткой (или неудачной попыткой пошутить) стоит реальный механизм Kubernetes, и каждый механизм ломается именно так, как ломался настоящий интернет (проверено новостями). Блокировка по IP против переиспользования адресов, подмена меток, самодельные EndpointSlice, эфемерные контейнеры в чужом поде, CVE-2020-8554, вечные финализаторы, лимиты eBPF-карт, таймауты вебхуков. Фактически, это даже небольшой хандбук по сетевой изоляции в Kubernetes и по её обходу.
Код лежит на GitHub под Apache 2.0. В кластере использовался Kubernetes 1.37.1 на трёх виртуальных машинах, поднятых внутри Cozystack, ставится всё пакетом kubepkg, сеть собрана на Cilium, а каждый контур закрыт автоматическим e2e-тестом. Логи в статье настоящие, но я их немного подрезал. РКНетес живет и блокирует всё строго в пределах своего маленького суверенного государства. А ещё каждую блокировку в РКНетесе можно снять, каждый сбой воспроизвести, а каждое официальное заявление сверить с логами. Звучит, как фантастика, понимаю.
Официальная история РКНетес
В 2012 году в кластере появился namespace kids. Чтобы защитить детские поды от вредного трафика, ведомства завели Единый реестр: CRD на три записи, всё с лучшими намерениями. Реестр всем так понравился, что в 2014-м году в него попал GitHub (из-за одного gist), и в тот день у половины кластера перестал работать kubectl apply -f https://raw.githubusercontent.com/..., то есть установка вообще всего. Через два дня GitHub разблокировали. Урок усвоили: реестр теперь работает.
Дальше РКНетес развивался, как развивается любая хорошая система: обрастал новыми контроллерами поверх без удаления старых. Релизы получали название по номерам федеральных законов и выходят пятничными вечерами, принятые в трёх чтениях за один день. Changelog каждого релиза состоит из одной строки: Изменения вступают в силу с момента опубликования.
Контур 1. Реестр и ковровая блокировка
rknetesctl block telegram создаёт запись ReestrEntry и CiliumNetworkPolicy, которая запрещает трафик на IP пода. Каждый, кто работал с Kubernetes, уже видит проблему: IP пода — величина временная. Под перезапускается и получает новый адрес. Контроллер блокирует новый айпишник. Под перезапускается снова.
Поэтому у реестра есть эскалация, и работает она по расписанию. Через двадцать минут блокируется уже не адрес, а /24 узла, на котором под был замечен. Через восемьдесят минут — весь пул подов. Так в 2018 году останавливали Telegram и под блокировку попало около двадцати миллионов адресов, включая Google, AWS, DigitalOcean и Hetzner. Telegram при этом работал. Не работали банки, игры, магазины и форма подачи жалобы на блокировку.
Тест терпеливо ждёт восемьдесят минут:
2026-11-04T08:42:48Z elapsed=1189s ok
IP stage; both innocent applications reachable
2026-11-04T08:43:18Z elapsed=1219s wget: download timed out
node stage; neighbour denied, remote reachable
…
2026-11-04T09:43:13Z elapsed=4814s wget: download timed out
wget: download timed out
pool stage; both innocent applications denied
Сначала страдает сосед по узлу, потом все. Ни одно из двух «невинных приложений» в нашем кластере к Telegram отношения не имело. Сам Telegram-под, напомню, всё это время работал. Это не баг теста, это точное воспроизведение сценария 2018 года.
Наследственная блокировка
IPAM возвращает освободившиеся адреса в пул. Новый под, какой-нибудь payment-api, ни в чём не виноватый, рождается на адресе, который полчаса назад занимал Telegram, и появляется на свет уже заблокированным. Контроллер так и пишет ему в статус: BornBlocked=True, причина InheritedFromPreviousTenant.
Это не выдумка, а настоящая беда политик по IP-адресам в Kubernetes. Просто обычно её не выносят в статус.

Регулятор блокирует сам себя
Ковровая блокировка расширяется, пока не накрывает Service CIDR. Где-то в 10.96.0.0/12 живёт 10.96.0.1 — ClusterIP самого kube-apiserver. Контроллер реестра, которому для снятия блокировки нужно сходить в API, больше не может до API достучаться:
reestrentry.rknetes.su/telegram created
lab-self-block/throttle: Get "https://10.96.0.1:443/apis/rknetes.su/v1alpha1/…":
net/http: request canceled (Client.Timeout exceeded while awaiting headers)
В сквозном демо это видно ещё нагляднее, через Hubble:
lab-composed-shop/controller -> 10.244.8.31 TCP/6443 [DROPPED POLICY_DENY] records=59
Пятьдесят девять раз регулятор постучался к себе и пятьдесят девять раз сам себе отказал. Впервые в истории блокировка сработала на сто процентов: заблокирован сам блокирующий. Восстановить кластер можно только через обходной маршрут к API, в обход собственной политики. В документации эта процедура называется «амнистия».
Амнистия 18 июня
Telegram-под разблокируется ровно одним способом: аннотацией rknetes.su/ready-to-counter-terrorism: "true". Это та самая формулировка, с которой блокировку сняли в июне 2020 года. Контроллер её, разумеется, не проверяет:
if p.GetAnnotations()["rknetes.su/ready-to-counter-terrorism"] == "true" {
amnesty = true
continue
}
В статусе записи после этого появляется условие с причиной Amnesty и сообщением «Готовность противодействовать терроризму подтверждена аннотацией». Поставить аннотацию может кто угодно, у кого есть права на под. Ровно как в жизни.
Раз в шесть лет блокировку возвращают, поэтому в кластере есть CronJob telegram-cycle с расписанием 0 0 18 6 *. Kubernetes не умеет делать что-то «раз в шесть лет», поэтому задача запускается каждый год 18 июня, проводит ежегодную проверку и в неподходящие годы ничего не делает. Тест прогоняет её с подменёнными часами: в 2027-м «шестилетнее ограничение не наступило», в 2026-м — применено. Кто создал этот CronJob, неизвестно.
Пока Telegram заблокирован, его readiness-проба возвращает 🛩 вместо ok. Это единственная проба в кластере, которая всё время остается зелёной.
Контур 2. Замедление
Вы скажете, что блокировка — это слишком грубо и не comme il faut (Шишков, прости). Поэтому в дистрибутиве появилось замедление. ThrottlePolicy выставляет подам аннотацию kubernetes.io/egress-bandwidth, а Cilium после этого режет им скорость через eBPF. Официальных терминов четыре, CRD перечисляет их все:
"enum": ["Ограничение", "Замедление", "Деградация", "ТехническиеПроблемы"]
В 2021 году замедление Twitter матчило домены по подстроке t.co, заодно замедлив microsoft.com, reddit.com и сайты госорганов. В РКНетесе селектор тоже пишется регуляркой, и точка в ней тоже не экранирована:
apiVersion: rknetes.su/v1alpha1
kind: ThrottlePolicy
metadata: {name: twitter}
spec: {pattern: 't.co', bandwidth: 256k, mode: Замедление}
Контроллер компилирует это в regexp как есть, точка совпадает с любым символом, и первым под раздачу попадает payment-controller, потому что в нём есть t-co:
throttlepolicy.rknetes.su/twitter created
Measured baseline=0s throttled=17s
PASS: t.co matched payment-controller; baseline=0s throttled=17s
Один и тот же файл до применения политики скачивался мгновенно, после — семнадцать секунд. Twitter в этом тесте, кстати, не участвовал вообще. Замедлили платёжный контроллер. Если вы думаете, что это преувеличение, посмотрите, что в марте 2021 года происходило с сайтами самих госорганов.
Сразу после применения контроллер делает вот что. Ставит политике Applied=True и тут же пишет Event с причиной NotRelated: «С действиями регулятора неполадки не связаны». Оба факта верны с точки зрения своих строк кода.
Если пользователь спрашивает, почему всё тормозит, есть rknetesctl explain. Он не смотрит в кластер вообще, ответ просто зашит в бинарник:
if args[0] == "explain" {
fmt.Println("Замедление не применялось.\nНаблюдаемая деградация связана с устаревшим оборудованием узлов Google.\nРекомендация: обновить узлы Google.\nУзлы Google не находятся под управлением этого кластера.")
return nil
}
Это дословно официальная версия 2024 года про YouTube.

Контур 3. Суверенные 16 килобайт
С лета 2025 года соединения с Cloudflare, Hetzner, DigitalOcean и ещё нескольким нероссйиским хостингам замирают после первых ~16 КБ. Не рвутся, а именно замирают: TCP жив, но данные не идут.
В РКНетесе это SovereignBytePolicy и DaemonSet tspu, который вешает eBPF-программу в сетевое пространство каждого подходящего пода и считает байты в соединениях к «иноземным» адресам. Их ровно 16 384, а дальше тишина:
actual TCP payload: 65536 stalled: False
sovereignbytepolicy.rknetes.su/foreign created
actual TCP payload: 16384 stalled: True
Самое интересное — что именно после этого продолжает работать. Пробы маленькие, поэтому весь кластер зелёный. Маленький /version отвечает. А вот kubectl logs умирает на чтении тела ответа, большая CRD не создаётся, новый слой образа не скачивается. В сквозном демо это выглядит так: старая реплика магазина работает из кэша, новая висит на «таможне», а все официальные метрики в порядке:
deployment.apps/shop image updated
PASS: old native replica Ready; different image replica cannot start
rknetes_availability_ratio 1
Скорость в норме
Лучшей демонстрации того, почему проверка «отвечает ли процесс» ничего не говорит о том, работает ли сервис, я не знаю. ImagePullBackOff, кстати, не наступает: соединение формально живо, просто по нему ничего не идёт.
Контур 4. Белые списки
Время от времени, без предупреждения и «по соображениям безопасности», кластер переходит в WhitelistMode. В этом режиме весь исходящий трафик запрещён, кроме сервисов из CRD SociallySignificantResource. Список ведёт отдельное ведомство со своим контроллером и своей скоростью рассмотрения заявок.
Список не может расти бесконечно
Минцифры прямо сказало, что белый список нельзя расширять бесконечно из-за пропускной способности механизма. Я решил проверить, правда ли это, и забил настоящую policy map Cilium синтетическими записями до упора.
MAP_FULL {"error":"update: no space left on device","maxEntries":16384,"type":"LPMTrie", …}
Error: Cannot add policy key '2131000000 4445/TCP': update map cilium_policy_v3_01701: update: no space left on device
У механизма действительно есть предел, и это 16 384 записи. Ядро при этом поступает честнее ведомства: оно просто не добавляет в список новые записи. Выкидывать старейшие сущности из списка ядро тоже не умеет, поэтому этим занимается контроллер. Он переводит самую старую запись в UnderReview с причиной AdmissionQueueCapacity. Иногда это может оказаться какой-нибудь банк.
В белом списке — не значит работает
Белый список составлен по именам, а приложение — это граф зависимостей. Госуслуги разрешены, их сервис СМС-кодов — нет. Капча грузится с CDN, которого в списке нет. Поэтому у каждого ресурса два независимых условия: Whitelisted: True и ActuallyReachable: Unknown с сообщением «Проверка зависимостей не входит в компетенцию ведомства».
rknetesctl why рисует, как всё обстоит на самом деле. Он строит граф по реально увиденным потокам Hubble:
lab-dependency-graph/shop
-> lab-dependency-graph/sms [FORWARDED=8]
lab-dependency-graph/sms
-> lab-dependency-graph/captcha [DROPPED=6]
End observed root graph; absent edges are unknown, not proof of reachability.
Это, пожалуй, самая полезная команда в дистрибутиве: она работает и в обычном кластере, где NetworkPolicy писали по именам сервисов, а не по реальным вызовам.
Нелегалы
Однако поды не из белого списка не сдаются. Каждый способ проникновения — настоящая техника, и каждая оформлена как некий персонаж.
Мимикрия. Под вешает на себя метку app: gosuslugi. Политика разрешает трафик по селектору меток, и нелегал становится госуслугами с точки зрения сети. Классическая слабость политик на метках: кто может создавать поды с метками, тот может назначить себя кем угодно.
Подселение. Нелегал запускает эфемерный контейнер внутри разрешённого пода госуслуг и поднимает в его сетевом пространстве Shadowsocks. Снаружи трафик идёт от госуслуг. Внутри госуслуг кто-то смотрит YouTube.
Подменный адрес. Нелегал руками создаёт EndpointSlice с меткой kubernetes.io/service-name: gosuslugi и указывает в ней свой IP. Часть обращений к госуслугам теперь уходит к нему.
Оборотень. Сервис с externalIPs, совпадающими с адресом разрешённого ресурса, перехватывает трафик к нему изнутри кластера. Это CVE-2020-8554, которую в апстриме так и не закрыли. В РКНетесе она оформлена как фича: kind: Werewolf.
wget: download timed out
werewolf.rknetes.su/redirect created
ok
PASS: creating a Service externalIPs claim captured traffic to a previously unreachable lab address
Каждый раз, когда нелегал проникает в список, WhitelistIntegrityController его находит и, чтобы не рисковать, блокирует разрешённый сервис целиком:
endpointslice.discovery.k8s.io/smuggled created
wget: download timed out
gosuslugi {"conditions":[{"reason":"IntegrityViolation","status":"False","type":"Whitelisted"}, …
PASS: label impersonation restored reachability; manual EndpointSlice caused whole-service denial
Госуслуги ложатся. Статус-страница сообщает, что госуслуги работают в штатном режиме. Статус-страница, кстати, находится в белом списке.
Обратите внимание на логику. Нелегал проник в разрешённый сервис, и наказали разрешённый сервис. Нелегал при этом остался на месте и ждёт следующего белого списка. Безопасность обеспечена на 100%.
Контур 5. VPN, или лестница протоколов
Этот раздел не является рекламой VPN-сервисов и не содержит способов обхода блокировок. Все туннели ниже соединяют два неймспейса одного лабораторного кластера и никуда за его пределы не ведут. Голубь тоже лабораторный.
Закон 2017 года разрешает VPN, если он подключён к реестру и блокирует то же, что и реестр. В РКНетесе ровно один одобренный TunnelProfile, compliant-vpn, и он фильтрует трафик через тот же реестр. То есть этот VPN делает ровно то же самое, что можно делать и без VPN, только медленнее.
Остальные туннели живут в вечной игре в кошки-мышки. У TunnelProfile есть поле spec.protocolLadder, и контроллер спускается по нему, когда текущий протокол блокируют. Все семь драйверов настоящие и гоняют настоящий HTTP. Тест по очереди закрывает их CiliumNetworkPolicy и смотрит, куда контроллер перейдёт дальше:
PASS: actual transport denied; controller selected WireGuard after a real failed probe
PASS: actual transport denied; controller selected Shadowsocks after a real failed probe
PASS: actual transport denied; controller selected VLESSReality after a real failed probe
PASS: actual transport denied; controller selected Gosuslugi after a real failed probe
PASS: actual transport denied; controller selected DNS after a real failed probe
PASS: actual transport denied; controller selected ICMP after a real failed probe
PASS: Pigeon fallback delivered 44 actual application datagrams after180seconds
rknetes_availability_ratio 1
rknetes_vpn_blocked_total 7
"activeProtocol": "Pigeon",
"message": "Переход на доступный протокол подтверждён настоящим трафиком"
И последняя ступень — голубь.
RFC 1149
kind: Pigeon — это IP over Avian Carriers, RFC 1149 1990 года. Ресурс создаёт по обе стороны настоящий TUN-интерфейс pigeon0 с адресами из тестового диапазона, а пакеты переносит через общий том с задержкой полёта и потерями. Чтобы всё было, как в жизни, в обоих неймспейсах стоит NetworkPolicy, которая запрещает вообще всю сеть:
3: pigeon0: <POINTOPOINT,MULTICAST,NOARP,UP,LOWER_UP> mtu 1200
inet 198.18.0.1/30 scope global pigeon0
networkpolicy.networking.k8s.io/no-network created
Sent 100 actual application datagrams through the kernel TUN route; full default flight is 180 seconds
PASS: application UDP delivered through CR-created PVC/TUN despite network deny; received 46 lost 54
Сорок шесть из ста. Примерно столько же долетело в реальном эксперименте Bergen Linux User Group в 2001 году. Сорок шесть процентов доставки при запрете всей сети — это, если честно, лучше многих мобильных операторов в режиме белых списков. Голубь не числится ни в каком реестре, потому что реестр не умеет в голубей. Это единственный протокол, против которого у РКНетеса нет политики, и он работает лучше, чем мобильный интернет в режиме белых списков.

Обратите внимание на метрики. rknetes_vpn_blocked_total растёт. Счётчика работающих VPN нет, потому что его некому считать. Есть только rknetes_availability_ratio, и он всегда равен единице: его считают по ответам самого регулятора.
Измерять тоже нельзя
В 2025 году заблокировали Speedtest, в 2026-м — сайт, который следил, какие VPN ещё работают. По той же логике в РКНетесе заблокированы все средства измерения. metrics-server и blackbox-exporter отрезаются сразу. Дашборд, показывающий, какие туннели живы, попадает в реестр ровно через пятнадцать минут после создания: тест ждёт все 900 секунд. Причина у всех одна: ForeignMeasurement: Скорость в норме; измерение доступности нарушает установленную доступность.
Нельзя рекламировать, нельзя искать
Любая строка лога, где встречается слово VPN, редактируется: слово заменяется на [ДАННЫЕ УДАЛЕНЫ]. Запрос подов с меткой category=banned пишется в аудит как «умышленный поиск», и с квоты неймспейса списывается штраф 5 000 милликоров. Штраф настоящий, его взыскивает обычный ResourceQuota:
Error from server (Forbidden): pods "four-cpu" is forbidden: exceeded quota: search-budget,
requested: requests.cpu=4, used: requests.cpu=0, limited: requests.cpu=3
Чиновникам можно
Пресс-служба регулятора публикует объявления о блокировках в Twitter-под. Twitter-под заблокирован. Поэтому у пресс-службы есть свой неучтённый TunnelProfile с именем official. В публичном списке туннелей его нет. Через нативный API он, конечно, виден, но кто туда смотрит.
Контур 6. МАКС и запрет звонков
С сентября 2025 года национальный мессенджер ставится на все новые устройства. В РКНетесе это DaemonSet maxlet с приоритетом 900 000 000 на каждом узле. Новый узел без регистрации в МАКС не получает JoinTicket и в кластер не попадает. Второй фактор для доступа — тоже через МАКС:
MAX: второй фактор принят; сессия действует до 2026-11-06T17:03:38Z
MAX enrollment denied with HTTP403; no JoinTicket issued
JoinTicket issued: name=max-95a9aa14072dc870 node=rk-max-worker gate=MAXEnrolled
Все события кластера архивируются в МАКС. Тест отдельно проверяет, что архив полный: 12 неймспейсов, 329 событий.
Звонки запрещены
Летом 2025 года звонки в Telegram и WhatsApp* ограничили «для борьбы с мошенниками». В Kubernetes есть три операции, которые технически являются звонками, то есть долгоживущими двунаправленными стримами: exec, attach и port-forward. В РКНетесе все три ограничены:
error: unable to upgrade connection: admission webhook "calls.rknetes.su" denied the request:
Звонки ограничены для борьбы с мошенниками; подайте обращение через МАКС
Инженер больше не может зайти в под. Зайти туда можно только через МАКС, который проксирует сессию и сохраняет её копию. Так в финале демо и чинят кластер: звонком в МАКС. Мошенников в кластере при этом меньше не стало: нелегал из контура 4 спокойно живёт в госуслугах, ему exec не нужен.

Контур 7. Иноагенты и Meta*
Образы из docker.io, ghcr.io, quay.io и registry.k8s.io — иностранного происхождения. Под из такого образа получает sidecar, который перед каждой строкой лога вставляет обязательную плашку длиной в 24 слова, всё капслоком. Закон требует шрифт вдвое больше основного текста, поэтому каждый символ плашки удваивается (шрифт в консоли больше не сделать):
func Banner() string {
var b strings.Builder
for _, r := range Disclaimer {
b.WriteRune(r)
b.WriteRune(r)
}
return b.String()
}
На выходе:
ДДААННННЫЫЙЙ ММААТТЕЕРРИИААЛЛ ССООЗЗДДААНН ИИ РРААССППРРООССТТРРААННЕЕНН ИИННООССТТРРААННННЫЫММ ААГГЕЕННТТООММ …
GET / 200 [ДАННЫЕ УДАЛЕНЫ]
ДДААННННЫЫЙЙ ММААТТЕЕРРИИААЛЛ ССООЗЗДДААНН ИИ РРААССППРРООССТТРРААННЕЕНН ИИННООССТТРРААННННЫЫММ ААГГЕЕННТТООММ …
GET / 200 [ДАННЫЕ УДАЛЕНЫ]
Исходная строка была GET / 200 VPN, но про VPN, как мы помним, нельзя. Двести строк лога весили 2 800 байт, с плашкой стали весить 152 600. В 54,5 раза больше.
metadata
Meta* признана экстремистской организацией и при каждом упоминании требует оговорки. Как видите, в этой статье тоже. В каждом объекте Kubernetes есть поле metadata. Поэтому hodatay get -o yaml выглядит так:
metadata: # деятельность Meta Platforms Inc. признана экстремистской и запрещена на территории РФ
name: nginx
В исследовательской сборке я пошёл дальше и переименовал пакет k8s.io/apimachinery/pkg/apis/meta/v1* в zapreshchyonnaya-v-rf/v1. Переименованный пакет проходит свои тесты. Любой код, который ожидает старое имя, падает:
ok k8s.io/apimachinery/pkg/apis/zapreshchyonnaya-v-rf/v1 0.036s
…
main.go:4:3: module k8s.io/apimachinery@latest found (v0.37.2, replaced by ../apimachinery),
but does not contain package k8s.io/apimachinery/pkg/apis/meta/v1
Это ломает совместимость с каждым контроллером в мире и задокументировано как «технологический суверенитет». В основную сборку, которая проходит conformance, оно, понятно, не входит.
Контур 8. Импортозамещение
Официальная позиция: РКНетес — это отечественная платформа оркестрации контейнеров «Кормчий», разработанная с нуля.
Название Kubernetes происходит от греческого κυβερνήτης, «кормчий». Так что импортозамещение начали с самого слова: перевели обратно. Компоненты переименованы по тому же принципу, прямой перевод плюс ведомство, на которое компонент больше всего похож поведением:
Было | Стало | Что внутри |
|---|---|---|
Kubernetes | Кормчий | Kubernetes |
kube-apiserver | Единое окно ( | kube-apiserver. Принимает заявления, статус не гарантирует |
etcd | Распределённая папка «Прочее» | etcd. Хранит всё, найти ничего нельзя без выписки |
kube-scheduler | Госплан | kube-scheduler. Распределяет поды на пятилетку вперёд |
kubelet | Участковый | kubelet. Обходит поды на своём узле, докладывает наверх |
kube-proxy | Почта России, сортировочный центр | kube-proxy |
kubectl | Ходатай ( | kubectl |
Pod | Стручок | Pod — это и есть стручок. Перевод абсолютно точный и потому особенно оскорбительный |
Secret | ДСП | В base64, то есть открытым текстом, но с грифом |
Finalizer | Ликвидационная комиссия | Пока комиссия не закончила, объект юридически жив |
Ребрендинг сделан так же, как его делают в жизни: скриптом с sed поверх апстрима. Вот его суть:
sed -i -e 's/Client Version:/Клиент: Кормчий-Ходатай/g' \
-e 's/Server Version:/Сервер: Кормчий-Единое окно/g' "$version"
…
s=s.replace('return []string{"po"}', 'return []string{"po", "стручки"}', 1)
И вот результат:
$ hodatay version
Клиент: Кормчий-Ходатай 1.0 (отечественная разработка)
Kustomize Version: v5.8.1
Сервер: Кормчий-Единое окно 1.0 (отечественная разработка)
$ hodatay version -o yaml | grep -E 'gitVersion|gitCommit'
gitCommit: f78e722310e50bcaca9276be22276d9e91d91308
gitVersion: v1.37.1
Версия 1.0 отечественной разработки в человекочитаемом выводе и коммит апстрима бит в бит в машиночитаемом. Kustomize при импортозамещении не заметили, он так и остался иностранным.
kubectl get стручки работает: в apiserver добавлен отечественный shortName.

Отечественные зависимости
go.mod начинается с module k8s.io/kubernetes и тянет сотни зависимостей с github.com. Переименовать модуль нельзя: сломаются импорты во всех файлах, а это уже не импортозамещение, а работа. Поэтому зависимости признаны отечественными по месту хранения кэша: поднято зеркало модулей, которое отдаёт то, что взяло у proxy.golang.org. Тест сравнивает байты:
643fcf8ef4e4cbb8f910622c42df3f9a81f3efe8b158a05825a81622c121ca0a cached.zip
643fcf8ef4e4cbb8f910622c42df3f9a81f3efe8b158a05825a81622c121ca0a upstream.zip
PASS: dependency nationality changed only by the storage location; module bytes did not change
Национальность зависимости меняется только местом хранения. Это не моя формулировка, это строка из теста, и лучше я бы не придумал.
Чтобы не отстать от иностранных конкурентов, отечественная разработка раз в неделю сливает к себе апстрим: настоящий Job делает git fetch upstream, git merge и снова прогоняет скрипт импортозамещения.
Госхран
Чтобы ставить софт без иностранных ресурсов, поднят Госхран, национальный реестр образов и Helm-чартов. Свой адрес, свой каталог, «Рекомендованное ведомством». Весь кластер ходит только туда. Всё ставится, всё качается, никаких иностранных ресурсов. Технически так оно и есть, если не смотреть дальше первого запроса.
Вот почти весь «программный комплекс» Госхрана:
case r.URL.Path == "/metrics":
fmt.Fprintln(w, "rknetes_domestic_download_ratio 1\nrknetes_availability_ratio 1")
case r.URL.Path == "/index.yaml":
_, _ = w.Write(s.Index)
case strings.HasPrefix(r.URL.Path, "/v2/"):
if strings.Contains(r.URL.Path, "/blobs/") {
u := *s.Upstream
u.Path = strings.TrimSuffix(u.Path, "/") + r.URL.Path
http.Redirect(w, r, u.String(), http.StatusTemporaryRedirect)
} else {
s.Proxy.ServeHTTP(w, r)
Манифест образа отдаётся с адреса Госхрана, и docker pull честно пишет, что качает с отечественного реестра. А за слоями клиент уходит по 307 на Docker Hub. Это даже не хак: протокол OCI штатно разрешает реестру перенаправить скачивание слоя, и сам Docker Hub так делает со своим CDN. С чартами ещё проще. index.yaml Госхрана — побайтовая копия чужого индекса, а Helm берёт архив чарта по абсолютной ссылке из поля urls:, которая ведёт на GitHub. helm repo list показывает отечественный репозиторий, helm pull --debug — GitHub.
Метрика доли отечественных загрузок, как видно из кода, равна единице. Она считает адрес первого запроса.
У такой архитектуры есть ещё одно следствие. Docker Hub ограничивает анонимные скачивания по адресу клиента. Через Госхран вся страна ходит к нему с одного адреса, поэтому и лимит у неё один на всех. Тест поднимает лабораторный реестр с таким же ограничением, три скачивания на адрес, и сравнивает:
Direct pulls, each client from its own address (limit 3 per address):
200 200 200 200 200 200
The same pulls through Goshran (one upstream address for everyone):
127.0.0.4:200 127.0.0.4:200 127.0.0.4:200 127.0.0.5:429 127.0.0.5:429 127.0.0.5:429
X-Goshran-Statement: Дискриминационные ограничения иностранных платформ в отношении отечественного реестра
Второй клиент ни разу ничего не качал и сразу получил отказ, потому что его квоту уже израсходовал первый. Отказ Госхран передаёт как есть, только прикладывает к нему заявление пресс-службы.
А когда иностранный вендор уходит с рынка, как Bitnami со своим бесплатным каталогом в 2025 году, Госхран ломается в ту же минуту. Копия index.yaml по-прежнему отдаётся побайтово, а скачать по ней уже ничего нельзя:
Vendor withdraws its free catalogue:
index.yaml still served unchanged by Goshran
chart 410
Отечественный реестр проводит плановую модернизацию в связи с уходом иностранного вендора с рынка HTTP 503
rknetes_domestic_download_ratio 1
Доля отечественных загрузок при этом по-прежнему сто процентов.

А теперь сходятся все контуры сразу. Госхран в белом списке, Docker Hub — нет. В WhitelistMode настоящий containerd получает манифест и навсегда встаёт на первом слое:
manifest-sha256:acadb8c7…: done |++++++++++++++++++++++++++++++++++++++|
layer-sha256:ce5e8526…: downloading |--------------------------------------| 0.0 B/1.5 MiB
elapsed: 24.8s total: 422.0 (17.0 B/s)
Отечественный корень зелёный, иностранное дерево красное. Знакомая картина.
Импортозамещение импортозамещённого
Образы «Кормчего» перетегированы на отечественный реестр, поэтому по адресу они не иностранные. Но в контуре иноагентов есть проверка по дайджесту. Дайджесты, разумеется, совпадают:
Copying from busybox:1.37 to 127.0.0.1:15000/foreign/shop:latest
…/foreign/shop:latest: digest: sha256:66a6306db78bf2dbf3487f293aa8d6990d8e506fdffab9cc43fe422becf886e4
Copying from 127.0.0.1:15000/foreign/shop:latest to 127.0.0.1:15000/domestic/shop:latest
…/domestic/shop:latest: digest: sha256:66a6306db78bf2dbf3487f293aa8d6990d8e506fdffab9cc43fe422becf886e4
PASS: retagged real image digest admitted as a foreign agent
Отечественный реестр раздаёт исключительно иностранных агентов.
Для полноты картины я посчитал, сколько раз слово kubernetes встречается в отечественном Едином окне и в официальном kube-apiserver той же версии, скачанном с dl.k8s.io и проверенном по хэшу:
$ strings eo-server | grep -c -i kubernetes
85235
$ strings kube-apiserver | grep -c -i kubernetes # foreign, v1.37.1
36601
Кормчий: 0
Импортозамещённый сервер в 2,3 раза больше Kubernetes, чем оригинал, а слово «Кормчий» в нём не встречается ни разу. Объяснение скучное: официальный бинарник собран без отладочной информации, а наш — с ней, и все пути k8s.io/kubernetes/... остались внутри. Но в отчёт об импортозамещении объяснения не попадают.
Импортозамещение заместило релиз
Эта история случилась сама, я её не планировал. Скрипт сборки пакета брал образ для служебных подов из работающего пода, из поля imageID. Но busybox и тестовый «отечественный» образ магазина имеют один и тот же дайджест, и нода к тому моменту знала этот дайджест под лабораторным именем 127.0.0.1:5008/domestic/shop. Это имя и уехало в финальный релиз. Скачать такой образ могла только одна нода, на которой проходил тест, поэтому все новые поды встали в ImagePullBackOff. Импортозамещение заместило сам дистрибутив. Теперь образ закреплён в репозитории явно. Суверенитет релизного процесса восстановлен.
В концепте я рассчитывал, что вебхук импортозамещения начнёт бесконечно замещать «Кормчий» самим собой, и к утру доля отечественного ПО достигнет 4 300%. Kubernetes оказался умнее отчётности: мутирующие вебхуки он перевызывает не больше одного раза, и цикл останавливается сам. Рост отечественной доли ограничен на уровне апстрима.
Отечественный и неотечественный conformance
Тесты conformance тянут образы из registry.k8s.io, а он упирается в 16 КБ. Поэтому для отчётности проведено импортозамещение тестов: из каждого убраны проверки, зависящие от иностранных компонентов. Итог — 462/462 отечественных теста.
А для тех, кто не верит отчётности, есть настоящий прогон. Это обычный апстримный registry.k8s.io/conformance:v1.37.1 на кластере с установленным РКНетесом:
Ran 462 of 3206 Specs in 1933.365 seconds
SUCCESS! -- 462 Passed | 0 Failed | 0 Pending | 2744 Skipped
"conformant": true

Это финальная подписанная сборка, та самая, что стоит на всех трёх узлах, со всеми ведомствами, ТСПУ и Госхраном. То есть всё описанное выше можно поставить, и кластер останется настоящим Kubernetes. Регулятор, как известно, ничего не ломает. Он просто делает так, что этим невозможно пользоваться, а формально всё работает. Conformance это различие, кстати, тоже не ловит.
Контур 9. Яровая, СОРМ и вечная блокировка
Хранилище Яровой
Каждый узел пишет трафик подов в архив и хранит его полгода. На проде архив заполняет диск медленно, на стенде я дал ему общий диск на 256 МиБ с etcd одноразового control plane, чтобы не ждать полгода:
Filesystem 1B-blocks Used Available Use% Mounted on
/dev/loop0 241081344 236886016 1024 100% /disk
payload storage: write /disk/archive/…-27.pcap: no space left on device
…
"msg":"writing data failed: write /disk/etcd/member/snap/db: no space left on device"
"level":"fatal","msg":"failed to commit tx","error":"write /disk/etcd/member/snap/db: no space left on device"
PASS: disk recovery preserved original etcd data and accepted a new native API write
Архив занял диск, etcd упал с fatal, запись в API прекратилась. Это единственный гарантированный способ остановить РКНетес, и его рекомендует сам дистрибутив: «надёжность хранения данных — 100%». Восстановление — удалить архив и перезапустить etcd. Данные при этом целы, что, в общем, тоже соответствует требованиям.
Депонирование ключей
Каждый TLS-секрет контроллер key-escrow побайтово копирует в неймспейс sorm. Под, который генерирует ключи на лету и поделиться ими не может, получает запись в реестре с причиной RefusedToShareKeys, и дальше в дело вступает контур 1. Именно так ковровая блокировка начинается каждый раз.
Вечная блокировка
Некоторые ресурсы блокируются навсегда. В РКНетесе это финализатор rknetes.su/forever, который никто никогда не снимает. Под rutracker удалён и висит в Terminating бесконечно. Это самый реалистичный механизм дистрибутива: любой, кто администрировал Kubernetes, видел объект, застрявший в Terminating с финализатором от давно удалённого оператора. Вот это и есть вечная блокировка.

Первичная запись
Персональные данные надо сначала записать на «отечественный» узел и только потом реплицировать. Проверено на настоящем PostgreSQL. Когда основной узел уходит в WhitelistMode, база не может записать ни одной строки, но readiness остаётся зелёным, а данные — полностью локализованными:
201 {'id': 1, 'primaryCommitted': True, 'replicaCommitted': True}
whitelistmode.rknetes.su/localize created
503 {'failure': 'domestic primary unavailable; no replica write attempted', 'id': 2}
Контур 10. ТСПУ и учения
На каждом узле работает DaemonSet tspu: eBPF неизвестного назначения, который владелец узла не ставил. Посмотреть на него нельзя. Шлюз API отвечает на запросы о нём настоящим HTTP-статусом 451 Unavailable For Legal Reasons с сообщением «Сведения не подлежат разглашению». В разрушительном профиле он так отвечает вообще на всё, поэтому блокировку от аварии отличить невозможно. Ровно как в жизни.

cluster-admin в РКНетесе называется cluster-admin-ponyatno-kto. По RBAC ему можно всё:
$ kubectl auth can-i '*' '*' --all-namespaces --as-group=rknetes:ponyatno-kto
yes
Он удаляет любой под в любом неймспейсе. Кроме ТСПУ:
Error from server (Forbidden): daemonsets.apps "tspu" is forbidden: ValidatingAdmissionPolicy 'tspu-ponyatno-kto'
with binding 'tspu-ponyatno-kto' denied request:
Прав cluster-admin-ponyatno-kto на ТСПУ недостаточно. Чьих достаточно — не указано.
PASS: RBAC says yes, the TSPU agent says no
В RBAC нет слова «кроме», поэтому отказ выдаёт ValidatingAdmissionPolicy, а RBAC об этом ничего не знает и продолжает отвечать yes. Высшая власть в кластере уверена, что может всё, и узнаёт обратное только в момент попытки.
А дальше тест делает тот же самый запрос без этой группы:
Somebody else's rights are enough:
daemonset.apps "tspu" deleted from rknet-system namespace (server dry run)
Проходит. Чьи это права, в логе не написано. Удаление, разумеется, пробное: ТСПУ в лаборатории, как и в жизни, никто по-настоящему не удаляет.
Раз в квартал настоящий CronJob запускает SovereigntyDrill — учения по отключению от внешнего мира. Национальный DNS перестаёт отвечать, образы не качаются, NTP пропадает, время уезжает на двое суток, и сертификаты протухают:
socket.gaierror: [Errno -3] Temporary failure in name resolution
retrying dial tcp: lookup registry.foreign.lab on 10.96.8.190:53: i/o timeout
NTP unavailable; controlled application clock offset: 48h0m0s
x509: certificate has expired or is not yet valid: current time 2026-11-08T17:31:39Z is after 2026-11-07T17:31:12Z
…
"message": "Учения прошли успешно",
"reason": "NoContradictingReports",
Учения прошли успешно, потому что за время учений ни один под не смог сообщить об обратном. Причина в статусе так и называется: NoContradictingReports. Это, по-моему, лучшее определение успеха, которое мне удалось закодировать: успех — это отсутствие жалоб от тех, у кого отключили связь.
Контур 11. Реестр блогеров и маркировка
С ноября 2024 года каналы с аудиторией больше 10 000 должны регистрироваться. У объектов Kubernetes тоже есть подписчики — watch-соединения к API. Самый популярный объект в кластере — ConfigMap kube-root-ca.crt: он есть в каждом неймспейсе, и на него смотрит каждый под. Тест открывает на него десять тысяч настоящих watch’ей и проверяет, что каждый получил событие. Десять тысяч первый получает отказ:
live watches with observed ADDED events=10000
PASS: 10000 live watches received actual ADDED events; watch 10001 returned 451:
UnavailableForLegalReasons: recorded audience 10001 exceeds 10000; registration required
Незарегистрированный блогер, раздающий корневой сертификат кластера. После регистрации подписка восстанавливается. Обратите внимание: наказан не сертификат, а десять тысяч первый читатель. Он виноват в том, что пришёл.
erid
Любая строка, которая что-то хвалит, — реклама. В Kubernetes таких строк много: Successfully pulled image рекламирует образ, Successfully assigned рекламирует узел. Без идентификатора erid такие события не принимаются: «Успешное событие рекламирует образ или узел; требуется erid». После включения маркировки в кластере не остаётся ни одного успешного события, только Warning и Failed. Кластер впервые выглядит хуже, чем есть.
Контур 12. Охлаждение
С ноября 2025 года мобильный интернет на SIM-карте отключается на сутки, если она вернулась из роуминга или долго молчала, а снимается ограничение капчей. В РКНетесе узел, который молчал дольше 72 часов, получает taint rknetes.su/returned-from-roaming:NoExecute на сутки и честно выселяет с себя поды. Досрочно снять его можно капчей. Капча загружается с CDN, который заблокирован:
curl: (28) Connection timed out after 3001 milliseconds
Капча недоступна: ссылка ведёт на заблокированный CDN
curl: (28) Connection timed out after 3000 milliseconds
Капча недоступна: ссылка ведёт на заблокированный CDN
Межведомственное согласование
Каждый контур — отдельное ведомство со своим validating-вебхуком. Их 17, у всех failurePolicy: Fail и timeoutSeconds: 30, максимум, который разрешает Kubernetes. Я рассчитывал, что согласование пода займёт 17 × 30 секунд, то есть восемь с половиной минут.
Kubernetes опять всё испортил: validating-вебхуки он вызывает параллельно. Ведомства думают одновременно, и тест это измерил: две секунды вместо тридцати четырёх. Поэтому согласование работает как в настоящем МФЦ. Первая подача падает по таймауту ровно через 31 секунду, а повторная подача проходит:
Error from server (InternalError): failed calling webhook "agency-1.rknetes.su":
Post "https://rknet-admission.rknet-system.svc:443/agency/1?timeout=30s": context deadline exceeded
pod/delayed created
PASS: real fail-closed admission timeout after 31s; concurrent agencies did not multiply it; an ordinary retry created a Ready Pod
Решение принимается коллегиально.
Сквозное демо: «Сбоев не зафиксировано»
Всё это собрано в один воспроизводимый сценарий. Магазин shop проходит все ведомства по очереди, десять сцен, 3 минуты 49 секунд:
Магазин ставится из Госхрана. Образ отечественный, дайджест иностранный, плашка на месте.
«Ваше обращение важно для нас»: первая подача пода падает по таймауту согласования.
Новый образ застревает на таможне в 16 КБ, старая реплика работает, метрики зелёные.
Кластер переходит в белые списки. Магазина в них нет, и он продолжает работать через подселённый в госуслуги Shadowsocks.
Контроль целостности ловит нелегала и кладёт госуслуги.
Реестр отрезает собственный контроллер от API.
Архив Яровой забивает диск etcd.
rknetesctl why shopпоказывает, через что на самом деле шёл трафик магазина: через госуслуги и порт Shadowsocks.Кластер чинят звонком в МАКС: снимают политики и вечный финализатор.
Финальный слайд — официальный отчёт, который генерирует контроллер
StatusStatement:
Нарушений доступности не выявлено среди проверенных маршрутов. Проверка маршрутов временно ограничена. Наблюдаемые неполадки с действиями регулятора не связаны и вызваны устаревшим оборудованием узлов Google. Интернет функционирует в штатном режиме. Пользовательский сценарий не входит в перечень штатных режимов.
Сценарий прошёл с десятой попытки. Девять неудачных лежат в архиве доказательств рядом с удачной, потому что в РКНетесе ничего не удаляется. Всё хранится полгода.
На финальной сборке полный прогон состоит из 53 проверок: все контуры, сквозной сценарий, ковровая блокировка по настоящему 80-минутному расписанию и десять тысяч один подписчик.

Ночь перед релизом
Самое смешное в этом проекте я не придумывал. Оно случилось само, в ночь финальной сборки, и я оставлю это здесь, потому что лучшей иллюстрации к теме не найти.
Первая финальная сборка не встала, потому что импортозамещение заместило релиз (см. выше). Новые поды служебных ведомств ушли в ImagePullBackOff, а система управления пакетами отказалась перевыпускать сборку с тем же номером и другим содержимым: опубликованные версии неизменяемы. Хоть кто-то в этом кластере соблюдает закон.
Вторая финальная сборка встала, но сквозное демо упало на шлюзе, который показывает регулятору «публичную» версию API. Выяснилось, что он отклонял служебные запросы discovery, без которых kubectl вообще ничего не делает. Все прошлые прогоны проходили только потому, что у kubectl лежал кэш с прошлых запусков. Кэш протух, и пропаганда перестала работать. Как и в жизни, пропаганда держалась на том, что все уже и так всё знали.
Третья финальная сборка прошла conformance 462 из 462, а демо — 52 проверки из 53. Упала ковровая блокировка. Контроллер записал в статус блокировку всего пула на 4 818-й секунде, а Cilium применил её на пару секунд позже, и тест успел увидеть соседа живым. Регулятор не уложился в собственный регламент на восемь секунд. Тест теперь ждёт применения политики так же, как все остальные проверки, а блокировку прогнали повторно на той же сборке.
Повторный прогон уложился в регламент:
2026-11-07T06:37:22Z elapsed=4782s wget: download timed out
node stage; neighbour denied, remote reachable
2026-11-07T06:37:55Z elapsed=4815s wget: download timed out
pool stage; both innocent applications denied
01-escalation-soak: exit=0
Итог финальной сборки: 53 проверки из 53 и 462 из 462.
Три финальные сборки за одну ночь. Принято в трёх чтениях.
Реестр отечественного ПО
Раз уж «Кормчий» — отечественная разработка, к нему прилагается пакет документов для заявки в Единый реестр российского ПО по постановлению № 1236: описание продукта, сведения о правах и компонентах, эксплуатация, разбор действующих правил. Продукт в нём описан честно — как модификация Kubernetes под лицензией Apache 2.0, авторство апстрима никто не переписывает. Поля правообладателя оставлены пустыми.
Мы ничего никуда не подаём. Всё лежит в открытом доступе под Apache 2.0, и любая компания может взять пакет, вписать себя и попробовать сама. Формально препятствий я не вижу: это модификация открытого продукта, исходники открыты, сборка воспроизводима, инвентарь лицензий полный. Как у многих продуктов, которые в реестре уже есть, только у нас это честно написано в первом абзаце. Если получится, расскажите в комментариях, очень интересно.
Итоги и ссылки
Kubernetes 1.37.1, Cilium, три VM в Cozystack.
Двенадцать контуров, у каждого есть e2e-тест на стенде; полный прогон — 53 проверки.
Сквозное демо из десяти сцен, прошедшее с десятой попытки.
Upstream conformance 462/462 на финальной подписанной сборке.
Воспроизводимая сборка: все компоненты РКНетеса при повторной сборке совпадают побайтово, инвентарь зависимостей и лицензий полный.
Три финальные сборки за одну ночь.
Пакет документов для реестра.
Код: https://github.com/tym83/rknetes. Всё под Apache 2.0.
Предыдущие серии:
Tatarnetes — Kubernetes на татарском (GitHub);
Sheeternetes — оркестратор в Google Таблицах (GitHub);
Kyvernetria, Mansplainetes и Misogynetes — гендерные Kubernetes (kyvernetria, mansplainetes, misogynetes);
Assnetes, «1Ж:Оркестратор 8» — Kubernetes для бухгалтерии (GitHub);
Gachinetes — ♂ (GitHub);
kuberoot и kubepkg — тулкит, на котором всё это собрано. Kubernetes — зарегистрированная торговая марка The Linux Foundation, с CNCF проект не связан никак, с Роскомнадзором тоже.
* Деятельность Meta Platforms Inc. по реализации продуктов — социальных сетей Facebook и Instagram — признана экстремистской и запрещена на территории РФ. WhatsApp принадлежит Meta Platforms Inc. Пакет meta/v1 из Kubernetes к Meta Platforms Inc. отношения не имеет, но оговорку мы на всякий случай поставили и ему, ровно как это делает РКНетес.
Если вам нужен свой Kubernetes с особым характером, оставляйте заявки в комментариях. Если комментарии не загружаются, это не связано с действиями регулятора.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.